diff --git a/src/app/features/admin-institutions/pages/institutions-projects/institutions-projects.component.spec.ts b/src/app/features/admin-institutions/pages/institutions-projects/institutions-projects.component.spec.ts index 1101e758a..824ee4ce2 100644 --- a/src/app/features/admin-institutions/pages/institutions-projects/institutions-projects.component.spec.ts +++ b/src/app/features/admin-institutions/pages/institutions-projects/institutions-projects.component.spec.ts @@ -2,8 +2,6 @@ import { Store } from '@ngxs/store'; import { MockComponents, MockProvider } from 'ng-mocks'; -import { DynamicDialogRef } from 'primeng/dynamicdialog'; - import { of, Subject, throwError } from 'rxjs'; import { Mock } from 'vitest'; @@ -35,6 +33,7 @@ import { import { MOCK_USER } from '@testing/mocks/data.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; import { + CustomDialogServiceMock, CustomDialogServiceMockBuilder, CustomDialogServiceMockType, } from '@testing/providers/custom-dialog-provider.mock'; @@ -65,11 +64,8 @@ describe('InstitutionsProjectsComponent', () => { const mockInstitution = { ...MOCK_ADMIN_INSTITUTIONS_INSTITUTION, id: 'inst-1' }; - function createDialogRef(onClose$: Subject): DynamicDialogRef { - return { - onClose: onClose$.asObservable(), - close: vi.fn(), - } as unknown as DynamicDialogRef; + function createDialogRef(onClose$: Subject) { + return CustomDialogServiceMock.dialogRefWithClose(onClose$.asObservable()); } function createIconClickEvent(): TableIconClickEvent { 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 index 1b67f9d6a..1e4610cb8 100644 --- 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 @@ -3,9 +3,14 @@ [innerHTML]="'files.dialogs.moveFile.message' | translate: { dropNodeName: currentFolder.name, dragNodeName }" >
- + - - + +
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 index 6e91d5349..74a33d80f 100644 --- 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 @@ -1,72 +1,147 @@ -import { MockComponents, MockProvider } from 'ng-mocks'; +import { MockProvider } from 'ng-mocks'; -import { DynamicDialogConfig } from 'primeng/dynamicdialog'; +import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; + +import { Subject } from 'rxjs'; 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 { FileModel } from '@osf/shared/models/files/file.model'; +import { FileModelMock } from '@testing/mocks/file.model.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; -import { CustomConfirmationServiceMock } from '@testing/providers/custom-confirmation-provider.mock'; import { provideDynamicDialogRefMock } from '@testing/providers/dynamic-dialog-ref.mock'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; -import { ToastServiceMock } from '@testing/providers/toast-provider.mock'; +import { + FilesMoveCopyServiceMock, + FilesMoveCopyServiceMockType, +} from '@testing/providers/files-move-copy-service.mock'; -import { FilesSelectors } from '../../store'; +import { MoveCopyAction } from '../../enums/move-copy-action.enum'; +import { ConfirmMoveFilesOptions } from '../../models/files-actions-options.model'; +import { FilesMoveCopyService } from '../../services/files-move-copy.service'; import { ConfirmMoveFileDialogComponent } from './confirm-move-file-dialog.component'; -describe('ConfirmConfirmMoveFileDialogComponent', () => { +describe('ConfirmMoveFileDialogComponent', () => { let component: ConfirmMoveFileDialogComponent; let fixture: ComponentFixture; + let dialogRefMock: DynamicDialogRef; + let dialogConfigMock: DynamicDialogConfig & { data: ConfirmMoveFilesOptions }; + let filesMoveCopyService: FilesMoveCopyServiceMockType; + + interface SetupOverrides { + files?: FileModel[]; + destination?: FileModel; + } + + function setup(overrides: SetupOverrides = {}) { + const defaultFile = FileModelMock.simple({ name: 'a.txt' }); + const defaultDestination = FileModelMock.simple({ name: 'folder' }); + const files = overrides.files ?? [defaultFile]; + const destination = overrides.destination ?? defaultDestination; - beforeEach(() => { - const dialogConfigMock = { - data: { files: [], destination: { name: 'files' } }, + const data: ConfirmMoveFilesOptions = { + files, + destination, + resourceId: 'resource-1', + storageProvider: 'osfstorage', }; + filesMoveCopyService = FilesMoveCopyServiceMock.simple(); + dialogConfigMock = { header: 'files.dialogs.moveFile.title', data }; + TestBed.configureTestingModule({ - imports: [ - ConfirmMoveFileDialogComponent, - ...MockComponents(IconComponent, LoadingSpinnerComponent, FileSelectDestinationComponent), - ], + imports: [ConfirmMoveFileDialogComponent], providers: [ provideOSFCore(), provideDynamicDialogRefMock(), MockProvider(DynamicDialogConfig, dialogConfigMock), - MockProvider(FilesService), - MockProvider(ToastService, ToastServiceMock.simple()), - MockProvider(CustomConfirmationService, CustomConfirmationServiceMock.simple()), - provideMockStore({ - signals: [ - { selector: FilesSelectors.getMoveDialogFiles, value: [] }, - { selector: FilesSelectors.getProvider, value: null }, - ], - }), + MockProvider(FilesMoveCopyService, filesMoveCopyService), ], }); fixture = TestBed.createComponent(ConfirmMoveFileDialogComponent); component = fixture.componentInstance; + dialogRefMock = TestBed.inject(DynamicDialogRef); fixture.detectChanges(); - }); + } it('should create', () => { + setup(); expect(component).toBeTruthy(); }); - it('should initialize with correct properties', () => { - expect(component.config).toBeDefined(); - expect(component.dialogRef).toBeDefined(); - expect(component.files).toBeDefined(); + it('should read currentFolder from dialog data', () => { + const dest = FileModelMock.simple({ name: 'target' }); + setup({ destination: dest }); + expect(component.currentFolder).toBe(dest); + }); + + it('should set dragNodeName to the file name when one file is selected', () => { + setup({ files: [FileModelMock.simple({ name: 'readme.md' })] }); + expect(component.dragNodeName).toBe('readme.md'); + }); + + it('should close without a result when cancel is clicked', () => { + setup(); + const buttons = fixture.nativeElement.querySelectorAll('button'); + buttons[0].click(); + expect(dialogRefMock.close).toHaveBeenCalledWith(); }); - it('should get files from store', () => { - expect(component.files()).toEqual([]); + it('should call move copy service with move action and close with true on success', () => { + const file = FileModelMock.simple({ name: 'a.txt' }); + const dest = FileModelMock.simple({ name: 'folder' }); + setup({ files: [file], destination: dest }); + component.moveFiles(); + expect(filesMoveCopyService.execute).toHaveBeenCalledWith({ + files: [file], + destination: dest, + resourceId: 'resource-1', + storageProvider: 'osfstorage', + action: MoveCopyAction.Move, + }); + expect(dialogRefMock.close).toHaveBeenCalledWith(true); + expect(component.isLoading()).toBe(false); + }); + + it('should call move copy service with copy action and close with true on success', () => { + const file = FileModelMock.simple({ name: 'a.txt' }); + const dest = FileModelMock.simple({ name: 'folder' }); + setup({ files: [file], destination: dest }); + component.copyFiles(); + expect(filesMoveCopyService.execute).toHaveBeenCalledWith({ + files: [file], + destination: dest, + resourceId: 'resource-1', + storageProvider: 'osfstorage', + action: MoveCopyAction.Copy, + }); + expect(dialogRefMock.close).toHaveBeenCalledWith(true); + expect(component.isLoading()).toBe(false); + }); + + it('should ignore a second move while the first is in progress', () => { + setup(); + const pending = new Subject(); + filesMoveCopyService.execute.mockReturnValue(pending.asObservable()); + component.moveFiles(); + component.moveFiles(); + expect(filesMoveCopyService.execute).toHaveBeenCalledTimes(1); + pending.next(true); + pending.complete(); + fixture.detectChanges(); + }); + + it('should keep loading true until move finishes', () => { + setup(); + const pending = new Subject(); + filesMoveCopyService.execute.mockReturnValue(pending.asObservable()); + component.moveFiles(); + expect(component.isLoading()).toBe(true); + pending.next(true); + pending.complete(); + fixture.detectChanges(); + expect(component.isLoading()).toBe(false); }); }); 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 index 038478525..b8c23c231 100644 --- 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 @@ -1,22 +1,17 @@ -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 { finalize, tap } from 'rxjs'; -import { ChangeDetectionStrategy, Component, DestroyRef, inject } from '@angular/core'; +import { ChangeDetectionStrategy, Component, DestroyRef, inject, signal } 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'; +import { FileModel } from '@osf/shared/models/files/file.model'; + +import { MoveCopyAction } from '../../enums/move-copy-action.enum'; +import { FilesMoveCopyService } from '../../services/files-move-copy.service'; @Component({ selector: 'osf-confirm-move-file-dialog', @@ -29,128 +24,54 @@ 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); + private readonly filesMoveCopyService = inject(FilesMoveCopyService); - readonly files = select(FilesSelectors.getMoveDialogFiles); + readonly currentFolder = this.config.data.destination as FileModel; - readonly provider = this.config.data.storageProvider; + readonly dragNodeName = + this.config.data.files.length > 1 + ? this.translateService.instant('files.dialogs.moveFile.multipleFiles', { count: this.config.data.files.length }) + : (this.config.data.files[0]?.name ?? ''); - 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; - } - } + readonly isLoading = signal(false); copyFiles(): void { - return this.copyOrMoveFiles(FileMenuType.Copy); + this.copyOrMoveFiles(MoveCopyAction.Copy); } moveFiles(): void { - return this.copyOrMoveFiles(FileMenuType.Move); + this.copyOrMoveFiles(MoveCopyAction.Move); } - private copyOrMoveFiles(action: FileMenuType): void { - const path = this.currentFolder.path; - if (!path) { - throw new Error(this.translateService.instant('files.dialogs.moveFile.pathError')); + private copyOrMoveFiles(action: MoveCopyAction): void { + if (this.isLoading()) { + return; } - 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(); - }); - } + this.isLoading.set(true); - 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`); - } + const headerKey = + action === MoveCopyAction.Move ? 'files.dialogs.moveFile.movingHeader' : 'files.dialogs.moveFile.copingHeader'; + this.config.header = this.translateService.instant(headerKey); - private completeMove(): void { - this.dialogRef.close(true); + this.filesMoveCopyService + .execute({ + files: this.config.data.files, + destination: this.currentFolder, + resourceId: this.config.data.resourceId, + storageProvider: this.config.data.storageProvider, + action, + }) + .pipe( + tap(() => this.dialogRef.close(true)), + finalize(() => { + this.isLoading.set(false); + this.config.header = this.translateService.instant('files.dialogs.moveFile.title'); + }), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(); } } diff --git a/src/app/features/files/components/create-folder-dialog/create-folder-dialog.component.html b/src/app/features/files/components/create-folder-dialog/create-folder-dialog.component.html index 0ca1b259b..6724f0c24 100644 --- a/src/app/features/files/components/create-folder-dialog/create-folder-dialog.component.html +++ b/src/app/features/files/components/create-folder-dialog/create-folder-dialog.component.html @@ -1,10 +1,10 @@
diff --git a/src/app/features/files/components/create-folder-dialog/create-folder-dialog.component.spec.ts b/src/app/features/files/components/create-folder-dialog/create-folder-dialog.component.spec.ts index b68e88ebb..33d0b1d89 100644 --- a/src/app/features/files/components/create-folder-dialog/create-folder-dialog.component.spec.ts +++ b/src/app/features/files/components/create-folder-dialog/create-folder-dialog.component.spec.ts @@ -34,7 +34,7 @@ describe('CreateFolderDialogComponent', () => { }); it('should expose name limits from shared input limits', () => { - expect(component.nameLimit).toBe(InputLimits.name.maxLength); + expect(component.nameMaxLength).toBe(InputLimits.name.maxLength); expect(component.nameMinLength).toBe(InputLimits.name.minLength); }); @@ -46,6 +46,14 @@ describe('CreateFolderDialogComponent', () => { expect(dialogRef.close).not.toHaveBeenCalled(); }); + it('should not close dialog when value is only whitespace', () => { + component.folderForm.controls.name.setValue(' '); + + component.onSubmit(); + + expect(dialogRef.close).not.toHaveBeenCalled(); + }); + it('should close dialog with trimmed folder name when form is valid', () => { component.folderForm.controls.name.setValue(' New Folder '); @@ -69,4 +77,20 @@ describe('CreateFolderDialogComponent', () => { expect(dialogRef.close).not.toHaveBeenCalled(); }); + + it('should not close dialog when value is shorter than minimum length', () => { + component.folderForm.controls.name.setValue('A'.repeat(InputLimits.name.minLength - 1)); + + component.onSubmit(); + + expect(dialogRef.close).not.toHaveBeenCalled(); + }); + + it('should not close dialog when value exceeds maximum length', () => { + component.folderForm.controls.name.setValue('A'.repeat(InputLimits.name.maxLength + 1)); + + component.onSubmit(); + + expect(dialogRef.close).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/features/files/components/create-folder-dialog/create-folder-dialog.component.ts b/src/app/features/files/components/create-folder-dialog/create-folder-dialog.component.ts index 187b46b6d..e2745f1d5 100644 --- a/src/app/features/files/components/create-folder-dialog/create-folder-dialog.component.ts +++ b/src/app/features/files/components/create-folder-dialog/create-folder-dialog.component.ts @@ -4,7 +4,7 @@ import { Button } from 'primeng/button'; import { DynamicDialogRef } from 'primeng/dynamicdialog'; import { Component, inject } from '@angular/core'; -import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { TextInputComponent } from '@osf/shared/components/text-input/text-input.component'; import { forbiddenFileNameCharacters, InputLimits } from '@osf/shared/constants/input-limits.const'; @@ -17,7 +17,8 @@ import { CustomValidators } from '@osf/shared/helpers/custom-form-validators.hel }) export class CreateFolderDialogComponent { readonly dialogRef = inject(DynamicDialogRef); - readonly nameLimit = InputLimits.name.maxLength; + + readonly nameMaxLength = InputLimits.name.maxLength; readonly nameMinLength = InputLimits.name.minLength; readonly folderForm = new FormGroup({ @@ -25,6 +26,8 @@ export class CreateFolderDialogComponent { nonNullable: true, validators: [ CustomValidators.requiredTrimmed(), + Validators.minLength(InputLimits.name.minLength), + Validators.maxLength(InputLimits.name.maxLength), CustomValidators.forbiddenCharactersValidator(forbiddenFileNameCharacters), CustomValidators.noPeriodAtEnd(), ], @@ -37,9 +40,6 @@ export class CreateFolderDialogComponent { } const folderName = this.folderForm.getRawValue().name.trim(); - - if (folderName) { - this.dialogRef.close(folderName); - } + this.dialogRef.close(folderName); } } diff --git a/src/app/features/files/components/edit-file-metadata-dialog/edit-file-metadata-dialog.component.html b/src/app/features/files/components/edit-file-metadata-dialog/edit-file-metadata-dialog.component.html index 6ee17bf54..3e22e96ad 100644 --- a/src/app/features/files/components/edit-file-metadata-dialog/edit-file-metadata-dialog.component.html +++ b/src/app/features/files/components/edit-file-metadata-dialog/edit-file-metadata-dialog.component.html @@ -1,38 +1,40 @@ - +
-

{{ 'common.labels.title' | translate }}

- + +
-

{{ 'common.labels.description' | translate }}

- + +
-

{{ 'files.detail.fileMetadata.fields.resourceType' | translate }}

+
-

{{ 'files.detail.fileMetadata.fields.resourceLanguage' | translate }}

+
- +
diff --git a/src/app/features/files/components/edit-file-metadata-dialog/edit-file-metadata-dialog.component.spec.ts b/src/app/features/files/components/edit-file-metadata-dialog/edit-file-metadata-dialog.component.spec.ts index e63d32474..3b35b662b 100644 --- a/src/app/features/files/components/edit-file-metadata-dialog/edit-file-metadata-dialog.component.spec.ts +++ b/src/app/features/files/components/edit-file-metadata-dialog/edit-file-metadata-dialog.component.spec.ts @@ -2,22 +2,19 @@ import { MockProvider } from 'ng-mocks'; import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; -import { Mocked } from 'vitest'; - import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { OsfFileCustomMetadata } from '@osf/features/files/models'; - import { provideOSFCore } from '@testing/osf.testing.provider'; import { provideDynamicDialogRefMock } from '@testing/providers/dynamic-dialog-ref.mock'; +import { OsfFileCustomMetadata } from '../../models/file-custom-metadata.model'; + import { EditFileMetadataDialogComponent } from './edit-file-metadata-dialog.component'; describe('EditFileMetadataDialogComponent', () => { let component: EditFileMetadataDialogComponent; let fixture: ComponentFixture; let dialogRef: DynamicDialogRef; - let dialogConfig: Mocked; const mockFileMetadata: OsfFileCustomMetadata = { id: '1', @@ -27,8 +24,8 @@ describe('EditFileMetadataDialogComponent', () => { language: 'en', }; - beforeEach(() => { - const dialogConfigMock = { data: mockFileMetadata }; + function setup(data?: Partial) { + const dialogConfigMock: Pick = { data }; TestBed.configureTestingModule({ imports: [EditFileMetadataDialogComponent], @@ -38,29 +35,49 @@ describe('EditFileMetadataDialogComponent', () => { fixture = TestBed.createComponent(EditFileMetadataDialogComponent); component = fixture.componentInstance; dialogRef = TestBed.inject(DynamicDialogRef); - dialogConfig = TestBed.inject(DynamicDialogConfig) as Mocked; fixture.detectChanges(); - }); + } it('should create', () => { + setup(mockFileMetadata); + expect(component).toBeTruthy(); }); - it('should have all required form controls', () => { - expect(component.titleControl).toBeDefined(); - expect(component.descriptionControl).toBeDefined(); - expect(component.resourceTypeControl).toBeDefined(); - expect(component.resourceLanguageControl).toBeDefined(); + it('should initialize form from dialog data', () => { + setup(mockFileMetadata); + + expect(component.fileMetadataForm.controls.title.value).toBe('Test File'); + expect(component.fileMetadataForm.controls.description.value).toBe('Test Description'); + expect(component.fileMetadataForm.controls.resourceType.value).toBe('Dataset'); + expect(component.fileMetadataForm.controls.resourceLanguage.value).toBe('en'); + }); + + it('should set null for empty resource type and language', () => { + setup({ + id: '1', + title: 'Title', + description: 'Description', + resourceTypeGeneral: '', + language: '', + }); + + expect(component.fileMetadataForm.controls.resourceType.value).toBeNull(); + expect(component.fileMetadataForm.controls.resourceLanguage.value).toBeNull(); }); - it('should return correct form controls', () => { - expect(component.titleControl).toBe(component.fileMetadataForm.get('title')); - expect(component.descriptionControl).toBe(component.fileMetadataForm.get('description')); - expect(component.resourceTypeControl).toBe(component.fileMetadataForm.get('resourceType')); - expect(component.resourceLanguageControl).toBe(component.fileMetadataForm.get('resourceLanguage')); + it('should initialize safe defaults when dialog data is missing', () => { + setup(); + + expect(component.fileMetadataForm.controls.title.value).toBeNull(); + expect(component.fileMetadataForm.controls.description.value).toBeNull(); + expect(component.fileMetadataForm.controls.resourceType.value).toBeNull(); + expect(component.fileMetadataForm.controls.resourceLanguage.value).toBeNull(); }); - it('should close dialog with form values when setFileMetadata is called with valid form', () => { + it('should close dialog with mapped form values when form is valid', () => { + setup(mockFileMetadata); + component.setFileMetadata(); expect(dialogRef.close).toHaveBeenCalledWith({ @@ -71,46 +88,41 @@ describe('EditFileMetadataDialogComponent', () => { }); }); - it('should always close dialog when setFileMetadata is called (form has no validators)', () => { - component.titleControl.setValue(''); + it('should map nullable select values to empty strings on submit', () => { + setup(mockFileMetadata); + + component.fileMetadataForm.patchValue({ + title: null, + description: null, + resourceType: null, + resourceLanguage: null, + }); component.setFileMetadata(); - expect(dialogRef.close).toHaveBeenCalled(); + expect(dialogRef.close).toHaveBeenCalledWith({ + title: null, + description: null, + resource_type_general: '', + language: '', + }); }); - it('should close dialog without result when cancel is called', () => { - component.cancel(); + it('should not close dialog when form is invalid', () => { + setup(mockFileMetadata); - expect(dialogRef.close).toHaveBeenCalledWith(); - }); + component.fileMetadataForm.setErrors({ invalid: true }); - it('should handle null values in metadata', () => { - dialogConfig.data = { - title: null, - description: null, - resourceTypeGeneral: [], - language: [], - }; - fixture = TestBed.createComponent(EditFileMetadataDialogComponent); - component = fixture.componentInstance; - fixture.detectChanges(); + component.setFileMetadata(); - expect(component.titleControl.value).toBeNull(); - expect(component.descriptionControl.value).toBeNull(); - expect(component.resourceTypeControl.value).toBeNull(); - expect(component.resourceLanguageControl.value).toBeNull(); + expect(dialogRef.close).not.toHaveBeenCalled(); }); - it('should be valid with default values', () => { - expect(component.fileMetadataForm.valid).toBe(true); - }); + it('should close dialog without payload when cancel is called', () => { + setup(mockFileMetadata); - it('should handle form updates', () => { - component.titleControl.setValue('Updated Title'); - component.descriptionControl.setValue('Updated Description'); + component.cancel(); - expect(component.titleControl.value).toBe('Updated Title'); - expect(component.descriptionControl.value).toBe('Updated Description'); + expect(dialogRef.close).toHaveBeenCalledWith(); }); }); diff --git a/src/app/features/files/components/edit-file-metadata-dialog/edit-file-metadata-dialog.component.ts b/src/app/features/files/components/edit-file-metadata-dialog/edit-file-metadata-dialog.component.ts index b1207e692..968d84679 100644 --- a/src/app/features/files/components/edit-file-metadata-dialog/edit-file-metadata-dialog.component.ts +++ b/src/app/features/files/components/edit-file-metadata-dialog/edit-file-metadata-dialog.component.ts @@ -4,6 +4,7 @@ import { Button } from 'primeng/button'; import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; import { InputText } from 'primeng/inputtext'; import { Select } from 'primeng/select'; +import { Textarea } from 'primeng/textarea'; import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; @@ -11,11 +12,12 @@ import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { LANGUAGE_CODES } from '@osf/shared/constants/language.const'; import { resourceTypes } from '@osf/shared/constants/resource-types.const'; -import { OsfFileCustomMetadata, PatchFileMetadata } from '../../models'; +import { OsfFileCustomMetadata } from '../../models/file-custom-metadata.model'; +import { PatchFileMetadata } from '../../models/patch-file-metadata.model'; @Component({ selector: 'osf-edit-file-metadata-dialog', - imports: [Button, InputText, Select, ReactiveFormsModule, TranslatePipe], + imports: [Button, InputText, Textarea, Select, ReactiveFormsModule, TranslatePipe], templateUrl: './edit-file-metadata-dialog.component.html', styleUrl: './edit-file-metadata-dialog.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -27,7 +29,7 @@ export class EditFileMetadataDialogComponent { private readonly dialogRef = inject(DynamicDialogRef); readonly config = inject(DynamicDialogConfig); - fileMetadataForm = new FormGroup({ + readonly fileMetadataForm = new FormGroup({ title: new FormControl(null), description: new FormControl(null), resourceType: new FormControl(null), @@ -35,30 +37,20 @@ export class EditFileMetadataDialogComponent { }); constructor() { - const fileMetadata = this.config.data as OsfFileCustomMetadata; - - this.fileMetadataForm.patchValue({ - title: fileMetadata.title, - description: fileMetadata.description, - resourceType: fileMetadata.resourceTypeGeneral.length ? fileMetadata.resourceTypeGeneral : null, - resourceLanguage: fileMetadata.language.length ? fileMetadata.language : null, - }); - } - - get titleControl(): FormControl { - return this.fileMetadataForm.get('title') as FormControl; + this.initializeForm(); } - get descriptionControl(): FormControl { - return this.fileMetadataForm.get('description') as FormControl; - } - - get resourceTypeControl(): FormControl { - return this.fileMetadataForm.get('resourceType') as FormControl; - } + private initializeForm(): void { + const fileMetadata = this.config.data as Partial | undefined; + const resourceTypeGeneral = fileMetadata?.resourceTypeGeneral ?? ''; + const language = fileMetadata?.language ?? ''; - get resourceLanguageControl(): FormControl { - return this.fileMetadataForm.get('resourceLanguage') as FormControl; + this.fileMetadataForm.patchValue({ + title: fileMetadata?.title ?? null, + description: fileMetadata?.description ?? null, + resourceType: resourceTypeGeneral.length ? resourceTypeGeneral : null, + resourceLanguage: language.length ? language : null, + }); } setFileMetadata() { @@ -66,11 +58,12 @@ export class EditFileMetadataDialogComponent { return; } + const { title, description, resourceType, resourceLanguage } = this.fileMetadataForm.getRawValue(); const formValues: PatchFileMetadata = { - title: this.fileMetadataForm.get('title')?.value ?? null, - description: this.fileMetadataForm.get('description')?.value ?? null, - resource_type_general: this.fileMetadataForm.get('resourceType')?.value ?? '', - language: this.fileMetadataForm.get('resourceLanguage')?.value ?? '', + title: title ?? null, + description: description ?? null, + resource_type_general: resourceType ?? '', + language: resourceLanguage ?? '', }; this.dialogRef.close(formValues); diff --git a/src/app/features/files/components/file-browser-info/file-browser-info.component.html b/src/app/features/files/components/file-browser-info/file-browser-info.component.html index c9fd941c7..f7e045c8b 100644 --- a/src/app/features/files/components/file-browser-info/file-browser-info.component.html +++ b/src/app/features/files/components/file-browser-info/file-browser-info.component.html @@ -1,5 +1,5 @@
- @for (item of filteredInfoItems(); track item.titleKey) { + @for (item of filteredInfoItems; track item.titleKey) {

{{ item.titleKey | translate }}

{{ item.descriptionKey | translate }}

@@ -15,5 +15,5 @@

{{ item.titleKey | translate }}

- +
diff --git a/src/app/features/files/components/file-browser-info/file-browser-info.component.spec.ts b/src/app/features/files/components/file-browser-info/file-browser-info.component.spec.ts index be82ae8c4..f00920808 100644 --- a/src/app/features/files/components/file-browser-info/file-browser-info.component.spec.ts +++ b/src/app/features/files/components/file-browser-info/file-browser-info.component.spec.ts @@ -2,8 +2,6 @@ import { MockProvider } from 'ng-mocks'; import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; -import { Mocked } from 'vitest'; - import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; @@ -17,10 +15,9 @@ describe('FileBrowserInfoComponent', () => { let component: FileBrowserInfoComponent; let fixture: ComponentFixture; let dialogRef: DynamicDialogRef; - let dialogConfig: Mocked; - beforeEach(() => { - const dialogConfigMock = { data: ResourceType.Project }; + function setup(resourceType?: ResourceType): void { + const dialogConfigMock: Pick = { data: resourceType }; TestBed.configureTestingModule({ imports: [FileBrowserInfoComponent], @@ -30,34 +27,47 @@ describe('FileBrowserInfoComponent', () => { fixture = TestBed.createComponent(FileBrowserInfoComponent); component = fixture.componentInstance; dialogRef = TestBed.inject(DynamicDialogRef); - dialogConfig = TestBed.inject(DynamicDialogConfig) as Mocked; fixture.detectChanges(); - }); + } it('should create', () => { + setup(ResourceType.Project); + expect(component).toBeTruthy(); }); - it('should initialize with correct properties', () => { - expect(component.dialogRef).toBeDefined(); - expect(component.config).toBeDefined(); - expect(component.infoItems).toBeDefined(); - expect(component.resourceType()).toBe(ResourceType.Project); - }); + it('should set resourceType from dialog config', () => { + setup(ResourceType.Registration); - it('should compute resourceType from config data', () => { - expect(component.resourceType()).toBe(ResourceType.Project); + expect(component.resourceType).toBe(ResourceType.Registration); }); it('should default to Project when config data is undefined', () => { - dialogConfig.data = undefined; - fixture.detectChanges(); + setup(); - expect(component.resourceType()).toBe(ResourceType.Project); + expect(component.resourceType).toBe(ResourceType.Project); + }); + + it('should filter items for project resource type', () => { + setup(ResourceType.Project); + + expect(component.filteredInfoItems.length).toBe(component.infoItems.length); + }); + + it('should filter items for registration resource type', () => { + setup(ResourceType.Registration); + + expect(component.filteredInfoItems.length).toBeLessThan(component.infoItems.length); + expect( + component.filteredInfoItems.every((item) => item.showForResourceTypes.includes(ResourceType.Registration)) + ).toBe(true); }); it('should close dialog when close method is called', () => { + setup(ResourceType.Project); + component.dialogRef.close(); + expect(dialogRef.close).toHaveBeenCalled(); }); }); diff --git a/src/app/features/files/components/file-browser-info/file-browser-info.component.ts b/src/app/features/files/components/file-browser-info/file-browser-info.component.ts index 7110c2873..c628fbe70 100644 --- a/src/app/features/files/components/file-browser-info/file-browser-info.component.ts +++ b/src/app/features/files/components/file-browser-info/file-browser-info.component.ts @@ -3,7 +3,7 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; -import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; @@ -20,11 +20,9 @@ export class FileBrowserInfoComponent { readonly dialogRef = inject(DynamicDialogRef); readonly config = inject(DynamicDialogConfig); - readonly resourceType = computed(() => (this.config.data as ResourceType) || ResourceType.Project); + readonly resourceType = (this.config.data as ResourceType) ?? ResourceType.Project; readonly infoItems = FILE_BROWSER_INFO_ITEMS; - readonly filteredInfoItems = computed(() => { - return this.infoItems.filter((item) => item.showForResourceTypes.includes(this.resourceType())); - }); + readonly filteredInfoItems = this.infoItems.filter((item) => item.showForResourceTypes.includes(this.resourceType)); } diff --git a/src/app/features/files/components/file-keywords/file-keywords.component.html b/src/app/features/files/components/file-keywords/file-keywords.component.html index 9cf9943d1..05f9ea0be 100644 --- a/src/app/features/files/components/file-keywords/file-keywords.component.html +++ b/src/app/features/files/components/file-keywords/file-keywords.component.html @@ -1,26 +1,25 @@

{{ 'files.detail.keywords.title' | translate }}

- @if (!hasViewOnly() && hasWriteAccess()) { + @if (canManageTags()) {
- + />
} @if (!isTagsLoading()) {
- @for (tag of tags(); track $index) { + @for (tag of tags(); track tag) { } @empty { diff --git a/src/app/features/files/components/file-keywords/file-keywords.component.spec.ts b/src/app/features/files/components/file-keywords/file-keywords.component.spec.ts index 4daf81e0e..afd3c60c9 100644 --- a/src/app/features/files/components/file-keywords/file-keywords.component.spec.ts +++ b/src/app/features/files/components/file-keywords/file-keywords.component.spec.ts @@ -1,88 +1,151 @@ -import { signal } from '@angular/core'; +import { Store } from '@ngxs/store'; + +import { MockProvider } from 'ng-mocks'; + +import { Mock } from 'vitest'; + import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; + +import { FileDetailsMock } from '@testing/mocks/file-details.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { BaseSetupOverrides, mergeSignalOverrides, provideMockStore } from '@testing/providers/store-provider.mock'; +import { ViewOnlyLinkHelperMock } from '@testing/providers/view-only-link-helper.mock'; import { FilesSelectors } from '../../store'; +import { UpdateTags } from '../../store/files.actions'; import { FileKeywordsComponent } from './file-keywords.component'; describe('FileKeywordsComponent', () => { let component: FileKeywordsComponent; let fixture: ComponentFixture; + let store: Store; - const mockFile = { + const mockFile = FileDetailsMock.simple({ guid: 'test-guid', name: 'test-file.txt', - }; + }); const mockTags = ['tag1', 'tag2', 'tag3']; - beforeEach(() => { + interface SetupOverrides extends BaseSetupOverrides { + hasViewOnly?: boolean; + } + + function setup(overrides: SetupOverrides = {}) { + const viewOnlyServiceMock = ViewOnlyLinkHelperMock.simple(overrides.hasViewOnly ?? false); + const defaultSignals = [ + { selector: FilesSelectors.getFileTags, value: mockTags }, + { selector: FilesSelectors.isFileTagsLoading, value: false }, + { selector: FilesSelectors.getOpenedFile, value: mockFile }, + { selector: FilesSelectors.hasWriteAccess, value: true }, + ]; + TestBed.configureTestingModule({ imports: [FileKeywordsComponent], providers: [ provideOSFCore(), - provideMockStore({ - signals: [ - { selector: FilesSelectors.getFileTags, value: signal(mockTags) }, - { selector: FilesSelectors.isFileTagsLoading, value: signal(false) }, - { selector: FilesSelectors.getOpenedFile, value: signal(mockFile) }, - { selector: FilesSelectors.hasWriteAccess, value: signal(true) }, - ], - }), + MockProvider(ViewOnlyLinkHelperService, viewOnlyServiceMock), + provideMockStore({ signals: mergeSignalOverrides(defaultSignals, overrides.selectorOverrides) }), ], }); fixture = TestBed.createComponent(FileKeywordsComponent); component = fixture.componentInstance; + store = TestBed.inject(Store); fixture.detectChanges(); - }); + } it('should create', () => { + setup(); + expect(component).toBeTruthy(); }); - it('should initialize with correct properties', () => { - expect(component.tags).toBeDefined(); - expect(component.isTagsLoading).toBeDefined(); - expect(component.file).toBeDefined(); - expect(component.hasWriteAccess).toBeDefined(); - expect(component.keywordControl).toBeDefined(); + it('should expose tag edit permissions for editable state', () => { + setup(); + + expect(component.canManageTags()).toBe(true); + expect(component.canEditTags()).toBe(true); }); - it('should initialize keyword control with empty value', () => { - expect(component.keywordControl.value).toBe(''); + it('should disable editing when loading', () => { + setup({ + selectorOverrides: [{ selector: FilesSelectors.isFileTagsLoading, value: true }], + }); + + expect(component.canEditTags()).toBe(false); + expect(component.keywordControl.disabled).toBe(true); }); - it('should validate keyword control', () => { - component.keywordControl.setValue('valid-keyword'); - expect(component.keywordControl.valid).toBe(true); + it('should disable editing for view only mode', () => { + setup({ hasViewOnly: true }); + + expect(component.canManageTags()).toBe(false); + expect(component.canEditTags()).toBe(false); }); - it('should be invalid when keyword is empty', () => { - component.keywordControl.setValue(''); - expect(component.keywordControl.invalid).toBe(true); + it('should dispatch update with trimmed tag on add', () => { + setup(); + (store.dispatch as Mock).mockClear(); + component.keywordControl.setValue(' new-tag '); + + component.addTag(); + + expect(store.dispatch).toHaveBeenCalledWith(new UpdateTags(['tag1', 'tag2', 'tag3', 'new-tag'], 'test-guid')); + expect(component.keywordControl.value).toBe(''); }); - it('should be invalid when keyword is only whitespace', () => { + it('should not dispatch update when add input is invalid', () => { + setup(); + (store.dispatch as Mock).mockClear(); component.keywordControl.setValue(' '); - expect(component.keywordControl.invalid).toBe(true); + + component.addTag(); + + expect(store.dispatch).not.toHaveBeenCalled(); }); - it('should not add tag when keyword is empty', () => { - component.keywordControl.setValue(''); + it('should dispatch update when removing existing tag', () => { + setup(); + (store.dispatch as Mock).mockClear(); + + component.deleteTag('tag2'); - expect(() => component.addTag()).not.toThrow(); + expect(store.dispatch).toHaveBeenCalledWith(new UpdateTags(['tag1', 'tag3'], 'test-guid')); + expect(component.keywordControl.value).toBe(''); }); - it('should delete tag when deleteTag is called', () => { - expect(() => component.deleteTag('tag1')).not.toThrow(); + it('should not dispatch update when tag is missing', () => { + setup(); + (store.dispatch as Mock).mockClear(); + + component.deleteTag('missing-tag'); + + expect(store.dispatch).not.toHaveBeenCalled(); }); - it('should compute hasViewOnly based on router', () => { - expect(component.hasViewOnly).toBeDefined(); - expect(typeof component.hasViewOnly()).toBe('boolean'); + it('should not dispatch update when cannot edit tags', () => { + setup({ + selectorOverrides: [{ selector: FilesSelectors.hasWriteAccess, value: false }], + }); + (store.dispatch as Mock).mockClear(); + + component.deleteTag('tag1'); + + expect(store.dispatch).not.toHaveBeenCalled(); + }); + + it('should not dispatch update when file guid is missing', () => { + setup({ + selectorOverrides: [{ selector: FilesSelectors.getOpenedFile, value: null }], + }); + (store.dispatch as Mock).mockClear(); + + component.deleteTag('tag1'); + + expect(store.dispatch).not.toHaveBeenCalled(); }); }); diff --git a/src/app/features/files/components/file-keywords/file-keywords.component.ts b/src/app/features/files/components/file-keywords/file-keywords.component.ts index 16ee1996d..c537c0c90 100644 --- a/src/app/features/files/components/file-keywords/file-keywords.component.ts +++ b/src/app/features/files/components/file-keywords/file-keywords.component.ts @@ -7,7 +7,7 @@ import { Chip } from 'primeng/chip'; import { InputText } from 'primeng/inputtext'; import { Skeleton } from 'primeng/skeleton'; -import { ChangeDetectionStrategy, Component, computed, DestroyRef, inject } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, DestroyRef, effect, inject } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms'; import { Router } from '@angular/router'; @@ -37,30 +37,51 @@ export class FileKeywordsComponent { readonly file = select(FilesSelectors.getOpenedFile); readonly hasWriteAccess = select(FilesSelectors.hasWriteAccess); - readonly hasViewOnly = computed(() => this.viewOnlyService.hasViewOnlyParam(this.router)); + readonly canManageTags = computed(() => !this.viewOnlyService.hasViewOnlyParam(this.router) && this.hasWriteAccess()); + readonly canEditTags = computed(() => this.canManageTags() && !this.isTagsLoading()); keywordControl = new FormControl('', { nonNullable: true, validators: [CustomValidators.requiredTrimmed(), Validators.maxLength(InputLimits.name.maxLength)], }); + constructor() { + effect(() => { + if (this.isTagsLoading()) { + this.keywordControl.disable({ emitEvent: false }); + } else { + this.keywordControl.enable({ emitEvent: false }); + } + }); + } + addTag(): void { const fileGuid = this.file()?.guid; - if (this.keywordControl.value && fileGuid) { - const updatedTags = [...this.tags(), this.keywordControl.value!]; - this.updateTags(updatedTags, fileGuid); + if (!this.canEditTags() || this.keywordControl.invalid || !fileGuid) { + return; } + + const updatedTags = [...this.tags(), this.keywordControl.value.trim()]; + this.updateTags(updatedTags, fileGuid); } deleteTag(value: string): void { const fileGuid = this.file()?.guid; - if (fileGuid) { - const updatedTags = [...this.tags()]; - updatedTags.splice(updatedTags.indexOf(value), 1); - this.updateTags(updatedTags, fileGuid); + if (!this.canEditTags() || !fileGuid) { + return; } + + const updatedTags = [...this.tags()]; + const tagIndex = updatedTags.indexOf(value); + + if (tagIndex < 0) { + return; + } + + updatedTags.splice(tagIndex, 1); + this.updateTags(updatedTags, fileGuid); } private updateTags(updatedTags: string[], fileGuid: string) { diff --git a/src/app/features/files/components/file-metadata/file-metadata.component.html b/src/app/features/files/components/file-metadata/file-metadata.component.html index 3ba2dda89..828c5c3af 100644 --- a/src/app/features/files/components/file-metadata/file-metadata.component.html +++ b/src/app/features/files/components/file-metadata/file-metadata.component.html @@ -1,8 +1,10 @@ +@let metadata = fileMetadata(); +

{{ 'files.detail.fileMetadata.title' | translate }}

- @if (!hasViewOnly()) { + @if (!hasViewOnly) {
{{ 'files.detail.fileMetadata.title' | translate }} } @else { @for (field of metadataFields; track field.key) { - @if (fileMetadata()?.[field.key]) { + @if (metadata?.[field.key]) {

{{ field.label | translate }}

- {{ field.key === 'language' ? getLanguageName(fileMetadata()?.[field.key]!) : fileMetadata()?.[field.key] }} + {{ field.key === 'language' ? (metadata?.[field.key] | languageLabel) : metadata?.[field.key] }}

} diff --git a/src/app/features/files/components/file-metadata/file-metadata.component.spec.ts b/src/app/features/files/components/file-metadata/file-metadata.component.spec.ts index 3a3452604..851e7efd9 100644 --- a/src/app/features/files/components/file-metadata/file-metadata.component.spec.ts +++ b/src/app/features/files/components/file-metadata/file-metadata.component.spec.ts @@ -1,29 +1,36 @@ +import { Store } from '@ngxs/store'; + import { MockProvider } from 'ng-mocks'; +import { Subject } from 'rxjs'; + +import { Mock } from 'vitest'; + import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute, Router } from '@angular/router'; -import { LANGUAGE_CODES } from '@osf/shared/constants/language.const'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { provideOSFCore } from '@testing/osf.testing.provider'; -import { CustomDialogServiceMock } from '@testing/providers/custom-dialog-provider.mock'; -import { ActivatedRouteMock } from '@testing/providers/route-provider.mock'; +import { CustomDialogServiceMock, CustomDialogServiceMockType } from '@testing/providers/custom-dialog-provider.mock'; +import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { BaseSetupOverrides, mergeSignalOverrides, provideMockStore } from '@testing/providers/store-provider.mock'; import { FileMetadataFields } from '../../constants'; -import { PatchFileMetadata } from '../../models'; -import { FilesSelectors } from '../../store'; +import { OsfFileCustomMetadata } from '../../models/file-custom-metadata.model'; +import { PatchFileMetadata } from '../../models/patch-file-metadata.model'; +import { FilesSelectors, SetFileMetadata } from '../../store'; import { FileMetadataComponent } from './file-metadata.component'; describe('FileMetadataComponent', () => { let component: FileMetadataComponent; let fixture: ComponentFixture; - let customDialogService: any; + let customDialogService: CustomDialogServiceMockType; + let store: Store; - const mockFileMetadata = { + const mockFileMetadata: OsfFileCustomMetadata = { id: 'file-123', title: 'Test File', description: 'Test Description', @@ -31,56 +38,54 @@ describe('FileMetadataComponent', () => { language: 'en', }; - beforeEach(() => { - customDialogService = CustomDialogServiceMock.simple(); + interface SetupOverrides extends BaseSetupOverrides { + url?: string; + dialogServiceMock?: CustomDialogServiceMockType; + } + + function setup(options: SetupOverrides = {}) { + customDialogService = options.dialogServiceMock ?? CustomDialogServiceMock.simple(); + const defaultSignals = [ + { selector: FilesSelectors.getFileCustomMetadata, value: mockFileMetadata }, + { selector: FilesSelectors.isFileMetadataLoading, value: false }, + { selector: FilesSelectors.hasWriteAccess, value: true }, + ]; TestBed.configureTestingModule({ imports: [FileMetadataComponent], providers: [ provideOSFCore(), MockProvider(CustomDialogService, customDialogService), - MockProvider(Router, RouterMockBuilder.create().withUrl('/test').build()), - MockProvider(ActivatedRoute, ActivatedRouteMock.withParams({ fileGuid: 'test-guid' }).build()), - provideMockStore({ - signals: [ - { selector: FilesSelectors.getFileCustomMetadata, value: mockFileMetadata }, - { selector: FilesSelectors.isFileMetadataLoading, value: false }, - { selector: FilesSelectors.hasWriteAccess, value: true }, - ], - }), + MockProvider( + Router, + RouterMockBuilder.create() + .withUrl(options.url ?? '/test') + .build() + ), + MockProvider( + ActivatedRoute, + ActivatedRouteMockBuilder.create() + .withParams(options.routeParams ?? { fileGuid: 'test-guid' }) + .build() + ), + provideMockStore({ signals: mergeSignalOverrides(defaultSignals, options.selectorOverrides) }), ], }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(FileMetadataComponent); component = fixture.componentInstance; fixture.detectChanges(); - }); + } it('should create', () => { + setup(); expect(component).toBeTruthy(); - }); - - it('should initialize with correct properties', () => { - expect(component.fileMetadata).toBeDefined(); - expect(component.isLoading).toBeDefined(); - expect(component.hasWriteAccess).toBeDefined(); - expect(component.languageCodes).toBe(LANGUAGE_CODES); expect(component.metadataFields).toBe(FileMetadataFields); }); - it('should get file metadata from store', () => { - expect(component.fileMetadata()).toEqual(mockFileMetadata); - }); - - it('should get loading state from store', () => { - expect(component.isLoading()).toBe(false); - }); - - it('should get write access from store', () => { - expect(component.hasWriteAccess()).toBe(true); - }); - - it('should not set file metadata when file ID is not available', () => { + it('should dispatch SetFileMetadata when file id exists', () => { + setup(); const formValues: PatchFileMetadata = { title: 'Updated Title', description: 'Updated Description', @@ -88,16 +93,30 @@ describe('FileMetadataComponent', () => { language: 'fr', }; - expect(() => component.setFileMetadata(formValues)).not.toThrow(); + component.setFileMetadata(formValues); + + expect(store.dispatch).toHaveBeenCalledWith(new SetFileMetadata(formValues, mockFileMetadata.id)); }); - it('should get language name from language codes', () => { - expect(component.getLanguageName('en')).toBe('en'); - expect(component.getLanguageName('fr')).toBe('fr'); - expect(component.getLanguageName('unknown')).toBe('unknown'); + it('should not dispatch SetFileMetadata when file id is missing', () => { + setup({ + selectorOverrides: [{ selector: FilesSelectors.getFileCustomMetadata, value: { ...mockFileMetadata, id: '' } }], + }); + + (store.dispatch as Mock).mockClear(); + + component.setFileMetadata({ + title: 'Updated', + description: 'Description', + resource_type_general: 'Software', + language: 'fr', + }); + + expect(store.dispatch).not.toHaveBeenCalled(); }); - it('should open edit dialog when openEditFileMetadataDialog is called', () => { + it('should open edit dialog', () => { + setup(); component.openEditFileMetadataDialog(); expect(customDialogService.open).toHaveBeenCalledWith(expect.any(Function), { @@ -107,12 +126,72 @@ describe('FileMetadataComponent', () => { }); }); - it('should have hasViewOnly computed property', () => { - expect(component.hasViewOnly).toBeDefined(); - expect(typeof component.hasViewOnly()).toBe('boolean'); + it('should set hasViewOnly from url', () => { + setup({ url: '/test?view_only=abc' }); + expect(component.hasViewOnly).toBe(true); + }); + + it('should open metadata url when file guid exists', () => { + setup({ routeParams: { fileGuid: 'guid-123' } }); + const focus = vi.fn(); + const openSpy = vi.spyOn(window, 'open').mockReturnValue({ focus } as unknown as Window); + + component.downloadFileMetadata(); + + expect(openSpy).toHaveBeenCalledWith(expect.stringMatching(/\/metadata\/guid-123$/)); + expect(focus).toHaveBeenCalled(); + }); + + it('should not open metadata url when file guid is missing', () => { + setup({ routeParams: {} }); + const openSpy = vi.spyOn(window, 'open').mockReturnValue(null); + + component.downloadFileMetadata(); + + expect(openSpy).not.toHaveBeenCalled(); }); - it('should have fileGuid signal', () => { - expect(component.fileGuid).toBeDefined(); + it('should call setFileMetadata when edit dialog closes with metadata', () => { + const metadataChange$ = new Subject(); + customDialogService = CustomDialogServiceMock.create() + .withOpen( + vi.fn().mockReturnValue({ + onClose: metadataChange$, + close: vi.fn(), + }) + ) + .build(); + setup({ dialogServiceMock: customDialogService }); + const setFileMetadataSpy = vi.spyOn(component, 'setFileMetadata'); + const formValues: PatchFileMetadata = { + title: 'Edited', + description: 'Edited desc', + resource_type_general: 'Dataset', + language: 'en', + }; + + component.openEditFileMetadataDialog(); + metadataChange$.next(formValues); + + expect(setFileMetadataSpy).toHaveBeenCalledWith(formValues); + }); + + it('should not call setFileMetadata when edit dialog closes with empty value', () => { + const metadataChange$ = new Subject(); + customDialogService = CustomDialogServiceMock.create() + .withOpen( + vi.fn().mockReturnValue({ + onClose: metadataChange$, + close: vi.fn(), + }) + ) + .build(); + setup({ dialogServiceMock: customDialogService }); + const setFileMetadataSpy = vi.spyOn(component, 'setFileMetadata'); + + component.openEditFileMetadataDialog(); + metadataChange$.next(null); + + expect(setFileMetadataSpy).not.toHaveBeenCalled(); }); }); diff --git a/src/app/features/files/components/file-metadata/file-metadata.component.ts b/src/app/features/files/components/file-metadata/file-metadata.component.ts index 3838dc86a..842bda3a4 100644 --- a/src/app/features/files/components/file-metadata/file-metadata.component.ts +++ b/src/app/features/files/components/file-metadata/file-metadata.component.ts @@ -7,24 +7,23 @@ import { Skeleton } from 'primeng/skeleton'; import { filter, map } from 'rxjs'; -import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; +import { ChangeDetectionStrategy, Component, DestroyRef, inject } from '@angular/core'; +import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router } from '@angular/router'; import { ENVIRONMENT } from '@core/provider/environment.provider'; -import { LANGUAGE_CODES } from '@osf/shared/constants/language.const'; +import { LanguageLabelPipe } from '@osf/shared/pipes/language-label.pipe'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; -import { LanguageCodeModel } from '@shared/models/language-code.model'; import { FileMetadataFields } from '../../constants'; -import { PatchFileMetadata } from '../../models'; +import { PatchFileMetadata } from '../../models/patch-file-metadata.model'; import { FilesSelectors, SetFileMetadata } from '../../store'; import { EditFileMetadataDialogComponent } from '../edit-file-metadata-dialog/edit-file-metadata-dialog.component'; @Component({ selector: 'osf-file-metadata', - imports: [Button, Skeleton, TranslatePipe], + imports: [Button, Skeleton, LanguageLabelPipe, TranslatePipe], templateUrl: './file-metadata.component.html', styleUrl: './file-metadata.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -32,31 +31,22 @@ import { EditFileMetadataDialogComponent } from '../edit-file-metadata-dialog/ed export class FileMetadataComponent { private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); + private readonly destroyRef = inject(DestroyRef); private readonly customDialogService = inject(CustomDialogService); private readonly environment = inject(ENVIRONMENT); private readonly viewOnlyService = inject(ViewOnlyLinkHelperService); private readonly actions = createDispatchMap({ setFileMetadata: SetFileMetadata }); - fileMetadata = select(FilesSelectors.getFileCustomMetadata); - isLoading = select(FilesSelectors.isFileMetadataLoading); - hasWriteAccess = select(FilesSelectors.hasWriteAccess); + readonly fileMetadata = select(FilesSelectors.getFileCustomMetadata); + readonly isLoading = select(FilesSelectors.isFileMetadataLoading); + readonly hasWriteAccess = select(FilesSelectors.hasWriteAccess); - hasViewOnly = computed(() => this.viewOnlyService.hasViewOnlyParam(this.router)); - - readonly languageCodes = LANGUAGE_CODES; + readonly hasViewOnly = this.viewOnlyService.hasViewOnlyParam(this.router); readonly fileGuid = toSignal(this.route.params.pipe(map((params) => params['fileGuid']))); - metadataFields = FileMetadataFields; - - setFileMetadata(formValues: PatchFileMetadata) { - const fileId = this.fileMetadata()?.id; - - if (fileId) { - this.actions.setFileMetadata(formValues, fileId); - } - } + readonly metadataFields = FileMetadataFields; downloadFileMetadata(): void { if (this.fileGuid()) { @@ -64,11 +54,6 @@ export class FileMetadataComponent { } } - getLanguageName(languageCode: string): string { - const language = this.languageCodes.find((lang: LanguageCodeModel) => lang.code === languageCode); - return language ? language.name : languageCode; - } - openEditFileMetadataDialog() { this.customDialogService .open(EditFileMetadataDialogComponent, { @@ -76,7 +61,18 @@ export class FileMetadataComponent { width: '448px', data: this.fileMetadata(), }) - .onClose.pipe(filter((res: PatchFileMetadata) => !!res)) + .onClose.pipe( + filter((res: PatchFileMetadata) => !!res), + takeUntilDestroyed(this.destroyRef) + ) .subscribe((res) => this.setFileMetadata(res)); } + + setFileMetadata(formValues: PatchFileMetadata) { + const fileId = this.fileMetadata()?.id; + + if (fileId) { + this.actions.setFileMetadata(formValues, fileId); + } + } } diff --git a/src/app/features/files/components/file-resource-metadata/file-resource-metadata.component.html b/src/app/features/files/components/file-resource-metadata/file-resource-metadata.component.html index 640ad07d4..541f7af8c 100644 --- a/src/app/features/files/components/file-resource-metadata/file-resource-metadata.component.html +++ b/src/app/features/files/components/file-resource-metadata/file-resource-metadata.component.html @@ -1,3 +1,6 @@ +@let metadata = resourceMetadata(); +@let resourceContributors = contributors(); +

{{ 'files.detail.resourceMetadata.title.' + resourceType() | translate }} @@ -9,7 +12,7 @@

} @else { - @for (funder of resourceMetadata()?.funders; track $index) { + @for (funder of metadata?.funders; track funder.funderIdentifier || $index) {
@if (funder.funderName) {
@@ -23,10 +26,10 @@

{{ 'files.detail.resourceMetadata.fields.awardTitle' | translate }}

{{ funder.awardTitle }}
} - @if (funder.awardTitle) { + @if (funder.awardNumber) {

{{ 'files.detail.resourceMetadata.fields.awardNumber' | translate }}

- {{ funder.awardTitle }} + {{ funder.awardNumber }}
} @if (funder.awardUri) { @@ -41,55 +44,55 @@

{{ 'files.detail.resourceMetadata.fields.awardUri' | translate }}

{{ 'common.labels.title' | translate }}

- {{ resourceMetadata()?.title }} + {{ metadata?.title }}
- @if (resourceMetadata()?.description) { + @if (metadata?.description) {

{{ 'common.labels.description' | translate }}

- {{ resourceMetadata()?.description }} + {{ metadata?.description }}
} - @if (resourceMetadata()?.resourceTypeGeneral) { + @if (metadata?.resourceTypeGeneral) {

{{ 'files.detail.resourceMetadata.fields.resourceType' | translate }}

- {{ resourceMetadata()?.resourceTypeGeneral }} + {{ metadata?.resourceTypeGeneral }}
} - @if (resourceMetadata()?.language) { + @if (metadata?.language) {

{{ 'files.detail.resourceMetadata.fields.resourceLanguage' | translate }}

- {{ resourceMetadata()?.language }} + {{ metadata?.language }}
} - @if (resourceMetadata()?.dateCreated) { + @if (metadata?.dateCreated) {

{{ 'common.labels.dateCreated' | translate }}

- {{ resourceMetadata()?.dateCreated | date: 'MMMM d, y' }} + {{ metadata?.dateCreated | date: 'MMMM d, y' }}
} - @if (resourceMetadata()?.dateModified) { + @if (metadata?.dateModified) {

{{ 'common.labels.dateModified' | translate }}

- {{ resourceMetadata()?.dateModified | date: 'MMMM d, y' }} + {{ metadata?.dateModified | date: 'MMMM d, y' }}
} @@ -98,11 +101,11 @@

{{ 'common.labels.dateModified' | translate }}

@if (isResourceContributorsLoading()) { } @else { - @if (hasViewOnly() || contributors().length) { + @if (resourceContributors.length) {

{{ 'common.labels.contributors' | translate }}

- +
} } diff --git a/src/app/features/files/components/file-resource-metadata/file-resource-metadata.component.spec.ts b/src/app/features/files/components/file-resource-metadata/file-resource-metadata.component.spec.ts index ca1e80388..fffe0f5b0 100644 --- a/src/app/features/files/components/file-resource-metadata/file-resource-metadata.component.spec.ts +++ b/src/app/features/files/components/file-resource-metadata/file-resource-metadata.component.spec.ts @@ -4,112 +4,130 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { Router } from '@angular/router'; import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component'; +import { ResourceMetadata } from '@osf/shared/models/resource-metadata.model'; +import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; +import { MOCK_CONTRIBUTOR, MOCK_CONTRIBUTOR_WITHOUT_HISTORY } from '@testing/mocks/contributors.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; -import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; +import { BaseSetupOverrides, mergeSignalOverrides, provideMockStore } from '@testing/providers/store-provider.mock'; +import { ViewOnlyLinkHelperMock, ViewOnlyLinkHelperMockType } from '@testing/providers/view-only-link-helper.mock'; import { FilesSelectors } from '../../store'; import { FileResourceMetadataComponent } from './file-resource-metadata.component'; +interface SetupOverrides extends BaseSetupOverrides { + hasViewOnly?: boolean; +} + describe('FileResourceMetadataComponent', () => { let component: FileResourceMetadataComponent; let fixture: ComponentFixture; - let mockRouter: ReturnType; + let mockRouter: RouterMockType; + let viewOnlyService: ViewOnlyLinkHelperMockType; - const mockResourceMetadata = { - id: 'resource-123', + const mockResourceMetadata: ResourceMetadata = { title: 'Test Resource', description: 'Test Description', - dateCreated: '2023-01-01', - dateModified: '2023-01-02', + dateCreated: new Date('2023-01-01'), + dateModified: new Date('2023-01-02'), + language: 'en', + resourceTypeGeneral: 'Dataset', + identifiers: [], + funders: [], }; - const mockContributors = [ - { id: 'contrib-1', name: 'John Doe', role: 'Author' }, - { id: 'contrib-2', name: 'Jane Smith', role: 'Contributor' }, + const mockContributors = [MOCK_CONTRIBUTOR, MOCK_CONTRIBUTOR_WITHOUT_HISTORY]; + + const defaultSignals = [ + { selector: FilesSelectors.getResourceMetadata, value: mockResourceMetadata }, + { selector: FilesSelectors.getContributors, value: mockContributors }, + { selector: FilesSelectors.isResourceMetadataLoading, value: false }, + { selector: FilesSelectors.isResourceContributorsLoading, value: false }, ]; - beforeEach(() => { + function setup(overrides: SetupOverrides = {}): void { mockRouter = RouterMockBuilder.create().withUrl('/test').build(); + viewOnlyService = ViewOnlyLinkHelperMock.simple(overrides.hasViewOnly ?? false); TestBed.configureTestingModule({ imports: [FileResourceMetadataComponent, MockComponent(ContributorsListComponent)], providers: [ provideOSFCore(), MockProvider(Router, mockRouter), - provideMockStore({ - signals: [ - { selector: FilesSelectors.getResourceMetadata, value: mockResourceMetadata }, - { selector: FilesSelectors.getContributors, value: mockContributors }, - { selector: FilesSelectors.isResourceMetadataLoading, value: false }, - { selector: FilesSelectors.isResourceContributorsLoading, value: false }, - ], - }), + MockProvider(ViewOnlyLinkHelperService, viewOnlyService), + provideMockStore({ signals: mergeSignalOverrides(defaultSignals, overrides.selectorOverrides) }), ], }); fixture = TestBed.createComponent(FileResourceMetadataComponent); component = fixture.componentInstance; fixture.detectChanges(); - }); + } it('should create', () => { - expect(component).toBeTruthy(); - }); + setup(); - it('should initialize with correct properties', () => { - expect(component.resourceType).toBeDefined(); - expect(component.resourceMetadata).toBeDefined(); - expect(component.contributors).toBeDefined(); - expect(component.isResourceMetadataLoading).toBeDefined(); - expect(component.isResourceContributorsLoading).toBeDefined(); - expect(component.hasViewOnly).toBeDefined(); + expect(component).toBeTruthy(); }); it('should have default resource type', () => { + setup(); + expect(component.resourceType()).toBe('nodes'); }); it('should get resource metadata from store', () => { + setup(); + expect(component.resourceMetadata()).toEqual(mockResourceMetadata); }); it('should get contributors from store', () => { + setup(); + expect(component.contributors()).toEqual(mockContributors); }); - it('should get resource metadata loading state from store', () => { - expect(component.isResourceMetadataLoading()).toBe(false); - }); + it('should expose loading states from store selectors', () => { + setup({ + selectorOverrides: [ + { selector: FilesSelectors.isResourceMetadataLoading, value: true }, + { selector: FilesSelectors.isResourceContributorsLoading, value: true }, + ], + }); - it('should get contributors loading state from store', () => { - expect(component.isResourceContributorsLoading()).toBe(false); + expect(component.isResourceMetadataLoading()).toBe(true); + expect(component.isResourceContributorsLoading()).toBe(true); }); - it('should have hasViewOnly computed property', () => { - expect(component.hasViewOnly).toBeDefined(); - expect(typeof component.hasViewOnly()).toBe('boolean'); + it('should set hasViewOnly based on helper service', () => { + setup({ hasViewOnly: true }); + + expect(component.hasViewOnly).toBe(true); + expect(viewOnlyService.hasViewOnlyParam).toHaveBeenCalled(); + expect(viewOnlyService.hasViewOnlyParam).toHaveBeenCalledWith(expect.objectContaining({ url: '/test' })); }); it('should handle input changes', () => { + setup(); + fixture.componentRef.setInput('resourceType', 'preprints'); fixture.detectChanges(); expect(component.resourceType()).toBe('preprints'); }); - it('should handle null resource metadata', () => { - expect(component.resourceMetadata).toBeDefined(); - }); - - it('should handle empty contributors array', () => { - expect(component.contributors).toBeDefined(); - }); + it('should support missing metadata and empty contributors', () => { + setup({ + selectorOverrides: [ + { selector: FilesSelectors.getResourceMetadata, value: null }, + { selector: FilesSelectors.getContributors, value: [] }, + ], + }); - it('should handle loading states', () => { - expect(component.isResourceMetadataLoading).toBeDefined(); - expect(component.isResourceContributorsLoading).toBeDefined(); + expect(component.resourceMetadata()).toBeNull(); + expect(component.contributors()).toEqual([]); }); }); diff --git a/src/app/features/files/components/file-resource-metadata/file-resource-metadata.component.ts b/src/app/features/files/components/file-resource-metadata/file-resource-metadata.component.ts index 8a684a5da..6a56cc300 100644 --- a/src/app/features/files/components/file-resource-metadata/file-resource-metadata.component.ts +++ b/src/app/features/files/components/file-resource-metadata/file-resource-metadata.component.ts @@ -5,7 +5,7 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Skeleton } from 'primeng/skeleton'; import { DatePipe } from '@angular/common'; -import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject, input } from '@angular/core'; import { Router } from '@angular/router'; import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component'; @@ -31,5 +31,5 @@ export class FileResourceMetadataComponent { isResourceMetadataLoading = select(FilesSelectors.isResourceMetadataLoading); isResourceContributorsLoading = select(FilesSelectors.isResourceContributorsLoading); - hasViewOnly = computed(() => this.viewOnlyService.hasViewOnlyParam(this.router)); + hasViewOnly = this.viewOnlyService.hasViewOnlyParam(this.router); } diff --git a/src/app/features/files/components/file-revisions/file-revisions.component.html b/src/app/features/files/components/file-revisions/file-revisions.component.html index 36788aba5..f6c04dd6a 100644 --- a/src/app/features/files/components/file-revisions/file-revisions.component.html +++ b/src/app/features/files/components/file-revisions/file-revisions.component.html @@ -1,7 +1,5 @@
-
-

{{ 'files.detail.revisions.title' | translate }}

-
+

{{ 'files.detail.revisions.title' | translate }}

@if (isLoading()) { diff --git a/src/app/features/files/components/file-revisions/file-revisions.component.spec.ts b/src/app/features/files/components/file-revisions/file-revisions.component.spec.ts index 94dbbb6c0..a55dd90b7 100644 --- a/src/app/features/files/components/file-revisions/file-revisions.component.spec.ts +++ b/src/app/features/files/components/file-revisions/file-revisions.component.spec.ts @@ -8,11 +8,27 @@ import { StopPropagationDirective } from '@osf/shared/directives/stop-propagatio import { provideOSFCore } from '@testing/osf.testing.provider'; +import { OsfFileRevision } from '../../models/file-revisions.model'; + import { FileRevisionsComponent } from './file-revisions.component'; describe('FileRevisionsComponent', () => { let component: FileRevisionsComponent; let fixture: ComponentFixture; + const revisions: OsfFileRevision[] = [ + { + version: '1', + dateTime: new Date('2026-01-01T00:00:00Z'), + downloads: 2, + hashes: { md5: 'md5-1', sha256: 'sha256-1' }, + }, + { + version: '2', + dateTime: new Date('2026-01-02T00:00:00Z'), + downloads: 4, + hashes: { md5: 'md5-2', sha256: 'sha256-2' }, + }, + ]; beforeEach(() => { TestBed.configureTestingModule({ @@ -33,82 +49,50 @@ describe('FileRevisionsComponent', () => { expect(component).toBeTruthy(); }); - it('should initialize with default values', () => { + it('should initialize with default inputs', () => { expect(component.fileRevisions()).toBeUndefined(); expect(component.isLoading()).toBe(false); }); - it('should handle loading state input', () => { - fixture.componentRef.setInput('isLoading', true); + it('should update file revisions input', () => { + fixture.componentRef.setInput('fileRevisions', revisions); fixture.detectChanges(); - expect(component.isLoading()).toBe(true); + expect(component.fileRevisions()).toEqual(revisions); }); - it('should emit openRevision event when onOpenRevision is called', () => { - const openRevisionSpy = vi.spyOn(component.openRevision, 'emit'); - - component.onOpenRevision('1'); - - expect(openRevisionSpy).toHaveBeenCalledWith('1'); - }); - - it('should emit downloadRevision event when onDownloadRevision is called', () => { - const downloadRevisionSpy = vi.spyOn(component.downloadRevision, 'emit'); - - component.onDownloadRevision('2'); + it('should support null file revisions input', () => { + fixture.componentRef.setInput('fileRevisions', null); + fixture.detectChanges(); - expect(downloadRevisionSpy).toHaveBeenCalledWith('2'); + expect(component.fileRevisions()).toBeNull(); }); - it('should handle empty file revisions array', () => { - fixture.componentRef.setInput('fileRevisions', []); + it('should handle loading state changes', () => { + fixture.componentRef.setInput('isLoading', true); fixture.detectChanges(); - expect(component.fileRevisions()).toEqual([]); - }); + expect(component.isLoading()).toBe(true); - it('should handle null file revisions', () => { - fixture.componentRef.setInput('fileRevisions', null); + fixture.componentRef.setInput('isLoading', false); fixture.detectChanges(); - expect(component.fileRevisions()).toBeNull(); - }); - - it('should have all required outputs defined', () => { - expect(component.downloadRevision).toBeDefined(); - expect(component.openRevision).toBeDefined(); + expect(component.isLoading()).toBe(false); }); - it('should handle multiple revision events', () => { + it('should emit open revision event', () => { const openRevisionSpy = vi.spyOn(component.openRevision, 'emit'); - const downloadRevisionSpy = vi.spyOn(component.downloadRevision, 'emit'); component.onOpenRevision('1'); - component.onDownloadRevision('1'); - component.onOpenRevision('2'); - component.onDownloadRevision('2'); - expect(openRevisionSpy).toHaveBeenCalledTimes(2); expect(openRevisionSpy).toHaveBeenCalledWith('1'); - expect(openRevisionSpy).toHaveBeenCalledWith('2'); - - expect(downloadRevisionSpy).toHaveBeenCalledTimes(2); - expect(downloadRevisionSpy).toHaveBeenCalledWith('1'); - expect(downloadRevisionSpy).toHaveBeenCalledWith('2'); }); - it('should handle loading state changes', () => { - expect(component.isLoading()).toBe(false); - - fixture.componentRef.setInput('isLoading', true); - fixture.detectChanges(); - - expect(component.isLoading()).toBe(true); + it('should emit download revision event', () => { + const downloadRevisionSpy = vi.spyOn(component.downloadRevision, 'emit'); - fixture.componentRef.setInput('isLoading', false); - fixture.detectChanges(); + component.onDownloadRevision('2'); - expect(component.isLoading()).toBe(false); + expect(downloadRevisionSpy).toHaveBeenCalledWith('2'); }); }); diff --git a/src/app/features/files/components/file-revisions/file-revisions.component.ts b/src/app/features/files/components/file-revisions/file-revisions.component.ts index a360dd71e..fcb0a8d9b 100644 --- a/src/app/features/files/components/file-revisions/file-revisions.component.ts +++ b/src/app/features/files/components/file-revisions/file-revisions.component.ts @@ -11,7 +11,7 @@ import { CopyButtonComponent } from '@osf/shared/components/copy-button/copy-but import { InfoIconComponent } from '@osf/shared/components/info-icon/info-icon.component'; import { StopPropagationDirective } from '@osf/shared/directives/stop-propagation.directive'; -import { OsfFileRevision } from '../../models'; +import { OsfFileRevision } from '../../models/file-revisions.model'; @Component({ selector: 'osf-file-revisions', diff --git a/src/app/shared/components/file-select-destination/file-select-destination.component.html b/src/app/features/files/components/file-select-destination/file-select-destination.component.html similarity index 94% rename from src/app/shared/components/file-select-destination/file-select-destination.component.html rename to src/app/features/files/components/file-select-destination/file-select-destination.component.html index d75d5f479..a35e7a1e8 100644 --- a/src/app/shared/components/file-select-destination/file-select-destination.component.html +++ b/src/app/features/files/components/file-select-destination/file-select-destination.component.html @@ -4,11 +4,11 @@ [selectedValue]="selectedProject()?.value" (changeValue)="onChangeProject($event)" [fullWidth]="true" - [disabled]="isStorageLoading" - [loading]="isLoading" + [disabled]="isStorageLoading()" + [loading]="isLoading()" />
- @if (isStorageLoading) { + @if (isStorageLoading()) {
diff --git a/src/app/shared/components/file-select-destination/file-select-destination.component.scss b/src/app/features/files/components/file-select-destination/file-select-destination.component.scss similarity index 100% rename from src/app/shared/components/file-select-destination/file-select-destination.component.scss rename to src/app/features/files/components/file-select-destination/file-select-destination.component.scss diff --git a/src/app/features/files/components/file-select-destination/file-select-destination.component.spec.ts b/src/app/features/files/components/file-select-destination/file-select-destination.component.spec.ts new file mode 100644 index 000000000..f762f5a26 --- /dev/null +++ b/src/app/features/files/components/file-select-destination/file-select-destination.component.spec.ts @@ -0,0 +1,158 @@ +import { Store } from '@ngxs/store'; + +import { MockComponent } from 'ng-mocks'; + +import { Mock } from 'vitest'; + +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SelectComponent } from '@osf/shared/components/select/select.component'; +import { SupportedFeature } from '@osf/shared/enums/addon-supported-features.enum'; +import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; +import { FileFolderModel } from '@osf/shared/models/files/file-folder.model'; +import { NodeShortInfoModel } from '@osf/shared/models/nodes/node-with-children.model'; + +import { MOCK_CONFIGURED_ADDON } from '@testing/mocks/configured-addon.mock'; +import { OSF_FILE_MOCK } from '@testing/mocks/osf-file.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { + BaseSetupOverrides, + mergeSignalOverrides, + provideMockStore, + SignalOverride, +} from '@testing/providers/store-provider.mock'; + +import { FileProvider } from '../../constants/file-provider.constants'; +import { + FilesSelectors, + GetMoveDialogConfiguredStorageAddons, + GetMoveDialogRootFolders, + GetStorageSupportedFeatures, +} from '../../store'; + +import { FileSelectDestinationComponent } from './file-select-destination.component'; + +interface SetupOverrides extends BaseSetupOverrides { + projectId?: string; + storageProvider?: string; + components?: NodeShortInfoModel[]; + areComponentsLoading?: boolean; +} + +describe('FileSelectDestinationComponent', () => { + let component: FileSelectDestinationComponent; + let fixture: ComponentFixture; + let store: Store; + + const rootFolder: FileFolderModel = { + ...OSF_FILE_MOCK, + id: 'root-1', + name: 'OSF Storage', + provider: FileProvider.OsfStorage, + }; + + const components: NodeShortInfoModel[] = [ + { id: 'project-1', title: 'Project 1', isPublic: true, permissions: [UserPermissions.Write] }, + { + id: 'component-1', + title: 'Component 1', + isPublic: true, + permissions: [UserPermissions.Write], + parentId: 'project-1', + }, + { id: 'readonly-1', title: 'Readonly', isPublic: true, permissions: [UserPermissions.Read], parentId: 'project-1' }, + ]; + + function setup(overrides: SetupOverrides = {}) { + const defaultSignals: SignalOverride[] = [ + { selector: FilesSelectors.getMoveDialogRootFolders, value: [rootFolder] }, + { selector: FilesSelectors.isMoveDialogRootFoldersLoading, value: false }, + { + selector: FilesSelectors.getMoveDialogConfiguredStorageAddons, + value: [{ ...MOCK_CONFIGURED_ADDON, id: 'addon-1', externalServiceName: FileProvider.OsfStorage }], + }, + { selector: FilesSelectors.isMoveDialogConfiguredStorageAddonsLoading, value: false }, + { selector: FilesSelectors.isMoveDialogFilesLoading, value: false }, + { + selector: FilesSelectors.getStorageSupportedFeatures, + value: { [FileProvider.OsfStorage]: [SupportedFeature.AddUpdateFiles] }, + }, + ]; + + TestBed.configureTestingModule({ + imports: [FileSelectDestinationComponent, MockComponent(SelectComponent)], + providers: [ + provideOSFCore(), + provideMockStore({ signals: mergeSignalOverrides(defaultSignals, overrides.selectorOverrides) }), + ], + }); + + store = TestBed.inject(Store); + fixture = TestBed.createComponent(FileSelectDestinationComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('projectId', overrides.projectId ?? 'project-1'); + fixture.componentRef.setInput('storageProvider', overrides.storageProvider ?? FileProvider.OsfStorage); + fixture.componentRef.setInput('components', overrides.components ?? components); + fixture.componentRef.setInput('areComponentsLoading', overrides.areComponentsLoading ?? false); + fixture.detectChanges(); + } + + it('should create', () => { + setup(); + expect(component).toBeTruthy(); + }); + + it('should load storage addons on init and request supported features', () => { + setup(); + const calls = (store.dispatch as Mock).mock.calls.map((c) => c[0]); + + expect(calls).toContainEqual(new GetMoveDialogRootFolders('project-1', ResourceType.Project)); + expect(calls).toContainEqual(new GetMoveDialogConfiguredStorageAddons('project-1')); + expect(calls).toContainEqual(new GetStorageSupportedFeatures('addon-1', FileProvider.OsfStorage)); + }); + + it('should emit project selection and reload storage addons on project change', () => { + setup(); + const emitSpy = vi.spyOn(component.selectProject, 'emit'); + (store.dispatch as Mock).mockClear(); + + component.onChangeProject('project-2'); + + expect(emitSpy).toHaveBeenCalledWith('project-2'); + expect(store.dispatch).toHaveBeenCalledWith(new GetMoveDialogRootFolders('project-2', ResourceType.Project)); + expect(store.dispatch).toHaveBeenCalledWith(new GetMoveDialogConfiguredStorageAddons('project-2')); + }); + + it('should update current root folder and emit storage selection on storage change', () => { + setup(); + const emitSpy = vi.spyOn(component.selectStorage, 'emit'); + + component.onStorageChange('root-1'); + + expect(component.currentRootFolder()?.folder.id).toBe('root-1'); + expect(emitSpy).toHaveBeenCalled(); + }); + + it('should include only write-access nodes in options', () => { + setup(); + const values = component.options().map((o) => o.value); + + expect(values).toContain('project-1'); + expect(values).toContain('component-1'); + expect(values).not.toContain('readonly-1'); + }); + + it('should detect add update feature by provider', () => { + setup(); + + expect(component.hasAddUpdateFeature(FileProvider.OsfStorage)).toBe(true); + expect(component.hasAddUpdateFeature('dropbox')).toBe(false); + }); + + it('should expose loading state when component loading is true', () => { + setup({ areComponentsLoading: true }); + + expect(component.isLoading()).toBe(true); + }); +}); diff --git a/src/app/shared/components/file-select-destination/file-select-destination.component.ts b/src/app/features/files/components/file-select-destination/file-select-destination.component.ts similarity index 58% rename from src/app/shared/components/file-select-destination/file-select-destination.component.ts rename to src/app/features/files/components/file-select-destination/file-select-destination.component.ts index b88134e4c..07d4356a5 100644 --- a/src/app/shared/components/file-select-destination/file-select-destination.component.ts +++ b/src/app/features/files/components/file-select-destination/file-select-destination.component.ts @@ -1,6 +1,6 @@ import { createDispatchMap, select } from '@ngxs/store'; -import { TranslatePipe } from '@ngx-translate/core'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { Skeleton } from 'primeng/skeleton'; @@ -23,8 +23,16 @@ import { } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { ENVIRONMENT } from '@core/provider/environment.provider'; -import { FileProvider } from '@osf/features/files/constants'; +import { SelectComponent } from '@osf/shared/components/select/select.component'; +import { SupportedFeature } from '@osf/shared/enums/addon-supported-features.enum'; +import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; +import { buildProjectPathOptions } from '@osf/shared/helpers/project-path-options.helper'; +import { mapRootFoldersToStorageLabels } from '@osf/shared/helpers/storage-addon-options.helper'; +import { Primitive } from '@osf/shared/helpers/types.helper'; +import { FileLabelModel } from '@osf/shared/models/files/file-label.model'; +import { NodeShortInfoModel } from '@osf/shared/models/nodes/node-with-children.model'; + import { FilesSelectors, GetMoveDialogConfiguredStorageAddons, @@ -32,16 +40,7 @@ import { GetStorageSupportedFeatures, SetCurrentProvider, SetMoveDialogCurrentFolder, -} from '@osf/features/files/store'; -import { SupportedFeature } from '@osf/shared/enums/addon-supported-features.enum'; -import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; -import { Primitive } from '@osf/shared/helpers/types.helper'; -import { ConfiguredAddonModel } from '@osf/shared/models/addons/configured-addon.model'; -import { FileLabelModel } from '@osf/shared/models/files/file-label.model'; -import { NodeShortInfoModel } from '@osf/shared/models/nodes/node-with-children.model'; -import { SelectOption } from '@osf/shared/models/select-option.model'; - -import { SelectComponent } from '../select/select.component'; +} from '../../store'; @Component({ selector: 'osf-file-select-destination', @@ -51,15 +50,16 @@ import { SelectComponent } from '../select/select.component'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class FileSelectDestinationComponent implements OnInit { - projectId = input.required(); - storageProvider = input.required(); - components = input.required(); - areComponentsLoading = input(false); - selectProject = output(); - selectStorage = output(); - - private readonly environment = inject(ENVIRONMENT); + readonly projectId = input.required(); + readonly storageProvider = input.required(); + readonly components = input.required(); + readonly areComponentsLoading = input(false); + + readonly selectProject = output(); + readonly selectStorage = output(); + private readonly destroyRef = inject(DestroyRef); + private readonly translateService = inject(TranslateService); readonly rootFolders = select(FilesSelectors.getMoveDialogRootFolders); readonly isRootFoldersLoading = select(FilesSelectors.isMoveDialogRootFoldersLoading); @@ -67,7 +67,6 @@ export class FileSelectDestinationComponent implements OnInit { readonly isConfiguredStorageAddonsLoading = select(FilesSelectors.isMoveDialogConfiguredStorageAddonsLoading); readonly isFilesLoading = select(FilesSelectors.isMoveDialogFilesLoading); readonly supportedFeatures = select(FilesSelectors.getStorageSupportedFeatures); - readonly currentFolder = select(FilesSelectors.getMoveDialogCurrentFolder); private readonly actions = createDispatchMap({ setCurrentFolder: SetMoveDialogCurrentFolder, @@ -77,39 +76,24 @@ export class FileSelectDestinationComponent implements OnInit { setCurrentProvider: SetCurrentProvider, }); - readonly osfStorageLabel = 'OSF Storage'; initialSetup = true; currentRootFolder = model(null); - selectedProject = computed(() => this.options().find((c) => c.value === this.projectId()) || null); - get isStorageLoading() { - return this.isConfiguredStorageAddonsLoading() || this.isRootFoldersLoading(); - } + readonly selectedProject = computed(() => this.options().find((c) => c.value === this.projectId()) || null); - get isLoading() { - return this.isStorageLoading || this.isFilesLoading() || this.areComponentsLoading(); - } + readonly isStorageLoading = computed(() => this.isConfiguredStorageAddonsLoading() || this.isRootFoldersLoading()); + readonly isLoading = computed(() => this.isStorageLoading() || this.isFilesLoading() || this.areComponentsLoading()); readonly options = computed(() => { - const components = this.components().filter((c) => this.getHasWriteAccess(c)); - return [...this.buildOptions(components)]; + const nodes = this.components().filter((c) => c.permissions.includes(UserPermissions.Write)); + return buildProjectPathOptions({ nodes, rootProjectId: this.projectId() }); }); readonly storageAddons = computed(() => { - const rootFolders = this.rootFolders(); - const addons = this.configuredStorageAddons(); - if (rootFolders && addons) { - return rootFolders.map((folder) => ({ - label: this.getAddonName(addons, folder.provider), - folder: folder, - })); - } - return []; + const osfLabel = this.translateService.instant('files.storageLocation'); + return mapRootFoldersToStorageLabels(this.rootFolders(), this.configuredStorageAddons(), osfLabel); }); - private getHasWriteAccess = (project: NodeShortInfoModel): boolean => - !!project?.permissions.includes(UserPermissions.Write); - constructor() { effect(() => { const currentRootFolder = this.currentRootFolder(); @@ -131,9 +115,6 @@ export class FileSelectDestinationComponent implements OnInit { const rootFolder = this.storageAddons().find((option) => option.folder.id === value); if (rootFolder) { this.currentRootFolder.set(rootFolder); - if (rootFolder.folder.provider) { - this.actions.setCurrentProvider(rootFolder.folder.provider); - } } this.selectStorage.emit(); } @@ -149,13 +130,9 @@ export class FileSelectDestinationComponent implements OnInit { } private getStorageAddons(projectId: string) { - const resourcePath = 'nodes'; - const folderLink = `${this.environment.apiDomainUrl}/v2/${resourcePath}/${projectId}/files/`; - const iriLink = `${this.environment.webUrl}/${projectId}`; - forkJoin({ - rootFolders: this.actions.getRootFolders(folderLink), - addons: this.actions.getConfiguredStorageAddons(iriLink), + rootFolders: this.actions.getRootFolders(projectId, ResourceType.Project), + addons: this.actions.getConfiguredStorageAddons(projectId), }) .pipe( takeUntilDestroyed(this.destroyRef), @@ -184,36 +161,4 @@ export class FileSelectDestinationComponent implements OnInit { } }); } - - private getAddonName(addons: ConfiguredAddonModel[], provider: string): string { - if (provider === FileProvider.OsfStorage) { - return this.osfStorageLabel; - } else { - return addons.find((addon) => addon.externalServiceName === provider)?.displayName ?? ''; - } - } - - private buildOptions(nodes: NodeShortInfoModel[] = [], parentPath = '..'): SelectOption[] { - return nodes.reduce((acc, node) => { - const pathParts: string[] = []; - - let current: NodeShortInfoModel | undefined = node; - while (current) { - pathParts.unshift(current.title ?? ''); - current = nodes.find((n) => n.id === current?.parentId); - } - - const isRootProject = node.id === this.projectId(); - const basePath = isRootProject ? '' : parentPath; - - const fullPath = basePath ? `${basePath}/${pathParts.join('/')}` : pathParts.join('/'); - - acc.push({ - value: node.id, - label: fullPath, - }); - - return acc; - }, []); - } } diff --git a/src/app/features/files/components/files-selection-actions/files-selection-actions.component.html b/src/app/features/files/components/files-selection-actions/files-selection-actions.component.html index a5c2a5ffc..c29b33428 100644 --- a/src/app/features/files/components/files-selection-actions/files-selection-actions.component.html +++ b/src/app/features/files/components/files-selection-actions/files-selection-actions.component.html @@ -1,8 +1,8 @@ -@if (selectedFiles().length > 0) { +@if (selectedFilesCount() > 0) {
- {{ selectedFiles().length }} {{ 'files.selectedFiles' | translate }} + {{ selectedFilesCount() }} {{ 'files.selectedFiles' | translate }} { expect(component).toBeTruthy(); }); - it('should initialize with default values', () => { - expect(component.selectedFiles()).toEqual([]); + it('should initialize with default inputs', () => { + expect(component.selectedFilesCount()).toBe(0); expect(component.canUpdateFiles()).toBe(true); expect(component.hasViewOnly()).toBe(false); }); - it('should handle canUpdateFiles input', () => { + it('should update selected files count input', () => { + fixture.componentRef.setInput('selectedFilesCount', 3); + fixture.detectChanges(); + + expect(component.selectedFilesCount()).toBe(3); + }); + + it('should update canUpdateFiles input', () => { fixture.componentRef.setInput('canUpdateFiles', false); fixture.detectChanges(); expect(component.canUpdateFiles()).toBe(false); }); - it('should handle hasViewOnly input', () => { + it('should update hasViewOnly input', () => { fixture.componentRef.setInput('hasViewOnly', true); fixture.detectChanges(); expect(component.hasViewOnly()).toBe(true); }); - it('should emit copySelected event', () => { + it('should emit copySelected output', () => { const copySelectedSpy = vi.spyOn(component.copySelected, 'emit'); component.copySelected.emit(); @@ -51,7 +58,7 @@ describe('FilesSelectionActionsComponent', () => { expect(copySelectedSpy).toHaveBeenCalled(); }); - it('should emit moveSelected event', () => { + it('should emit moveSelected output', () => { const moveSelectedSpy = vi.spyOn(component.moveSelected, 'emit'); component.moveSelected.emit(); @@ -59,7 +66,7 @@ describe('FilesSelectionActionsComponent', () => { expect(moveSelectedSpy).toHaveBeenCalled(); }); - it('should emit deleteSelected event', () => { + it('should emit deleteSelected output', () => { const deleteSelectedSpy = vi.spyOn(component.deleteSelected, 'emit'); component.deleteSelected.emit(); @@ -67,25 +74,11 @@ describe('FilesSelectionActionsComponent', () => { expect(deleteSelectedSpy).toHaveBeenCalled(); }); - it('should emit clearSelection event', () => { + it('should emit clearSelection output', () => { const clearSelectionSpy = vi.spyOn(component.clearSelection, 'emit'); component.clearSelection.emit(); expect(clearSelectionSpy).toHaveBeenCalled(); }); - - it('should have all required outputs defined', () => { - expect(component.copySelected).toBeDefined(); - expect(component.moveSelected).toBeDefined(); - expect(component.deleteSelected).toBeDefined(); - expect(component.clearSelection).toBeDefined(); - }); - - it('should handle empty selected files array', () => { - fixture.componentRef.setInput('selectedFiles', []); - fixture.detectChanges(); - - expect(component.selectedFiles()).toEqual([]); - }); }); diff --git a/src/app/features/files/components/files-selection-actions/files-selection-actions.component.ts b/src/app/features/files/components/files-selection-actions/files-selection-actions.component.ts index 8077aa3f3..54f281165 100644 --- a/src/app/features/files/components/files-selection-actions/files-selection-actions.component.ts +++ b/src/app/features/files/components/files-selection-actions/files-selection-actions.component.ts @@ -4,8 +4,6 @@ import { Button } from 'primeng/button'; import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; -import { FileModel } from '@osf/shared/models/files/file.model'; - @Component({ selector: 'osf-files-selection-actions', imports: [Button, TranslatePipe], @@ -14,7 +12,7 @@ import { FileModel } from '@osf/shared/models/files/file.model'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class FilesSelectionActionsComponent { - selectedFiles = input([]); + selectedFilesCount = input(0); canUpdateFiles = input(true); hasViewOnly = input(false); copySelected = output(); diff --git a/src/app/features/files/components/files-tree-explorer/files-tree-explorer.component.html b/src/app/features/files/components/files-tree-explorer/files-tree-explorer.component.html new file mode 100644 index 000000000..51edd60a1 --- /dev/null +++ b/src/app/features/files/components/files-tree-explorer/files-tree-explorer.component.html @@ -0,0 +1,58 @@ + + @if (isLoading() && !isLoadingMore()) { +
+ +
+ } @else { +
+ + + + + + +
+ @if (!canUpload()) { +

{{ 'files.emptyState' | translate }}

+ } @else { +
+ +

{{ 'files.dropText' | translate }}

+
+ } +
+
+
+
+ } +
+ + + + diff --git a/src/app/features/files/components/files-tree-explorer/files-tree-explorer.component.scss b/src/app/features/files/components/files-tree-explorer/files-tree-explorer.component.scss new file mode 100644 index 000000000..190e70847 --- /dev/null +++ b/src/app/features/files/components/files-tree-explorer/files-tree-explorer.component.scss @@ -0,0 +1,13 @@ +:host { + display: flex; + flex-direction: column; + min-height: 11.25rem; +} + +.files-table { + border: 1px solid var(--grey-2); + border-radius: 0.5rem; + min-width: 100%; + min-height: 11.25rem; + overflow-x: auto; +} diff --git a/src/app/features/files/components/files-tree-explorer/files-tree-explorer.component.spec.ts b/src/app/features/files/components/files-tree-explorer/files-tree-explorer.component.spec.ts new file mode 100644 index 000000000..b4d45a2c8 --- /dev/null +++ b/src/app/features/files/components/files-tree-explorer/files-tree-explorer.component.spec.ts @@ -0,0 +1,276 @@ +import { MockComponents, MockProvider } from 'ng-mocks'; + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; + +import { FileProvider } from '@osf/features/files/constants'; +import { FileMenuComponent } from '@osf/shared/components/file-menu/file-menu.component'; +import { FilesDropZoneComponent } from '@osf/shared/components/files-drop-zone/files-drop-zone.component'; +import { FilesTreeRowComponent } from '@osf/shared/components/files-tree-row/files-tree-row.component'; +import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; +import { FileKind } from '@osf/shared/enums/file-kind.enum'; +import { FileMenuType } from '@osf/shared/enums/file-menu-type.enum'; +import { CurrentResourceType } from '@osf/shared/enums/resource-type.enum'; +import { FileFolderModel } from '@osf/shared/models/files/file-folder.model'; +import { FileLabelModel } from '@osf/shared/models/files/file-label.model'; +import { DataciteService } from '@osf/shared/services/datacite/datacite.service'; +import { FilesService } from '@osf/shared/services/files.service'; +import { FilesShareEmbedService } from '@osf/shared/services/files-share-embed.service'; +import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; + +import { FileModelMock } from '@testing/mocks/file.model.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { DataciteServiceMock, DataciteServiceMockType } from '@testing/providers/datacite.service.mock'; +import { FilesServiceMock, FilesServiceMockType } from '@testing/providers/files-service.mock'; +import { + FilesShareEmbedServiceMock, + FilesShareEmbedServiceMockType, +} from '@testing/providers/files-share-embed-provider.mock'; +import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; +import { ViewOnlyLinkHelperMock, ViewOnlyLinkHelperMockType } from '@testing/providers/view-only-link-helper.mock'; + +import { MoveCopyAction } from '../../enums/move-copy-action.enum'; + +import { FilesTreeExplorerComponent } from './files-tree-explorer.component'; + +describe('FilesTreeExplorerComponent', () => { + let component: FilesTreeExplorerComponent; + let fixture: ComponentFixture; + let routerMock: RouterMockType; + let filesService: FilesServiceMockType; + let dataciteService: DataciteServiceMockType; + let filesShareEmbedService: FilesShareEmbedServiceMockType; + let viewOnlyHelper: ViewOnlyLinkHelperMockType; + + const currentFolder: FileFolderModel = { + id: 'folder-1', + kind: FileKind.Folder, + name: 'Folder 1', + node: 'node-1', + path: '/folder-1', + provider: FileProvider.OsfStorage, + links: { + newFolder: '/new-folder', + storageAddons: '/storage-addons', + upload: '/upload', + filesLink: '/files-link', + download: '/download', + }, + }; + + const storage: FileLabelModel = { label: 'OSF Storage', folder: currentFolder }; + + function setup() { + routerMock = RouterMockBuilder.create().withUrl('/node-1/files').build(); + filesService = FilesServiceMock.simple(); + dataciteService = DataciteServiceMock.simple(); + viewOnlyHelper = ViewOnlyLinkHelperMock.simple(false); + filesShareEmbedService = FilesShareEmbedServiceMock.simple(); + + TestBed.configureTestingModule({ + imports: [ + FilesTreeExplorerComponent, + ...MockComponents(LoadingSpinnerComponent, FilesDropZoneComponent, FilesTreeRowComponent, FileMenuComponent), + ], + providers: [ + provideOSFCore(), + MockProvider(Router, routerMock), + MockProvider(FilesService, filesService), + MockProvider(DataciteService, dataciteService), + MockProvider(FilesShareEmbedService, filesShareEmbedService), + MockProvider(ViewOnlyLinkHelperService, viewOnlyHelper), + ], + }); + + fixture = TestBed.createComponent(FilesTreeExplorerComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('files', []); + fixture.componentRef.setInput('currentFolder', currentFolder); + fixture.componentRef.setInput('storage', storage); + fixture.componentRef.setInput('resourceId', 'node-1'); + fixture.componentRef.setInput('resourceType', CurrentResourceType.Projects); + fixture.componentRef.setInput('totalCount', 0); + fixture.detectChanges(); + } + + it('should create', () => { + setup(); + expect(component).toBeTruthy(); + }); + + it('should emit dropped files', () => { + setup(); + const emitSpy = vi.spyOn(component.uploadFiles, 'emit'); + const dropped = [new File(['a'], 'a.txt')]; + + component.onDropFiles(dropped); + + expect(emitSpy).toHaveBeenCalledWith(dropped); + }); + + it('should open file entry and emit fileOpened', () => { + setup(); + const file = FileModelMock.simple({ id: 'f1', kind: FileKind.File }); + const emitSpy = vi.spyOn(component.fileOpened, 'emit'); + + component.openEntry(file); + + expect(emitSpy).toHaveBeenCalledWith(file); + }); + + it('should open folder entry and emit currentFolderChanged', () => { + setup(); + const folderFile = FileModelMock.simple({ + id: 'sub', + kind: FileKind.Folder, + name: 'Sub', + path: '/folder-1/sub', + provider: FileProvider.OsfStorage, + filesLink: '/sub-files', + links: { ...FileModelMock.simple().links, upload: '/sub-upload' }, + }); + const emitSpy = vi.spyOn(component.currentFolderChanged, 'emit'); + + component.openEntry(folderFile); + + expect(component.foldersStack()).toEqual([currentFolder]); + expect(emitSpy).toHaveBeenCalledWith({ + id: 'sub', + kind: FileKind.Folder, + name: 'Sub', + node: '', + path: '/folder-1/sub', + provider: FileProvider.OsfStorage, + links: { + newFolder: '/sub-upload?kind=folder', + storageAddons: '', + upload: '/sub-upload', + filesLink: '/sub-files', + download: '/sub-upload', + }, + }); + }); + + it('should open parent folder from stack', () => { + setup(); + const emitSpy = vi.spyOn(component.currentFolderChanged, 'emit'); + const parent: FileFolderModel = { ...currentFolder, id: 'parent', name: 'Parent', path: '/parent' }; + component.foldersStack.set([parent, currentFolder]); + + component.openParentFolder(); + + expect(component.foldersStack()).toEqual([parent]); + expect(emitSpy).toHaveBeenCalledWith(currentFolder); + }); + + it('should emit loadFiles on lazy load when end reached', () => { + setup(); + const emitSpy = vi.spyOn(component.loadFiles, 'emit'); + fixture.componentRef.setInput( + 'files', + Array.from({ length: 10 }).map((_, idx) => FileModelMock.simple({ id: `f-${idx}` })) + ); + fixture.componentRef.setInput('totalCount', 25); + fixture.detectChanges(); + + component.onLazyLoad({ first: 0, last: 9 }); + + expect(emitSpy).toHaveBeenCalledWith({ link: '/files-link', page: 2 }); + }); + + it('should emit selected range with shift key and selected node', () => { + setup(); + const files = [ + FileModelMock.simple({ id: 'f1' }), + FileModelMock.simple({ id: 'f2' }), + FileModelMock.simple({ id: 'f3' }), + ]; + fixture.componentRef.setInput('files', files); + fixture.detectChanges(); + component.lastSelectedFile = files[0]; + const emitSpy = vi.spyOn(component.fileSelected, 'emit'); + + component.onNodeSelect({ + node: { data: files[2] }, + originalEvent: { shiftKey: true } as PointerEvent, + }); + + expect(emitSpy).toHaveBeenNthCalledWith(1, files[0]); + expect(emitSpy).toHaveBeenNthCalledWith(2, files[1]); + expect(emitSpy).toHaveBeenNthCalledWith(3, files[2]); + expect(component.lastSelectedFile).toBe(files[2]); + }); + + it('should emit dropMove for folder drop and select drag file if missing', () => { + setup(); + const dragFile = FileModelMock.simple({ id: 'drag' }); + const selectedFile = FileModelMock.simple({ id: 'sel' }); + const dropFolder = FileModelMock.simple({ id: 'dest', kind: FileKind.Folder }); + fixture.componentRef.setInput('selectedFiles', [selectedFile]); + fixture.detectChanges(); + const selectSpy = vi.spyOn(component.fileSelected, 'emit'); + const moveSpy = vi.spyOn(component.dropMove, 'emit'); + + component.onNodeDrop({ + dragNode: { data: dragFile }, + dropNode: { data: dropFolder }, + }); + + expect(selectSpy).toHaveBeenCalledWith(dragFile); + expect(moveSpy).toHaveBeenCalledWith({ files: [selectedFile, dragFile], destination: dropFolder }); + }); + + it('should ignore node drop when destination is not folder', () => { + setup(); + const moveSpy = vi.spyOn(component.dropMove, 'emit'); + + component.onNodeDrop({ + dragNode: { data: FileModelMock.simple({ id: 'drag' }) }, + dropNode: { data: FileModelMock.simple({ id: 'dest', kind: FileKind.File }) }, + }); + + expect(moveSpy).not.toHaveBeenCalled(); + }); + + it('should trigger move and copy menu actions', () => { + setup(); + const file = FileModelMock.simple({ id: 'f1' }); + const emitSpy = vi.spyOn(component.menuMoveCopy, 'emit'); + + component.onFileMenuAction({ value: FileMenuType.Move }, file); + component.onFileMenuAction({ value: FileMenuType.Copy }, file); + + expect(emitSpy).toHaveBeenNthCalledWith(1, { file, action: MoveCopyAction.Move }); + expect(emitSpy).toHaveBeenNthCalledWith(2, { file, action: MoveCopyAction.Copy }); + }); + + it('should download folder with resolved zip link', () => { + setup(); + const openSpy = vi.spyOn(window, 'open').mockReturnValue({ focus: vi.fn() } as unknown as Window); + + component.downloadFolder('/upload-folder'); + + expect(filesService.getFolderDownloadLink).toHaveBeenCalledWith('/upload-folder'); + expect(openSpy).toHaveBeenCalledWith('/upload-folder?zip=', '_blank'); + }); + + it('should open share link in new tab for non self target', () => { + setup(); + const file = FileModelMock.simple({ links: { ...FileModelMock.simple().links, html: '/html' } }); + filesShareEmbedService.getShareLink.mockReturnValue({ link: 'https://x.test', target: '_blank' }); + const openSpy = vi.spyOn(window, 'open').mockReturnValue({ focus: vi.fn() } as unknown as Window); + + component.onFileMenuAction({ value: FileMenuType.Share, data: { type: 'twitter' } }, file); + + expect(openSpy).toHaveBeenCalledWith('https://x.test', '_blank', 'noopener,noreferrer'); + }); + + it('should copy embed on embed menu action', () => { + setup(); + const file = FileModelMock.simple({ links: { ...FileModelMock.simple().links, render: 'https://render.test' } }); + + component.onFileMenuAction({ value: FileMenuType.Embed, data: { type: 'dynamic' } }, file); + + expect(filesShareEmbedService.copyEmbedToClipboard).toHaveBeenCalledWith('https://render.test', 'dynamic'); + }); +}); diff --git a/src/app/features/files/components/files-tree-explorer/files-tree-explorer.component.ts b/src/app/features/files/components/files-tree-explorer/files-tree-explorer.component.ts new file mode 100644 index 000000000..622e6ffa0 --- /dev/null +++ b/src/app/features/files/components/files-tree-explorer/files-tree-explorer.component.ts @@ -0,0 +1,312 @@ +import { TranslatePipe } from '@ngx-translate/core'; + +import { PrimeTemplate, TreeDragDropService } from 'primeng/api'; +import { Tree, TreeLazyLoadEvent, TreeNodeDropEvent, TreeNodeSelectEvent } from 'primeng/tree'; + +import { isPlatformBrowser } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + effect, + inject, + input, + model, + output, + PLATFORM_ID, + signal, +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { Router } from '@angular/router'; + +import { FileMenuComponent } from '@osf/shared/components/file-menu/file-menu.component'; +import { FilesDropZoneComponent } from '@osf/shared/components/files-drop-zone/files-drop-zone.component'; +import { FilesTreeRowComponent } from '@osf/shared/components/files-tree-row/files-tree-row.component'; +import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; +import { FileKind } from '@osf/shared/enums/file-kind.enum'; +import { FileMenuType } from '@osf/shared/enums/file-menu-type.enum'; +import { CurrentResourceType } from '@osf/shared/enums/resource-type.enum'; +import { FileTreeMapper } from '@osf/shared/mappers/files/file-tree.mapper'; +import { FilesMapper } from '@osf/shared/mappers/files/files.mapper'; +import { FileModel } from '@osf/shared/models/files/file.model'; +import { FileFolderModel } from '@osf/shared/models/files/file-folder.model'; +import { FileLabelModel } from '@osf/shared/models/files/file-label.model'; +import { FileMenuAction, FileMenuFlags } from '@osf/shared/models/files/file-menu-action.model'; +import { FilePageLinkModel } from '@osf/shared/models/files/file-page-link.model'; +import { DataciteService } from '@osf/shared/services/datacite/datacite.service'; +import { FilesService } from '@osf/shared/services/files.service'; +import { FilesShareEmbedService } from '@osf/shared/services/files-share-embed.service'; +import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; + +import { MoveCopyAction } from '../../enums/move-copy-action.enum'; +import { DropMovePayload } from '../../models/files-actions-options.model'; +import { MenuMoveCopyPayload } from '../../models/menu-move-copy.model'; + +@Component({ + selector: 'osf-files-tree-explorer', + imports: [ + PrimeTemplate, + TranslatePipe, + Tree, + LoadingSpinnerComponent, + FilesDropZoneComponent, + FilesTreeRowComponent, + FileMenuComponent, + ], + providers: [TreeDragDropService], + templateUrl: './files-tree-explorer.component.html', + styleUrl: './files-tree-explorer.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class FilesTreeExplorerComponent { + private readonly destroyRef = inject(DestroyRef); + private readonly router = inject(Router); + private readonly filesService = inject(FilesService); + private readonly dataciteService = inject(DataciteService); + private readonly filesShareEmbedService = inject(FilesShareEmbedService); + private readonly viewOnlyService = inject(ViewOnlyLinkHelperService); + private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); + + readonly files = input.required(); + readonly currentFolder = input.required(); + readonly storage = input.required(); + + readonly totalCount = input(0); + readonly isLoading = input(false); + readonly resourceId = input.required(); + readonly resourceType = input(CurrentResourceType.Projects); + readonly viewOnly = input(true); + readonly allowedMenuActions = input({} as FileMenuFlags); + readonly supportUpload = input(true); + readonly selectedFiles = input([]); + readonly scrollHeight = input('300px'); + readonly selectionMode = input<'multiple' | null>('multiple'); + + readonly fileOpened = output(); + readonly uploadFiles = output(); + readonly currentFolderChanged = output(); + readonly deleteFile = output(); + readonly renameFile = output(); + readonly loadFiles = output(); + readonly fileSelected = output(); + readonly fileUnselected = output(); + readonly dropMove = output(); + readonly menuMoveCopy = output(); + + foldersStack = model([]); + lastSelectedFile: FileModel | null = null; + + readonly itemsPerPage = 10; + readonly virtualScrollItemSize = 46; + + readonly isLoadingMore = signal(false); + + readonly hasViewOnly = computed(() => this.viewOnlyService.hasViewOnlyParam(this.router) || this.viewOnly()); + readonly canUpload = computed(() => !this.hasViewOnly() && this.supportUpload()); + + readonly nodes = computed(() => { + const currentFolder = this.currentFolder(); + const files = this.files(); + + const values = this.foldersStack().length + ? ([{ ...currentFolder, previousFolder: true }, ...files] as FileModel[]) + : files; + + return FileTreeMapper.toTreeNodes(values); + }); + + readonly selectedNodes = computed(() => FileTreeMapper.toTreeNodes(this.selectedFiles())); + + constructor() { + effect(() => { + const storageChanged = this.storage(); + if (storageChanged) { + this.foldersStack.set([]); + } + }); + + effect(() => { + if (!this.isLoading()) { + this.isLoadingMore.set(false); + } + }); + } + + onDropFiles(fileArray: File[]): void { + this.uploadFiles.emit(fileArray); + } + + deleteEntry(file: FileModel): void { + this.deleteFile.emit(file); + } + + openEntry(file: FileModel | FileFolderModel) { + if (file.kind === FileKind.File) { + this.fileOpened.emit(file); + } else { + const current = this.currentFolder(); + this.foldersStack.update((stack) => [...stack, current]); + const folder = FilesMapper.mapFileToFolder(file as FileModel); + this.currentFolderChanged.emit(folder); + } + } + + openParentFolder() { + const stack = this.foldersStack(); + const previous = stack[stack.length - 1]; + this.foldersStack.set(stack.slice(0, -1)); + + this.currentFolderChanged.emit(previous); + } + + onFileMenuAction(action: FileMenuAction, file: FileModel): void { + const { value, data } = action; + + switch (value) { + case FileMenuType.Download: + this.downloadFileOrFolder(file); + break; + case FileMenuType.Delete: + this.deleteEntry(file); + break; + case FileMenuType.Share: + this.handleShareAction(file, data?.type); + break; + case FileMenuType.Embed: + this.handleEmbedAction(file, data?.type); + break; + case FileMenuType.Rename: + this.renameFile.emit(file); + break; + case FileMenuType.Move: + this.menuMoveCopy.emit({ file, action: MoveCopyAction.Move }); + break; + case FileMenuType.Copy: + this.menuMoveCopy.emit({ file, action: MoveCopyAction.Copy }); + break; + } + } + + downloadFileOrFolder(file: FileModel) { + this.dataciteService + .logFileDownload(this.resourceId(), this.resourceType()) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(); + + if (file.kind === FileKind.File) { + this.downloadFile(file.links.download); + } else { + this.downloadFolder(file.links.upload); + } + } + + downloadFile(link: string): void { + if (this.isBrowser) { + window.open(link)?.focus(); + } + } + + downloadFolder(downloadLink: string): void { + if (downloadLink) { + const link = this.filesService.getFolderDownloadLink(downloadLink); + window.open(link, '_blank')?.focus(); + } + } + + onLazyLoad(event: TreeLazyLoadEvent) { + const loaded = this.files().length; + if (event.last >= loaded - 1) { + this.loadNextPage(); + } + } + + onNodeSelect(event: TreeNodeSelectEvent) { + const files = this.files(); + const selectedNode = event.node.data as FileModel; + + if (!selectedNode) { + return; + } + + if ((event.originalEvent as PointerEvent).shiftKey && this.lastSelectedFile) { + const lastIndex = files.indexOf(this.lastSelectedFile); + const currentIndex = files.indexOf(selectedNode); + if (lastIndex == currentIndex) { + return; + } + + const start = Math.min(lastIndex, currentIndex); + const end = Math.max(lastIndex, currentIndex); + + for (const file of files.slice(start, end)) { + this.fileSelected.emit(file); + } + } + + this.fileSelected.emit(selectedNode); + this.lastSelectedFile = selectedNode; + } + + onNodeDrop(event: TreeNodeDropEvent) { + const dropFile = event.dropNode?.data as FileModel; + + if (dropFile?.kind !== FileKind.Folder) { + return; + } + + const selectedFiles = this.selectedFiles(); + const dragFile = event.dragNode?.data as FileModel; + + if (!dragFile) { + return; + } + + const filesToMove = selectedFiles.includes(dragFile) ? selectedFiles : [...selectedFiles, dragFile]; + + if (!selectedFiles.includes(dragFile)) { + this.fileSelected.emit(dragFile); + } + + this.dropMove.emit({ files: filesToMove, destination: dropFile }); + } + + onNodeUnselect(event: TreeNodeSelectEvent) { + const unselectedNode = event.node.data as FileModel; + + if (!unselectedNode) { + return; + } + + this.fileUnselected.emit(unselectedNode); + } + + private loadNextPage(): void { + const total = this.totalCount(); + const loaded = this.files().length; + const nextPage = Math.floor(loaded / this.itemsPerPage) + 1; + + if (!this.isLoadingMore() && loaded < total) { + this.isLoadingMore.set(true); + this.loadFiles.emit({ link: this.currentFolder()?.links.filesLink ?? '', page: nextPage }); + } + } + + private handleShareAction(file: FileModel, shareType?: string): void { + const shareAction = this.filesShareEmbedService.getShareLink(file, shareType); + if (!shareAction || !this.isBrowser) { + return; + } + + if (shareAction.target === '_self') { + window.location.href = shareAction.link; + return; + } + + window.open(shareAction.link, shareAction.target, 'noopener,noreferrer'); + } + + private handleEmbedAction(file: FileModel, embedType?: string): void { + this.filesShareEmbedService.copyEmbedToClipboard(file.links.render, embedType); + } +} diff --git a/src/app/features/files/components/index.ts b/src/app/features/files/components/index.ts deleted file mode 100644 index 3dc6b83b0..000000000 --- a/src/app/features/files/components/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -export { CreateFolderDialogComponent } from './create-folder-dialog/create-folder-dialog.component'; -export { EditFileMetadataDialogComponent } from './edit-file-metadata-dialog/edit-file-metadata-dialog.component'; -export { FileBrowserInfoComponent } from './file-browser-info/file-browser-info.component'; -export { FileKeywordsComponent } from './file-keywords/file-keywords.component'; -export { FileMetadataComponent } from './file-metadata/file-metadata.component'; -export { FileResourceMetadataComponent } from './file-resource-metadata/file-resource-metadata.component'; -export { FileRevisionsComponent } from './file-revisions/file-revisions.component'; -export { FilesSelectionActionsComponent } from './files-selection-actions/files-selection-actions.component'; -export { MoveFileDialogComponent } from './move-file-dialog/move-file-dialog.component'; -export { RenameFileDialogComponent } from './rename-file-dialog/rename-file-dialog.component'; diff --git a/src/app/features/files/components/move-file-dialog/move-file-dialog.component.html b/src/app/features/files/components/move-file-dialog/move-file-dialog.component.html index 8597f0696..c703e0764 100644 --- a/src/app/features/files/components/move-file-dialog/move-file-dialog.component.html +++ b/src/app/features/files/components/move-file-dialog/move-file-dialog.component.html @@ -15,61 +15,39 @@
@if (previousFolder()) { -
-
+ - + {{ currentFolder()?.name ?? '' }} -
+
} @if (files().length) { -
-
- @if (item.kind !== 'folder') { - - - } @else if (fileIdsInList().has(item.id) || !hasAddUpdateFeature()) { - - - - } @else { - - } -
-
+
- @if (isLoadingMore()) { -
- -
- } } @else {

{{ 'files.emptyState' | translate }}

diff --git a/src/app/features/files/components/move-file-dialog/move-file-dialog.component.scss b/src/app/features/files/components/move-file-dialog/move-file-dialog.component.scss index 4e5ccf48b..487ab508d 100644 --- a/src/app/features/files/components/move-file-dialog/move-file-dialog.component.scss +++ b/src/app/features/files/components/move-file-dialog/move-file-dialog.component.scss @@ -5,53 +5,17 @@ flex: 1; } -.loading-overlay { - @include mix.flex-center; - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - background-color: rgba(0, 0, 0, 0.2); - z-index: 1; -} - .files-table { border: 1px solid var(--grey-2); - border-radius: mix.rem(8px); + border-radius: 0.5rem; - &-row { + .files-table-row { border-bottom: 1px solid var(--grey-2); - color: var(--dark-blue-1); - height: mix.rem(44px); - } - - &-row:last-child { - border-bottom: none; - } -} - -.filename-link { - min-width: 0; - max-width: 100%; - cursor: pointer; - - &.disabled { - color: var(--grey-1); - cursor: not-allowed; - } - - &:not(.disabled):hover { - text-decoration: underline; } } .link-btn-no-padding { --p-button-label-font-weight: 400; - --p-button-link-hover-color: var(--dark-blue-1); --p-button-link-color: var(--dark-blue-1); -} - -.disabled-icon { - color: var(--grey-1); + --p-button-padding-y: 0.5rem; } 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 a5cf75e5c..c986b4ae8 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 @@ -1,101 +1,292 @@ +import { Store } from '@ngxs/store'; + import { MockComponents, MockProvider } from 'ng-mocks'; -import { DynamicDialogConfig } from 'primeng/dynamicdialog'; +import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; + +import { Subject } from 'rxjs'; + +import { Mock } from 'vitest'; 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 { CurrentResourceSelectors } from '@shared/stores/current-resource'; +import { SupportedFeature } from '@osf/shared/enums/addon-supported-features.enum'; +import { FileKind } from '@osf/shared/enums/file-kind.enum'; +import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { FileModel } from '@osf/shared/models/files/file.model'; +import { FileFolderModel } from '@osf/shared/models/files/file-folder.model'; +import { CurrentResourceSelectors, GetResourceWithChildren } from '@osf/shared/stores/current-resource'; +import { FileModelMock } from '@testing/mocks/file.model.mock'; +import { OSF_FILE_MOCK } from '@testing/mocks/osf-file.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; -import { CustomConfirmationServiceMock } from '@testing/providers/custom-confirmation-provider.mock'; import { provideDynamicDialogRefMock } from '@testing/providers/dynamic-dialog-ref.mock'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; -import { ToastServiceMock } from '@testing/providers/toast-provider.mock'; +import { + FilesMoveCopyServiceMock, + FilesMoveCopyServiceMockType, +} from '@testing/providers/files-move-copy-service.mock'; +import { + BaseSetupOverrides, + mergeSignalOverrides, + provideMockStore, + SignalOverride, +} from '@testing/providers/store-provider.mock'; -import { FilesSelectors } from '../../store'; +import { FileProvider } from '../../constants'; +import { MoveCopyAction } from '../../enums/move-copy-action.enum'; +import { MoveFilesOptions } from '../../models/files-actions-options.model'; +import { FilesMoveCopyService } from '../../services/files-move-copy.service'; +import { FilesSelectors, GetMoveDialogFiles, SetFilesCurrentFolder, SetMoveDialogCurrentFolder } from '../../store'; +import { FileSelectDestinationComponent } from '../file-select-destination/file-select-destination.component'; +import { MoveFileRowComponent } from '../move-file-row/move-file-row.component'; import { MoveFileDialogComponent } from './move-file-dialog.component'; describe('MoveFileDialogComponent', () => { let component: MoveFileDialogComponent; let fixture: ComponentFixture; + let store: Store; + let dialogRef: DynamicDialogRef; + let filesMoveCopyService: FilesMoveCopyServiceMockType; + let dialogConfig: DynamicDialogConfig & { data: MoveFilesOptions }; + + const initialFolder: FileFolderModel = { + ...OSF_FILE_MOCK, + id: 'folder-1', + name: 'Folder 1', + node: 'node-1', + path: '/folder-1', + provider: FileProvider.OsfStorage, + links: { + ...OSF_FILE_MOCK.links, + filesLink: '/files-folder-1', + }, + }; + + const nestedFolder: FileFolderModel = { + ...OSF_FILE_MOCK, + id: 'folder-2', + name: 'Folder 2', + node: 'node-1', + path: '/folder-2', + provider: FileProvider.OsfStorage, + links: { + ...OSF_FILE_MOCK.links, + filesLink: '/files-folder-2', + }, + }; - beforeEach(() => { - const dialogConfigMock = { - data: { files: [], currentFolder: null }, + const project = { + id: 'project-1', + rootResourceId: 'root-1', + }; + + interface SetupOverrides extends BaseSetupOverrides { + configData?: Partial; + } + + function setup(overrides: SetupOverrides = {}) { + const file = FileModelMock.simple({ id: 'file-1', name: 'file-1.txt' }); + filesMoveCopyService = FilesMoveCopyServiceMock.simple(); + const defaultSignals: SignalOverride[] = [ + { selector: FilesSelectors.getMoveDialogFiles, value: [] }, + { selector: FilesSelectors.getMoveDialogFilesTotalCount, value: 0 }, + { selector: FilesSelectors.isMoveDialogFilesLoading, value: false }, + { selector: FilesSelectors.getMoveDialogCurrentFolder, value: initialFolder }, + { selector: FilesSelectors.getProvider, value: FileProvider.OsfStorage }, + { + selector: FilesSelectors.getStorageSupportedFeatures, + value: { [FileProvider.OsfStorage]: [SupportedFeature.AddUpdateFiles] }, + }, + { selector: FilesSelectors.isMoveDialogConfiguredStorageAddonsLoading, value: false }, + { selector: FilesSelectors.isMoveDialogRootFoldersLoading, value: false }, + { selector: CurrentResourceSelectors.getCurrentResource, value: project }, + { selector: CurrentResourceSelectors.getResourceWithChildren, value: [] }, + { selector: CurrentResourceSelectors.isResourceWithChildrenLoading, value: false }, + ]; + + dialogConfig = { + header: 'files.dialogs.moveFile.title', + data: { + files: [file], + action: MoveCopyAction.Move, + resourceId: 'project-1', + storageProvider: FileProvider.OsfStorage, + foldersStack: [initialFolder], + initialFolder, + ...overrides.configData, + }, }; TestBed.configureTestingModule({ imports: [ MoveFileDialogComponent, - ...MockComponents(IconComponent, LoadingSpinnerComponent, FileSelectDestinationComponent), + ...MockComponents(LoadingSpinnerComponent, FileSelectDestinationComponent, MoveFileRowComponent), ], providers: [ provideOSFCore(), provideDynamicDialogRefMock(), - MockProvider(DynamicDialogConfig, dialogConfigMock), - MockProvider(FilesService), - MockProvider(ToastService, ToastServiceMock.simple()), - MockProvider(CustomConfirmationService, CustomConfirmationServiceMock.simple()), + MockProvider(DynamicDialogConfig, dialogConfig), + MockProvider(FilesMoveCopyService, filesMoveCopyService), provideMockStore({ - signals: [ - { 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 }, - ], + signals: mergeSignalOverrides(defaultSignals, overrides.selectorOverrides), }), ], }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(MoveFileDialogComponent); component = fixture.componentInstance; + dialogRef = TestBed.inject(DynamicDialogRef); fixture.detectChanges(); - }); + } it('should create', () => { + setup(); expect(component).toBeTruthy(); }); - it('should initialize with correct properties', () => { - expect(component.config).toBeDefined(); - expect(component.dialogRef).toBeDefined(); - expect(component.files).toBeDefined(); - expect(component.isLoading).toBeDefined(); - expect(component.currentFolder).toBeDefined(); + it('should initialize previous folder from stack', () => { + setup({ + configData: { + foldersStack: [initialFolder, nestedFolder], + }, + }); + expect(component.previousFolder()).toEqual(nestedFolder); }); - it('should get files from store', () => { - expect(component.files()).toEqual([]); + it('should open selected folder and update stack', () => { + setup(); + (store.dispatch as Mock).mockClear(); + const folderFile = FileModelMock.simple({ + id: 'folder-3', + name: 'Folder 3', + kind: FileKind.Folder, + path: '/folder-3', + filesLink: '/files-folder-3', + provider: FileProvider.OsfStorage, + links: { + info: '', + move: '', + upload: '/upload', + delete: '', + download: '', + render: '', + html: '', + self: '', + }, + }); + + component.openFolder(folderFile); + + expect(component.foldersStack()).toEqual([initialFolder, initialFolder]); + expect(component.previousFolder()).toEqual(initialFolder); + expect(store.dispatch).toHaveBeenCalledWith( + new SetMoveDialogCurrentFolder({ + id: 'folder-3', + kind: FileKind.Folder, + name: 'Folder 3', + node: '', + path: '/folder-3', + provider: FileProvider.OsfStorage, + links: { + newFolder: '/upload?kind=folder', + storageAddons: '', + upload: '/upload', + filesLink: '/files-folder-3', + download: '/upload', + }, + }) + ); }); - it('should get loading state from store', () => { - expect(component.isLoading()).toBe(false); + it('should open parent folder and dispatch previous folder', () => { + setup({ + configData: { + foldersStack: [initialFolder, nestedFolder], + }, + selectorOverrides: [{ selector: FilesSelectors.getMoveDialogCurrentFolder, value: nestedFolder }], + }); + (store.dispatch as Mock).mockClear(); + + component.openParentFolder(); + + expect(component.foldersStack()).toEqual([initialFolder]); + expect(component.previousFolder()).toEqual(initialFolder); + expect(store.dispatch).toHaveBeenCalledWith(new SetMoveDialogCurrentFolder(nestedFolder)); }); - it('should get current folder from store', () => { - expect(component.currentFolder()).toBeNull(); + it('should load next page on list end scroll', () => { + const files: FileModel[] = Array.from({ length: 10 }).map((_, index) => + FileModelMock.simple({ id: `file-${index + 1}`, name: `file-${index + 1}.txt` }) + ); + + setup({ + selectorOverrides: [ + { selector: FilesSelectors.getMoveDialogFiles, value: files }, + { selector: FilesSelectors.getMoveDialogFilesTotalCount, value: 25 }, + ], + }); + (store.dispatch as Mock).mockClear(); + + component.onScrollIndexChange({ first: 0, last: 9 }); + + expect(component.isLoadingMore()).toBe(true); + expect(store.dispatch).toHaveBeenCalledWith(new GetMoveDialogFiles('/files-folder-1', 2)); }); - it('should have isFilesUpdating signal', () => { + it('should change project and reset folder state', () => { + setup({ + configData: { + foldersStack: [initialFolder, nestedFolder], + }, + }); + + component.onProjectChange('project-2'); + + expect(component.foldersStack()).toEqual([]); + expect(component.previousFolder()).toBeNull(); + }); + + it('should execute move and close dialog on success', () => { + setup(); + (store.dispatch as Mock).mockClear(); + + component.moveFiles(); + + expect(filesMoveCopyService.execute).toHaveBeenCalledWith({ + files: dialogConfig.data.files, + destination: initialFolder, + resourceId: 'project-1', + storageProvider: FileProvider.OsfStorage, + action: MoveCopyAction.Move, + }); + expect(store.dispatch).toHaveBeenCalledWith(new SetFilesCurrentFolder(initialFolder)); + expect(store.dispatch).toHaveBeenCalledWith(new SetMoveDialogCurrentFolder(null)); + expect(dialogRef.close).toHaveBeenCalledWith(true); + }); + + it('should keep updating state true until move request completes', () => { + setup(); + const pending = new Subject(); + filesMoveCopyService.execute.mockReturnValue(pending.asObservable()); + + component.moveFiles(); + + expect(component.isFilesUpdating()).toBe(true); + + pending.next(true); + pending.complete(); + expect(component.isFilesUpdating()).toBe(false); + expect(dialogConfig.header).toBe('files.dialogs.moveFile.title'); }); - it('should have all required selectors defined', () => { - expect(component.filesTotalCount).toBeDefined(); - expect(component.currentProject).toBeDefined(); - expect(component.components).toBeDefined(); - expect(component.areComponentsLoading).toBeDefined(); - expect(component.isConfiguredStorageAddonsLoading).toBeDefined(); + it('should request components tree on init', () => { + setup(); + expect(store.dispatch).toHaveBeenCalledWith( + new GetResourceWithChildren('root-1', 'project-1', ResourceType.Project, true) + ); }); }); 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 d765183a3..9adc1686d 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 @@ -4,48 +4,39 @@ import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; -import { ScrollerModule } from 'primeng/scroller'; -import { Tooltip } from 'primeng/tooltip'; +import { Scroller } from 'primeng/scroller'; import { TreeScrollIndexChangeEvent } from 'primeng/tree'; -import { finalize, forkJoin, of } from 'rxjs'; -import { catchError } from 'rxjs/operators'; +import { finalize, tap } from 'rxjs'; import { ChangeDetectionStrategy, Component, computed, DestroyRef, effect, inject, signal } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { - FilesSelectors, - GetMoveDialogFiles, - SetFilesCurrentFolder, - SetMoveDialogCurrentFolder, -} from '@osf/features/files/store'; -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 { SupportedFeature } from '@osf/shared/enums/addon-supported-features.enum'; import { FileKind } from '@osf/shared/enums/file-kind.enum'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { FilesMapper } from '@osf/shared/mappers/files/files.mapper'; -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 { FileModel } from '@osf/shared/models/files/file.model'; +import { FileFolderModel } from '@osf/shared/models/files/file-folder.model'; import { CurrentResourceSelectors, GetResourceWithChildren } from '@osf/shared/stores/current-resource'; -import { FileModel } from '@shared/models/files/file.model'; -import { FileFolderModel } from '@shared/models/files/file-folder.model'; import { FileProvider } from '../../constants'; +import { MoveCopyAction } from '../../enums/move-copy-action.enum'; +import { FilesMoveCopyService } from '../../services/files-move-copy.service'; +import { FilesSelectors, GetMoveDialogFiles, SetFilesCurrentFolder, SetMoveDialogCurrentFolder } from '../../store'; +import { FileSelectDestinationComponent } from '../file-select-destination/file-select-destination.component'; +import { MoveFileRowComponent } from '../move-file-row/move-file-row.component'; @Component({ selector: 'osf-move-file-dialog', imports: [ Button, - Tooltip, - ScrollerModule, + Scroller, TranslatePipe, - IconComponent, LoadingSpinnerComponent, FileSelectDestinationComponent, + MoveFileRowComponent, ], templateUrl: './move-file-dialog.component.html', styleUrl: './move-file-dialog.component.scss', @@ -55,11 +46,9 @@ export class MoveFileDialogComponent { 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); + private readonly filesMoveCopyService = inject(FilesMoveCopyService); readonly files = select(FilesSelectors.getMoveDialogFiles); readonly filesTotalCount = select(FilesSelectors.getMoveDialogFilesTotalCount); @@ -85,17 +74,22 @@ export class MoveFileDialogComponent { foldersStack = signal(this.config.data.foldersStack ?? []); storageProvider = signal(this.config.data.storageProvider ?? FileProvider.OsfStorage); previousFolder = signal(null); - isLoadingMore = signal(false); - itemsPerPage = 10; + readonly isLoadingMore = signal(false); + + readonly itemsPerPage = 10; + readonly virtualScrollItemSize = 44; + private lastFolderId: string | null = null; + private lastLoadedComponentsProjectId: string | null = null; private initialFolder = this.config.data.initialFolder; private fileProjectId = this.config.data.resourceId; - readonly isFolderSame = computed(() => this.currentFolder()?.id === this.initialFolder?.id); + readonly isMoveAction = this.config.data.action === MoveCopyAction.Move; readonly fileIdsInList = computed(() => new Set((this.config.data.files as FileModel[]).map((f) => f.id))); + readonly isFolderSame = computed(() => this.currentFolder()?.id === this.initialFolder?.id); readonly isDestinationLoading = computed( () => this.isConfiguredStorageAddonsLoading() || this.areComponentsLoading() || this.isRootFoldersLoading() ); @@ -118,54 +112,26 @@ export class MoveFileDialogComponent { !this.hasAddUpdateFeature() ); - get isMoveAction() { - return this.config.data.action === 'move'; - } - constructor() { this.initPreviousFolder(); - const currentProject = this.currentProject(); - if (currentProject) { - const rootParentId = currentProject.rootResourceId ?? currentProject.id; - this.actions.getComponentsTree(rootParentId, currentProject.id, ResourceType.Project, true); - } - - effect(() => { - const folder = this.currentFolder(); - const isLoading = this.isDestinationLoading(); - - if (isLoading) return; - - if (!folder || folder.id === this.lastFolderId) return; - - this.lastFolderId = folder.id; - this.actions.getMoveDialogFiles(folder.links.filesLink, 1); - }); - - effect(() => { - if (!this.isLoading()) { - this.isLoadingMore.set(false); - } - }); + this.setupComponentsTreeLoader(); + this.setupMoveDialogFilesLoader(); + this.setupLoadingMoreReset(); } initPreviousFolder() { const stack = this.foldersStack(); - if (stack.length === 0) { - this.previousFolder.set(null); - } else { - this.previousFolder.set(stack[stack.length - 1]); - } + this.previousFolder.set(stack.at(-1) ?? null); } - openFolder(file: FileModel | FileFolderModel) { + openFolder(file: FileModel) { if (file.kind === FileKind.Folder) { const current = this.currentFolder(); if (current) { this.previousFolder.set(current); this.foldersStack.update((stack) => [...stack, current]); } - const folder = FilesMapper.mapFileToFolder(file as FileModel); + const folder = FilesMapper.mapFileToFolder(file); this.actions.setMoveDialogCurrentFolder(folder); } } @@ -183,132 +149,102 @@ export class MoveFileDialogComponent { }); } - moveFiles(): void { - const path = this.currentFolder()?.path; - if (!path) { - throw new Error(this.translateService.instant('files.dialogs.moveFile.pathError')); + onScrollIndexChange(event: TreeScrollIndexChangeEvent) { + const loaded = this.files().length; + if (event.last >= loaded - 1) { + this.loadNextPage(); } - - this.isFilesUpdating.set(true); - const headerKey = this.isMoveAction ? 'files.dialogs.moveFile.movingHeader' : 'files.dialogs.moveFile.copingHeader'; - this.config.header = this.translateService.instant(headerKey); - const action = this.config.data.action; - 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 ?? 'Error'); - } - 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(); - }, - }); + onProjectChange(projectId: string) { + this.fileProjectId = projectId; + this.foldersStack.set([]); + this.previousFolder.set(null); } - private showSuccessToast(action: string) { - const messageType = action === 'move' ? 'moveFile' : 'copyFile'; - this.toastService.showSuccess(`files.dialogs.${messageType}.success`); + onStorageChange() { + this.foldersStack.set([]); + this.previousFolder.set(null); } - private showErrorToast(action: string, errorMessage?: string) { - const messageType = action === 'move' ? 'moveFile' : 'copyFile'; - this.toastService.showError(errorMessage ?? `files.dialogs.${messageType}.error`); + moveFiles(): void { + this.isFilesUpdating.set(true); + const headerKey = this.isMoveAction ? 'files.dialogs.moveFile.movingHeader' : 'files.dialogs.moveFile.copingHeader'; + this.config.header = this.translateService.instant(headerKey); + const action = this.config.data.action as MoveCopyAction; + this.filesMoveCopyService + .execute({ + files: this.config.data.files, + destination: this.currentFolder(), + resourceId: this.fileProjectId, + storageProvider: this.provider(), + action, + }) + .pipe( + tap(() => this.completeMove()), + finalize(() => { + this.config.header = this.translateService.instant('files.dialogs.moveFile.title'); + this.isFilesUpdating.set(false); + }), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(); } private completeMove(): void { - this.isFilesUpdating.set(false); this.actions.setCurrentFolder(this.initialFolder); this.actions.setMoveDialogCurrentFolder(null); this.dialogRef.close(true); } - private loadNextPage(): void { - const total = this.filesTotalCount(); - const loaded = this.files().length; - const nextPage = Math.floor(loaded / this.itemsPerPage) + 1; + private setupComponentsTreeLoader(): void { + effect(() => { + const currentProject = this.currentProject(); + if (!currentProject || currentProject.id === this.lastLoadedComponentsProjectId) { + return; + } - if (!this.isLoadingMore() && loaded < total) { - this.isLoadingMore.set(true); - this.actions.getMoveDialogFiles(this.currentFolder()?.links.filesLink ?? '', nextPage); - } + this.lastLoadedComponentsProjectId = currentProject.id; + const rootParentId = currentProject.rootResourceId ?? currentProject.id; + this.actions.getComponentsTree(rootParentId, currentProject.id, ResourceType.Project, true); + }); } - onScrollIndexChange(event: TreeScrollIndexChangeEvent) { - const loaded = this.files().length; - if (event.last >= loaded - 1) { - this.loadNextPage(); - } + private setupMoveDialogFilesLoader(): void { + effect(() => { + const folder = this.currentFolder(); + const isLoading = this.isDestinationLoading(); + + if (isLoading) { + return; + } + + if (!folder || folder.id === this.lastFolderId) { + return; + } + + this.lastFolderId = folder.id; + this.actions.getMoveDialogFiles(folder.links.filesLink, 1); + }); } - onProjectChange(projectId: string) { - this.fileProjectId = projectId; - this.foldersStack.set([]); - this.previousFolder.set(null); + private setupLoadingMoreReset(): void { + effect(() => { + if (!this.isLoading()) { + this.isLoadingMore.set(false); + } + }); } - onStorageChange() { - this.foldersStack.set([]); - this.previousFolder.set(null); + private loadNextPage(): void { + const total = this.filesTotalCount(); + const loaded = this.files().length; + const nextPage = Math.floor(loaded / this.itemsPerPage) + 1; + const filesLink = this.currentFolder()?.links.filesLink; + + if (!this.isLoadingMore() && loaded < total && filesLink) { + this.isLoadingMore.set(true); + this.actions.getMoveDialogFiles(filesLink, nextPage); + } } } diff --git a/src/app/features/files/components/move-file-row/move-file-row.component.html b/src/app/features/files/components/move-file-row/move-file-row.component.html new file mode 100644 index 000000000..da4251893 --- /dev/null +++ b/src/app/features/files/components/move-file-row/move-file-row.component.html @@ -0,0 +1,25 @@ +@let rowItem = item(); + +
+
+ @if (isFile()) { + + + } @else if (isBlocked()) { + + + + } @else { + + } +
+
diff --git a/src/app/features/files/components/move-file-row/move-file-row.component.scss b/src/app/features/files/components/move-file-row/move-file-row.component.scss new file mode 100644 index 000000000..1d6f80833 --- /dev/null +++ b/src/app/features/files/components/move-file-row/move-file-row.component.scss @@ -0,0 +1,30 @@ +.files-table-row { + border-bottom: 1px solid var(--grey-2); + color: var(--dark-blue-1); + height: 2.75rem; +} + +.filename-link { + min-width: 0; + max-width: 100%; + cursor: pointer; + + &.disabled { + color: var(--grey-1); + cursor: not-allowed; + } + + &:not(.disabled):hover { + text-decoration: underline; + } +} + +.disabled-icon { + color: var(--grey-1); +} + +.link-btn-no-padding { + --p-button-label-font-weight: 400; + --p-button-link-hover-color: var(--dark-blue-1); + --p-button-link-color: var(--dark-blue-1); +} diff --git a/src/app/features/files/components/move-file-row/move-file-row.component.spec.ts b/src/app/features/files/components/move-file-row/move-file-row.component.spec.ts new file mode 100644 index 000000000..7481a5e4c --- /dev/null +++ b/src/app/features/files/components/move-file-row/move-file-row.component.spec.ts @@ -0,0 +1,77 @@ +import { MockComponent } from 'ng-mocks'; + +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { IconComponent } from '@osf/shared/components/icon/icon.component'; +import { FileKind } from '@osf/shared/enums/file-kind.enum'; +import { FileModel } from '@osf/shared/models/files/file.model'; + +import { FileModelMock } from '@testing/mocks/file.model.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; + +import { MoveFileRowComponent } from './move-file-row.component'; + +describe('MoveFileRowComponent', () => { + let component: MoveFileRowComponent; + let fixture: ComponentFixture; + + const createFile = (kind: FileKind, name: string): FileModel => + FileModelMock.simple({ + id: `${kind}-id`, + name, + kind, + path: `/${name}`, + materializedPath: `/${name}`, + }); + + function setup(item: FileModel, isBlocked = false, isIndented = false) { + TestBed.configureTestingModule({ + imports: [MoveFileRowComponent, MockComponent(IconComponent)], + providers: [provideOSFCore()], + }); + + fixture = TestBed.createComponent(MoveFileRowComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('item', item); + fixture.componentRef.setInput('isBlocked', isBlocked); + fixture.componentRef.setInput('isIndented', isIndented); + fixture.detectChanges(); + } + + it('should render file row as disabled', () => { + setup(createFile(FileKind.File, 'paper.pdf')); + + expect(component.isFile()).toBe(true); + expect(fixture.nativeElement.textContent).toContain('paper.pdf'); + expect(fixture.nativeElement.querySelector('.filename-link.disabled')?.textContent?.trim()).toBe('paper.pdf'); + expect(fixture.nativeElement.querySelector('button')).toBeNull(); + }); + + it('should render blocked folder row as disabled', () => { + setup(createFile(FileKind.Folder, 'docs'), true); + + expect(component.isFile()).toBe(false); + expect(fixture.nativeElement.textContent).toContain('docs'); + expect(fixture.nativeElement.querySelector('.filename-link.disabled')).toBeNull(); + expect(fixture.nativeElement.querySelector('p-button')).toBeNull(); + expect(fixture.nativeElement.querySelector('button')).toBeNull(); + }); + + it('should emit openFolder when active folder row clicked', () => { + const folder = createFile(FileKind.Folder, 'images'); + setup(folder); + const emitSpy = vi.spyOn(component.openFolder, 'emit'); + + const button = fixture.nativeElement.querySelector('button'); + button.click(); + + expect(emitSpy).toHaveBeenCalledWith(folder); + }); + + it('should apply indented row class when enabled', () => { + setup(createFile(FileKind.Folder, 'nested'), false, true); + + const row = fixture.nativeElement.querySelector('.files-table-row'); + expect(row.classList.contains('pl-6')).toBe(true); + }); +}); diff --git a/src/app/features/files/components/move-file-row/move-file-row.component.ts b/src/app/features/files/components/move-file-row/move-file-row.component.ts new file mode 100644 index 000000000..9f642d19c --- /dev/null +++ b/src/app/features/files/components/move-file-row/move-file-row.component.ts @@ -0,0 +1,26 @@ +import { TranslatePipe } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { Tooltip } from 'primeng/tooltip'; + +import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core'; + +import { IconComponent } from '@osf/shared/components/icon/icon.component'; +import { FileKind } from '@osf/shared/enums/file-kind.enum'; +import { FileModel } from '@osf/shared/models/files/file.model'; + +@Component({ + selector: 'osf-move-file-row', + imports: [Button, Tooltip, TranslatePipe, IconComponent], + templateUrl: './move-file-row.component.html', + styleUrl: './move-file-row.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MoveFileRowComponent { + readonly item = input.required(); + readonly isIndented = input(false); + readonly isBlocked = input(false); + readonly openFolder = output(); + + readonly isFile = computed(() => this.item().kind === FileKind.File); +} diff --git a/src/app/features/files/components/rename-file-dialog/rename-file-dialog.component.html b/src/app/features/files/components/rename-file-dialog/rename-file-dialog.component.html index b052e04d4..ff4dd52e5 100644 --- a/src/app/features/files/components/rename-file-dialog/rename-file-dialog.component.html +++ b/src/app/features/files/components/rename-file-dialog/rename-file-dialog.component.html @@ -1,7 +1,7 @@
{ let component: RenameFileDialogComponent; let fixture: ComponentFixture; let dialogRef: DynamicDialogRef; - let dialogConfig: Mocked; + let dialogConfig: DynamicDialogConfig; beforeEach(() => { const dialogConfigMock = { @@ -33,7 +31,7 @@ describe('RenameFileDialogComponent', () => { fixture = TestBed.createComponent(RenameFileDialogComponent); component = fixture.componentInstance; dialogRef = TestBed.inject(DynamicDialogRef); - dialogConfig = TestBed.inject(DynamicDialogConfig) as Mocked; + dialogConfig = TestBed.inject(DynamicDialogConfig); fixture.detectChanges(); }); @@ -48,38 +46,44 @@ describe('RenameFileDialogComponent', () => { }); it('should initialize form with current name from config', () => { - const nameControl = component.renameForm.get('name'); - expect(nameControl?.value).toBe('test-file.txt'); + expect(component.renameForm.controls.name.value).toBe('test-file.txt'); }); it('should be invalid when name is empty', () => { - const nameControl = component.renameForm.get('name'); - nameControl?.setValue(''); - expect(nameControl?.hasError('required')).toBe(true); + component.renameForm.controls.name.setValue(''); + expect(component.renameForm.controls.name.hasError('required')).toBe(true); expect(component.renameForm.invalid).toBe(true); }); it('should be invalid when name contains forbidden characters', () => { - const nameControl = component.renameForm.get('name'); - nameControl?.setValue('file@name'); - expect(nameControl?.hasError('forbiddenCharacters')).toBe(true); + component.renameForm.controls.name.setValue('file/name'); + expect(component.renameForm.controls.name.hasError('forbiddenCharacters')).toBe(true); }); it('should be invalid when name ends with period', () => { - const nameControl = component.renameForm.get('name'); - nameControl?.setValue('filename.'); - expect(nameControl?.hasError('periodAtEnd')).toBe(true); + component.renameForm.controls.name.setValue('filename.'); + expect(component.renameForm.controls.name.hasError('periodAtEnd')).toBe(true); + }); + + it('should be invalid when name is shorter than min length', () => { + component.renameForm.controls.name.setValue('A'.repeat(InputLimits.title.minLength - 1)); + + expect(component.renameForm.controls.name.hasError('minlength')).toBe(true); + }); + + it('should be invalid when name exceeds max length', () => { + component.renameForm.controls.name.setValue('A'.repeat(InputLimits.title.maxLength + 1)); + + expect(component.renameForm.controls.name.hasError('maxlength')).toBe(true); }); it('should be valid when name passes all validations', () => { - const nameControl = component.renameForm.get('name'); - nameControl?.setValue('valid-filename'); + component.renameForm.controls.name.setValue('valid-filename'); expect(component.renameForm.valid).toBe(true); }); - it('should close dialog with new name when onSubmit is called with valid form', () => { - const nameControl = component.renameForm.get('name'); - nameControl?.setValue('new-filename.txt'); + it('should close dialog with trimmed name when onSubmit is called with valid form', () => { + component.renameForm.controls.name.setValue(' new-filename.txt '); component.onSubmit(); @@ -87,8 +91,7 @@ describe('RenameFileDialogComponent', () => { }); it('should not close dialog when onSubmit is called with invalid form', () => { - const nameControl = component.renameForm.get('name'); - nameControl?.setValue(''); + component.renameForm.controls.name.setValue(''); component.onSubmit(); @@ -101,19 +104,12 @@ describe('RenameFileDialogComponent', () => { expect(dialogRef.close).toHaveBeenCalledWith(); }); - it('should have name control with correct validators', () => { - const nameControl = component.renameForm.get('name'); - expect(nameControl).toBeDefined(); - expect(nameControl?.hasError('required')).toBe(false); - }); - it('should handle empty config data', () => { dialogConfig.data = undefined; fixture = TestBed.createComponent(RenameFileDialogComponent); component = fixture.componentInstance; fixture.detectChanges(); - const nameControl = component.renameForm.get('name'); - expect(nameControl?.value).toBe(''); + expect(component.renameForm.controls.name.value).toBe(''); }); }); diff --git a/src/app/features/files/components/rename-file-dialog/rename-file-dialog.component.ts b/src/app/features/files/components/rename-file-dialog/rename-file-dialog.component.ts index a32b53f3c..423a4022b 100644 --- a/src/app/features/files/components/rename-file-dialog/rename-file-dialog.component.ts +++ b/src/app/features/files/components/rename-file-dialog/rename-file-dialog.component.ts @@ -4,7 +4,7 @@ import { Button } from 'primeng/button'; import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; -import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { TextInputComponent } from '@osf/shared/components/text-input/text-input.component'; import { forbiddenFileNameCharacters, InputLimits } from '@osf/shared/constants/input-limits.const'; @@ -28,6 +28,8 @@ export class RenameFileDialogComponent { nonNullable: true, validators: [ CustomValidators.requiredTrimmed(), + Validators.minLength(InputLimits.title.minLength), + Validators.maxLength(InputLimits.title.maxLength), CustomValidators.forbiddenCharactersValidator(forbiddenFileNameCharacters), CustomValidators.noPeriodAtEnd(), ], @@ -36,7 +38,7 @@ export class RenameFileDialogComponent { onSubmit(): void { if (this.renameForm.valid) { - const newName = this.renameForm.getRawValue().name; + const newName = this.renameForm.getRawValue().name.trim(); this.dialogRef.close(newName); } } diff --git a/src/app/features/files/constants/file-browser-info.constants.ts b/src/app/features/files/constants/file-browser-info.constants.ts index 9e0c95ec0..1de435ecb 100644 --- a/src/app/features/files/constants/file-browser-info.constants.ts +++ b/src/app/features/files/constants/file-browser-info.constants.ts @@ -1,6 +1,6 @@ import { ResourceType } from '@osf/shared/enums/resource-type.enum'; -import { FileInfoItem } from '../models'; +import { FileInfoItem } from '../models/info-item.model'; export const FILE_BROWSER_INFO_ITEMS: FileInfoItem[] = [ { diff --git a/src/app/features/files/constants/file-metadata-fields.constants.ts b/src/app/features/files/constants/file-metadata-fields.constants.ts index d67123b6a..e2c4e5a6f 100644 --- a/src/app/features/files/constants/file-metadata-fields.constants.ts +++ b/src/app/features/files/constants/file-metadata-fields.constants.ts @@ -1,4 +1,4 @@ -import { MetadataField } from '../models'; +import { MetadataField } from '../models/files-metadata-fields.model'; export const FileMetadataFields: MetadataField[] = [ { key: 'title', label: 'common.labels.title' }, diff --git a/src/app/features/files/constants/index.ts b/src/app/features/files/constants/index.ts index af9827638..bed8ae58c 100644 --- a/src/app/features/files/constants/index.ts +++ b/src/app/features/files/constants/index.ts @@ -1,4 +1,3 @@ -export * from './embed-content.constants'; export * from './file-browser-info.constants'; export * from './file-metadata-fields.constants'; export * from './file-provider.constants'; diff --git a/src/app/features/files/enums/index.ts b/src/app/features/files/enums/index.ts deleted file mode 100644 index 01282d755..000000000 --- a/src/app/features/files/enums/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './file-detail-tab.enum'; diff --git a/src/app/features/files/enums/move-copy-action.enum.ts b/src/app/features/files/enums/move-copy-action.enum.ts new file mode 100644 index 000000000..cb83880b2 --- /dev/null +++ b/src/app/features/files/enums/move-copy-action.enum.ts @@ -0,0 +1,6 @@ +import { FileMenuType } from '@osf/shared/enums/file-menu-type.enum'; + +export enum MoveCopyAction { + Move = FileMenuType.Move, + Copy = FileMenuType.Copy, +} diff --git a/src/app/features/files/files.routes.ts b/src/app/features/files/files.routes.ts index 8b4cb2b77..9f53d2c08 100644 --- a/src/app/features/files/files.routes.ts +++ b/src/app/features/files/files.routes.ts @@ -29,11 +29,8 @@ export const filesRoutes: Routes = [ { path: ':fileGuid', data: { canonicalPathTemplate: 'files/:fileGuid' }, - loadComponent: () => { - return import('@osf/features/files/pages/file-detail/file-detail.component').then( - (c) => c.FileDetailComponent - ); - }, + loadComponent: () => + import('@osf/features/files/pages/file-detail/file-detail.component').then((c) => c.FileDetailComponent), }, ], }, diff --git a/src/app/features/files/mappers/file-custom-metadata.mapper.ts b/src/app/features/files/mappers/file-custom-metadata.mapper.ts index 3af893193..1292bd004 100644 --- a/src/app/features/files/mappers/file-custom-metadata.mapper.ts +++ b/src/app/features/files/mappers/file-custom-metadata.mapper.ts @@ -1,7 +1,8 @@ import { ApiData } from '@osf/shared/models/common/json-api.model'; import { replaceBadEncodedChars } from '@shared/helpers/format-bad-encoding.helper'; -import { FileCustomMetadata, OsfFileCustomMetadata } from '../models'; +import { OsfFileCustomMetadata } from '../models/file-custom-metadata.model'; +import { FileCustomMetadata } from '../models/get-file-metadata-response.model'; export function MapFileCustomMetadata(data: ApiData): OsfFileCustomMetadata { return { diff --git a/src/app/features/files/mappers/file-menu-actions.mapper.ts b/src/app/features/files/mappers/file-menu-actions.mapper.ts new file mode 100644 index 000000000..186a2b39d --- /dev/null +++ b/src/app/features/files/mappers/file-menu-actions.mapper.ts @@ -0,0 +1,17 @@ +import { SupportedFeature } from '@osf/shared/enums/addon-supported-features.enum'; +import { FileMenuType } from '@osf/shared/enums/file-menu-type.enum'; + +export function mapMenuActions(supportedFeatures: SupportedFeature[]): Record { + return { + [FileMenuType.Download]: supportedFeatures.includes(SupportedFeature.DownloadAsZip), + [FileMenuType.Rename]: supportedFeatures.includes(SupportedFeature.AddUpdateFiles), + [FileMenuType.Delete]: supportedFeatures.includes(SupportedFeature.DeleteFiles), + [FileMenuType.Move]: + supportedFeatures.includes(SupportedFeature.CopyInto) && + supportedFeatures.includes(SupportedFeature.DeleteFiles) && + supportedFeatures.includes(SupportedFeature.AddUpdateFiles), + [FileMenuType.Embed]: true, + [FileMenuType.Share]: true, + [FileMenuType.Copy]: true, + }; +} diff --git a/src/app/features/files/mappers/file-revision.mapper.ts b/src/app/features/files/mappers/file-revision.mapper.ts index 19046d20f..34c6b08a4 100644 --- a/src/app/features/files/mappers/file-revision.mapper.ts +++ b/src/app/features/files/mappers/file-revision.mapper.ts @@ -1,10 +1,11 @@ import { ApiData } from '@osf/shared/models/common/json-api.model'; -import { FileRevisionJsonApi, OsfFileRevision } from '../models'; +import { OsfFileRevision } from '../models/file-revisions.model'; +import { FileRevisionJsonApi } from '../models/get-file-revisions-response.model'; export function MapFileRevision(data: ApiData[]): OsfFileRevision[] { const revision = data.map((revision) => ({ - downloads: revision.attributes.extra.downloads, + downloads: revision.attributes.extra.downloads ?? 0, hashes: { md5: revision.attributes.extra.hashes?.md5, sha256: revision.attributes.extra.hashes?.sha256 }, dateTime: new Date(revision.attributes.modified_utc), version: revision.attributes.version, diff --git a/src/app/features/files/mappers/index.ts b/src/app/features/files/mappers/index.ts deleted file mode 100644 index 96736c05c..000000000 --- a/src/app/features/files/mappers/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './file-custom-metadata.mapper'; -export * from './file-revision.mapper'; -export * from './resource-metadata.mapper'; diff --git a/src/app/features/files/models/file-revisions.model.ts b/src/app/features/files/models/file-revisions.model.ts index 84148c1d7..9bb41e509 100644 --- a/src/app/features/files/models/file-revisions.model.ts +++ b/src/app/features/files/models/file-revisions.model.ts @@ -1,5 +1,5 @@ export interface OsfFileRevision { - downloads: 0; + downloads: number; hashes: { md5: string; sha256: string; diff --git a/src/app/features/files/models/files-actions-options.model.ts b/src/app/features/files/models/files-actions-options.model.ts new file mode 100644 index 000000000..3debfcdef --- /dev/null +++ b/src/app/features/files/models/files-actions-options.model.ts @@ -0,0 +1,36 @@ +import { Observable } from 'rxjs'; + +import { FileModel } from '@shared/models/files/file.model'; +import { FileFolderModel } from '@shared/models/files/file-folder.model'; + +import { MoveCopyAction } from '../enums/move-copy-action.enum'; + +export interface DeleteSelectedOptions { + files: FileModel[]; + deleteEntry: (link: string) => Observable; + onSuccess: () => void; +} + +export interface MoveFilesOptions { + files: FileModel[]; + action: MoveCopyAction; + resourceId: string; + storageProvider: string; + foldersStack: FileFolderModel[]; + initialFolder: FileFolderModel | null | undefined; +} + +export interface DropMovePayload { + files: FileModel[]; + destination: FileModel; +} + +export interface ConfirmMoveFilesOptions extends DropMovePayload { + resourceId: string; + storageProvider: string; +} + +export interface CreateFolderOptions { + newFolderLink: string; + createFolder: (newFolderLink: string, folderName: string) => Observable; +} diff --git a/src/app/features/files/models/files-upload-options.model.ts b/src/app/features/files/models/files-upload-options.model.ts new file mode 100644 index 000000000..489cc20b7 --- /dev/null +++ b/src/app/features/files/models/files-upload-options.model.ts @@ -0,0 +1,16 @@ +import { FileUploadLinkModel } from '@osf/shared/models/files/file-upload-link.model'; + +export interface UploadFilesOptions { + files: File | File[]; + uploadLink: string; + allowRevisions: boolean; + onStart: (fileName: string) => void; + onProgress: (progress: number) => void; + onComplete: () => void; +} + +export interface UploadState { + completedUploads: number; + totalFiles: number; + conflictFiles: FileUploadLinkModel[]; +} diff --git a/src/app/features/files/models/get-file-revisions-response.model.ts b/src/app/features/files/models/get-file-revisions-response.model.ts index 1491314c3..04de4bc59 100644 --- a/src/app/features/files/models/get-file-revisions-response.model.ts +++ b/src/app/features/files/models/get-file-revisions-response.model.ts @@ -2,7 +2,7 @@ import { ApiData, JsonApiResponse } from '@osf/shared/models/common/json-api.mod export interface FileRevisionJsonApi { extra: { - downloads: 0; + downloads: number; hashes: { md5: string; sha256: string; diff --git a/src/app/features/files/models/index.ts b/src/app/features/files/models/index.ts deleted file mode 100644 index 29d50dfc6..000000000 --- a/src/app/features/files/models/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export * from './file-custom-metadata.model'; -export * from './file-revisions.model'; -export * from './files-metadata-fields.model'; -export * from './get-custom-metadata-response.model'; -export * from './get-file-metadata-response.model'; -export * from './get-file-revisions-response.model'; -export * from './get-short-info-response.model'; -export * from './info-item.model'; -export * from './patch-file-metadata.model'; diff --git a/src/app/features/files/models/menu-move-copy.model.ts b/src/app/features/files/models/menu-move-copy.model.ts new file mode 100644 index 000000000..3ef8e9349 --- /dev/null +++ b/src/app/features/files/models/menu-move-copy.model.ts @@ -0,0 +1,8 @@ +import { FileModel } from '@osf/shared/models/files/file.model'; + +import { MoveCopyAction } from '../enums/move-copy-action.enum'; + +export interface MenuMoveCopyPayload { + file: FileModel; + action: MoveCopyAction; +} diff --git a/src/app/features/files/models/move-copy-options.model.ts b/src/app/features/files/models/move-copy-options.model.ts new file mode 100644 index 000000000..0c9631107 --- /dev/null +++ b/src/app/features/files/models/move-copy-options.model.ts @@ -0,0 +1,12 @@ +import { FileModel } from '@osf/shared/models/files/file.model'; +import { FileFolderModel } from '@osf/shared/models/files/file-folder.model'; + +import { MoveCopyAction } from '../enums/move-copy-action.enum'; + +export interface MoveCopyOptions { + files: FileModel[]; + destination: FileModel | FileFolderModel | null | undefined; + resourceId: string; + storageProvider: string; + action: MoveCopyAction; +} diff --git a/src/app/features/files/pages/file-detail/file-detail.component.html b/src/app/features/files/pages/file-detail/file-detail.component.html index fc44a06e3..1e72e92f0 100644 --- a/src/app/features/files/pages/file-detail/file-detail.component.html +++ b/src/app/features/files/pages/file-detail/file-detail.component.html @@ -1,9 +1,6 @@ - +@let currentFile = file(); + + @@ -13,7 +10,7 @@ -
+
@@ -23,7 +20,7 @@
- @if (!isAnonymous() && !hasViewOnly() && hasWriteAccess()) { + @if (canManageFileActions()) { } - @if (file()?.links?.download) { + @if (currentFile?.links?.download) { } - @if (file()?.links?.render) { + @if (currentFile?.links?.render) {
@@ -57,7 +54,7 @@
} - @if (file()?.links?.html) { + @if (currentFile?.links?.html) {
} - @if (showDeleteButton()) { + @if (canManageFileActions()) { }
@@ -121,7 +118,7 @@ } @else { @if (filesSelection.length) { @if (!isMoveDialogOpened()) { } } @else { @@ -128,7 +122,7 @@ [progress]="progress()" > - - +
} diff --git a/src/app/features/files/pages/files/files.component.scss b/src/app/features/files/pages/files/files.component.scss index c9be697b1..e69de29bb 100644 --- a/src/app/features/files/pages/files/files.component.scss +++ b/src/app/features/files/pages/files/files.component.scss @@ -1,23 +0,0 @@ -@use "styles/mixins" as mix; - -:host { - @include mix.flex-column; - flex: 1; - overflow: hidden; -} - -.blue-text { - color: var(--pr-blue-1); -} - -.filename { - overflow-wrap: anywhere; -} - -.upload-dialog { - width: mix.rem(128px); -} - -.provider-name { - text-transform: capitalize; -} diff --git a/src/app/features/files/pages/files/files.component.spec.ts b/src/app/features/files/pages/files/files.component.spec.ts index 05c5727ac..cc0982983 100644 --- a/src/app/features/files/pages/files/files.component.spec.ts +++ b/src/app/features/files/pages/files/files.component.spec.ts @@ -7,13 +7,9 @@ import { of } from 'rxjs'; import { Mock } from 'vitest'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, Router, UrlTree } from '@angular/router'; -import { ENVIRONMENT } from '@core/provider/environment.provider'; -import { FileProvider } from '@osf/features/files/constants'; -import { FilesSelectors, GetFiles } from '@osf/features/files/store'; import { FileUploadDialogComponent } from '@osf/shared/components/file-upload-dialog/file-upload-dialog.component'; -import { FilesTreeComponent } from '@osf/shared/components/files-tree/files-tree.component'; import { FormSelectComponent } from '@osf/shared/components/form-select/form-select.component'; import { GoogleFilePickerComponent } from '@osf/shared/components/google-file-picker/google-file-picker.component'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; @@ -22,28 +18,27 @@ import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header import { ViewOnlyLinkMessageComponent } from '@osf/shared/components/view-only-link-message/view-only-link-message.component'; import { SupportedFeature } from '@osf/shared/enums/addon-supported-features.enum'; import { FileKind } from '@osf/shared/enums/file-kind.enum'; -import { FileMenuType } from '@osf/shared/enums/file-menu-type.enum'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; -import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; -import { ConfiguredAddonModel } from '@osf/shared/models/addons/configured-addon.model'; import { CurrentResource } from '@osf/shared/models/current-resource.model'; import { FileFolderModel } from '@osf/shared/models/files/file-folder.model'; import { FileLabelModel } from '@osf/shared/models/files/file-label.model'; -import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; +import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { FilesService } from '@osf/shared/services/files.service'; +import { FilesTreeActionsService } from '@osf/shared/services/files-tree-actions.service'; import { ToastService } from '@osf/shared/services/toast.service'; import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; -import { CurrentResourceSelectors } from '@osf/shared/stores/current-resource'; -import { CustomDialogService } from '@shared/services/custom-dialog.service'; +import { CurrentResourceSelectors, GetResourceDetails } from '@osf/shared/stores/current-resource'; +import { DataciteService } from '@shared/services/datacite/datacite.service'; +import { MOCK_CONFIGURED_ADDON } from '@testing/mocks/configured-addon.mock'; +import { FileModelMock } from '@testing/mocks/file.model.mock'; +import { OSF_FILE_MOCK } from '@testing/mocks/osf-file.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; -import { - CustomConfirmationServiceMock, - CustomConfirmationServiceMockType, -} from '@testing/providers/custom-confirmation-provider.mock'; import { CustomDialogServiceMock, CustomDialogServiceMockType } from '@testing/providers/custom-dialog-provider.mock'; +import { DataciteServiceMock, DataciteServiceMockType } from '@testing/providers/datacite.service.mock'; +import { FilesServiceMock, FilesServiceMockType } from '@testing/providers/files-service.mock'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; -import { provideRouterMock, RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; +import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; import { BaseSetupOverrides, mergeSignalOverrides, @@ -53,154 +48,135 @@ import { import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; import { ViewOnlyLinkHelperMock, ViewOnlyLinkHelperMockType } from '@testing/providers/view-only-link-helper.mock'; -import { FilesSelectionActionsComponent } from '../../components'; +import { FileBrowserInfoComponent } from '../../components/file-browser-info/file-browser-info.component'; +import { FilesSelectionActionsComponent } from '../../components/files-selection-actions/files-selection-actions.component'; +import { FilesTreeExplorerComponent } from '../../components/files-tree-explorer/files-tree-explorer.component'; +import { FileProvider } from '../../constants/file-provider.constants'; +import { MoveCopyAction } from '../../enums/move-copy-action.enum'; +import { FilesActionsService } from '../../services/files-actions.service'; +import { FilesUploadService } from '../../services/files-upload.service'; +import { + DeleteEntry, + FilesSelectors, + GetConfiguredStorageAddons, + GetFiles, + GetRootFolders, + RenameEntry, + SetCurrentProvider, + SetFilesCurrentFolder, + SetMoveDialogCurrentFolder, +} from '../../store'; import { FilesComponent } from './files.component'; interface SetupOverrides extends BaseSetupOverrides { - fileProvider?: string; - hasViewOnlyParam?: boolean; + routeParams?: Record; + resourceId?: string; } describe('FilesComponent', () => { let component: FilesComponent; let fixture: ComponentFixture; let store: Store; - let routerMock: RouterMockType & { serializeUrl: Mock }; - let customDialogServiceMock: CustomDialogServiceMockType; - let customConfirmationServiceMock: CustomConfirmationServiceMockType; + let routerMock: RouterMockType; + let filesService: FilesServiceMockType; let toastService: ToastServiceMockType; - let viewOnlyLinkHelperMock: ViewOnlyLinkHelperMockType; + let viewOnlyHelper: ViewOnlyLinkHelperMockType; + let filesActionsService: { + deleteSelected: Mock; + openMoveDialog: Mock; + openConfirmMoveDialog: Mock; + openCreateFolderDialog: Mock; + openRenameFileDialog: Mock; + }; + let filesTreeActionsService: { + confirmDropFiles: Mock; + confirmDeleteEntry: Mock; + }; + let filesUploadService: { + uploadFiles: Mock; + }; + let customDialogService: CustomDialogServiceMockType; + let dataciteService: DataciteServiceMockType; const currentFolder: FileFolderModel = { - id: 'folder-1', - kind: FileKind.Folder, - name: 'Root folder', - node: 'node-1', - path: '/', + ...OSF_FILE_MOCK, + id: 'root-1', + name: 'OSF Storage', provider: FileProvider.OsfStorage, - links: { - newFolder: '/new-folder', - storageAddons: '/storage-addons', - upload: '/upload', - filesLink: '/files-link', - download: '/download-link', - }, + links: { ...OSF_FILE_MOCK.links, filesLink: '/files-link', upload: '/upload-link', newFolder: '/new-folder' }, }; - const rootFolders: FileFolderModel[] = [currentFolder]; - - const configuredAddons: ConfiguredAddonModel[] = [ - { - id: 'addon-osfstorage', - type: 'addons', - externalServiceName: FileProvider.OsfStorage, - displayName: 'OSF Storage', - connectedCapabilities: [], - connectedOperationNames: [], - currentUserIsOwner: true, - selectedStorageItemId: '', - baseAccountId: '', - baseAccountType: '', - iconUrl: '', - authUrl: '', - credentialsAvailable: true, - }, - { - id: 'addon-gdrive', - type: 'addons', - externalServiceName: FileProvider.GoogleDrive, - displayName: 'Google Drive', - connectedCapabilities: [], - connectedOperationNames: [], - currentUserIsOwner: true, - selectedStorageItemId: 'google-item', - baseAccountId: 'base-google', - baseAccountType: 'users', - iconUrl: '', - authUrl: '', - credentialsAvailable: true, - }, - ]; - - const defaultSignals: SignalOverride[] = [ - { selector: FilesSelectors.getFiles, value: [] }, - { selector: FilesSelectors.getFilesTotalCount, value: 0 }, - { selector: FilesSelectors.isFilesLoading, value: false }, - { selector: FilesSelectors.getCurrentFolder, value: currentFolder }, - { selector: FilesSelectors.getProvider, value: FileProvider.OsfStorage }, - { - selector: CurrentResourceSelectors.getResourceDetails, - value: { - id: 'node-1', - type: 'nodes', - title: 'Node', - description: '', - category: 'project', - dateCreated: '', - dateModified: '', - isRegistration: false, - isPreprint: false, - isFork: false, - isCollection: false, - isPublic: true, - tags: [], - accessRequestsEnabled: false, - nodeLicense: { copyrightHolders: null, year: null }, - currentUserPermissions: [UserPermissions.Admin], - currentUserIsContributor: true, - wikiEnabled: true, - }, - }, - { - selector: CurrentResourceSelectors.getCurrentResource, - value: { id: 'node-1', type: 'nodes', permissions: [UserPermissions.Admin] } as CurrentResource, - }, - { selector: FilesSelectors.getRootFolders, value: rootFolders }, - { selector: FilesSelectors.isRootFoldersLoading, value: false }, - { selector: FilesSelectors.getConfiguredStorageAddons, value: configuredAddons }, - { selector: FilesSelectors.isConfiguredStorageAddonsLoading, value: false }, - { - selector: FilesSelectors.getStorageSupportedFeatures, - value: { - [FileProvider.OsfStorage]: [ - SupportedFeature.DownloadAsZip, - SupportedFeature.AddUpdateFiles, - SupportedFeature.DeleteFiles, - SupportedFeature.CopyInto, - ], - }, - }, - ]; + const currentResource = { id: 'node-1', type: 'nodes' } as CurrentResource; + const rootFolderOption: FileLabelModel = { label: 'OSF Storage', folder: currentFolder }; function setup(overrides: SetupOverrides = {}) { - const routerBuilder = RouterMockBuilder.create().withUrl('/abc'); - routerMock = { - ...routerBuilder.build(), - serializeUrl: vi.fn().mockReturnValue('/guid-url'), - }; - (routerMock.createUrlTree as Mock).mockReturnValue('/guid-url'); - customDialogServiceMock = CustomDialogServiceMock.simple(); - customConfirmationServiceMock = CustomConfirmationServiceMock.simple(); + routerMock = RouterMockBuilder.create() + .withUrl('/node-1/files/osfstorage') + .withCreateUrlTree(vi.fn().mockReturnValue({} as UrlTree)) + .withSerializeUrl(vi.fn().mockReturnValue('/serialized')) + .build(); + + filesService = FilesServiceMock.simple(); toastService = ToastServiceMock.simple(); - viewOnlyLinkHelperMock = ViewOnlyLinkHelperMock.simple(overrides.hasViewOnlyParam ?? false); - viewOnlyLinkHelperMock.getViewOnlyParamFromUrl.mockReturnValue('view-only-token'); + viewOnlyHelper = ViewOnlyLinkHelperMock.simple(false); + customDialogService = CustomDialogServiceMock.simple(); + dataciteService = DataciteServiceMock.simple(); + + filesActionsService = { + deleteSelected: vi.fn(), + openMoveDialog: vi.fn().mockReturnValue(of(true)), + openConfirmMoveDialog: vi.fn().mockReturnValue(of(true)), + openCreateFolderDialog: vi.fn().mockReturnValue(of(true)), + openRenameFileDialog: vi.fn().mockReturnValue(of({ link: '/rename', newName: 'new-name' })), + }; + filesTreeActionsService = { + confirmDropFiles: vi.fn(), + confirmDeleteEntry: vi.fn(), + }; + filesUploadService = { + uploadFiles: vi.fn(), + }; - const resourceRouteMock = ActivatedRouteMockBuilder.create().withParams({ id: 'node-1' }).build(); - const dataRouteMock = ActivatedRouteMockBuilder.create() + const resourceRoute = ActivatedRouteMockBuilder.create() + .withParams({ id: overrides.resourceId ?? 'node-1' }) + .build(); + const dataRoute = ActivatedRouteMockBuilder.create() .withData({ resourceType: ResourceType.Project }) - .withParentRoute(resourceRouteMock) + .withParentRoute(resourceRoute) .build(); - const activatedRouteMock = ActivatedRouteMockBuilder.create() - .withParams({ fileProvider: overrides.fileProvider ?? FileProvider.OsfStorage }) - .withParentRoute(dataRouteMock) + const routeMock = ActivatedRouteMockBuilder.create() + .withParams(overrides.routeParams ?? { fileProvider: FileProvider.OsfStorage }) + .withParentRoute(dataRoute) .build(); + const defaultSignals: SignalOverride[] = [ + { selector: FilesSelectors.getFiles, value: [] }, + { selector: FilesSelectors.getFilesTotalCount, value: 0 }, + { selector: FilesSelectors.isFilesLoading, value: false }, + { selector: FilesSelectors.getCurrentFolder, value: currentFolder }, + { selector: FilesSelectors.getProvider, value: FileProvider.OsfStorage }, + { selector: CurrentResourceSelectors.getCurrentResource, value: currentResource }, + { selector: FilesSelectors.getRootFolders, value: [currentFolder] }, + { selector: FilesSelectors.isRootFoldersLoading, value: false }, + { + selector: FilesSelectors.getConfiguredStorageAddons, + value: [{ ...MOCK_CONFIGURED_ADDON, id: 'addon-1', externalServiceName: FileProvider.OsfStorage }], + }, + { selector: FilesSelectors.isConfiguredStorageAddonsLoading, value: false }, + { + selector: FilesSelectors.getStorageSupportedFeatures, + value: { [FileProvider.OsfStorage]: [SupportedFeature.AddUpdateFiles] }, + }, + { selector: CurrentResourceSelectors.hasResourceWriteAccess, value: true }, + { selector: CurrentResourceSelectors.hasResourceAdminAccess, value: false }, + ]; + TestBed.configureTestingModule({ imports: [ FilesComponent, ...MockComponents( - FilesTreeComponent, + FilesTreeExplorerComponent, FormSelectComponent, GoogleFilePickerComponent, LoadingSpinnerComponent, @@ -208,149 +184,395 @@ describe('FilesComponent', () => { SubHeaderComponent, FileUploadDialogComponent, ViewOnlyLinkMessageComponent, - GoogleFilePickerComponent, FilesSelectionActionsComponent ), ], providers: [ provideOSFCore(), - MockProvider(ActivatedRoute, activatedRouteMock), - provideRouterMock(routerMock), - MockProvider(FilesService, { - uploadFile: vi.fn().mockReturnValue(of({})), - getFolderDownloadLink: vi.fn().mockReturnValue('https://download.link'), - }), - MockProvider(CustomDialogService, customDialogServiceMock), - MockProvider(CustomConfirmationService, customConfirmationServiceMock), + MockProvider(ActivatedRoute, routeMock), + MockProvider(Router, routerMock), + MockProvider(FilesService, filesService), MockProvider(ToastService, toastService), - MockProvider(ViewOnlyLinkHelperService, viewOnlyLinkHelperMock), - MockProvider(ENVIRONMENT, { webUrl: 'http://localhost:4200', apiDomainUrl: 'http://localhost:8000' }), + MockProvider(ViewOnlyLinkHelperService, viewOnlyHelper), + MockProvider(CustomDialogService, customDialogService), + MockProvider(DataciteService, dataciteService), + MockProvider(FilesActionsService, filesActionsService), + MockProvider(FilesTreeActionsService, filesTreeActionsService), + MockProvider(FilesUploadService, filesUploadService), provideMockStore({ signals: mergeSignalOverrides(defaultSignals, overrides.selectorOverrides) }), ], }); + TestBed.overrideComponent(FilesComponent, { + set: { + providers: [ + MockProvider(FilesActionsService, filesActionsService), + MockProvider(FilesUploadService, filesUploadService), + ], + }, + }); store = TestBed.inject(Store); fixture = TestBed.createComponent(FilesComponent); component = fixture.componentInstance; + component.currentRootFolder.set(rootFolderOption); fixture.detectChanges(); } it('should create', () => { setup(); - expect(component).toBeTruthy(); }); - it('should compute canEdit based on current user permissions', () => { + it('should dispatch resource and storage loading actions on init', () => { setup(); - expect(component.canEdit()).toBe(true); + const calls = (store.dispatch as Mock).mock.calls.map((c) => c[0]); + + expect(calls).toContainEqual(new GetResourceDetails('node-1', ResourceType.Project)); + expect(calls).toContainEqual(new GetRootFolders('node-1', ResourceType.Project)); + expect(calls).toContainEqual(new GetConfiguredStorageAddons('node-1')); }); - it('should return false for canEdit without admin/write permissions', () => { - setup({ - selectorOverrides: [ - { - selector: CurrentResourceSelectors.getResourceDetails, - value: { - id: 'node-1', - type: 'nodes', - title: 'Node', - description: '', - category: 'project', - dateCreated: '', - dateModified: '', - isRegistration: false, - isPreprint: false, - isFork: false, - isCollection: false, - isPublic: true, - tags: [], - accessRequestsEnabled: false, - nodeLicense: { copyrightHolders: null, year: null }, - currentUserPermissions: [UserPermissions.Read], - currentUserIsContributor: true, - wikiEnabled: true, - }, - }, - ], + it('should call uploadFiles from tree upload confirm callback', () => { + setup(); + const uploadSpy = vi.spyOn(component, 'uploadFiles').mockImplementation(() => {}); + const dropped = [new File(['a'], 'a.txt')]; + filesTreeActionsService.confirmDropFiles.mockImplementation((_files, onConfirm) => onConfirm()); + + component.confirmTreeUpload(dropped); + + expect(filesTreeActionsService.confirmDropFiles).toHaveBeenCalledWith(dropped, expect.any(Function)); + expect(uploadSpy).toHaveBeenCalledWith(dropped); + }); + + it('should skip upload when selected file exceeds size limit', () => { + setup(); + const uploadSpy = vi.spyOn(component, 'uploadFiles').mockImplementation(() => {}); + const big = new File(['x'], 'big.txt'); + Object.defineProperty(big, 'size', { value: 5 * 1024 * 1024 * 1024 }); + const input = document.createElement('input'); + Object.defineProperty(input, 'files', { value: [big] }); + + component.onFileSelected({ target: input } as unknown as Event); + + expect(toastService.showWarn).toHaveBeenCalledWith('shared.files.limitText'); + expect(uploadSpy).not.toHaveBeenCalled(); + }); + + it('should open move dialog and clear selection on success', () => { + setup(); + const file = FileModelMock.simple({ id: 'file-1' }); + component.filesSelection = [file]; + (store.dispatch as Mock).mockClear(); + + component.moveFiles([file], MoveCopyAction.Move); + + expect(store.dispatch).toHaveBeenCalledWith(new SetMoveDialogCurrentFolder(currentFolder)); + expect(filesActionsService.openMoveDialog).toHaveBeenCalled(); + expect(component.filesSelection).toEqual([]); + }); + + it('should confirm and delete entry through tree action service', () => { + setup(); + const file = FileModelMock.simple({ id: 'f1', links: { ...FileModelMock.simple().links, delete: '/delete-link' } }); + (store.dispatch as Mock).mockClear(); + filesTreeActionsService.confirmDeleteEntry.mockImplementation((_file, onConfirm) => onConfirm()); + + component.deleteEntry(file); + + expect(filesTreeActionsService.confirmDeleteEntry).toHaveBeenCalledWith(file, expect.any(Function)); + expect(store.dispatch).toHaveBeenCalledWith(new DeleteEntry('/delete-link')); + expect(toastService.showSuccess).toHaveBeenCalledWith('files.dialogs.deleteFile.success'); + }); + + it('should navigate to provider route when root folder changes', () => { + setup(); + + component.handleRootFolderChange(rootFolderOption); + + expect(routerMock.navigate).toHaveBeenCalledWith(['/node-1/files', FileProvider.OsfStorage], { + queryParamsHandling: 'preserve', }); - expect(component.canEdit()).toBe(false); }); - it('should expose read-only menu actions when view-only mode is enabled', () => { - setup({ hasViewOnlyParam: true }); + it('should dispatch get files when loading a page', () => { + setup(); + (store.dispatch as Mock).mockClear(); - const actions = component.allowedMenuActions(); + component.onLoadFiles({ link: '/page-link', page: 3 }); - expect(actions[FileMenuType.Download]).toBe(true); - expect(actions[FileMenuType.Embed]).toBe(true); - expect(actions[FileMenuType.Share]).toBe(true); - expect(actions[FileMenuType.Rename]).toBe(false); - expect(actions[FileMenuType.Delete]).toBe(false); - expect(actions[FileMenuType.Move]).toBe(false); - expect(actions[FileMenuType.Copy]).toBe(false); + expect(store.dispatch).toHaveBeenCalledWith(new GetFiles('/page-link', 3)); }); - it('should map root folder options from folders and configured addons', () => { + it('should refresh files list from current folder link', () => { setup(); + (store.dispatch as Mock).mockClear(); - const options = component.rootFoldersOptions(); + component.updateFilesList(); - expect(options.length).toBe(1); - expect(options[0].folder.id).toBe('folder-1'); + expect(store.dispatch).toHaveBeenCalledWith(new GetFiles('/files-link', 1)); }); - it('should return addon display name for non-osf provider in getAddonName', () => { + it('should delegate upload to upload service when upload link exists', () => { setup(); + const small = new File(['a'], 'a.txt'); + + component.uploadFiles(small); + + expect(filesUploadService.uploadFiles).toHaveBeenCalledWith( + expect.objectContaining({ + files: small, + uploadLink: '/upload-link', + allowRevisions: true, + }) + ); + }); + + it('should skip upload service when upload link is missing', () => { + const folderWithoutUpload = { ...currentFolder, links: { ...currentFolder.links, upload: '' } }; + setup({ + selectorOverrides: [{ selector: FilesSelectors.getCurrentFolder, value: folderWithoutUpload }], + }); - const name = component.getAddonName(configuredAddons, FileProvider.GoogleDrive); + component.uploadFiles(new File(['a'], 'a.txt')); - expect(name).toBe('Google Drive'); + expect(filesUploadService.uploadFiles).not.toHaveBeenCalled(); }); - it('should show warning and skip upload when selected file exceeds size limit', () => { + it('should start upload for valid file input selection', () => { setup(); - const uploadSpy = vi.spyOn(component, 'uploadFiles'); - const oversizedFile = new File([new ArrayBuffer(1)], 'large.txt'); - Object.defineProperty(oversizedFile, 'size', { value: 5 * 1024 * 1024 * 1024 }); + const small = new File(['a'], 'a.txt'); const input = document.createElement('input'); - Object.defineProperty(input, 'files', { value: [oversizedFile] }); + Object.defineProperty(input, 'files', { value: [small] }); component.onFileSelected({ target: input } as unknown as Event); - expect(toastService.showWarn).toHaveBeenCalledWith('shared.files.limitText'); - expect(uploadSpy).not.toHaveBeenCalled(); + expect(filesUploadService.uploadFiles).toHaveBeenCalled(); }); - it('should pass selected files to uploadFiles when files are valid', () => { + it('should add and remove tree selection entries', () => { setup(); - const uploadSpy = vi.spyOn(component, 'uploadFiles').mockImplementation(() => {}); - const validFile = new File(['body'], 'small.txt'); - const input = document.createElement('input'); - Object.defineProperty(input, 'files', { value: [validFile] }); + const first = FileModelMock.simple({ id: 'a' }); + const second = FileModelMock.simple({ id: 'b' }); - component.onFileSelected({ target: input } as unknown as Event); + component.onFileTreeSelected(first); + component.onFileTreeSelected(first); + component.onFileTreeSelected(second); + + expect(component.filesSelection).toEqual([first, second]); + + component.onFileTreeUnselected(first); + + expect(component.filesSelection).toEqual([second]); + + component.clearFilesSelection(); + + expect(component.filesSelection).toEqual([]); + }); - expect(uploadSpy).toHaveBeenCalledWith([validFile]); + it('should delete selected files through actions service', () => { + setup(); + const file = FileModelMock.simple({ id: 'sel-1' }); + component.filesSelection = [file]; + + component.onDeleteSelected(); + + expect(filesActionsService.deleteSelected).toHaveBeenCalledWith( + expect.objectContaining({ + files: [file], + deleteEntry: expect.any(Function), + onSuccess: expect.any(Function), + }) + ); }); - it('should dispatch GetFiles from updateFilesList when current folder has files link', () => { + it('should open move dialog for move selection action', () => { setup(); + const file = FileModelMock.simple({ id: 'm1' }); + component.filesSelection = [file]; (store.dispatch as Mock).mockClear(); - component.updateFilesList(); + component.onMoveSelected(); - expect(store.dispatch).toHaveBeenCalledWith(new GetFiles('/files-link', 1)); + expect(filesActionsService.openMoveDialog).toHaveBeenCalledWith( + expect.objectContaining({ files: [file], action: MoveCopyAction.Move }) + ); }); - it('should navigate with provider on root folder change', () => { + it('should open move dialog for copy selection action', () => { setup(); - const selectedFolder: FileLabelModel = { label: 'OSF Storage', folder: currentFolder }; + const file = FileModelMock.simple({ id: 'c1' }); + component.filesSelection = [file]; + (store.dispatch as Mock).mockClear(); - component.handleRootFolderChange(selectedFolder); + component.onCopySelected(); - expect(routerMock.navigate).toHaveBeenCalledWith(['/node-1/files', FileProvider.OsfStorage], { - queryParamsHandling: 'preserve', + expect(filesActionsService.openMoveDialog).toHaveBeenCalledWith( + expect.objectContaining({ files: [file], action: MoveCopyAction.Copy }) + ); + }); + + it('should open move dialog from menu move copy payload', () => { + setup(); + const file = FileModelMock.simple({ id: 'menu-1' }); + + component.onMenuMoveCopy({ file, action: MoveCopyAction.Copy }); + + expect(filesActionsService.openMoveDialog).toHaveBeenCalledWith( + expect.objectContaining({ files: [file], action: MoveCopyAction.Copy }) + ); + }); + + it('should open confirm move dialog when provider is set', () => { + setup(); + const file = FileModelMock.simple({ id: 'f1' }); + const destination = FileModelMock.simple({ id: 'dest', kind: FileKind.Folder }); + (store.dispatch as Mock).mockClear(); + + component.onDropMove({ files: [file], destination }); + + expect(filesActionsService.openConfirmMoveDialog).toHaveBeenCalledWith( + expect.objectContaining({ + files: [file], + destination, + resourceId: 'node-1', + storageProvider: FileProvider.OsfStorage, + }) + ); + }); + + it('should skip confirm move dialog when provider is missing', () => { + setup({ + selectorOverrides: [{ selector: FilesSelectors.getProvider, value: null }], }); + const file = FileModelMock.simple({ id: 'f1' }); + const destination = FileModelMock.simple({ id: 'dest', kind: FileKind.Folder }); + + component.onDropMove({ files: [file], destination }); + + expect(filesActionsService.openConfirmMoveDialog).not.toHaveBeenCalled(); + }); + + it('should open create folder dialog and toast on success', () => { + setup(); + (store.dispatch as Mock).mockClear(); + + component.createFolder(); + + expect(filesActionsService.openCreateFolderDialog).toHaveBeenCalledWith( + expect.objectContaining({ + newFolderLink: '/new-folder', + createFolder: expect.any(Function), + }) + ); + expect(toastService.showSuccess).toHaveBeenCalledWith('files.dialogs.createFolder.success'); + expect(store.dispatch).toHaveBeenCalledWith(new GetFiles('/files-link', 1)); + }); + + it('should log download and open folder zip link', () => { + setup(); + (store.dispatch as Mock).mockClear(); + const openSpy = vi.spyOn(window, 'open').mockReturnValue({ focus: vi.fn() } as unknown as Window); + + component.downloadFolder(); + + expect(dataciteService.logFileDownload).toHaveBeenCalledWith('node-1', 'nodes'); + expect(filesService.getFolderDownloadLink).toHaveBeenCalledWith('/v2/files/file-123/download/'); + expect(openSpy).toHaveBeenCalledWith('/v2/files/file-123/download/?zip=', '_blank'); + openSpy.mockRestore(); + }); + + it('should skip download when resource id is missing', () => { + setup({ resourceId: '' }); + const openSpy = vi.spyOn(window, 'open'); + + component.downloadFolder(); + + expect(dataciteService.logFileDownload).not.toHaveBeenCalled(); + expect(openSpy).not.toHaveBeenCalled(); + openSpy.mockRestore(); + }); + + it('should open files browser info dialog', () => { + setup(); + + component.showInfoDialog(); + + expect(customDialogService.open).toHaveBeenCalledWith( + FileBrowserInfoComponent, + expect.objectContaining({ + header: 'files.filesBrowserDialog.title', + width: '850px', + data: ResourceType.Project, + }) + ); + }); + + it('should set current folder and clear selection', () => { + setup(); + const file = FileModelMock.simple({ id: 'keep' }); + component.filesSelection = [file]; + (store.dispatch as Mock).mockClear(); + const nextFolder: FileFolderModel = { ...currentFolder, id: 'nested', path: '/nested' }; + + component.setCurrentFolder(nextFolder); + + expect(component.filesSelection).toEqual([]); + expect(store.dispatch).toHaveBeenCalledWith(new SetFilesCurrentFolder(nextFolder)); + }); + + it('should dispatch rename and toast on rename success', () => { + setup(); + (store.dispatch as Mock).mockClear(); + + component.onRenameFile(FileModelMock.simple({ id: 'r1' })); + + expect(store.dispatch).toHaveBeenCalledWith(new RenameEntry('/rename', 'new-name')); + expect(toastService.showSuccess).toHaveBeenCalledWith('files.dialogs.renameFile.success'); + }); + + it('should open file detail when file has guid', () => { + setup(); + const openSpy = vi.spyOn(window, 'open').mockReturnValue(null); + const file = FileModelMock.simple({ id: 'x', guid: 'guid-99' }); + + component.navigateToFile(file); + + expect(openSpy).toHaveBeenCalledWith('/serialized', '_blank'); + expect(filesService.getFileGuid).not.toHaveBeenCalled(); + openSpy.mockRestore(); + }); + + it('should resolve guid then open when file guid is missing', () => { + setup(); + const openSpy = vi.spyOn(window, 'open').mockReturnValue(null); + const resolved = FileModelMock.simple({ id: 'y', guid: 'resolved-guid' }); + filesService.getFileGuid.mockReturnValue(of(resolved)); + const file = FileModelMock.simple({ id: 'y', guid: undefined }); + + component.navigateToFile(file); + + expect(filesService.getFileGuid).toHaveBeenCalledWith('y'); + expect(openSpy).toHaveBeenCalledWith('/serialized', '_blank'); + openSpy.mockRestore(); + }); + + it('should dispatch current provider on resetProvider', () => { + setup(); + (store.dispatch as Mock).mockClear(); + + component.resetProvider(); + + expect(store.dispatch).toHaveBeenCalledWith(new SetCurrentProvider(FileProvider.OsfStorage)); + }); + + it('should clear selection and refresh files on reset after dialog', () => { + setup(); + component.filesSelection = [FileModelMock.simple({ id: 'z1' })]; + (store.dispatch as Mock).mockClear(); + + component.resetOnDialogClose(); + + expect(component.filesSelection).toEqual([]); + expect(store.dispatch).toHaveBeenCalledWith(new GetFiles('/files-link', 1)); }); }); diff --git a/src/app/features/files/pages/files/files.component.ts b/src/app/features/files/pages/files/files.component.ts index 00ab2a2ef..5f55e4633 100644 --- a/src/app/features/files/pages/files/files.component.ts +++ b/src/app/features/files/pages/files/files.component.ts @@ -2,33 +2,18 @@ import { createDispatchMap, select } from '@ngxs/store'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; -import { TreeDragDropService } from 'primeng/api'; import { Button } from 'primeng/button'; import { Select } from 'primeng/select'; -import { TableModule } from 'primeng/table'; -import { - catchError, - debounceTime, - distinctUntilChanged, - filter, - finalize, - forkJoin, - map, - of, - switchMap, - take, -} from 'rxjs'; +import { debounceTime, distinctUntilChanged, finalize, map, of, switchMap, tap } from 'rxjs'; import { isPlatformBrowser } from '@angular/common'; -import { HttpEventType, HttpResponse } from '@angular/common/http'; import { ChangeDetectionStrategy, Component, computed, DestroyRef, effect, - HostBinding, inject, model, PLATFORM_ID, @@ -39,24 +24,7 @@ import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; -import { ENVIRONMENT } from '@core/provider/environment.provider'; -import { - CreateFolder, - DeleteEntry, - GetConfiguredStorageAddons, - GetFiles, - GetRootFolders, - GetStorageSupportedFeatures, - RenameEntry, - ResetFilesState, - SetCurrentProvider, - SetFilesCurrentFolder, - SetMoveDialogCurrentFolder, - SetSearch, - SetSort, -} from '@osf/features/files/store'; import { FileUploadDialogComponent } from '@osf/shared/components/file-upload-dialog/file-upload-dialog.component'; -import { FilesTreeComponent } from '@osf/shared/components/files-tree/files-tree.component'; import { FormSelectComponent } from '@osf/shared/components/form-select/form-select.component'; import { GoogleFilePickerComponent } from '@osf/shared/components/google-file-picker/google-file-picker.component'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; @@ -67,39 +35,56 @@ import { FILE_SIZE_LIMIT } from '@osf/shared/constants/files-limits.const'; import { ALL_SORT_OPTIONS } from '@osf/shared/constants/sort-options.const'; import { SupportedFeature } from '@osf/shared/enums/addon-supported-features.enum'; import { FileMenuType } from '@osf/shared/enums/file-menu-type.enum'; -import { ResourceType } from '@osf/shared/enums/resource-type.enum'; -import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; -import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; +import { CurrentResourceType, ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { mapRootFoldersToStorageLabels } from '@osf/shared/helpers/storage-addon-options.helper'; +import { FilePageLinkModel } from '@osf/shared/models/files/file-page-link.model'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { FilesService } from '@osf/shared/services/files.service'; +import { FilesTreeActionsService } from '@osf/shared/services/files-tree-actions.service'; import { ToastService } from '@osf/shared/services/toast.service'; import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; import { CurrentResourceSelectors, GetResourceDetails } from '@osf/shared/stores/current-resource'; -import { ConfiguredAddonModel } from '@shared/models/addons/configured-addon.model'; import { StorageItem } from '@shared/models/addons/storage-item.model'; import { FileModel } from '@shared/models/files/file.model'; import { FileFolderModel } from '@shared/models/files/file-folder.model'; import { FileLabelModel } from '@shared/models/files/file-label.model'; import { DataciteService } from '@shared/services/datacite/datacite.service'; -import { - CreateFolderDialogComponent, - FileBrowserInfoComponent, - FilesSelectionActionsComponent, - MoveFileDialogComponent, -} from '../../components'; +import { FileBrowserInfoComponent } from '../../components/file-browser-info/file-browser-info.component'; +import { FilesSelectionActionsComponent } from '../../components/files-selection-actions/files-selection-actions.component'; +import { FilesTreeExplorerComponent } from '../../components/files-tree-explorer/files-tree-explorer.component'; import { FileProvider } from '../../constants'; -import { FilesSelectors } from '../../store'; +import { MoveCopyAction } from '../../enums/move-copy-action.enum'; +import { mapMenuActions } from '../../mappers/file-menu-actions.mapper'; +import { ConfirmMoveFilesOptions, DropMovePayload } from '../../models/files-actions-options.model'; +import { MenuMoveCopyPayload } from '../../models/menu-move-copy.model'; +import { FilesActionsService } from '../../services/files-actions.service'; +import { FilesUploadService } from '../../services/files-upload.service'; +import { + CreateFolder, + DeleteEntry, + FilesSelectors, + GetConfiguredStorageAddons, + GetFiles, + GetRootFolders, + GetStorageSupportedFeatures, + RenameEntry, + ResetFilesState, + SetCurrentProvider, + SetFilesCurrentFolder, + SetMoveDialogCurrentFolder, + SetSearch, + SetSort, +} from '../../store'; @Component({ selector: 'osf-files', imports: [ Button, - TableModule, Select, FormsModule, ReactiveFormsModule, - FilesTreeComponent, + FilesTreeExplorerComponent, FormSelectComponent, GoogleFilePickerComponent, LoadingSpinnerComponent, @@ -107,20 +92,15 @@ import { FilesSelectors } from '../../store'; SubHeaderComponent, FileUploadDialogComponent, ViewOnlyLinkMessageComponent, - GoogleFilePickerComponent, FilesSelectionActionsComponent, TranslatePipe, ], templateUrl: './files.component.html', styleUrl: './files.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, - providers: [TreeDragDropService], + providers: [FilesActionsService, FilesUploadService], }) export class FilesComponent { - googleFilePickerComponent = viewChild(GoogleFilePickerComponent); - - @HostBinding('class') classes = 'flex flex-column flex-1 w-full h-full'; - private readonly filesService = inject(FilesService); private readonly activeRoute = inject(ActivatedRoute); private readonly destroyRef = inject(DestroyRef); @@ -128,15 +108,14 @@ export class FilesComponent { private readonly translateService = inject(TranslateService); private readonly router = inject(Router); private readonly dataciteService = inject(DataciteService); - private readonly environment = inject(ENVIRONMENT); - private readonly customConfirmationService = inject(CustomConfirmationService); + private readonly filesActionsService = inject(FilesActionsService); + private readonly filesTreeActionsService = inject(FilesTreeActionsService); + private readonly filesUploadService = inject(FilesUploadService); private readonly toastService = inject(ToastService); private readonly viewOnlyService = inject(ViewOnlyLinkHelperService); - private readonly platformId = inject(PLATFORM_ID); - private readonly isBrowser = isPlatformBrowser(this.platformId); + private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); - private readonly webUrl = this.environment.webUrl; - private readonly apiDomainUrl = this.environment.apiDomainUrl; + googleFilePickerComponent = viewChild(GoogleFilePickerComponent); private readonly actions = createDispatchMap({ createFolder: CreateFolder, @@ -160,13 +139,17 @@ export class FilesComponent { readonly isFilesLoading = select(FilesSelectors.isFilesLoading); readonly currentFolder = select(FilesSelectors.getCurrentFolder); readonly provider = select(FilesSelectors.getProvider); - readonly resourceDetails = select(CurrentResourceSelectors.getResourceDetails); readonly resourceMetadata = select(CurrentResourceSelectors.getCurrentResource); readonly rootFolders = select(FilesSelectors.getRootFolders); readonly isRootFoldersLoading = select(FilesSelectors.isRootFoldersLoading); readonly configuredStorageAddons = select(FilesSelectors.getConfiguredStorageAddons); readonly isConfiguredStorageAddonsLoading = select(FilesSelectors.isConfiguredStorageAddonsLoading); readonly supportedFeatures = select(FilesSelectors.getStorageSupportedFeatures); + readonly hasWriteAccess = select(CurrentResourceSelectors.hasResourceWriteAccess); + readonly hasAdminAccess = select(CurrentResourceSelectors.hasResourceAdminAccess); + readonly currentResourceType = computed( + () => (this.resourceMetadata()?.type as CurrentResourceType) ?? CurrentResourceType.Projects + ); readonly isGoogleDrive = signal(false); readonly accountId = signal(''); @@ -193,17 +176,12 @@ export class FilesComponent { allowRevisions = false; filesSelection: FileModel[] = []; - private readonly urlMap = new Map([ - [ResourceType.Project, 'nodes'], - [ResourceType.Registration, 'registrations'], - ]); - readonly allowedMenuActions = computed(() => { const provider = this.provider(); const supportedFeatures = this.supportedFeatures()[provider] || []; const hasViewOnly = this.hasViewOnly(); const isRegistration = this.resourceType() === ResourceType.Registration; - const menuMap = this.mapMenuActions(supportedFeatures); + const menuMap = mapMenuActions(supportedFeatures); const result: Record = { ...menuMap }; @@ -219,15 +197,8 @@ export class FilesComponent { }); readonly rootFoldersOptions = computed(() => { - const rootFolders = this.rootFolders(); - const addons = this.configuredStorageAddons(); - if (rootFolders && addons) { - return rootFolders.map((folder) => ({ - label: this.getAddonName(addons, folder.provider), - folder: folder, - })); - } - return []; + const osfLabel = this.translateService.instant('files.storageLocation'); + return mapRootFoldersToStorageLabels(this.rootFolders(), this.configuredStorageAddons(), osfLabel); }); resourceType = signal( @@ -235,16 +206,7 @@ export class FilesComponent { ); readonly hasViewOnly = computed(() => this.viewOnlyService.hasViewOnlyParam(this.router)); - - readonly canEdit = computed(() => { - const details = this.resourceDetails(); - const hasAdminOrWrite = details.currentUserPermissions?.some( - (permission) => permission === UserPermissions.Admin || permission === UserPermissions.Write - ); - - return hasAdminOrWrite; - }); - + readonly canEdit = computed(() => this.hasWriteAccess() || this.hasAdminAccess()); readonly isRegistration = computed(() => this.resourceType() === ResourceType.Registration); canUploadFiles = computed( @@ -260,28 +222,33 @@ export class FilesComponent { () => this.isButtonDisabled() || (this.googleFilePickerComponent()?.isGFPDisabled() ?? false) ); - private route = inject(ActivatedRoute); readonly providerName = toSignal( - this.route?.params?.pipe(map((params) => params['fileProvider'])) ?? of('osfstorage') + this.activeRoute?.params?.pipe(map((params) => params['fileProvider'])) ?? of('osfstorage') ); constructor() { + this.initResourceId(); + this.initEffects(); + this.initFilters(); + this.initDestroyHandler(); + } + + private initResourceId(): void { this.activeRoute.parent?.parent?.parent?.params.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => { if (params['id']) { this.resourceId.set(params['id']); } }); + } + private initEffects(): void { effect(() => { const resourceId = this.resourceId(); + if (!resourceId) return; - const resourcePath = this.urlMap.get(this.resourceType()!); - const folderLink = `${this.apiDomainUrl}/v2/${resourcePath}/${resourceId}/files/`; - const iriLink = `${this.webUrl}/${resourceId}`; - - this.actions.getResourceDetails(resourceId, this.resourceType()!); - this.actions.getRootFolders(folderLink); - this.actions.getConfiguredStorageAddons(iriLink); + this.actions.getResourceDetails(resourceId, this.resourceType()); + this.actions.getRootFolders(resourceId, this.resourceType()); + this.actions.getConfiguredStorageAddons(resourceId); }); effect(() => { @@ -319,7 +286,7 @@ export class FilesComponent { } this.actions.setCurrentProvider(provider ?? FileProvider.OsfStorage); this.actions.setCurrentFolder(currentRootFolder.folder); - this.filesSelection = []; + this.clearFilesSelection(); } }); @@ -336,7 +303,9 @@ export class FilesComponent { this.updateFilesList(); } }); + } + private initFilters(): void { this.searchControl.valueChanges .pipe(takeUntilDestroyed(this.destroyRef), distinctUntilChanged(), debounceTime(500)) .subscribe((searchText) => { @@ -350,7 +319,9 @@ export class FilesComponent { this.updateFilesList(); }); + } + private initDestroyHandler(): void { this.destroyRef.onDestroy(() => { if (this.isBrowser) { this.actions.resetState(); @@ -358,147 +329,101 @@ export class FilesComponent { }); } - onLoadFiles(event: { link: string; page: number }) { + onLoadFiles(event: FilePageLinkModel) { this.actions.getFiles(event.link, event.page); } - uploadFiles(files: File | File[]): void { - const currentFolder = this.currentFolder(); - const uploadLink = currentFolder?.links.upload; - if (!uploadLink) return; - + confirmTreeUpload(files: File | File[]): void { const fileArray = Array.isArray(files) ? files : [files]; - if (fileArray.length === 0) return; - - this.fileName.set(fileArray.length === 1 ? fileArray[0].name : `${fileArray.length} files`); - this.fileIsUploading.set(true); - this.progress.set(0); - - let completedUploads = 0; - const totalFiles = fileArray.length; - const conflictFiles: { file: File; link: string }[] = []; - - fileArray.forEach((file) => { - this.filesService - .uploadFile(file, uploadLink) - .pipe( - takeUntilDestroyed(this.destroyRef), - catchError((err) => { - const conflictLink = err.error?.data?.links?.upload; - if (err.status === 409 && conflictLink) { - if (this.allowRevisions) { - return this.filesService.uploadFile(file, conflictLink, true); - } else { - conflictFiles.push({ file, link: conflictLink }); - } - } - return of(new HttpResponse()); - }) - ) - .subscribe((event) => { - if (event.type === HttpEventType.UploadProgress && event.total) { - const progressPercentage = Math.round((event.loaded / event.total) * 100); - if (totalFiles === 1) { - this.progress.set(progressPercentage); - } - } - - if (event.type === HttpEventType.Response) { - completedUploads++; - - if (totalFiles > 1) { - const progressPercentage = Math.round((completedUploads / totalFiles) * 100); - this.progress.set(progressPercentage); - } - - if (completedUploads === totalFiles) { - if (conflictFiles.length > 0) { - this.openReplaceFileDialog(conflictFiles); - } else { - this.completeUpload(); - } - } - } - }); - }); + this.filesTreeActionsService.confirmDropFiles(fileArray, () => this.uploadFiles(files)); } - private openReplaceFileDialog(conflictFiles: { file: File; link: string }[]) { - 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(', '), + uploadFiles(files: File | File[]): void { + const uploadLink = this.currentFolder()?.links.upload; + if (!uploadLink) return; + + this.filesUploadService.uploadFiles({ + files, + uploadLink, + allowRevisions: this.allowRevisions, + onStart: (fileName) => { + this.fileName.set(fileName); + this.fileIsUploading.set(true); + this.progress.set(0); + }, + onProgress: (progress) => { + this.progress.set(progress); }, - acceptLabelKey: 'common.buttons.replace', - onConfirm: () => { - const replaceRequests$ = conflictFiles.map(({ file, link }) => - this.filesService.uploadFile(file, link, true).pipe( - takeUntilDestroyed(this.destroyRef), - catchError(() => of(null)) - ) - ); - - forkJoin(replaceRequests$).subscribe({ - next: () => this.completeUpload(), - }); + onComplete: () => { + this.fileIsUploading.set(false); + this.fileName.set(''); + this.updateFilesList(); }, }); } - private completeUpload(): void { - this.fileIsUploading.set(false); - this.fileName.set(''); - this.updateFilesList(); - } - onFileTreeSelected(file: FileModel): void { - this.filesSelection.push(file); - this.filesSelection = [...new Set(this.filesSelection)]; + if (this.filesSelection.some((selectedFile) => selectedFile.id === file.id)) { + return; + } + + this.filesSelection = [...this.filesSelection, file]; } onFileTreeUnselected(file: FileModel): void { this.filesSelection = this.filesSelection.filter((f) => f.id !== file.id); } - onClearSelection(): void { + clearFilesSelection(): void { this.filesSelection = []; } onDeleteSelected(): void { - if (!this.filesSelection.length) return; - - this.customConfirmationService.confirmDelete({ - headerKey: 'files.dialogs.deleteMultipleItems.title', - messageKey: 'files.dialogs.deleteMultipleItems.message', - messageParams: { - name: this.filesSelection.map((f) => f.name).join(', '), - }, - acceptLabelKey: 'common.buttons.delete', - onConfirm: () => { - const deleteRequests$ = this.filesSelection.map((file) => - this.actions.deleteEntry(file.links.delete).pipe(catchError(() => of(null))) - ); - - forkJoin(deleteRequests$) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe({ - next: () => { - this.toastService.showSuccess('files.dialogs.deleteFile.success'); - this.filesSelection = []; - this.updateFilesList(); - }, - }); + this.filesActionsService.deleteSelected({ + files: this.filesSelection, + deleteEntry: (link) => this.actions.deleteEntry(link), + onSuccess: () => { + this.clearFilesSelection(); + this.updateFilesList(); }, }); } onMoveSelected(): void { - this.moveFiles(this.filesSelection, 'move'); + this.moveFiles(this.filesSelection, MoveCopyAction.Move); } onCopySelected(): void { - this.moveFiles(this.filesSelection, 'copy'); + this.moveFiles(this.filesSelection, MoveCopyAction.Copy); + } + + onMenuMoveCopy(payload: MenuMoveCopyPayload): void { + this.moveFiles([payload.file], payload.action); + } + + onDropMove(payload: DropMovePayload): void { + const storageProvider = this.provider(); + if (!storageProvider) { + return; + } + + const options: ConfirmMoveFilesOptions = { + ...payload, + resourceId: this.resourceId(), + storageProvider, + }; + + this.filesActionsService + .openConfirmMoveDialog(options) + .pipe( + tap((result) => { + if (result) { + this.resetOnDialogClose(); + } + }), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(); } onFileSelected(event: Event): void { @@ -517,30 +442,31 @@ export class FilesComponent { this.uploadFiles(Array.from(files)); } - moveFiles(files: FileModel[], action: string): void { + moveFiles(files: FileModel[], action: MoveCopyAction): void { const currentFolder = this.currentFolder(); this.actions.setMoveDialogCurrentFolder(currentFolder); this.isMoveDialogOpened.set(true); - this.customDialogService - .open(MoveFileDialogComponent, { - header: 'files.dialogs.moveFile.title', - width: '552px', - data: { - files: files, - resourceId: this.resourceId(), - action: action, - storageProvider: this.provider(), - foldersStack: this.foldersStack, - initialFolder: structuredClone(this.currentFolder()), - }, + + this.filesActionsService + .openMoveDialog({ + files, + action, + resourceId: this.resourceId(), + storageProvider: this.provider(), + foldersStack: this.foldersStack, + initialFolder: currentFolder, }) - .onClose.subscribe((result) => { - if (result) { - this.filesSelection = []; - } - this.isMoveDialogOpened.set(false); - this.resetProvider(); - }); + .pipe( + tap((result) => { + if (result) { + this.clearFilesSelection(); + } + this.isMoveDialogOpened.set(false); + this.resetProvider(); + }), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(); } resetProvider() { @@ -552,7 +478,7 @@ export class FilesComponent { } resetOnDialogClose(): void { - this.onClearSelection(); + this.clearFilesSelection(); this.resetProvider(); this.updateFilesList(); } @@ -563,20 +489,18 @@ export class FilesComponent { if (!newFolderLink) return; - this.customDialogService - .open(CreateFolderDialogComponent, { - header: 'files.dialogs.createFolder.title', - width: '448px', + this.filesActionsService + .openCreateFolderDialog({ + newFolderLink, + createFolder: (link, folderName) => this.actions.createFolder(link, folderName), }) - .onClose.pipe( - filter((folderName: string) => !!folderName), - switchMap((folderName: string) => this.actions.createFolder(newFolderLink, folderName)), - take(1), + .pipe( + tap(() => this.toastService.showSuccess('files.dialogs.createFolder.success')), finalize(() => { this.updateFilesList(); this.fileIsUploading.set(false); - this.toastService.showSuccess('files.dialogs.createFolder.success'); - }) + }), + takeUntilDestroyed(this.destroyRef) ) .subscribe(); } @@ -612,44 +536,61 @@ export class FilesComponent { }; setCurrentFolder(folder: FileFolderModel) { + this.clearFilesSelection(); this.actions.setCurrentFolder(folder); } - setMoveDialogCurrentFolder(folder: FileFolderModel) { - this.actions.setMoveDialogCurrentFolder(folder); - } - deleteEntry(file: FileModel): void { - this.actions.deleteEntry(file?.links.delete).subscribe(() => { - this.toastService.showSuccess('files.dialogs.deleteFile.success'); - this.updateFilesList(); + this.filesTreeActionsService.confirmDeleteEntry(file, () => { + this.actions.deleteEntry(file?.links.delete).subscribe(() => { + this.toastService.showSuccess('files.dialogs.deleteFile.success'); + this.updateFilesList(); + }); }); } - renameEntry(event: { newName: string; link: string }) { - const { newName, link } = event; - this.actions.renameEntry(link, newName).subscribe(() => { - this.toastService.showSuccess('files.dialogs.renameFile.success'); - this.updateFilesList(); - }); + onRenameFile(file: FileModel): void { + this.filesActionsService + .openRenameFileDialog(file) + .pipe( + switchMap(({ link, newName }) => this.actions.renameEntry(link, newName)), + tap(() => { + this.toastService.showSuccess('files.dialogs.renameFile.success'); + this.updateFilesList(); + }), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(); } navigateToFile(file: FileModel) { - const extras = this.hasViewOnly() - ? { queryParams: { view_only: this.viewOnlyService.getViewOnlyParamFromUrl(this.router.url) } } - : undefined; + if (file.guid) { + this.openFile(file.guid); + return; + } - const url = this.router.serializeUrl(this.router.createUrlTree(['/', file.guid], extras)); + this.filesService + .getFileGuid(file.id) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((file) => { + if (file.guid) { + this.openFile(file.guid); + } + }); + } - window.open(url, '_blank'); + handleRootFolderChange(selectedFolder: FileLabelModel) { + const provider = selectedFolder.folder?.provider; + const resourceId = this.resourceId(); + this.router.navigate([`/${resourceId}/files`, provider], { queryParamsHandling: 'preserve' }); } - getAddonName(addons: ConfiguredAddonModel[], provider: string): string { - if (provider === FileProvider.OsfStorage) { - return this.translateService.instant('files.storageLocation'); - } else { - return addons.find((addon) => addon.externalServiceName === provider)?.displayName ?? ''; - } + private openFile(guid: string): void { + const extras = this.hasViewOnly() + ? { queryParams: { view_only: this.viewOnlyService.getViewOnlyParamFromUrl(this.router.url) } } + : undefined; + + window.open(this.router.serializeUrl(this.router.createUrlTree(['/', guid], extras)), '_blank'); } private setGoogleAccountId(): void { @@ -657,38 +598,7 @@ export class FilesComponent { const googleDrive = addons?.find((addon) => addon.externalServiceName === FileProvider.GoogleDrive); if (googleDrive) { this.accountId.set(googleDrive.baseAccountId); - this.selectedRootFolder.set({ - itemId: googleDrive.selectedStorageItemId, - }); + this.selectedRootFolder.set({ itemId: googleDrive.selectedStorageItemId }); } } - - private mapMenuActions(supportedFeatures: SupportedFeature[]): Record { - return { - [FileMenuType.Download]: supportedFeatures.includes(SupportedFeature.DownloadAsZip), - [FileMenuType.Rename]: supportedFeatures.includes(SupportedFeature.AddUpdateFiles), - [FileMenuType.Delete]: supportedFeatures.includes(SupportedFeature.DeleteFiles), - [FileMenuType.Move]: - supportedFeatures.includes(SupportedFeature.DeleteFiles) && - supportedFeatures.includes(SupportedFeature.AddUpdateFiles), - [FileMenuType.Embed]: true, - [FileMenuType.Share]: true, - [FileMenuType.Copy]: true, - }; - } - - openGoogleFilePicker(): void { - this.googleFilePickerComponent()?.createPicker(); - this.updateFilesList(); - } - - onUpdateFoldersStack(newStack: FileFolderModel[]): void { - this.foldersStack = [...newStack]; - } - - handleRootFolderChange(selectedFolder: FileLabelModel) { - const provider = selectedFolder.folder?.provider; - const resourceId = this.resourceId(); - this.router.navigate([`/${resourceId}/files`, provider], { queryParamsHandling: 'preserve' }); - } } diff --git a/src/app/features/files/services/files-actions.service.spec.ts b/src/app/features/files/services/files-actions.service.spec.ts new file mode 100644 index 000000000..21635ce99 --- /dev/null +++ b/src/app/features/files/services/files-actions.service.spec.ts @@ -0,0 +1,194 @@ +import { MockProvider } from 'ng-mocks'; + +import { of, Subject, throwError } from 'rxjs'; + +import { TestBed } from '@angular/core/testing'; + +import { FileModel } from '@osf/shared/models/files/file.model'; +import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; +import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; +import { ToastService } from '@osf/shared/services/toast.service'; + +import { FileModelMock } from '@testing/mocks/file.model.mock'; +import { + CustomConfirmationServiceMock, + CustomConfirmationServiceMockType, +} from '@testing/providers/custom-confirmation-provider.mock'; +import { CustomDialogServiceMock, CustomDialogServiceMockType } from '@testing/providers/custom-dialog-provider.mock'; +import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; + +import { MoveCopyAction } from '../enums/move-copy-action.enum'; + +import { FilesActionsService } from './files-actions.service'; + +describe('FilesActionsService', () => { + let service: FilesActionsService; + let customDialogService: CustomDialogServiceMockType; + let customConfirmationService: CustomConfirmationServiceMockType; + let toastService: ToastServiceMockType; + + function setup() { + customDialogService = CustomDialogServiceMock.simple(); + customConfirmationService = CustomConfirmationServiceMock.simple(); + toastService = ToastServiceMock.simple(); + + TestBed.configureTestingModule({ + providers: [ + FilesActionsService, + MockProvider(CustomDialogService, customDialogService), + MockProvider(CustomConfirmationService, customConfirmationService), + MockProvider(ToastService, toastService), + ], + }); + + service = TestBed.inject(FilesActionsService); + } + + it('should create', () => { + setup(); + expect(service).toBeTruthy(); + }); + + it('should not open delete confirmation when files list is empty', () => { + setup(); + + service.deleteSelected({ + files: [], + deleteEntry: vi.fn().mockReturnValue(of(true)), + onSuccess: vi.fn(), + }); + + expect(customConfirmationService.confirmDelete).not.toHaveBeenCalled(); + }); + + it('should delete selected files and call success handler', () => { + setup(); + const onSuccess = vi.fn(); + const deleteEntry = vi.fn().mockReturnValue(of(true)); + const files = [ + FileModelMock.simple({ name: 'a.txt', links: { ...FileModelMock.simple().links, delete: '/delete-a' } }), + FileModelMock.simple({ name: 'b.txt', links: { ...FileModelMock.simple().links, delete: '/delete-b' } }), + ]; + + service.deleteSelected({ files, deleteEntry, onSuccess }); + + const options = customConfirmationService.confirmDelete.mock.calls[0][0]; + expect(options.onConfirm).toBeDefined(); + options.onConfirm?.(); + + expect(deleteEntry).toHaveBeenCalledWith('/delete-a'); + expect(deleteEntry).toHaveBeenCalledWith('/delete-b'); + expect(toastService.showSuccess).toHaveBeenCalledWith('files.dialogs.deleteFile.success'); + expect(onSuccess).toHaveBeenCalled(); + }); + + it('should continue delete flow when one delete request fails', () => { + setup(); + const onSuccess = vi.fn(); + const deleteEntry = vi + .fn() + .mockReturnValueOnce(of(true)) + .mockReturnValueOnce(throwError(() => new Error('delete failed'))); + const files = [ + FileModelMock.simple({ links: { ...FileModelMock.simple().links, delete: '/delete-a' } }), + FileModelMock.simple({ links: { ...FileModelMock.simple().links, delete: '/delete-b' } }), + ]; + + service.deleteSelected({ files, deleteEntry, onSuccess }); + + const options = customConfirmationService.confirmDelete.mock.calls[0][0]; + expect(options.onConfirm).toBeDefined(); + options.onConfirm?.(); + + expect(onSuccess).toHaveBeenCalled(); + expect(toastService.showSuccess).toHaveBeenCalledWith('files.dialogs.deleteFile.success'); + }); + + it('should open move dialog and pass move options data', () => { + setup(); + const onClose$ = new Subject(); + customDialogService.open.mockReturnValue(CustomDialogServiceMock.dialogRefWithClose(onClose$)); + + const files: FileModel[] = [FileModelMock.simple({ id: 'file-1' })]; + const result: boolean[] = []; + service + .openMoveDialog({ + files, + action: MoveCopyAction.Move, + resourceId: 'node-1', + storageProvider: 'osfstorage', + foldersStack: [], + initialFolder: null, + }) + .subscribe((value) => result.push(value as boolean)); + + onClose$.next(true); + + expect(customDialogService.open).toHaveBeenCalled(); + expect(result).toEqual([true]); + }); + + it('should open create folder dialog and call createFolder for valid name only', () => { + setup(); + const onClose$ = new Subject(); + customDialogService.open.mockReturnValue(CustomDialogServiceMock.dialogRefWithClose(onClose$)); + const createFolder = vi.fn().mockReturnValue(of('created')); + const emitted: unknown[] = []; + + service + .openCreateFolderDialog({ newFolderLink: '/new-folder', createFolder }) + .subscribe((value) => emitted.push(value)); + + onClose$.next(''); + onClose$.next('folder-1'); + + expect(createFolder).toHaveBeenCalledWith('/new-folder', 'folder-1'); + expect(emitted).toEqual(['created']); + }); + + it('should open confirm move dialog with multiple header for multiple files', () => { + setup(); + const onClose$ = new Subject(); + customDialogService.open.mockReturnValue(CustomDialogServiceMock.dialogRefWithClose(onClose$)); + + service.openConfirmMoveDialog({ + files: [FileModelMock.simple({ id: '1' }), FileModelMock.simple({ id: '2' })], + destination: FileModelMock.simple({ id: 'dest', name: 'folder' }), + resourceId: 'node-1', + storageProvider: 'osfstorage', + }); + + expect(customDialogService.open.mock.calls[0][1]?.header).toBe('files.dialogs.moveFile.dialogTitleMultiple'); + }); + + it('should return empty observable when rename link is missing', () => { + setup(); + const values: unknown[] = []; + service + .openRenameFileDialog(FileModelMock.simple({ links: { ...FileModelMock.simple().links, upload: '' } })) + .subscribe({ + next: (v) => values.push(v), + }); + + expect(customDialogService.open).not.toHaveBeenCalled(); + expect(values).toEqual([]); + }); + + it('should map renamed file result when rename dialog returns valid name', () => { + setup(); + const onClose$ = new Subject(); + customDialogService.open.mockReturnValue(CustomDialogServiceMock.dialogRefWithClose(onClose$)); + const file = FileModelMock.simple({ + name: 'old.txt', + links: { ...FileModelMock.simple().links, upload: '/upload' }, + }); + const result: { newName: string; link: string }[] = []; + + service.openRenameFileDialog(file).subscribe((value) => result.push(value)); + + onClose$.next(' '); + onClose$.next('new.txt'); + + expect(result).toEqual([{ newName: 'new.txt', link: '/upload' }]); + }); +}); diff --git a/src/app/features/files/services/files-actions.service.ts b/src/app/features/files/services/files-actions.service.ts new file mode 100644 index 000000000..fa320344e --- /dev/null +++ b/src/app/features/files/services/files-actions.service.ts @@ -0,0 +1,117 @@ +import { catchError, EMPTY, filter, forkJoin, map, Observable, of, switchMap, take } from 'rxjs'; + +import { inject, Injectable } from '@angular/core'; + +import { FileModel } from '@osf/shared/models/files/file.model'; +import { RenamedFileLinkModel } from '@osf/shared/models/files/renamed-file-link.model'; +import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; +import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; +import { ToastService } from '@osf/shared/services/toast.service'; + +import { ConfirmMoveFileDialogComponent } from '../components/confirm-move-file-dialog/confirm-move-file-dialog.component'; +import { CreateFolderDialogComponent } from '../components/create-folder-dialog/create-folder-dialog.component'; +import { MoveFileDialogComponent } from '../components/move-file-dialog/move-file-dialog.component'; +import { RenameFileDialogComponent } from '../components/rename-file-dialog/rename-file-dialog.component'; +import { + ConfirmMoveFilesOptions, + CreateFolderOptions, + DeleteSelectedOptions, + MoveFilesOptions, +} from '../models/files-actions-options.model'; + +@Injectable() +export class FilesActionsService { + private readonly customConfirmationService = inject(CustomConfirmationService); + private readonly customDialogService = inject(CustomDialogService); + private readonly toastService = inject(ToastService); + + deleteSelected(options: DeleteSelectedOptions): void { + if (!options.files.length) return; + + const fileNames = options.files.map((f) => f.name).join(', '); + + this.customConfirmationService.confirmDelete({ + headerKey: 'files.dialogs.deleteMultipleItems.title', + messageKey: 'files.dialogs.deleteMultipleItems.message', + messageParams: { name: fileNames }, + acceptLabelKey: 'common.buttons.delete', + onConfirm: () => { + const deleteRequests$ = options.files.map((file) => + options.deleteEntry(file.links.delete).pipe(catchError(() => of(null))) + ); + + forkJoin(deleteRequests$).subscribe({ + next: () => { + this.toastService.showSuccess('files.dialogs.deleteFile.success'); + options.onSuccess(); + }, + }); + }, + }); + } + + openMoveDialog(options: MoveFilesOptions): Observable { + return this.customDialogService + .open(MoveFileDialogComponent, { + header: 'files.dialogs.moveFile.title', + width: '552px', + data: { + files: options.files, + resourceId: options.resourceId, + action: options.action, + storageProvider: options.storageProvider, + foldersStack: options.foldersStack, + initialFolder: structuredClone(options.initialFolder), + }, + }) + .onClose.pipe(take(1)); + } + + openCreateFolderDialog(options: CreateFolderOptions): Observable { + return this.customDialogService + .open(CreateFolderDialogComponent, { + header: 'files.dialogs.createFolder.title', + width: '448px', + }) + .onClose.pipe( + filter((folderName: string) => !!folderName), + switchMap((folderName) => options.createFolder(options.newFolderLink, folderName)), + take(1) + ); + } + + openConfirmMoveDialog(options: ConfirmMoveFilesOptions): Observable { + const isMultiple = options.files.length > 1; + return this.customDialogService + .open(ConfirmMoveFileDialogComponent, { + header: isMultiple ? 'files.dialogs.moveFile.dialogTitleMultiple' : 'files.dialogs.moveFile.dialogTitle', + width: '552px', + data: { + destination: options.destination, + files: options.files, + resourceId: options.resourceId, + storageProvider: options.storageProvider, + }, + }) + .onClose.pipe(take(1)); + } + + openRenameFileDialog(file: FileModel): Observable { + const link = file.links.upload; + if (!link) { + return EMPTY; + } + + return this.customDialogService + .open(RenameFileDialogComponent, { + header: 'files.dialogs.renameFile.title', + width: '448px', + data: { currentName: file.name }, + }) + .onClose.pipe( + filter((newName: string) => !!newName?.trim()), + map((newName) => ({ newName, link })), + take(1) + ); + } +} diff --git a/src/app/features/files/services/files-move-copy.service.spec.ts b/src/app/features/files/services/files-move-copy.service.spec.ts new file mode 100644 index 000000000..c3685954c --- /dev/null +++ b/src/app/features/files/services/files-move-copy.service.spec.ts @@ -0,0 +1,165 @@ +import { MockProvider } from 'ng-mocks'; + +import { firstValueFrom, of, throwError } from 'rxjs'; + +import { TestBed } from '@angular/core/testing'; + +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 { FileModelMock } from '@testing/mocks/file.model.mock'; +import { + CustomConfirmationServiceMock, + CustomConfirmationServiceMockType, +} from '@testing/providers/custom-confirmation-provider.mock'; +import { FilesServiceMock, FilesServiceMockType } from '@testing/providers/files-service.mock'; +import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; + +import { MoveCopyAction } from '../enums/move-copy-action.enum'; +import { MoveCopyOptions } from '../models/move-copy-options.model'; + +import { FilesMoveCopyService } from './files-move-copy.service'; + +describe('FilesMoveCopyService', () => { + let service: FilesMoveCopyService; + let filesService: FilesServiceMockType; + let toastService: ToastServiceMockType; + let confirmationService: CustomConfirmationServiceMockType; + + const fileA = FileModelMock.simple({ + id: 'a', + name: 'a.txt', + path: '/a.txt', + links: { ...FileModelMock.simple().links, move: '/move-a' }, + }); + const fileB = FileModelMock.simple({ + id: 'b', + name: 'b.txt', + path: '/b.txt', + links: { ...FileModelMock.simple().links, move: '/move-b' }, + }); + + function buildOptions(files = [fileA], action = MoveCopyAction.Move): MoveCopyOptions { + return { + files, + destination: FileModelMock.simple({ path: '/dest' }), + resourceId: 'node-1', + storageProvider: 'osfstorage', + action, + }; + } + + function setup() { + filesService = FilesServiceMock.simple(); + toastService = ToastServiceMock.simple(); + confirmationService = CustomConfirmationServiceMock.simple(); + + TestBed.configureTestingModule({ + providers: [ + FilesMoveCopyService, + MockProvider(FilesService, filesService), + MockProvider(ToastService, toastService), + MockProvider(CustomConfirmationService, confirmationService), + ], + }); + + service = TestBed.inject(FilesMoveCopyService); + } + + it('should create', () => { + setup(); + expect(service).toBeTruthy(); + }); + + it('should error when destination path is missing', async () => { + setup(); + + await expect( + firstValueFrom(service.execute({ ...buildOptions(), destination: FileModelMock.simple({ path: '' }) })) + ).rejects.toThrow('files.dialogs.moveFile.pathError'); + }); + + it('should return false when files list is empty', async () => { + setup(); + + const result = await firstValueFrom(service.execute(buildOptions([]))); + + expect(result).toBe(false); + expect(filesService.moveFile).not.toHaveBeenCalled(); + }); + + it('should show success when all initial moves succeed', async () => { + setup(); + + const result = await firstValueFrom(service.execute(buildOptions([fileA, fileB]))); + + expect(result).toBe(true); + expect(filesService.moveFile).toHaveBeenCalledTimes(2); + expect(toastService.showSuccess).toHaveBeenCalledWith('files.dialogs.moveFile.success'); + }); + + it('should handle conflicts and replace on confirm', async () => { + setup(); + filesService.moveFile + .mockReturnValueOnce(throwError(() => ({ status: 409 }))) + .mockReturnValueOnce(of({})) + .mockReturnValueOnce(of({})); + + const execution = firstValueFrom(service.execute(buildOptions([fileA, fileB]))); + + const options = confirmationService.confirmDelete.mock.calls[0][0]; + expect(options.headerKey).toBe('files.dialogs.replaceFile.single'); + expect(options.onConfirm).toBeDefined(); + options.onConfirm(); + + const result = await execution; + + expect(result).toBe(true); + expect(filesService.moveFile).toHaveBeenLastCalledWith( + '/move-a', + '/dest', + 'node-1', + 'osfstorage', + MoveCopyAction.Move, + true + ); + expect(toastService.showSuccess).toHaveBeenCalledWith('files.dialogs.moveFile.success'); + }); + + it('should return false and show partial error when conflict replace is rejected', async () => { + setup(); + filesService.moveFile.mockReturnValueOnce(throwError(() => ({ status: 409 }))).mockReturnValueOnce(of({})); + + const execution = firstValueFrom(service.execute(buildOptions([fileA, fileB]))); + + const options = confirmationService.confirmDelete.mock.calls[0][0]; + expect(options.onReject).toBeDefined(); + options.onReject?.(); + + const result = await execution; + + expect(result).toBe(false); + expect(toastService.showError).toHaveBeenCalledWith('files.dialogs.moveFile.error'); + }); + + it('should show explicit backend error for non conflict failures', async () => { + setup(); + filesService.moveFile.mockReturnValueOnce(throwError(() => ({ status: 500, error: { message: 'server fail' } }))); + + const result = await firstValueFrom(service.execute(buildOptions([fileA]))); + + expect(result).toBe(true); + expect(toastService.showError).toHaveBeenCalledWith('server fail'); + expect(toastService.showSuccess).toHaveBeenCalledWith('files.dialogs.moveFile.success'); + }); + + it('should use copy toast keys for copy action', async () => { + setup(); + + const result = await firstValueFrom(service.execute(buildOptions([fileA], MoveCopyAction.Copy))); + + expect(result).toBe(true); + expect(toastService.showSuccess).toHaveBeenCalledWith('files.dialogs.copyFile.success'); + }); +}); diff --git a/src/app/features/files/services/files-move-copy.service.ts b/src/app/features/files/services/files-move-copy.service.ts new file mode 100644 index 000000000..3e8986cb1 --- /dev/null +++ b/src/app/features/files/services/files-move-copy.service.ts @@ -0,0 +1,131 @@ +import { forkJoin, Observable, of, throwError } from 'rxjs'; +import { catchError, map, switchMap } from 'rxjs/operators'; + +import { inject, Injectable } from '@angular/core'; + +import { FileModel } from '@osf/shared/models/files/file.model'; +import { FileMoveLinkModel } from '@osf/shared/models/files/file-move-link.model'; +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 { MoveCopyAction } from '../enums/move-copy-action.enum'; +import { MoveCopyOptions } from '../models/move-copy-options.model'; + +@Injectable({ providedIn: 'root' }) +export class FilesMoveCopyService { + private readonly filesService = inject(FilesService); + private readonly toastService = inject(ToastService); + private readonly customConfirmationService = inject(CustomConfirmationService); + + execute(options: MoveCopyOptions): Observable { + const path = options.destination?.path; + if (!path) { + return throwError(() => new Error('files.dialogs.moveFile.pathError')); + } + + if (!options.files.length) { + return of(false); + } + + const initialMoves$ = options.files.map((file) => this.moveFileInitialAttempt(file, path, options)); + + return forkJoin(initialMoves$).pipe(switchMap((results) => this.afterInitialMoves(results, path, options))); + } + + private moveFileInitialAttempt( + file: FileModel, + path: string, + options: MoveCopyOptions + ): Observable { + return this.filesService + .moveFile(file.links.move, path, options.resourceId, options.storageProvider, options.action) + .pipe( + map(() => null), + catchError((error) => { + if (error.status === 409) { + return of({ file, link: file.links.move } as FileMoveLinkModel); + } + this.showErrorToast(options.action, error.error?.message); + return of(null); + }) + ); + } + + private afterInitialMoves( + results: (FileMoveLinkModel | null)[], + path: string, + options: MoveCopyOptions + ): Observable { + const conflictFiles = results.filter((result): result is FileMoveLinkModel => result !== null); + + if (!conflictFiles.length) { + this.showSuccessToast(options.action); + return of(true); + } + + return this.handleConflicts(conflictFiles, path, options); + } + + private handleConflicts( + conflictFiles: FileMoveLinkModel[], + path: string, + options: MoveCopyOptions + ): Observable { + return new Observable((subscriber) => { + this.customConfirmationService.confirmDelete({ + ...this.replaceDialogFields(conflictFiles), + onConfirm: () => { + this.executeReplaceMoves(conflictFiles, path, options).subscribe({ + next: () => { + this.showSuccessToast(options.action); + subscriber.next(true); + subscriber.complete(); + }, + }); + }, + onReject: () => { + this.onReplaceRejected(options, conflictFiles.length); + subscriber.next(false); + subscriber.complete(); + }, + }); + }); + } + + private replaceDialogFields(conflictFiles: FileMoveLinkModel[]) { + return { + 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', + } as const; + } + + private executeReplaceMoves(conflictFiles: FileMoveLinkModel[], path: string, options: MoveCopyOptions) { + const replaceRequests = conflictFiles.map(({ link }) => + this.filesService + .moveFile(link, path, options.resourceId, options.storageProvider, options.action, true) + .pipe(catchError(() => of(null))) + ); + + return forkJoin(replaceRequests); + } + + private onReplaceRejected(options: MoveCopyOptions, conflictCount: number): void { + const hasPartialSuccess = options.files.length > conflictCount; + if (hasPartialSuccess) { + this.showErrorToast(options.action); + } + } + + private showSuccessToast(action: MoveCopyAction): void { + const messageType = action === MoveCopyAction.Move ? 'moveFile' : 'copyFile'; + this.toastService.showSuccess(`files.dialogs.${messageType}.success`); + } + + private showErrorToast(action: MoveCopyAction, errorMessage?: string): void { + const messageType = action === MoveCopyAction.Move ? 'moveFile' : 'copyFile'; + this.toastService.showError(errorMessage ?? `files.dialogs.${messageType}.error`); + } +} diff --git a/src/app/features/files/services/files-upload.service.spec.ts b/src/app/features/files/services/files-upload.service.spec.ts new file mode 100644 index 000000000..acc5c989c --- /dev/null +++ b/src/app/features/files/services/files-upload.service.spec.ts @@ -0,0 +1,164 @@ +import { MockProvider } from 'ng-mocks'; + +import { concat, of, throwError } from 'rxjs'; + +import { HttpEventType, HttpResponse } from '@angular/common/http'; +import { TestBed } from '@angular/core/testing'; + +import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; +import { FilesService } from '@osf/shared/services/files.service'; + +import { + CustomConfirmationServiceMock, + CustomConfirmationServiceMockType, +} from '@testing/providers/custom-confirmation-provider.mock'; +import { FilesServiceMock, FilesServiceMockType } from '@testing/providers/files-service.mock'; + +import { FilesUploadService } from './files-upload.service'; + +describe('FilesUploadService', () => { + let service: FilesUploadService; + let filesService: FilesServiceMockType; + let confirmationService: CustomConfirmationServiceMockType; + + function setup() { + filesService = FilesServiceMock.simple(); + confirmationService = CustomConfirmationServiceMock.simple(); + + TestBed.configureTestingModule({ + providers: [ + FilesUploadService, + MockProvider(FilesService, filesService), + MockProvider(CustomConfirmationService, confirmationService), + ], + }); + + service = TestBed.inject(FilesUploadService); + } + + it('should create', () => { + setup(); + expect(service).toBeTruthy(); + }); + + it('should return early when no files provided', () => { + setup(); + const onStart = vi.fn(); + const onProgress = vi.fn(); + const onComplete = vi.fn(); + + service.uploadFiles({ + files: [], + uploadLink: '/upload', + allowRevisions: false, + onStart, + onProgress, + onComplete, + }); + + expect(onStart).not.toHaveBeenCalled(); + expect(filesService.uploadFile).not.toHaveBeenCalled(); + }); + + it('should track single file progress and complete', () => { + setup(); + const onStart = vi.fn(); + const onProgress = vi.fn(); + const onComplete = vi.fn(); + const file = new File(['test'], 'file-a.txt'); + filesService.uploadFile.mockReturnValue( + concat( + of({ type: HttpEventType.UploadProgress, loaded: 50, total: 100 }), + of(new HttpResponse({ status: 200 })) + ) as never + ); + + service.uploadFiles({ + files: [file], + uploadLink: '/upload', + allowRevisions: false, + onStart, + onProgress, + onComplete, + }); + + expect(onStart).toHaveBeenCalledWith('file-a.txt'); + expect(onProgress).toHaveBeenNthCalledWith(1, 0); + expect(onProgress).toHaveBeenNthCalledWith(2, 50); + expect(onComplete).toHaveBeenCalled(); + }); + + it('should report aggregate progress for multiple files', () => { + setup(); + const onProgress = vi.fn(); + const onComplete = vi.fn(); + const fileA = new File(['a'], 'a.txt'); + const fileB = new File(['b'], 'b.txt'); + filesService.uploadFile.mockReturnValue(of(new HttpResponse({ status: 200 }))); + + service.uploadFiles({ + files: [fileA, fileB], + uploadLink: '/upload', + allowRevisions: false, + onStart: vi.fn(), + onProgress, + onComplete, + }); + + expect(filesService.uploadFile).toHaveBeenCalledTimes(2); + expect(onProgress).toHaveBeenNthCalledWith(1, 0); + expect(onProgress).toHaveBeenNthCalledWith(2, 50); + expect(onProgress).toHaveBeenNthCalledWith(3, 100); + expect(onComplete).toHaveBeenCalled(); + }); + + it('should retry with revision upload link when conflict and revisions are allowed', () => { + setup(); + const file = new File(['x'], 'conflict.txt'); + filesService.uploadFile + .mockReturnValueOnce( + throwError(() => ({ status: 409, error: { data: { links: { upload: '/revision-upload' } } } })) + ) + .mockReturnValueOnce(of(new HttpResponse({ status: 200 }))); + + service.uploadFiles({ + files: [file], + uploadLink: '/upload', + allowRevisions: true, + onStart: vi.fn(), + onProgress: vi.fn(), + onComplete: vi.fn(), + }); + + expect(filesService.uploadFile).toHaveBeenNthCalledWith(1, file, '/upload'); + expect(filesService.uploadFile).toHaveBeenNthCalledWith(2, file, '/revision-upload', true); + }); + + it('should open replace dialog for conflicts and replace on confirm', () => { + setup(); + const onComplete = vi.fn(); + const file = new File(['x'], 'conflict.txt'); + filesService.uploadFile + .mockReturnValueOnce( + throwError(() => ({ status: 409, error: { data: { links: { upload: '/replace-upload' } } } })) + ) + .mockReturnValueOnce(of(new HttpResponse({ status: 200 }))); + + service.uploadFiles({ + files: [file], + uploadLink: '/upload', + allowRevisions: false, + onStart: vi.fn(), + onProgress: vi.fn(), + onComplete, + }); + + expect(confirmationService.confirmDelete).toHaveBeenCalled(); + const dialogOptions = confirmationService.confirmDelete.mock.calls[0][0]; + expect(dialogOptions.onConfirm).toBeDefined(); + dialogOptions.onConfirm?.(); + + expect(filesService.uploadFile).toHaveBeenLastCalledWith(file, '/replace-upload', true); + expect(onComplete).toHaveBeenCalled(); + }); +}); diff --git a/src/app/features/files/services/files-upload.service.ts b/src/app/features/files/services/files-upload.service.ts new file mode 100644 index 000000000..80ce71aab --- /dev/null +++ b/src/app/features/files/services/files-upload.service.ts @@ -0,0 +1,103 @@ +import { catchError, forkJoin, of } from 'rxjs'; + +import { HttpEvent, HttpEventType, HttpResponse } from '@angular/common/http'; +import { inject, Injectable } from '@angular/core'; + +import { FileUploadLinkModel } from '@osf/shared/models/files/file-upload-link.model'; +import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; +import { FilesService } from '@osf/shared/services/files.service'; + +import { UploadFilesOptions, UploadState } from '../models/files-upload-options.model'; + +@Injectable() +export class FilesUploadService { + private readonly filesService = inject(FilesService); + private readonly customConfirmationService = inject(CustomConfirmationService); + + uploadFiles(options: UploadFilesOptions): void { + const fileArray = Array.isArray(options.files) ? options.files : [options.files]; + if (!fileArray.length) return; + + const uploadLabel = fileArray.length === 1 ? fileArray[0].name : `${fileArray.length} files`; + + options.onStart(uploadLabel); + options.onProgress(0); + + const state: UploadState = { + completedUploads: 0, + totalFiles: fileArray.length, + conflictFiles: [], + }; + + fileArray.forEach((file) => { + this.createUploadRequest(file, options, state).subscribe((event) => { + this.handleUploadEvent(event, options, state); + }); + }); + } + + private createUploadRequest(file: File, options: UploadFilesOptions, state: UploadState) { + return this.filesService.uploadFile(file, options.uploadLink).pipe( + catchError((err) => { + const conflictLink = err.error?.data?.links?.upload; + if (err.status === 409 && conflictLink) { + if (options.allowRevisions) { + return this.filesService.uploadFile(file, conflictLink, true); + } + + state.conflictFiles.push({ file, link: conflictLink }); + } + + return of(new HttpResponse()); + }) + ); + } + + private handleUploadEvent(event: HttpEvent, options: UploadFilesOptions, state: UploadState): void { + if (event.type === HttpEventType.UploadProgress && event.total && state.totalFiles === 1) { + options.onProgress(Math.round(((event.loaded ?? 0) / event.total) * 100)); + } + + if (event.type !== HttpEventType.Response) { + return; + } + + state.completedUploads++; + + if (state.totalFiles > 1) { + options.onProgress(Math.round((state.completedUploads / state.totalFiles) * 100)); + } + + if (state.completedUploads !== state.totalFiles) { + return; + } + + if (state.conflictFiles.length > 0) { + this.openReplaceFileDialog(state.conflictFiles, options.onComplete); + return; + } + + options.onComplete(); + } + + private openReplaceFileDialog(conflictFiles: FileUploadLinkModel[], onComplete: () => void): void { + const headerKey = + conflictFiles.length > 1 ? 'files.dialogs.replaceFile.multiple' : 'files.dialogs.replaceFile.single'; + + this.customConfirmationService.confirmDelete({ + headerKey, + messageKey: 'files.dialogs.replaceFile.message', + messageParams: { name: conflictFiles.map((c) => c.file.name).join(', ') }, + acceptLabelKey: 'common.buttons.replace', + onConfirm: () => { + const replaceRequests$ = conflictFiles.map(({ file, link }) => + this.filesService.uploadFile(file, link, true).pipe(catchError(() => of(null))) + ); + + forkJoin(replaceRequests$).subscribe({ + next: () => onComplete(), + }); + }, + }); + } +} diff --git a/src/app/features/files/store/files.actions.ts b/src/app/features/files/store/files.actions.ts index 999863b71..0236ee41d 100644 --- a/src/app/features/files/store/files.actions.ts +++ b/src/app/features/files/store/files.actions.ts @@ -1,6 +1,7 @@ import { FileFolderModel } from '@osf/shared/models/files/file-folder.model'; +import { ResourceType } from '@shared/enums/resource-type.enum'; -import { PatchFileMetadata } from '../models'; +import { PatchFileMetadata } from '../models/patch-file-metadata.model'; export class GetFiles { static readonly type = '[Files] Get Files'; @@ -131,25 +132,31 @@ export class DeleteEntry { export class GetRootFolders { static readonly type = '[Files] Get Folders'; - constructor(public folderLink: string) {} + constructor( + public resourceId: string, + public resourceType: ResourceType + ) {} } export class GetConfiguredStorageAddons { static readonly type = '[Files] Get ConfiguredStorageAddons'; - constructor(public resourceUri: string) {} + constructor(public resourceId: string) {} } export class GetMoveDialogRootFolders { static readonly type = '[Files] Get Move Dialog Folders'; - constructor(public folderLink: string) {} + constructor( + public resourceId: string, + public resourceType: ResourceType + ) {} } export class GetMoveDialogConfiguredStorageAddons { static readonly type = '[Files] Get Move Dialog ConfiguredStorageAddons'; - constructor(public resourceUri: string) {} + constructor(public resourceId: string) {} } export class GetStorageSupportedFeatures { diff --git a/src/app/features/files/store/files.model.ts b/src/app/features/files/store/files.model.ts index 518bed572..9c5dcb4b1 100644 --- a/src/app/features/files/store/files.model.ts +++ b/src/app/features/files/store/files.model.ts @@ -8,7 +8,8 @@ import { AsyncStateModel } from '@osf/shared/models/store/async-state.model'; import { AsyncStateWithTotalCount } from '@osf/shared/models/store/async-state-with-total-count.model'; import { FileProvider } from '../constants'; -import { OsfFileCustomMetadata, OsfFileRevision } from '../models'; +import { OsfFileCustomMetadata } from '../models/file-custom-metadata.model'; +import { OsfFileRevision } from '../models/file-revisions.model'; export interface FilesStateModel { files: AsyncStateWithTotalCount; diff --git a/src/app/features/files/store/files.selectors.ts b/src/app/features/files/store/files.selectors.ts index 81e0cca87..d4af2d066 100644 --- a/src/app/features/files/store/files.selectors.ts +++ b/src/app/features/files/store/files.selectors.ts @@ -8,7 +8,8 @@ import { FileDetailsModel, FileModel } from '@osf/shared/models/files/file.model import { FileFolderModel } from '@osf/shared/models/files/file-folder.model'; import { ResourceMetadata } from '@osf/shared/models/resource-metadata.model'; -import { OsfFileCustomMetadata, OsfFileRevision } from '../models'; +import { OsfFileCustomMetadata } from '../models/file-custom-metadata.model'; +import { OsfFileRevision } from '../models/file-revisions.model'; import { FilesStateModel } from './files.model'; import { FilesState } from './files.state'; diff --git a/src/app/features/files/store/files.state.ts b/src/app/features/files/store/files.state.ts index 52e6e31a8..5ba307e6d 100644 --- a/src/app/features/files/store/files.state.ts +++ b/src/app/features/files/store/files.state.ts @@ -7,9 +7,8 @@ import { inject, Injectable } from '@angular/core'; import { SupportedFeature } from '@osf/shared/enums/addon-supported-features.enum'; import { handleSectionError } from '@osf/shared/helpers/state-error.handler'; import { FilesService } from '@osf/shared/services/files.service'; -import { ToastService } from '@osf/shared/services/toast.service'; -import { MapResourceMetadata } from '../mappers'; +import { MapResourceMetadata } from '../mappers/resource-metadata.mapper'; import { CreateFolder, @@ -45,7 +44,6 @@ import { FILES_STATE_DEFAULTS, FilesStateModel } from './files.model'; }) export class FilesState { filesService = inject(FilesService); - toastService = inject(ToastService); @Action(GetFiles) getFiles(ctx: StateContext, action: GetFiles) { @@ -262,7 +260,7 @@ export class FilesState { getRootFolders(ctx: StateContext, action: GetRootFolders) { const state = ctx.getState(); ctx.patchState({ rootFolders: { ...state.rootFolders, isLoading: true } }); - return this.filesService.getFolders(action.folderLink).pipe( + return this.filesService.getRootFolders(action.resourceId, action.resourceType).pipe( tap((response) => ctx.patchState({ rootFolders: { @@ -282,7 +280,7 @@ export class FilesState { const state = ctx.getState(); ctx.patchState({ moveDialogRootFolders: { ...state.moveDialogRootFolders, isLoading: true } }); - return this.filesService.getFolders(action.folderLink).pipe( + return this.filesService.getRootFolders(action.resourceId, action.resourceType).pipe( tap((response) => ctx.patchState({ moveDialogRootFolders: { @@ -302,7 +300,7 @@ export class FilesState { const state = ctx.getState(); ctx.patchState({ configuredStorageAddons: { ...state.configuredStorageAddons, isLoading: true } }); - return this.filesService.getConfiguredStorageAddons(action.resourceUri).pipe( + return this.filesService.getConfiguredStorageAddons(action.resourceId).pipe( tap((addons) => ctx.patchState({ configuredStorageAddons: { @@ -326,7 +324,7 @@ export class FilesState { moveDialogConfiguredStorageAddons: { ...state.moveDialogConfiguredStorageAddons, isLoading: true }, }); - return this.filesService.getConfiguredStorageAddons(action.resourceUri).pipe( + return this.filesService.getConfiguredStorageAddons(action.resourceId).pipe( tap((addons) => ctx.patchState({ moveDialogConfiguredStorageAddons: { diff --git a/src/app/features/home/pages/dashboard/dashboard.component.spec.ts b/src/app/features/home/pages/dashboard/dashboard.component.spec.ts index ba3b7cf3c..f8683790c 100644 --- a/src/app/features/home/pages/dashboard/dashboard.component.spec.ts +++ b/src/app/features/home/pages/dashboard/dashboard.component.spec.ts @@ -2,8 +2,6 @@ import { Store } from '@ngxs/store'; import { MockComponents, MockProvider } from 'ng-mocks'; -import { DynamicDialogRef } from 'primeng/dynamicdialog'; - import { Subject } from 'rxjs'; import { Mock } from 'vitest'; @@ -192,7 +190,7 @@ describe('DashboardComponent', () => { it('should open create project dialog and redirect on close result', () => { setup(); const onClose$ = new Subject<{ project: { id: string } }>(); - customDialogService.open.mockReturnValue({ onClose: onClose$.asObservable() } as unknown as DynamicDialogRef); + customDialogService.open.mockReturnValue(CustomDialogServiceMock.dialogRefWithClose(onClose$.asObservable())); component.createProject(); onClose$.next({ project: { id: 'p1' } }); diff --git a/src/app/features/metadata/components/cedar-template-form/cedar-template-form.component.ts b/src/app/features/metadata/components/cedar-template-form/cedar-template-form.component.ts index 032e378f3..58b859259 100644 --- a/src/app/features/metadata/components/cedar-template-form/cedar-template-form.component.ts +++ b/src/app/features/metadata/components/cedar-template-form/cedar-template-form.component.ts @@ -23,6 +23,7 @@ import { toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute } from '@angular/router'; import { ENVIRONMENT } from '@core/provider/environment.provider'; +import { SocialShareService } from '@osf/shared/services/social-share.service'; import { CEDAR_CONFIG, CEDAR_VIEWER_CONFIG } from '../../constants'; import { CedarMetadataHelper } from '../../helpers'; @@ -62,6 +63,7 @@ export class CedarTemplateFormComponent { private route = inject(ActivatedRoute); readonly environment = inject(ENVIRONMENT); + private readonly socialShareService = inject(SocialShareService); readonly recordId = signal(''); readonly downloadUrl = signal(''); @@ -184,18 +186,18 @@ export class CedarTemplateFormComponent { handleEmailShare(): void { const url = window.location.href; - window.location.href = `mailto:?subject=${this.schemaName()}&body=${url}`; + window.location.href = this.socialShareService.getEmailLink(this.schemaName(), url); } handleXShare(): void { const url = window.location.href; - const link = `https://x.com/intent/tweet?url=${url}&text=${this.schemaName()}&via=OSFramework`; + const link = this.socialShareService.getXLink(this.schemaName(), url); window.open(link, '_blank', 'noopener,noreferrer'); } handleFacebookShare(): void { const url = window.location.href; - const link = `https://www.facebook.com/sharer/sharer.php?u=${url}`; + const link = this.socialShareService.getFacebookLink(url); window.open(link, '_blank', 'noopener,noreferrer'); } } diff --git a/src/app/features/my-projects/my-projects.component.spec.ts b/src/app/features/my-projects/my-projects.component.spec.ts index 016753ab2..1e6b40b43 100644 --- a/src/app/features/my-projects/my-projects.component.spec.ts +++ b/src/app/features/my-projects/my-projects.component.spec.ts @@ -2,8 +2,6 @@ import { Store } from '@ngxs/store'; import { MockComponents, MockProvider } from 'ng-mocks'; -import { DynamicDialogRef } from 'primeng/dynamicdialog'; - import { of, Subject } from 'rxjs'; import { Mock } from 'vitest'; @@ -191,11 +189,7 @@ describe('MyProjectsComponent', () => { it('should open create project dialog and redirect after close result', () => { setup(); const onClose$ = new Subject<{ project: { id: string } }>(); - customDialogService.open.mockReturnValue({ - close: vi.fn(), - destroy: vi.fn(), - onClose: onClose$.asObservable(), - } as unknown as DynamicDialogRef); + customDialogService.open.mockReturnValue(CustomDialogServiceMock.dialogRefWithClose(onClose$.asObservable())); component.createProject(); onClose$.next({ project: { id: 'project-123' } }); diff --git a/src/app/features/preprints/components/preprint-details/share-and-download/share-and-download.component.spec.ts b/src/app/features/preprints/components/preprint-details/share-and-download/share-and-download.component.spec.ts index f88b9b6f2..13f353715 100644 --- a/src/app/features/preprints/components/preprint-details/share-and-download/share-and-download.component.spec.ts +++ b/src/app/features/preprints/components/preprint-details/share-and-download/share-and-download.component.spec.ts @@ -1,7 +1,5 @@ import { MockComponent, MockProvider } from 'ng-mocks'; -import { Mock } from 'vitest'; - import { PLATFORM_ID } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; @@ -14,6 +12,10 @@ import { PREPRINT_MOCK } from '@testing/mocks/preprint.mock'; import { PREPRINT_PROVIDER_DETAILS_MOCK } from '@testing/mocks/preprint-provider-details'; import { provideOSFCore } from '@testing/osf.testing.provider'; import { DataciteServiceMockBuilder, DataciteServiceMockType } from '@testing/providers/datacite.service.mock'; +import { + SocialShareServiceMockBuilder, + SocialShareServiceMockType, +} from '@testing/providers/social-share-provider.mock'; import { BaseSetupOverrides, mergeSignalOverrides, provideMockStore } from '@testing/providers/store-provider.mock'; import { ShareAndDownloadComponent } from './share-and-download.component'; @@ -22,7 +24,7 @@ describe('ShareAndDownloadComponent', () => { let component: ShareAndDownloadComponent; let fixture: ComponentFixture; let dataciteService: DataciteServiceMockType; - let socialShareService: { createDownloadUrl: Mock }; + let socialShareService: SocialShareServiceMockType; const mockPreprint = PREPRINT_MOCK; const mockProvider = PREPRINT_PROVIDER_DETAILS_MOCK; @@ -33,7 +35,9 @@ describe('ShareAndDownloadComponent', () => { function setup(overrides: SetupOverrides = {}) { dataciteService = DataciteServiceMockBuilder.create().build(); - socialShareService = { createDownloadUrl: vi.fn().mockReturnValue('https://example.com/download') }; + socialShareService = SocialShareServiceMockBuilder.create() + .withCreateDownloadUrl(vi.fn().mockReturnValue('https://example.com/download')) + .build(); TestBed.configureTestingModule({ imports: [ShareAndDownloadComponent, MockComponent(SocialsShareButtonComponent)], diff --git a/src/app/features/preprints/components/stepper/file-step/file-step.component.html b/src/app/features/preprints/components/stepper/file-step/file-step.component.html index 8d9c845ab..e11ec2ac7 100644 --- a/src/app/features/preprints/components/stepper/file-step/file-step.component.html +++ b/src/app/features/preprints/components/stepper/file-step/file-step.component.html @@ -94,13 +94,10 @@

{{ 'preprints.preprintStepper.file.title' | translate }}

[currentFolder]="currentFolder()!" [files]="projectFiles()" [totalCount]="filesTotalCount()" - [storage]="null" - [selectionMode]="null" [isLoading]="areProjectFilesLoading() || isCurrentFolderLoading()" - [resourceId]="selectedProjectId()!" [scrollHeight]="'500px'" - (entryFileClicked)="selectProjectFile($event)" - (setCurrentFolder)="setCurrentFolder($event)" + (fileOpened)="selectProjectFile($event)" + (currentFolderChanged)="setCurrentFolder($event)" (loadFiles)="onLoadFiles($event)" /> } diff --git a/src/app/features/preprints/components/stepper/file-step/file-step.component.ts b/src/app/features/preprints/components/stepper/file-step/file-step.component.ts index 48f87991d..854f901b5 100644 --- a/src/app/features/preprints/components/stepper/file-step/file-step.component.ts +++ b/src/app/features/preprints/components/stepper/file-step/file-step.component.ts @@ -46,6 +46,7 @@ import { ClearFileDirective } from '@osf/shared/directives/clear-file.directive' import { StringOrNull } from '@osf/shared/helpers/types.helper'; import { FileModel } from '@osf/shared/models/files/file.model'; import { FileFolderModel } from '@osf/shared/models/files/file-folder.model'; +import { FilePageLinkModel } from '@osf/shared/models/files/file-page-link.model'; import { ToastService } from '@osf/shared/services/toast.service'; @Component({ @@ -225,7 +226,7 @@ export class FileStepComponent implements OnInit { this.actions.getProjectFilesByLink(folder.links.filesLink, 1); } - onLoadFiles(event: { link: string; page: number }) { + onLoadFiles(event: FilePageLinkModel) { this.actions.getProjectFilesByLink(event.link, event.page); } } diff --git a/src/app/features/preprints/pages/preprint-download-redirect/preprint-download-redirect.component.spec.ts b/src/app/features/preprints/pages/preprint-download-redirect/preprint-download-redirect.component.spec.ts index cd954bfe0..8bbf62371 100644 --- a/src/app/features/preprints/pages/preprint-download-redirect/preprint-download-redirect.component.spec.ts +++ b/src/app/features/preprints/pages/preprint-download-redirect/preprint-download-redirect.component.spec.ts @@ -8,6 +8,10 @@ import { SocialShareService } from '@osf/shared/services/social-share.service'; import { provideOSFCore } from '@testing/osf.testing.provider'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; +import { + SocialShareServiceMockBuilder, + SocialShareServiceMockType, +} from '@testing/providers/social-share-provider.mock'; import { PreprintDownloadRedirectComponent } from './preprint-download-redirect.component'; @@ -22,9 +26,9 @@ describe('PreprintDownloadRedirectComponent', () => { .withParams(id ? { id } : {}) .build(); - const mockSocialShareService = { - createDownloadUrl: vi.fn().mockReturnValue(MOCK_DOWNLOAD_URL), - }; + const mockSocialShareService: SocialShareServiceMockType = SocialShareServiceMockBuilder.create() + .withCreateDownloadUrl(vi.fn().mockReturnValue(MOCK_DOWNLOAD_URL)) + .build(); TestBed.configureTestingModule({ imports: [PreprintDownloadRedirectComponent], diff --git a/src/app/features/project/overview/components/files-widget/files-widget.component.html b/src/app/features/project/overview/components/files-widget/files-widget.component.html index bc85b93cc..3e92691ab 100644 --- a/src/app/features/project/overview/components/files-widget/files-widget.component.html +++ b/src/app/features/project/overview/components/files-widget/files-widget.component.html @@ -6,9 +6,9 @@

{{ 'project.overview.files.filesPreview' | translate }}

[(selectedValue)]="selectedRoot" (changeValue)="onChangeProject($event)" [fullWidth]="true" - [disabled]="isStorageLoading" + [disabled]="isStorageLoading()" /> - @if (isStorageLoading) { + @if (isStorageLoading()) {
@@ -37,14 +37,11 @@

{{ 'project.overview.files.filesPreview' | translate }}

[totalCount]="filesTotalCount()" [currentFolder]="currentFolder()!" [storage]="currentRootFolder()!" - [isLoading]="isFilesLoading() || isStorageLoading" - [resourceId]="selectedRoot!" - [provider]="provider()" - [selectionMode]="null" + [isLoading]="isFilesLoading() || isStorageLoading()" [scrollHeight]="'300px'" - (entryFileClicked)="navigateToFile($event)" + (fileOpened)="navigateToFile($event)" (loadFiles)="onLoadFiles($event)" - (setCurrentFolder)="setCurrentFolder($event)" + (currentFolderChanged)="setCurrentFolder($event)" > diff --git a/src/app/features/project/overview/components/files-widget/files-widget.component.spec.ts b/src/app/features/project/overview/components/files-widget/files-widget.component.spec.ts index a4852f51f..d26ce6780 100644 --- a/src/app/features/project/overview/components/files-widget/files-widget.component.spec.ts +++ b/src/app/features/project/overview/components/files-widget/files-widget.component.spec.ts @@ -1,30 +1,219 @@ -import { MockComponents } from 'ng-mocks'; +import { Store } from '@ngxs/store'; + +import { MockProvider } from 'ng-mocks'; + +import { of } from 'rxjs'; + +import { Mock } from 'vitest'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Router, UrlTree } from '@angular/router'; -import { FilesTreeComponent } from '@osf/shared/components/files-tree/files-tree.component'; -import { SelectComponent } from '@osf/shared/components/select/select.component'; +import { FileProvider } from '@osf/features/files/constants'; +import { + FilesSelectors, + GetConfiguredStorageAddons, + GetFiles, + GetRootFolders, + SetFilesCurrentFolder, +} from '@osf/features/files/store'; +import { SupportedFeature } from '@osf/shared/enums/addon-supported-features.enum'; +import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { FileFolderModel } from '@osf/shared/models/files/file-folder.model'; +import { FilePageLinkModel } from '@osf/shared/models/files/file-page-link.model'; +import { NodeShortInfoModel } from '@osf/shared/models/nodes/node-with-children.model'; +import { SelectOption } from '@osf/shared/models/select-option.model'; +import { FilesService } from '@osf/shared/services/files.service'; +import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; +import { MOCK_CONFIGURED_ADDON } from '@testing/mocks/configured-addon.mock'; +import { FileModelMock } from '@testing/mocks/file.model.mock'; +import { OSF_FILE_MOCK } from '@testing/mocks/osf-file.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; +import { FilesServiceMock, FilesServiceMockType } from '@testing/providers/files-service.mock'; +import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; +import { + BaseSetupOverrides, + mergeSignalOverrides, + provideMockStore, + SignalOverride, +} from '@testing/providers/store-provider.mock'; +import { ViewOnlyLinkHelperMock, ViewOnlyLinkHelperMockType } from '@testing/providers/view-only-link-helper.mock'; import { FilesWidgetComponent } from './files-widget.component'; -describe.skip('FilesWidgetComponent', () => { +interface SetupOverrides extends BaseSetupOverrides { + rootOption?: SelectOption; + components?: NodeShortInfoModel[]; + areComponentsLoading?: boolean; + hasViewOnly?: boolean; +} + +describe('FilesWidgetComponent', () => { let component: FilesWidgetComponent; let fixture: ComponentFixture; + let store: Store; + let routerMock: RouterMockType; + let filesService: FilesServiceMockType; + let viewOnlyHelper: ViewOnlyLinkHelperMockType; + + const rootFolder: FileFolderModel = { + ...OSF_FILE_MOCK, + id: 'root-1', + name: 'OSF Storage', + provider: FileProvider.OsfStorage, + links: { ...OSF_FILE_MOCK.links, filesLink: '/files-link' }, + }; + + const rootOption: SelectOption = { label: 'Root', value: 'project-1' }; + const components: NodeShortInfoModel[] = [ + { id: 'project-1', title: 'Project 1', isPublic: true, permissions: [] }, + { id: 'component-1', title: 'Component 1', isPublic: true, permissions: [], parentId: 'project-1' }, + ]; + + function setup(overrides: SetupOverrides = {}) { + routerMock = RouterMockBuilder.create() + .withUrl('/abc?view_only=token') + .withCreateUrlTree(vi.fn().mockReturnValue({} as UrlTree)) + .withSerializeUrl(vi.fn().mockReturnValue('/serialized')) + .build(); + filesService = FilesServiceMock.simple(); + viewOnlyHelper = ViewOnlyLinkHelperMock.simple(overrides.hasViewOnly ?? false); + viewOnlyHelper.getViewOnlyParamFromUrl.mockReturnValue('token'); + + const defaultSignals: SignalOverride[] = [ + { selector: FilesSelectors.getFiles, value: [] }, + { selector: FilesSelectors.getFilesTotalCount, value: 0 }, + { selector: FilesSelectors.isFilesLoading, value: false }, + { selector: FilesSelectors.getCurrentFolder, value: rootFolder }, + { selector: FilesSelectors.getRootFolders, value: [rootFolder] }, + { selector: FilesSelectors.isRootFoldersLoading, value: false }, + { + selector: FilesSelectors.getConfiguredStorageAddons, + value: [{ ...MOCK_CONFIGURED_ADDON, id: 'addon-1', externalServiceName: FileProvider.OsfStorage }], + }, + { selector: FilesSelectors.isConfiguredStorageAddonsLoading, value: false }, + { + selector: FilesSelectors.getStorageSupportedFeatures, + value: { [FileProvider.OsfStorage]: [SupportedFeature.AddUpdateFiles] }, + }, + ]; - beforeEach(() => { TestBed.configureTestingModule({ - imports: [FilesWidgetComponent, ...MockComponents(SelectComponent, FilesTreeComponent)], - providers: [provideOSFCore()], + imports: [FilesWidgetComponent], + providers: [ + provideOSFCore(), + MockProvider(Router, routerMock), + MockProvider(FilesService, filesService), + MockProvider(ViewOnlyLinkHelperService, viewOnlyHelper), + provideMockStore({ signals: mergeSignalOverrides(defaultSignals, overrides.selectorOverrides) }), + ], }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(FilesWidgetComponent); component = fixture.componentInstance; + fixture.componentRef.setInput('rootOption', overrides.rootOption ?? rootOption); + fixture.componentRef.setInput('components', overrides.components ?? components); + fixture.componentRef.setInput('areComponentsLoading', overrides.areComponentsLoading ?? false); fixture.detectChanges(); - }); + } it('should create', () => { + setup(); expect(component).toBeTruthy(); }); + + it('should load storage addons and files on init', () => { + setup(); + const calls = (store.dispatch as Mock).mock.calls.map((c) => c[0]); + + expect(calls).toContainEqual(new GetRootFolders('project-1', ResourceType.Project)); + expect(calls).toContainEqual(new GetConfiguredStorageAddons('project-1')); + expect(calls).toContainEqual(new SetFilesCurrentFolder(rootFolder)); + expect(calls).toContainEqual(new GetFiles('/files-link', 1)); + }); + + it('should build options with root option and filtered component options', () => { + setup(); + const values = component.options().map((o) => o.value); + + expect(values[0]).toBe('project-1'); + expect(values).toContain('component-1'); + }); + + it('should reload storage addons on project change', () => { + setup(); + (store.dispatch as Mock).mockClear(); + + component.onChangeProject('project-2'); + + expect(store.dispatch).toHaveBeenCalledWith(new GetRootFolders('project-2', ResourceType.Project)); + expect(store.dispatch).toHaveBeenCalledWith(new GetConfiguredStorageAddons('project-2')); + }); + + it('should set current root folder on storage change', () => { + setup(); + + component.onStorageChange('root-1'); + + expect(component.currentRootFolder()?.folder.id).toBe('root-1'); + }); + + it('should dispatch getFiles from onLoadFiles', () => { + setup(); + (store.dispatch as Mock).mockClear(); + const event: FilePageLinkModel = { link: '/next-page', page: 3 }; + + component.onLoadFiles(event); + + expect(store.dispatch).toHaveBeenCalledWith(new GetFiles('/next-page', 3)); + }); + + it('should dispatch set current folder', () => { + setup(); + (store.dispatch as Mock).mockClear(); + + component.setCurrentFolder(rootFolder); + + expect(store.dispatch).toHaveBeenCalledWith(new SetFilesCurrentFolder(rootFolder)); + }); + + it('should open file directly when guid exists', () => { + setup(); + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null); + const file = FileModelMock.simple({ guid: 'guid-1' }); + + component.navigateToFile(file); + + expect(routerMock.createUrlTree).toHaveBeenCalledWith(['/', 'guid-1'], undefined); + expect(openSpy).toHaveBeenCalledWith('/serialized', '_blank'); + expect(filesService.getFileGuid).not.toHaveBeenCalled(); + }); + + it('should resolve guid then open file when guid is missing', () => { + setup(); + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null); + filesService.getFileGuid.mockReturnValue(of(FileModelMock.simple({ guid: 'resolved-guid' }))); + const file = FileModelMock.simple({ id: 'file-1', guid: null }); + + component.navigateToFile(file); + + expect(filesService.getFileGuid).toHaveBeenCalledWith('file-1'); + expect(routerMock.createUrlTree).toHaveBeenCalledWith(['/', 'resolved-guid'], undefined); + expect(openSpy).toHaveBeenCalledWith('/serialized', '_blank'); + }); + + it('should include view_only query param when hasViewOnly is true', () => { + setup({ hasViewOnly: true }); + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null); + const file = FileModelMock.simple({ guid: 'guid-1' }); + + component.navigateToFile(file); + + expect(routerMock.createUrlTree).toHaveBeenCalledWith(['/', 'guid-1'], { + queryParams: { view_only: 'token' }, + }); + expect(openSpy).toHaveBeenCalledWith('/serialized', '_blank'); + }); }); diff --git a/src/app/features/project/overview/components/files-widget/files-widget.component.ts b/src/app/features/project/overview/components/files-widget/files-widget.component.ts index 535b01a43..79a4d8326 100644 --- a/src/app/features/project/overview/components/files-widget/files-widget.component.ts +++ b/src/app/features/project/overview/components/files-widget/files-widget.component.ts @@ -1,6 +1,6 @@ import { createDispatchMap, select } from '@ngxs/store'; -import { TranslatePipe } from '@ngx-translate/core'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { Skeleton } from 'primeng/skeleton'; import { TabsModule } from 'primeng/tabs'; @@ -18,9 +18,9 @@ import { PLATFORM_ID, signal, } from '@angular/core'; -import { ActivatedRoute, Router } from '@angular/router'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { Router } from '@angular/router'; -import { ENVIRONMENT } from '@core/provider/environment.provider'; import { FileProvider } from '@osf/features/files/constants'; import { FilesSelectors, @@ -32,14 +32,17 @@ import { } from '@osf/features/files/store'; import { FilesTreeComponent } from '@osf/shared/components/files-tree/files-tree.component'; import { SelectComponent } from '@osf/shared/components/select/select.component'; +import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { buildProjectPathOptions } from '@osf/shared/helpers/project-path-options.helper'; +import { mapRootFoldersToStorageLabels } from '@osf/shared/helpers/storage-addon-options.helper'; import { Primitive } from '@osf/shared/helpers/types.helper'; -import { ConfiguredAddonModel } from '@osf/shared/models/addons/configured-addon.model'; import { FileModel } from '@osf/shared/models/files/file.model'; import { FileFolderModel } from '@osf/shared/models/files/file-folder.model'; import { FileLabelModel } from '@osf/shared/models/files/file-label.model'; +import { FilePageLinkModel } from '@osf/shared/models/files/file-page-link.model'; import { NodeShortInfoModel } from '@osf/shared/models/nodes/node-with-children.model'; -import { ProjectModel } from '@osf/shared/models/projects/projects.model'; import { SelectOption } from '@osf/shared/models/select-option.model'; +import { FilesService } from '@osf/shared/services/files.service'; import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; @Component({ @@ -50,23 +53,21 @@ import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-h changeDetection: ChangeDetectionStrategy.OnPush, }) export class FilesWidgetComponent { - rootOption = input.required(); - components = input.required(); - areComponentsLoading = input(false); - router = inject(Router); - activeRoute = inject(ActivatedRoute); + readonly rootOption = input.required(); + readonly components = input.required(); + readonly areComponentsLoading = input(false); - private readonly environment = inject(ENVIRONMENT); + private readonly router = inject(Router); private readonly destroyRef = inject(DestroyRef); private readonly viewOnlyService = inject(ViewOnlyLinkHelperService); - private readonly platformId = inject(PLATFORM_ID); - private readonly isBrowser = isPlatformBrowser(this.platformId); + private readonly filesService = inject(FilesService); + private readonly translateService = inject(TranslateService); + private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); readonly files = select(FilesSelectors.getFiles); readonly filesTotalCount = select(FilesSelectors.getFilesTotalCount); readonly isFilesLoading = select(FilesSelectors.isFilesLoading); readonly currentFolder = select(FilesSelectors.getCurrentFolder); - readonly provider = select(FilesSelectors.getProvider); readonly rootFolders = select(FilesSelectors.getRootFolders); readonly isRootFoldersLoading = select(FilesSelectors.isRootFoldersLoading); readonly configuredStorageAddons = select(FilesSelectors.getConfiguredStorageAddons); @@ -75,23 +76,14 @@ export class FilesWidgetComponent { currentRootFolder = model(null); pageNumber = signal(1); - readonly osfStorageLabel = 'OSF Storage'; - readonly options = computed(() => { const components = this.components().filter((component) => this.rootOption().value !== component.id); - return [this.rootOption(), ...this.buildOptions(components)]; + return [this.rootOption(), ...buildProjectPathOptions({ nodes: components })]; }); readonly storageAddons = computed(() => { - const rootFolders = this.rootFolders(); - const addons = this.configuredStorageAddons(); - if (rootFolders && addons) { - return rootFolders.map((folder) => ({ - label: this.getAddonName(addons, folder.provider), - folder: folder, - })); - } - return []; + const osfLabel = this.translateService.instant('files.storageLocation'); + return mapRootFoldersToStorageLabels(this.rootFolders(), this.configuredStorageAddons(), osfLabel); }); readonly hasViewOnly = computed(() => this.viewOnlyService.hasViewOnlyParam(this.router)); @@ -104,9 +96,7 @@ export class FilesWidgetComponent { resetState: ResetFilesState, }); - get isStorageLoading() { - return this.isConfiguredStorageAddonsLoading() || this.isRootFoldersLoading(); - } + readonly isStorageLoading = computed(() => this.isConfiguredStorageAddonsLoading() || this.isRootFoldersLoading()); selectedRoot: string | null = null; @@ -129,7 +119,7 @@ export class FilesWidgetComponent { const osfRootFolder = rootFolders.find((folder) => folder.provider === FileProvider.OsfStorage); if (osfRootFolder) { this.currentRootFolder.set({ - label: this.osfStorageLabel, + label: this.translateService.instant('files.storageLocation'), folder: osfRootFolder, }); } @@ -160,57 +150,8 @@ export class FilesWidgetComponent { } private getStorageAddons(projectId: string) { - const resourcePath = 'nodes'; - const folderLink = `${this.environment.apiDomainUrl}/v2/${resourcePath}/${projectId}/files/`; - const iriLink = `${this.environment.webUrl}/${projectId}`; - this.actions.getRootFolders(folderLink); - this.actions.getConfiguredStorageAddons(iriLink); - } - - private flatComponents( - components: (Partial & { children?: ProjectModel[] })[] = [], - parentPath = '..' - ): SelectOption[] { - return components.flatMap((component) => { - const currentPath = parentPath ? `${parentPath}/${component.title ?? ''}` : (component.title ?? ''); - - return [ - { - value: component.id ?? '', - label: currentPath, - }, - ...this.flatComponents(component.children ?? [], currentPath), - ]; - }); - } - - private buildOptions(nodes: NodeShortInfoModel[] = [], parentPath = '..'): SelectOption[] { - return nodes.reduce((acc, node) => { - const pathParts: string[] = []; - - let current: NodeShortInfoModel | undefined = node; - while (current) { - pathParts.unshift(current.title ?? ''); - current = nodes.find((n) => n.id === current?.parentId); - } - - const fullPath = parentPath ? `${parentPath}/${pathParts.join('/')}` : pathParts.join('/'); - - acc.push({ - value: node.id, - label: fullPath, - }); - - return acc; - }, []); - } - - private getAddonName(addons: ConfiguredAddonModel[], provider: string): string { - if (provider === FileProvider.OsfStorage) { - return this.osfStorageLabel; - } else { - return addons.find((addon) => addon.externalServiceName === provider)?.displayName ?? ''; - } + this.actions.getRootFolders(projectId, ResourceType.Project); + this.actions.getConfiguredStorageAddons(projectId); } onChangeProject(value: Primitive) { @@ -225,20 +166,34 @@ export class FilesWidgetComponent { } navigateToFile(file: FileModel) { - const extras = this.hasViewOnly() - ? { queryParams: { view_only: this.viewOnlyService.getViewOnlyParamFromUrl(this.router.url) } } - : undefined; - - const url = this.router.serializeUrl(this.router.createUrlTree(['/', file.guid], extras)); + if (file.guid) { + this.openFile(file.guid); + return; + } - window.open(url, '_blank'); + this.filesService + .getFileGuid(file.id) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((file) => { + if (file.guid) { + this.openFile(file.guid); + } + }); } - onLoadFiles(event: { link: string; page: number }) { + onLoadFiles(event: FilePageLinkModel) { this.actions.getFiles(event.link, event.page); } setCurrentFolder(folder: FileFolderModel) { this.actions.setCurrentFolder(folder); } + + private openFile(guid: string): void { + const extras = this.hasViewOnly() + ? { queryParams: { view_only: this.viewOnlyService.getViewOnlyParamFromUrl(this.router.url) } } + : undefined; + + window.open(this.router.serializeUrl(this.router.createUrlTree(['/', guid], extras)), '_blank'); + } } diff --git a/src/app/features/project/overview/project-overview.component.spec.ts b/src/app/features/project/overview/project-overview.component.spec.ts index 236840821..505ca0767 100644 --- a/src/app/features/project/overview/project-overview.component.spec.ts +++ b/src/app/features/project/overview/project-overview.component.spec.ts @@ -34,6 +34,7 @@ import { provideOSFCore } from '@testing/osf.testing.provider'; import { CustomDialogServiceMockBuilder } from '@testing/providers/custom-dialog-provider.mock'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; +import { SignpostingServiceMock, SignpostingServiceMockType } from '@testing/providers/signposting-provider.mock'; import { BaseSetupOverrides, mergeSignalOverrides, @@ -68,10 +69,7 @@ describe('ProjectOverviewComponent', () => { let routerMock: RouterMockType; let customDialogServiceMock: ReturnType; let toastServiceMock: ToastServiceMockType; - let signpostingServiceMock: { - addSignposting: Mock; - removeSignpostingLinkTags: Mock; - }; + let signpostingServiceMock: SignpostingServiceMockType; const mockProject = MOCK_PROJECT_OVERVIEW as ProjectOverviewModel; @@ -126,10 +124,7 @@ describe('ProjectOverviewComponent', () => { .build(); toastServiceMock = ToastServiceMock.simple(); - signpostingServiceMock = { - addSignposting: vi.fn(), - removeSignpostingLinkTags: vi.fn(), - }; + signpostingServiceMock = SignpostingServiceMock.simple(); const viewOnlyLinkHelperMock = ViewOnlyLinkHelperMock.simple(); const signals = mergeSignalOverrides(defaultSignals, overrides.selectorOverrides); diff --git a/src/app/features/registries/components/custom-step/custom-step.component.html b/src/app/features/registries/components/custom-step/custom-step.component.html index e3d0540a6..edd5941bf 100644 --- a/src/app/features/registries/components/custom-step/custom-step.component.html +++ b/src/app/features/registries/components/custom-step/custom-step.component.html @@ -177,10 +177,9 @@

{{ 'files.actions.uploadFile' | translate }}

[attachedFiles]="attachedFiles[q.responseKey!]" [filesLink]="filesLink()" [projectId]="projectId()" - [provider]="provider()" + [filesViewOnly]="filesViewOnly()" (attachFile)="onAttachFile($event, q.responseKey!)" (openFile)="onOpenFile($event)" - [filesViewOnly]="filesViewOnly()" (removeFromAttachedFiles)="removeFromAttachedFiles($event, q.responseKey!)" >
diff --git a/src/app/features/registries/components/custom-step/custom-step.component.spec.ts b/src/app/features/registries/components/custom-step/custom-step.component.spec.ts index 68cca32a7..b01fd3e86 100644 --- a/src/app/features/registries/components/custom-step/custom-step.component.spec.ts +++ b/src/app/features/registries/components/custom-step/custom-step.component.spec.ts @@ -6,7 +6,7 @@ import { Mock } from 'vitest'; import { TestBed } from '@angular/core/testing'; import { FormGroup } from '@angular/forms'; -import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRoute, Router, UrlTree } from '@angular/router'; import { InfoIconComponent } from '@osf/shared/components/info-icon/info-icon.component'; import { FieldType } from '@osf/shared/enums/field-type.enum'; @@ -40,7 +40,8 @@ interface SetupOverrides extends BaseSetupOverrides { stepsData?: Record; filesLink?: string; projectId?: string; - provider?: string; + serializedUrl?: string; + urlTree?: UrlTree; } describe('CustomStepComponent', () => { @@ -57,7 +58,11 @@ describe('CustomStepComponent', () => { routeBuilder.withNoParent(); } - const mockRouter = RouterMockBuilder.create().withUrl('/registries/drafts/id/1').build(); + const mockRouter = RouterMockBuilder.create() + .withUrl('/registries/drafts/id/1') + .withCreateUrlTree(vi.fn().mockReturnValue(overrides.urlTree ?? ({} as UrlTree))) + .withSerializeUrl(vi.fn().mockReturnValue(overrides.serializedUrl ?? '/')) + .build(); const toastMock = ToastServiceMock.simple(); const defaultSignals: SignalOverride[] = [ @@ -83,7 +88,6 @@ describe('CustomStepComponent', () => { fixture.componentRef.setInput('stepsData', overrides.stepsData ?? MOCK_STEPS_DATA); fixture.componentRef.setInput('filesLink', overrides.filesLink ?? 'files-link'); fixture.componentRef.setInput('projectId', overrides.projectId ?? 'project'); - fixture.componentRef.setInput('provider', overrides.provider ?? 'provider'); fixture.detectChanges(); return { component, fixture, store, routeBuilder, mockRouter, toastMock }; } @@ -220,6 +224,31 @@ describe('CustomStepComponent', () => { expect(emitSpy).toHaveBeenCalled(); }); + it('should open file preview in new tab when draftId and file guid exist', () => { + const urlTree = {} as UrlTree; + const { component, mockRouter } = setup({ + routeParams: { step: '1', id: 'draft-1' }, + urlTree, + serializedUrl: '/draft-1/files/file-guid/preview', + }); + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null); + + component.onOpenFile({ guid: 'file-guid' } as FileModel); + + expect(mockRouter.createUrlTree).toHaveBeenCalledWith(['draft-1', 'files', 'file-guid', 'preview']); + expect(mockRouter.serializeUrl).toHaveBeenCalledWith(urlTree); + expect(openSpy).toHaveBeenCalledWith('/draft-1/files/file-guid/preview', '_blank'); + }); + + it('should not open file preview when file guid is missing', () => { + const { component } = setup({ routeParams: { step: '1', id: 'draft-1' } }); + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null); + + component.onOpenFile({ guid: null } as FileModel); + + expect(openSpy).not.toHaveBeenCalled(); + }); + it('should skip non-existent questionKey', () => { const { component } = setup(); const emitSpy = vi.spyOn(component.updateAction, 'emit'); diff --git a/src/app/features/registries/components/custom-step/custom-step.component.ts b/src/app/features/registries/components/custom-step/custom-step.component.ts index 484b5e4a9..571ba03fb 100644 --- a/src/app/features/registries/components/custom-step/custom-step.component.ts +++ b/src/app/features/registries/components/custom-step/custom-step.component.ts @@ -72,7 +72,6 @@ export class CustomStepComponent implements OnDestroy { stepsData = input.required>(); filesLink = input.required(); projectId = input.required(); - provider = input.required(); filesViewOnly = input(false); // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/app/features/registries/components/files-control/files-control.component.html b/src/app/features/registries/components/files-control/files-control.component.html index 951fcac20..387f38907 100644 --- a/src/app/features/registries/components/files-control/files-control.component.html +++ b/src/app/features/registries/components/files-control/files-control.component.html @@ -41,23 +41,17 @@
diff --git a/src/app/features/registries/components/files-control/files-control.component.spec.ts b/src/app/features/registries/components/files-control/files-control.component.spec.ts index 1557f575a..222c9f6c9 100644 --- a/src/app/features/registries/components/files-control/files-control.component.spec.ts +++ b/src/app/features/registries/components/files-control/files-control.component.spec.ts @@ -2,18 +2,17 @@ import { Store } from '@ngxs/store'; import { MockComponents, MockProvider } from 'ng-mocks'; -import { TreeDragDropService } from 'primeng/api'; - import { of, Subject } from 'rxjs'; import { Mock } from 'vitest'; -import { HttpEventType } from '@angular/common/http'; +import { HttpEvent, HttpEventType, HttpResponse, HttpUploadProgressEvent } from '@angular/common/http'; import { signal, WritableSignal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { CreateFolder, + DeleteDraftRegistrationFiles, GetFiles, RegistriesSelectors, SetFilesIsLoading, @@ -35,6 +34,7 @@ import { CustomDialogServiceMockBuilder, CustomDialogServiceMockType, } from '@testing/providers/custom-dialog-provider.mock'; +import { FilesServiceMock, FilesServiceMockType } from '@testing/providers/files-service.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; @@ -44,7 +44,7 @@ describe('FilesControlComponent', () => { let component: FilesControlComponent; let fixture: ComponentFixture; let store: Store; - let mockFilesService: { uploadFile: Mock; getFileGuid: Mock }; + let filesServiceMock: FilesServiceMockType; let mockDialogService: CustomDialogServiceMockType; let currentFolderSignal: WritableSignal; let toastService: ToastServiceMockType; @@ -54,7 +54,7 @@ describe('FilesControlComponent', () => { } as FileFolderModel; beforeEach(() => { - mockFilesService = { uploadFile: vi.fn(), getFileGuid: vi.fn() }; + filesServiceMock = FilesServiceMock.simple(); mockDialogService = CustomDialogServiceMockBuilder.create().withDefaultOpen().build(); currentFolderSignal = signal(CURRENT_FOLDER); toastService = ToastServiceMock.simple(); @@ -68,9 +68,8 @@ describe('FilesControlComponent', () => { provideOSFCore(), MockProvider(ToastService, toastService), MockProvider(CustomConfirmationService), - MockProvider(FilesService, mockFilesService), + MockProvider(FilesService, filesServiceMock), MockProvider(CustomDialogService, mockDialogService), - MockProvider(TreeDragDropService), provideMockStore({ signals: [ { selector: RegistriesSelectors.getFiles, value: [] }, @@ -88,7 +87,6 @@ describe('FilesControlComponent', () => { fixture.componentRef.setInput('attachedFiles', []); fixture.componentRef.setInput('filesLink', '/files-link'); fixture.componentRef.setInput('projectId', 'project-1'); - fixture.componentRef.setInput('provider', 'provider-1'); fixture.componentRef.setInput('filesViewOnly', false); fixture.detectChanges(); }); @@ -131,7 +129,7 @@ describe('FilesControlComponent', () => { it('should open dialog and dispatch createFolder on confirm', () => { const onClose$ = new Subject(); - mockDialogService.open.mockReturnValue({ onClose: onClose$ } as any); + mockDialogService.open.mockReturnValue({ onClose: onClose$ } as never); (store.dispatch as Mock).mockClear(); component.createFolder(); @@ -143,17 +141,18 @@ describe('FilesControlComponent', () => { it('should upload file, track progress, and select uploaded file', () => { const file = new File(['data'], 'test.txt'); - const progress = { type: HttpEventType.UploadProgress, loaded: 50, total: 100 }; - const response = { type: HttpEventType.Response, body: { data: { id: 'files/abc' } } }; + const progress: HttpUploadProgressEvent = { type: HttpEventType.UploadProgress, loaded: 50, total: 100 }; + const response = new HttpResponse({ body: { data: { id: 'files/abc' } } }); + const uploadEvents: HttpEvent[] = [progress, response]; - mockFilesService.uploadFile.mockReturnValue(of(progress, response)); - mockFilesService.getFileGuid.mockReturnValue(of({ id: 'abc' } as FileModel)); + filesServiceMock.uploadFile.mockReturnValue(of(...uploadEvents)); + filesServiceMock.getFileGuid.mockReturnValue(of({ id: 'abc' } as FileModel)); const selectSpy = vi.spyOn(component, 'selectFile'); component.uploadFiles(file); - expect(mockFilesService.uploadFile).toHaveBeenCalledWith(file, '/upload'); + expect(filesServiceMock.uploadFile).toHaveBeenCalledWith(file, '/upload'); expect(component.progress()).toBe(50); expect(selectSpy).toHaveBeenCalledWith({ id: 'abc' } as FileModel); }); @@ -164,16 +163,16 @@ describe('FilesControlComponent', () => { const file = new File(['data'], 'test.txt'); component.uploadFiles(file); - expect(mockFilesService.uploadFile).not.toHaveBeenCalled(); + expect(filesServiceMock.uploadFile).not.toHaveBeenCalled(); }); it('should handle File array input', () => { const file = new File(['data'], 'test.txt'); - mockFilesService.uploadFile.mockReturnValue(of({ type: HttpEventType.Sent })); + filesServiceMock.uploadFile.mockReturnValue(of({ type: HttpEventType.Sent })); component.uploadFiles([file]); - expect(mockFilesService.uploadFile).toHaveBeenCalledWith(file, '/upload'); + expect(filesServiceMock.uploadFile).toHaveBeenCalledWith(file, '/upload'); }); it('should not emit attachFile when filesViewOnly is true', () => { @@ -203,15 +202,6 @@ describe('FilesControlComponent', () => { expect(store.dispatch).toHaveBeenCalledWith(new SetRegistriesCurrentFolder(folder)); }); - it('should add file to filesSelection and deduplicate', () => { - const file = { id: 'file-1' } as FileModel; - - component.onFileTreeSelected(file); - component.onFileTreeSelected(file); - - expect(component.filesSelection).toEqual([file]); - }); - it('should not open dialog when no newFolder link', () => { currentFolderSignal.set({ links: {} } as FileFolderModel); @@ -231,16 +221,16 @@ describe('FilesControlComponent', () => { it('should delete entry, show success toast, refresh files, and emit removal', () => { const file = { id: 'file-1', links: { delete: '/delete-link' } } as FileModel; - const deleteSpy = vi.spyOn(component['actions'], 'deleteDraftRegistrationFiles').mockReturnValue(of(void 0)); - const refreshSpy = vi.spyOn(component as any, 'refreshFilesList'); const emitSpy = vi.spyOn(component.removeFromAttachedFiles, 'emit'); const toastSpy = vi.spyOn(toastService, 'showSuccess'); + (store.dispatch as Mock).mockClear(); - component.deleteEntry(file); + component.deleteFile(file); - expect(deleteSpy).toHaveBeenCalledWith('/delete-link'); + expect(store.dispatch).toHaveBeenCalledWith(new DeleteDraftRegistrationFiles('/delete-link')); expect(toastSpy).toHaveBeenCalledWith('files.dialogs.deleteFile.success'); - expect(refreshSpy).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith(new SetFilesIsLoading(true)); + expect(store.dispatch).toHaveBeenCalledWith(new GetFiles('/files-link', 1)); expect(emitSpy).toHaveBeenCalledWith('file-1'); }); }); diff --git a/src/app/features/registries/components/files-control/files-control.component.ts b/src/app/features/registries/components/files-control/files-control.component.ts index fd367abbc..ae5cd844d 100644 --- a/src/app/features/registries/components/files-control/files-control.component.ts +++ b/src/app/features/registries/components/files-control/files-control.component.ts @@ -2,7 +2,6 @@ import { createDispatchMap, select } from '@ngxs/store'; import { TranslatePipe } from '@ngx-translate/core'; -import { TreeDragDropService } from 'primeng/api'; import { Button } from 'primeng/button'; import { filter, finalize, switchMap, take } from 'rxjs'; @@ -11,14 +10,18 @@ import { HttpEventType } from '@angular/common/http'; import { ChangeDetectionStrategy, Component, DestroyRef, inject, input, output, signal } from '@angular/core'; import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; -import { CreateFolderDialogComponent } from '@osf/features/files/components'; +import { CreateFolderDialogComponent } from '@osf/features/files/components/create-folder-dialog/create-folder-dialog.component'; import { FileUploadDialogComponent } from '@osf/shared/components/file-upload-dialog/file-upload-dialog.component'; import { FilesTreeComponent } from '@osf/shared/components/files-tree/files-tree.component'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; import { FILE_SIZE_LIMIT } from '@osf/shared/constants/files-limits.const'; import { ClearFileDirective } from '@osf/shared/directives/clear-file.directive'; +import { FileMenuType } from '@osf/shared/enums/file-menu-type.enum'; +import { FileMenuFlags } from '@osf/shared/models/files/file-menu-action.model'; +import { FilePageLinkModel } from '@osf/shared/models/files/file-page-link.model'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { FilesService } from '@osf/shared/services/files.service'; +import { FilesTreeActionsService } from '@osf/shared/services/files-tree-actions.service'; import { ToastService } from '@osf/shared/services/toast.service'; import { FileModel } from '@shared/models/files/file.model'; import { FileFolderModel } from '@shared/models/files/file-folder.model'; @@ -46,19 +49,18 @@ import { templateUrl: './files-control.component.html', styleUrl: './files-control.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, - providers: [TreeDragDropService], }) export class FilesControlComponent { attachedFiles = input.required[]>(); filesLink = input.required(); projectId = input.required(); - provider = input.required(); filesViewOnly = input(false); attachFile = output(); removeFromAttachedFiles = output(); openFile = output(); private readonly filesService = inject(FilesService); + private readonly filesTreeActionsService = inject(FilesTreeActionsService); private readonly customDialogService = inject(CustomDialogService); private readonly destroyRef = inject(DestroyRef); private readonly toastService = inject(ToastService); @@ -71,9 +73,9 @@ export class FilesControlComponent { readonly progress = signal(0); readonly fileName = signal(''); readonly dataLoaded = signal(false); + readonly allowedMenuActions = { [FileMenuType.Delete]: true } as FileMenuFlags; fileIsUploading = signal(false); - filesSelection: FileModel[] = []; private readonly actions = createDispatchMap({ createFolder: CreateFolder, @@ -89,7 +91,7 @@ export class FilesControlComponent { this.setupCurrentFolderWatcher(); } - deleteEntry(file: FileModel): void { + deleteFile(file: FileModel): void { this.actions.deleteDraftRegistrationFiles(file?.links.delete).subscribe(() => { this.toastService.showSuccess('files.dialogs.deleteFile.success'); this.refreshFilesList(); @@ -129,6 +131,11 @@ export class FilesControlComponent { .subscribe(() => this.refreshFilesList()); } + confirmTreeUpload(files: File | File[]): void { + const fileArray = Array.isArray(files) ? files : [files]; + this.filesTreeActionsService.confirmDropFiles(fileArray, () => this.uploadFiles(files)); + } + uploadFiles(files: File | File[]): void { const file = Array.isArray(files) ? files[0] : files; const uploadLink = this.currentFolder()?.links.upload; @@ -175,12 +182,7 @@ export class FilesControlComponent { this.attachFile.emit(file); } - onFileTreeSelected(file: FileModel): void { - this.filesSelection.push(file); - this.filesSelection = [...new Set(this.filesSelection)]; - } - - onLoadFiles(event: { link: string; page: number }) { + onLoadFiles(event: FilePageLinkModel) { this.actions.getFiles(event.link, event.page); } diff --git a/src/app/features/registries/pages/draft-registration-custom-step/draft-registration-custom-step.component.html b/src/app/features/registries/pages/draft-registration-custom-step/draft-registration-custom-step.component.html index 3d80cbe6b..695dcf844 100644 --- a/src/app/features/registries/pages/draft-registration-custom-step/draft-registration-custom-step.component.html +++ b/src/app/features/registries/pages/draft-registration-custom-step/draft-registration-custom-step.component.html @@ -5,5 +5,4 @@ (next)="onNext()" [filesLink]="filesLink()" [projectId]="projectId()" - [provider]="provider()" > diff --git a/src/app/features/registries/pages/revisions-custom-step/revisions-custom-step.component.html b/src/app/features/registries/pages/revisions-custom-step/revisions-custom-step.component.html index 9761eaee0..bf6d01a85 100644 --- a/src/app/features/registries/pages/revisions-custom-step/revisions-custom-step.component.html +++ b/src/app/features/registries/pages/revisions-custom-step/revisions-custom-step.component.html @@ -5,6 +5,5 @@ (next)="onNext()" [filesLink]="filesLink()" [projectId]="projectId()" - [provider]="provider()" [filesViewOnly]="true" > diff --git a/src/app/features/settings/account-settings/components/connected-emails/connected-emails.component.spec.ts b/src/app/features/settings/account-settings/components/connected-emails/connected-emails.component.spec.ts index f869f7c7f..ce413d71a 100644 --- a/src/app/features/settings/account-settings/components/connected-emails/connected-emails.component.spec.ts +++ b/src/app/features/settings/account-settings/components/connected-emails/connected-emails.component.spec.ts @@ -2,8 +2,6 @@ import { Store } from '@ngxs/store'; import { MockComponent, MockProvider } from 'ng-mocks'; -import { DynamicDialogRef } from 'primeng/dynamicdialog'; - import { of, Subject } from 'rxjs'; import { Mock } from 'vitest'; @@ -114,9 +112,7 @@ describe('ConnectedEmailsComponent', () => { it('should open add email dialog and show confirmation dialog when dialog returns email', () => { const onClose = new Subject(); - customDialogService.open.mockReturnValue({ - onClose, - } as unknown as DynamicDialogRef); + customDialogService.open.mockReturnValue(CustomDialogServiceMock.dialogRefWithClose(onClose)); const showConfirmationSpy = vi.spyOn(component, 'showConfirmationSentDialog'); component.addEmail(); diff --git a/src/app/shared/components/file-menu/file-menu.component.html b/src/app/shared/components/file-menu/file-menu.component.html index cb17cb23f..f5b180995 100644 --- a/src/app/shared/components/file-menu/file-menu.component.html +++ b/src/app/shared/components/file-menu/file-menu.component.html @@ -6,7 +6,7 @@ variant="text" [raised]="true" icon="fas fa-ellipsis-v" - (click)="onMenuToggle($event)" + (onClick)="onMenuToggle($event)" > diff --git a/src/app/shared/components/file-menu/file-menu.component.ts b/src/app/shared/components/file-menu/file-menu.component.ts index d4b1aad50..9e33b995a 100644 --- a/src/app/shared/components/file-menu/file-menu.component.ts +++ b/src/app/shared/components/file-menu/file-menu.component.ts @@ -19,16 +19,14 @@ import { FileMenuAction, FileMenuData, FileMenuFlags } from '@shared/models/file styleUrl: './file-menu.component.scss', }) export class FileMenuComponent { - private router = inject(Router); - private menuManager = inject(MenuManagerService); - private viewOnlyService = inject(ViewOnlyLinkHelperService); + private readonly router = inject(Router); + private readonly menuManager = inject(MenuManagerService); + private readonly viewOnlyService = inject(ViewOnlyLinkHelperService); - isFolder = input(false); - allowedActions = input({} as FileMenuFlags); - menu = viewChild.required('menu'); - action = output(); - - hasViewOnly = computed(() => this.viewOnlyService.hasViewOnlyParam(this.router)); + readonly isFolder = input(false); + readonly allowedActions = input({} as FileMenuFlags); + readonly menu = viewChild.required('menu'); + readonly action = output(); private readonly allMenuItems: MenuItem[] = [ { @@ -108,7 +106,9 @@ export class FileMenuComponent { ]; menuItems = computed(() => { - if (this.hasViewOnly()) { + const hasViewOnly = this.viewOnlyService.hasViewOnlyParam(this.router); + + if (hasViewOnly) { const allowedActionsForFiles = [ FileMenuType.Download, FileMenuType.Embed, diff --git a/src/app/shared/components/file-select-destination/file-select-destination.component.spec.ts b/src/app/shared/components/file-select-destination/file-select-destination.component.spec.ts deleted file mode 100644 index 143e049a8..000000000 --- a/src/app/shared/components/file-select-destination/file-select-destination.component.spec.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { MockComponent } from 'ng-mocks'; - -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { provideOSFCore } from '@testing/osf.testing.provider'; - -import { SelectComponent } from '../select/select.component'; - -import { FileSelectDestinationComponent } from './file-select-destination.component'; - -describe.skip('FileSelectDestinationComponent', () => { - let component: FileSelectDestinationComponent; - let fixture: ComponentFixture; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [FileSelectDestinationComponent, MockComponent(SelectComponent)], - providers: [provideOSFCore()], - }); - - fixture = TestBed.createComponent(FileSelectDestinationComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/src/app/shared/components/files-drop-zone/files-drop-zone.component.html b/src/app/shared/components/files-drop-zone/files-drop-zone.component.html new file mode 100644 index 000000000..16f925a91 --- /dev/null +++ b/src/app/shared/components/files-drop-zone/files-drop-zone.component.html @@ -0,0 +1,20 @@ +
+ @if (enabled()) { +
+ @if (isDragOver()) { +
+ +

{{ 'files.dropText' | translate }}

+
+ } +
+ } + + +
diff --git a/src/app/shared/components/files-drop-zone/files-drop-zone.component.scss b/src/app/shared/components/files-drop-zone/files-drop-zone.component.scss new file mode 100644 index 000000000..8e5f40b7b --- /dev/null +++ b/src/app/shared/components/files-drop-zone/files-drop-zone.component.scss @@ -0,0 +1,27 @@ +.drop-zone-container { + position: relative; +} + +.drop-zone { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + color: var(--white); + transition: + background 0.3s ease, + backdrop-filter 0.3s ease; + pointer-events: none; + background: transparent; + + &.active { + backdrop-filter: blur(0.3rem); + background: rgba(132, 174, 210, 0.5); + pointer-events: all; + } +} diff --git a/src/app/shared/components/files-drop-zone/files-drop-zone.component.spec.ts b/src/app/shared/components/files-drop-zone/files-drop-zone.component.spec.ts new file mode 100644 index 000000000..5354a3e9d --- /dev/null +++ b/src/app/shared/components/files-drop-zone/files-drop-zone.component.spec.ts @@ -0,0 +1,232 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { provideOSFCore } from '@testing/osf.testing.provider'; + +import { FilesDropZoneComponent } from './files-drop-zone.component'; + +describe('FilesDropZoneComponent', () => { + let component: FilesDropZoneComponent; + let fixture: ComponentFixture; + + function fileList(...files: File[]): FileList { + const list: { length: number; item: (i: number) => File | null } & Record = { + length: files.length, + item: (i: number) => files[i] ?? null, + }; + files.forEach((f, i) => (list[i] = f)); + return list as unknown as FileList; + } + + function makeDragEvent( + dataTransfer: { types: string[]; files?: FileList | null; dropEffect?: string } | null + ): DragEvent { + return { + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + dataTransfer: dataTransfer as unknown as DataTransfer, + } as unknown as DragEvent; + } + + function makeLeaveEvent(): Event { + return { + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + } as unknown as Event; + } + + function makeFile(name = 'file.txt'): File { + return new File(['x'], name, { type: 'text/plain' }); + } + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [FilesDropZoneComponent], + providers: [provideOSFCore()], + }); + + fixture = TestBed.createComponent(FilesDropZoneComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should set drag-over state when entering with a file payload', () => { + component.onDragEnter(makeDragEvent({ types: ['Files'] })); + fixture.detectChanges(); + + expect(component.isDragOver()).toBe(true); + expect(fixture.nativeElement.querySelector('.drop-zone.active')).toBeTruthy(); + }); + + it('should not set drag-over when there is no file payload', () => { + component.onDragEnter(makeDragEvent({ types: [] })); + fixture.detectChanges(); + + expect(component.isDragOver()).toBe(false); + expect(fixture.nativeElement.querySelector('.drop-zone.active')).toBeNull(); + }); + + it('should not set drag-over on dragenter when disabled', () => { + fixture.componentRef.setInput('enabled', false); + fixture.detectChanges(); + + component.onDragEnter(makeDragEvent({ types: ['Files'] })); + fixture.detectChanges(); + + expect(component.isDragOver()).toBe(false); + }); + + it('should call preventDefault and stopPropagation on dragenter regardless of enabled state', () => { + fixture.componentRef.setInput('enabled', false); + const event = makeDragEvent({ types: ['Files'] }); + component.onDragEnter(event); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(event.stopPropagation).toHaveBeenCalled(); + }); + + it('should call preventDefault and stopPropagation on dragover', () => { + const event = makeDragEvent({ types: ['Files'] }); + component.onDragOver(event); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(event.stopPropagation).toHaveBeenCalled(); + }); + + it('should set dropEffect to copy on dragover', () => { + const dataTransfer = { types: ['Files'], dropEffect: '' }; + component.onDragOver(makeDragEvent(dataTransfer)); + + expect(dataTransfer.dropEffect).toBe('copy'); + }); + + it('should not set dropEffect and not set drag-over on dragover when disabled', () => { + fixture.componentRef.setInput('enabled', false); + const dataTransfer = { types: ['Files'], dropEffect: '' }; + component.onDragOver(makeDragEvent(dataTransfer)); + + expect(dataTransfer.dropEffect).toBe(''); + expect(component.isDragOver()).toBe(false); + }); + + it('should still call preventDefault and stopPropagation on dragover when disabled', () => { + fixture.componentRef.setInput('enabled', false); + const event = makeDragEvent({ types: ['Files'] }); + component.onDragOver(event); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(event.stopPropagation).toHaveBeenCalled(); + }); + + it('should clear drag-over only after all nested enters have left', () => { + const enter = makeDragEvent({ types: ['Files'] }); + component.onDragEnter(enter); + component.onDragEnter(enter); + expect(component.isDragOver()).toBe(true); + + component.onDragLeave(makeLeaveEvent()); + expect(component.isDragOver()).toBe(true); + + component.onDragLeave(makeLeaveEvent()); + expect(component.isDragOver()).toBe(false); + }); + + it('should call preventDefault and stopPropagation on dragleave', () => { + component.onDragEnter(makeDragEvent({ types: ['Files'] })); + const event = makeLeaveEvent(); + component.onDragLeave(event); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(event.stopPropagation).toHaveBeenCalled(); + }); + + it('should unwind dragDepth on dragleave when disabled keeping state consistent on re-enable', () => { + component.onDragEnter(makeDragEvent({ types: ['Files'] })); + component.onDragEnter(makeDragEvent({ types: ['Files'] })); + + fixture.componentRef.setInput('enabled', false); + fixture.detectChanges(); + + component.onDragLeave(makeLeaveEvent()); + component.onDragLeave(makeLeaveEvent()); + + fixture.componentRef.setInput('enabled', true); + fixture.detectChanges(); + + expect(component.isDragOver()).toBe(false); + }); + + it('should not decrement dragDepth below zero on dragleave', () => { + component.onDragLeave(makeLeaveEvent()); + component.onDragLeave(makeLeaveEvent()); + + expect(component.isDragOver()).toBe(false); + }); + + it('should emit dropped files and clear drag state on drop', () => { + const emitSpy = vi.spyOn(component.filesDropped, 'emit'); + + component.onDragEnter(makeDragEvent({ types: ['Files'] })); + component.onDrop(makeDragEvent({ types: ['Files'], files: fileList(makeFile('a.txt')) })); + fixture.detectChanges(); + + expect(emitSpy).toHaveBeenCalledOnce(); + expect(emitSpy).toHaveBeenCalledWith(expect.arrayContaining([expect.objectContaining({ name: 'a.txt' })])); + expect(component.isDragOver()).toBe(false); + }); + + it('should emit all dropped files on drop', () => { + const emitSpy = vi.spyOn(component.filesDropped, 'emit'); + const files = [makeFile('a.txt'), makeFile('b.txt'), makeFile('c.txt')]; + + component.onDrop(makeDragEvent({ types: ['Files'], files: fileList(...files) })); + + expect(emitSpy).toHaveBeenCalledOnce(); + expect(emitSpy).toHaveBeenCalledWith( + expect.arrayContaining(files.map((f) => expect.objectContaining({ name: f.name }))) + ); + }); + + it('should not emit when drop has no files', () => { + const emitSpy = vi.spyOn(component.filesDropped, 'emit'); + component.onDrop(makeDragEvent({ types: [], files: fileList() })); + + expect(emitSpy).not.toHaveBeenCalled(); + }); + + it('should not emit when disabled but should still reset drag state on drop', () => { + component.onDragEnter(makeDragEvent({ types: ['Files'] })); + fixture.componentRef.setInput('enabled', false); + fixture.detectChanges(); + + const emitSpy = vi.spyOn(component.filesDropped, 'emit'); + component.onDrop(makeDragEvent({ types: ['Files'], files: fileList(makeFile()) })); + + expect(emitSpy).not.toHaveBeenCalled(); + expect(component.isDragOver()).toBe(false); + }); + + it('should call preventDefault and stopPropagation on drop regardless of enabled state', () => { + fixture.componentRef.setInput('enabled', false); + const event = makeDragEvent({ types: ['Files'], files: fileList(makeFile()) }); + component.onDrop(event); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(event.stopPropagation).toHaveBeenCalled(); + }); + + it('should reset dragDepth to zero on drop preventing stuck state after re-enter', () => { + component.onDragEnter(makeDragEvent({ types: ['Files'] })); + component.onDragEnter(makeDragEvent({ types: ['Files'] })); + + component.onDrop(makeDragEvent({ types: ['Files'], files: fileList(makeFile()) })); + + component.onDragEnter(makeDragEvent({ types: ['Files'] })); + component.onDragLeave(makeLeaveEvent()); + + expect(component.isDragOver()).toBe(false); + }); +}); diff --git a/src/app/shared/components/files-drop-zone/files-drop-zone.component.ts b/src/app/shared/components/files-drop-zone/files-drop-zone.component.ts new file mode 100644 index 000000000..79eaca28a --- /dev/null +++ b/src/app/shared/components/files-drop-zone/files-drop-zone.component.ts @@ -0,0 +1,75 @@ +import { TranslatePipe } from '@ngx-translate/core'; + +import { ChangeDetectionStrategy, Component, input, output, signal } from '@angular/core'; + +@Component({ + selector: 'osf-files-drop-zone', + imports: [TranslatePipe], + templateUrl: './files-drop-zone.component.html', + styleUrl: './files-drop-zone.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class FilesDropZoneComponent { + readonly enabled = input(true); + readonly filesDropped = output(); + readonly isDragOver = signal(false); + + private dragDepth = 0; + + onDragEnter(event: DragEvent): void { + event.preventDefault(); + event.stopPropagation(); + + if (!this.enabled() || !event.dataTransfer?.types?.includes('Files')) { + return; + } + + this.dragDepth += 1; + this.isDragOver.set(true); + } + + onDragOver(event: DragEvent): void { + event.preventDefault(); + event.stopPropagation(); + + if (!this.enabled()) { + return; + } + + if (event.dataTransfer) { + event.dataTransfer.dropEffect = 'copy'; + } + + this.isDragOver.set(true); + } + + onDragLeave(event: Event): void { + event.preventDefault(); + event.stopPropagation(); + + this.dragDepth = Math.max(0, this.dragDepth - 1); + + if (this.dragDepth === 0) { + this.isDragOver.set(false); + } + } + + onDrop(event: DragEvent): void { + event.preventDefault(); + event.stopPropagation(); + + this.dragDepth = 0; + this.isDragOver.set(false); + + if (!this.enabled()) { + return; + } + + const files = event.dataTransfer?.files; + if (!files || files.length === 0) { + return; + } + + this.filesDropped.emit(Array.from(files)); + } +} diff --git a/src/app/shared/components/files-tree-row/files-tree-row.component.html b/src/app/shared/components/files-tree-row/files-tree-row.component.html new file mode 100644 index 000000000..200f8d39a --- /dev/null +++ b/src/app/shared/components/files-tree-row/files-tree-row.component.html @@ -0,0 +1,54 @@ +@let rowFile = file(); + +@if (rowFile.previousFolder) { +
+ + + + {{ rowFile.name }} + +
+} @else { +
+
+ +
+ +
+ @if (downloadsCount()) { + {{ downloadsCount() }} {{ 'common.labels.downloads' | translate }} + } +
+ +
+ {{ rowFile.size | fileSize }} +
+ +
+ {{ rowFile.dateModified | date: 'MMM d, y hh:mm a' }} +
+ + @if (actionsTemplate()) { +
+ +
+ } +
+} diff --git a/src/app/shared/components/files-tree-row/files-tree-row.component.scss b/src/app/shared/components/files-tree-row/files-tree-row.component.scss new file mode 100644 index 000000000..0511678ba --- /dev/null +++ b/src/app/shared/components/files-tree-row/files-tree-row.component.scss @@ -0,0 +1,34 @@ +@use "styles/mixins" as mix; + +.files-table-row { + display: grid; + align-items: center; + grid-template-columns: + minmax(mix.rem(200px), 32rem) minmax(mix.rem(150px), 0.7fr) minmax(mix.rem(100px), 100px) + minmax(mix.rem(150px), 1fr) minmax(mix.rem(50px), 50px); + grid-template-rows: mix.rem(44px); + border-bottom: 1px solid var(--grey-2); + padding: 0 0.75rem; + cursor: pointer; + + &.previous-folder { + grid-template-columns: auto; + } + + &:hover { + background: var(--bg-blue-3); + } + + &:active { + background: var(--bg-blue-2); + } + + .table-cell { + width: 100%; + height: 100%; + } +} + +.file-link-btn { + max-width: 95%; +} diff --git a/src/app/shared/components/files-tree-row/files-tree-row.component.spec.ts b/src/app/shared/components/files-tree-row/files-tree-row.component.spec.ts new file mode 100644 index 000000000..a6f3e57c0 --- /dev/null +++ b/src/app/shared/components/files-tree-row/files-tree-row.component.spec.ts @@ -0,0 +1,90 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { FileKind } from '@osf/shared/enums/file-kind.enum'; +import { FileModel } from '@shared/models/files/file.model'; + +import { FileModelMock } from '@testing/mocks/file.model.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; + +import { FilesTreeRowComponent } from './files-tree-row.component'; + +describe('FilesTreeRowComponent', () => { + let component: FilesTreeRowComponent; + let fixture: ComponentFixture; + + function setup(overrides: { file?: FileModel; hasFoldersStack?: boolean } = {}): void { + fixture = TestBed.createComponent(FilesTreeRowComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('file', overrides.file ?? FileModelMock.simple()); + fixture.componentRef.setInput('hasFoldersStack', overrides.hasFoldersStack ?? false); + fixture.detectChanges(); + } + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [FilesTreeRowComponent], + providers: [provideOSFCore()], + }); + }); + + it('should create', () => { + setup(); + + expect(component).toBeTruthy(); + }); + + it('should set isFolder when kind is folder', () => { + setup({ file: FileModelMock.simple({ kind: FileKind.Folder }) }); + + expect(component.isFolder()).toBe(true); + }); + + it('should set isFolder false when kind is file', () => { + setup({ file: FileModelMock.simple({ kind: FileKind.File }) }); + + expect(component.isFolder()).toBe(false); + }); + + it('should clear downloadsCount for folder', () => { + setup({ + file: FileModelMock.simple({ + kind: FileKind.Folder, + extra: { hashes: { md5: 'm', sha256: 's' }, downloads: 99 }, + }), + }); + + expect(component.downloadsCount()).toBe(''); + }); + + it('should expose downloadsCount for file with downloads', () => { + setup({ + file: FileModelMock.simple({ + kind: FileKind.File, + extra: { hashes: { md5: 'm', sha256: 's' }, downloads: 12 }, + }), + }); + + expect(component.downloadsCount()).toBe(12); + }); + + it('should clear downloadsCount when downloads is zero', () => { + setup({ + file: FileModelMock.simple({ + kind: FileKind.File, + extra: { hashes: { md5: 'm', sha256: 's' }, downloads: 0 }, + }), + }); + + expect(component.downloadsCount()).toBe(''); + }); + + it('should emit openEntry with current file', () => { + const file = FileModelMock.simple({ id: 'file-1' }); + setup({ file }); + const openEntryEmit = vi.spyOn(component.openEntry, 'emit'); + + component.onOpenEntry(); + + expect(openEntryEmit).toHaveBeenCalledWith(file); + }); +}); diff --git a/src/app/shared/components/files-tree-row/files-tree-row.component.ts b/src/app/shared/components/files-tree-row/files-tree-row.component.ts new file mode 100644 index 000000000..ccaa7a16e --- /dev/null +++ b/src/app/shared/components/files-tree-row/files-tree-row.component.ts @@ -0,0 +1,43 @@ +import { TranslatePipe } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; + +import { DatePipe, NgTemplateOutlet } from '@angular/common'; +import { ChangeDetectionStrategy, Component, computed, input, output, TemplateRef } from '@angular/core'; + +import { StopPropagationDirective } from '@osf/shared/directives/stop-propagation.directive'; +import { FileKind } from '@osf/shared/enums/file-kind.enum'; +import { FileModel } from '@shared/models/files/file.model'; +import { FileMenuAction } from '@shared/models/files/file-menu-action.model'; + +import { FileSizePipe } from '../../pipes/file-size.pipe'; + +@Component({ + selector: 'osf-files-tree-row', + imports: [Button, DatePipe, NgTemplateOutlet, FileSizePipe, TranslatePipe, StopPropagationDirective], + templateUrl: './files-tree-row.component.html', + styleUrl: './files-tree-row.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class FilesTreeRowComponent { + readonly file = input.required(); + readonly hasFoldersStack = input(false); + readonly actionsTemplate = input | null>(null); + + readonly openParentFolder = output(); + readonly openEntry = output(); + readonly menuAction = output(); + + readonly isFolder = computed(() => this.file().kind === FileKind.Folder); + + readonly downloadsCount = computed(() => { + if (!this.file().extra.downloads || this.isFolder()) { + return ''; + } + return this.file().extra.downloads; + }); + + onOpenEntry(): void { + this.openEntry.emit(this.file()); + } +} 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 ba61a2c22..313fce49b 100644 --- a/src/app/shared/components/files-tree/files-tree.component.html +++ b/src/app/shared/components/files-tree/files-tree.component.html @@ -1,130 +1,58 @@ -
- @if (!hasViewOnly() && supportUpload()) { -
- @if (isDragOver()) { -
- -

{{ 'files.dropText' | translate }}

-
- } -
- } - + @if (isLoading() && !isLoadingMore()) {
} @else { -
-
- - - @if (file.previousFolder) { -
-
- - - {{ file.name ?? '' }} -
-
- } @else { -
-
-
- - {{ file?.name ?? '' }} -
-
- -
- @if (file.extra.downloads) { - {{ - file.kind === 'file' ? file.extra.downloads + ' ' + ('common.labels.downloads' | translate) : '' - }} - } -
- -
- {{ file.size | fileSize }} -
+
+ + + + -
- {{ file.dateModified | date: 'MMM d, y hh:mm a' }} -
- - @if (isSomeFileActionAllowed && !selectedFiles().length) { -
- - -
- } - @if (isDraftResource()) { - - } -
- } - - - - @if (!files().length) { -
- @if (hasViewOnly() || !supportUpload()) { + +
+ @if (viewOnly()) {

{{ 'files.emptyState' | translate }}

} @else { -
+
-

{{ 'files.dropText' | translate }}

+

{{ 'files.dropText' | translate }}

}
- } -
+
+
} -
+ + + + @if (allowedMenuActions().delete) { + + } + diff --git a/src/app/shared/components/files-tree/files-tree.component.scss b/src/app/shared/components/files-tree/files-tree.component.scss index 3984d465f..190e70847 100644 --- a/src/app/shared/components/files-tree/files-tree.component.scss +++ b/src/app/shared/components/files-tree/files-tree.component.scss @@ -1,88 +1,13 @@ -@use "styles/mixins" as mix; - :host { - min-height: 180px; display: flex; flex-direction: column; + min-height: 11.25rem; } .files-table { - display: flex; - flex-direction: column; border: 1px solid var(--grey-2); - border-radius: 8px; - overflow-x: auto; + border-radius: 0.5rem; min-width: 100%; - min-height: 180px; - - &-row { - color: var(--dark-blue-1); - display: grid; - align-items: center; - grid-template-columns: - minmax(mix.rem(200px), 32rem) minmax(mix.rem(150px), 0.7fr) minmax(mix.rem(100px), 100px) - minmax(mix.rem(150px), 1fr) minmax(mix.rem(50px), 50px); - grid-template-rows: mix.rem(44px); - border-bottom: 1px solid var(--grey-2); - padding: 0 mix.rem(12px); - cursor: pointer; - - &:hover { - background: var(--bg-blue-3); - } - - &:active { - background: var(--bg-blue-2); - } - - .table-cell { - width: 100%; - height: 100%; - display: flex; - align-items: center; - } - - > .table-cell:first-child { - max-width: 95%; - } - } -} - -.entry-title { - display: block; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - min-width: 0; - max-width: 100%; -} - -.tree-table { - .p-tree { - padding: 0; - } -} - -.drop-zone { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - z-index: 1000; - display: flex; - align-items: center; - justify-content: center; - color: var(--white); - transition: - background 0.3s ease, - backdrop-filter 0.3s ease; - pointer-events: none; - background: transparent; - - &.active { - backdrop-filter: blur(0.3rem); - background: rgba(132, 174, 210, 0.5); - pointer-events: all; - } + min-height: 11.25rem; + overflow-x: auto; } 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 59a8bf61c..8a4e3bc32 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,27 +1,23 @@ import { MockComponents, MockProvider } from 'ng-mocks'; import { TreeDragDropService } from 'primeng/api'; +import { TreeLazyLoadEvent } from 'primeng/tree'; + +import { vi } from 'vitest'; -import { signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { provideRouter } from '@angular/router'; import { FileKind } from '@osf/shared/enums/file-kind.enum'; -import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; -import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; -import { DataciteService } from '@osf/shared/services/datacite/datacite.service'; -import { FilesService } from '@osf/shared/services/files.service'; -import { ToastService } from '@osf/shared/services/toast.service'; -import { CurrentResourceSelectors } from '@osf/shared/stores/current-resource'; +import { FileModel } from '@shared/models/files/file.model'; import { FileFolderModel } from '@shared/models/files/file-folder.model'; import { FileLabelModel } from '@shared/models/files/file-label.model'; +import { FileModelMock } from '@testing/mocks/file.model.mock'; import { OSF_FILE_MOCK } from '@testing/mocks/osf-file.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; -import { DataciteServiceMock, DataciteServiceMockType } from '@testing/providers/datacite.service.mock'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; -import { FileMenuComponent } from '../file-menu/file-menu.component'; +import { FilesDropZoneComponent } from '../files-drop-zone/files-drop-zone.component'; +import { FilesTreeRowComponent } from '../files-tree-row/files-tree-row.component'; import { LoadingSpinnerComponent } from '../loading-spinner/loading-spinner.component'; import { FilesTreeComponent } from './files-tree.component'; @@ -29,66 +25,313 @@ import { FilesTreeComponent } from './files-tree.component'; describe('FilesTreeComponent', () => { let component: FilesTreeComponent; let fixture: ComponentFixture; - let dataciteMock: DataciteServiceMockType; + let currentFolder: FileFolderModel; + let storage: FileLabelModel; + let files: FileModel[]; - const mockFolderFile: FileFolderModel = { - ...OSF_FILE_MOCK, - kind: FileKind.Folder, - name: 'Test Folder', - }; + interface SetupOverrides { + files?: FileModel[]; + currentFolder?: FileFolderModel; + storage?: FileLabelModel | null; + totalCount?: number; + isLoading?: boolean; + } - const mockStorage: FileLabelModel = { - label: 'OSF Storage', - folder: mockFolderFile, - }; + function setup(overrides: SetupOverrides = {}): void { + files = overrides.files ?? []; + currentFolder = overrides.currentFolder ?? { + ...OSF_FILE_MOCK, + id: 'folder-1', + name: 'Current folder', + kind: FileKind.Folder, + }; + storage = overrides.storage ?? { + label: 'OSF Storage', + folder: currentFolder, + }; - beforeEach(() => { - dataciteMock = DataciteServiceMock.simple(); + fixture = TestBed.createComponent(FilesTreeComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('files', files); + fixture.componentRef.setInput('currentFolder', currentFolder); + fixture.componentRef.setInput('storage', storage); + fixture.componentRef.setInput('totalCount', overrides.totalCount ?? files.length); + fixture.componentRef.setInput('isLoading', overrides.isLoading ?? false); + fixture.detectChanges(); + } + + function createLazyLoadEvent(last: number): TreeLazyLoadEvent { + return { first: 0, last }; + } + function createCurrentFolderWithFilesLink(filesLink: string): FileFolderModel { + return { + ...OSF_FILE_MOCK, + id: 'folder-with-files-link', + name: 'Folder with files link', + kind: FileKind.Folder, + links: { + ...OSF_FILE_MOCK.links, + filesLink, + }, + }; + } + + beforeEach(() => { TestBed.configureTestingModule({ - imports: [FilesTreeComponent, ...MockComponents(LoadingSpinnerComponent, FileMenuComponent)], - providers: [ - provideOSFCore(), - provideRouter([]), - provideMockStore({ - signals: [{ selector: CurrentResourceSelectors.getCurrentResource, value: signal(null) }], - }), - MockProvider(DataciteService, dataciteMock), - MockProvider(FilesService), - MockProvider(ToastService), - MockProvider(CustomConfirmationService), - MockProvider(CustomDialogService), - TreeDragDropService, + imports: [ + FilesTreeComponent, + ...MockComponents(LoadingSpinnerComponent, FilesDropZoneComponent, FilesTreeRowComponent), ], + providers: [provideOSFCore(), MockProvider(TreeDragDropService)], }); - - fixture = TestBed.createComponent(FilesTreeComponent); - component = fixture.componentInstance; - fixture.componentRef.setInput('files', []); - fixture.componentRef.setInput('currentFolder', null); - fixture.componentRef.setInput('storage', mockStorage); - fixture.componentRef.setInput('resourceId', 'resource-123'); - fixture.detectChanges(); }); it('should create', () => { + setup(); + expect(component).toBeTruthy(); }); - it('should have all required inputs', () => { - expect(component.files()).toEqual([]); - expect(component.currentFolder()).toBe(null); - expect(component.storage()).toEqual(mockStorage); - expect(component.resourceId()).toBe('resource-123'); + it('should expose provided inputs', () => { + setup(); + + expect(component.files()).toEqual(files); + expect(component.currentFolder()).toEqual(currentFolder); + expect(component.storage()).toEqual(storage); + }); + + it('should reset folders stack when storage changes', () => { + setup(); + component.foldersStack.set([currentFolder]); + + fixture.componentRef.setInput('storage', { + label: 'Another storage', + folder: { ...currentFolder, id: 'folder-2' }, + }); + fixture.detectChanges(); + + expect(component.foldersStack()).toEqual([]); + }); + + it('should stop loading more when loading finishes', () => { + setup({ isLoading: true }); + component.isLoadingMore.set(true); + + fixture.componentRef.setInput('isLoading', false); + fixture.detectChanges(); + + expect(component.isLoadingMore()).toBe(false); + }); + + it('should include previous folder node when folders stack is not empty', () => { + const file = FileModelMock.simple({ id: 'file-1', name: 'file-1' }); + const folderFile = FileModelMock.simple({ + id: 'folder-file-1', + name: 'folder-file-1', + kind: FileKind.Folder, + filesLink: '/folder-file-1/files', + previousFolder: false, + }); + setup({ + files: [file, folderFile], + currentFolder: { ...currentFolder, id: 'folder-with-stack' }, + }); + component.foldersStack.set([{ ...currentFolder, id: 'parent-folder' }]); + fixture.detectChanges(); + + const mappedNodes = component.nodes(); + + expect(mappedNodes).toHaveLength(3); + expect(mappedNodes).toEqual([ + expect.objectContaining({ + data: expect.objectContaining({ + id: 'folder-with-stack', + previousFolder: true, + }), + }), + expect.any(Object), + expect.any(Object), + ]); + }); + + it('should emit dropped files', () => { + setup(); + const uploadFilesEmit = vi.spyOn(component.uploadFiles, 'emit'); + const droppedFiles = [new File(['a'], 'a.txt')]; + + component.onDropFiles(droppedFiles); + + expect(uploadFilesEmit).toHaveBeenCalledWith(droppedFiles); + }); + + it('should emit delete file action', () => { + setup(); + const deleteEmit = vi.spyOn(component.deleteFile, 'emit'); + const file = FileModelMock.simple(); + + component.deleteEntry(file); + + expect(deleteEmit).toHaveBeenCalledWith(file); + }); + + it('should emit opened file when entry is a file', () => { + setup(); + const fileOpenedEmit = vi.spyOn(component.fileOpened, 'emit'); + const folderChangedEmit = vi.spyOn(component.currentFolderChanged, 'emit'); + const file = FileModelMock.simple({ kind: FileKind.File }); + + component.openEntry(file); + + expect(fileOpenedEmit).toHaveBeenCalledWith(file); + expect(folderChangedEmit).not.toHaveBeenCalled(); + }); + + it('should open folder and emit current folder change', () => { + setup(); + const folderChangedEmit = vi.spyOn(component.currentFolderChanged, 'emit'); + const folderFile = FileModelMock.simple({ + id: 'folder-id', + kind: FileKind.Folder, + name: 'Folder', + path: '/folder', + provider: 'osfstorage', + links: { + info: '', + move: '', + upload: '/upload', + delete: '', + download: '', + render: '', + html: '', + self: '', + }, + filesLink: '/folder/files', + }); + + component.openEntry(folderFile); + + expect(component.foldersStack()).toEqual([currentFolder]); + expect(folderChangedEmit).toHaveBeenCalledWith({ + id: 'folder-id', + kind: FileKind.Folder, + name: 'Folder', + node: '', + path: '/folder', + provider: 'osfstorage', + links: { + newFolder: '/upload?kind=folder', + storageAddons: '', + upload: '/upload', + filesLink: '/folder/files', + download: '/upload', + }, + }); + }); + + it('should open parent folder and emit folder change', () => { + setup(); + const folderChangedEmit = vi.spyOn(component.currentFolderChanged, 'emit'); + const parentFolder = { ...currentFolder, id: 'parent-id' }; + component.foldersStack.set([parentFolder]); + + component.openParentFolder(); + + expect(component.foldersStack()).toEqual([]); + expect(folderChangedEmit).toHaveBeenCalledWith(parentFolder); + }); + + it('should load next page on lazy load end', () => { + const fileA = FileModelMock.simple({ id: 'file-a' }); + const fileB = FileModelMock.simple({ id: 'file-b' }); + setup({ + files: [fileA, fileB], + totalCount: 25, + currentFolder: createCurrentFolderWithFilesLink('/next/files'), + }); + const loadFilesEmit = vi.spyOn(component.loadFiles, 'emit'); + + component.onLazyLoad(createLazyLoadEvent(1)); + + expect(loadFilesEmit).toHaveBeenCalledWith({ link: '/next/files', page: 1 }); + expect(component.isLoadingMore()).toBe(true); + }); + + it('should not load next page when all files are loaded', () => { + const fileA = FileModelMock.simple({ id: 'file-a' }); + setup({ + files: [fileA], + totalCount: 1, + currentFolder: createCurrentFolderWithFilesLink('/next/files'), + }); + const loadFilesEmit = vi.spyOn(component.loadFiles, 'emit'); + + component.onLazyLoad(createLazyLoadEvent(0)); + + expect(loadFilesEmit).not.toHaveBeenCalled(); }); - it('should log Download', () => { - const mockOpen = vi.fn().mockReturnValue({ focus: vi.fn() }); - window.open = mockOpen; + it('should not trigger load next page when lazy load index is before the end', () => { + const fileA = FileModelMock.simple({ id: 'file-a' }); + const fileB = FileModelMock.simple({ id: 'file-b' }); + setup({ + files: [fileA, fileB], + totalCount: 20, + currentFolder: createCurrentFolderWithFilesLink('/next/files'), + }); + const loadFilesEmit = vi.spyOn(component.loadFiles, 'emit'); + + component.onLazyLoad(createLazyLoadEvent(0)); + + expect(loadFilesEmit).not.toHaveBeenCalled(); + }); + + it('should not load next page while loading more is in progress', () => { + const fileA = FileModelMock.simple({ id: 'file-a' }); + const fileB = FileModelMock.simple({ id: 'file-b' }); + setup({ + files: [fileA, fileB], + totalCount: 20, + currentFolder: createCurrentFolderWithFilesLink('/next/files'), + }); + const loadFilesEmit = vi.spyOn(component.loadFiles, 'emit'); + component.isLoadingMore.set(true); + + component.onLazyLoad(createLazyLoadEvent(1)); + + expect(loadFilesEmit).not.toHaveBeenCalled(); + }); + + it('should clear loading more when isLoading becomes false after pagination request', () => { + const fileA = FileModelMock.simple({ id: 'file-a' }); + const fileB = FileModelMock.simple({ id: 'file-b' }); + setup({ + files: [fileA, fileB], + totalCount: 20, + currentFolder: createCurrentFolderWithFilesLink('/next/files'), + isLoading: true, + }); + + component.onLazyLoad(createLazyLoadEvent(1)); + expect(component.isLoadingMore()).toBe(true); + + fixture.componentRef.setInput('isLoading', false); + fixture.detectChanges(); + + expect(component.isLoadingMore()).toBe(false); + }); + + it('should support multiple page calculation for next request', () => { + const filesForPage = Array.from({ length: 20 }, (_, index) => FileModelMock.simple({ id: `file-${index}` })); + setup({ + files: filesForPage, + totalCount: 50, + currentFolder: createCurrentFolderWithFilesLink('/next/files'), + }); + const loadFilesEmit = vi.spyOn(component.loadFiles, 'emit'); - component.downloadFileOrFolder(OSF_FILE_MOCK as any); + component.onLazyLoad(createLazyLoadEvent(19)); - expect(dataciteMock.logFileDownload).toHaveBeenCalledWith('resource-123', 'nodes'); - expect(mockOpen).toHaveBeenCalled(); + expect(loadFilesEmit).toHaveBeenCalledWith({ link: '/next/files', page: 3 }); }); }); 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 efe638512..60e48488f 100644 --- a/src/app/shared/components/files-tree/files-tree.component.ts +++ b/src/app/shared/components/files-tree/files-tree.component.ts @@ -1,171 +1,82 @@ -import { select } from '@ngxs/store'; - import { TranslatePipe } from '@ngx-translate/core'; -import { PrimeTemplate, TreeNode } from 'primeng/api'; +import { PrimeTemplate, TreeDragDropService } from 'primeng/api'; import { Button } from 'primeng/button'; import { Tooltip } from 'primeng/tooltip'; -import { Tree, TreeLazyLoadEvent, TreeNodeDropEvent, TreeNodeSelectEvent } from 'primeng/tree'; +import { Tree, TreeLazyLoadEvent } from 'primeng/tree'; -import { Clipboard } from '@angular/cdk/clipboard'; -import { DatePipe, isPlatformBrowser } from '@angular/common'; -import { - AfterViewInit, - ChangeDetectionStrategy, - Component, - computed, - DestroyRef, - effect, - ElementRef, - HostBinding, - inject, - input, - OnDestroy, - output, - PLATFORM_ID, - signal, - viewChild, -} from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { ActivatedRoute, Router } from '@angular/router'; +import { ChangeDetectionStrategy, Component, computed, effect, input, output, signal } from '@angular/core'; -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'; -import { StopPropagationDirective } from '@osf/shared/directives/stop-propagation.directive'; import { FileKind } from '@osf/shared/enums/file-kind.enum'; -import { FileMenuType } from '@osf/shared/enums/file-menu-type.enum'; +import { FileTreeMapper } from '@osf/shared/mappers/files/file-tree.mapper'; import { FilesMapper } from '@osf/shared/mappers/files/files.mapper'; -import { FileSizePipe } from '@osf/shared/pipes/file-size.pipe'; -import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; -import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; -import { DataciteService } from '@osf/shared/services/datacite/datacite.service'; -import { FilesService } from '@osf/shared/services/files.service'; -import { ToastService } from '@osf/shared/services/toast.service'; -import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; +import { FileMenuFlags } from '@osf/shared/models/files/file-menu-action.model'; +import { FilePageLinkModel } from '@osf/shared/models/files/file-page-link.model'; import { FileModel } from '@shared/models/files/file.model'; import { FileFolderModel } from '@shared/models/files/file-folder.model'; import { FileLabelModel } from '@shared/models/files/file-label.model'; -import { FileMenuAction, FileMenuFlags } from '@shared/models/files/file-menu-action.model'; -import { CurrentResourceSelectors } from '@shared/stores/current-resource'; -import { FileMenuComponent } from '../file-menu/file-menu.component'; +import { FilesDropZoneComponent } from '../files-drop-zone/files-drop-zone.component'; +import { FilesTreeRowComponent } from '../files-tree-row/files-tree-row.component'; import { LoadingSpinnerComponent } from '../loading-spinner/loading-spinner.component'; -// [NS] Temporary fix -type FileTreeNode = FileModel & TreeNode; - @Component({ selector: 'osf-files-tree', imports: [ - DatePipe, - FileSizePipe, - PrimeTemplate, - TranslatePipe, - Tree, - LoadingSpinnerComponent, - FileMenuComponent, - StopPropagationDirective, Button, + Tree, Tooltip, + PrimeTemplate, + LoadingSpinnerComponent, + FilesDropZoneComponent, + FilesTreeRowComponent, + TranslatePipe, ], + providers: [TreeDragDropService], templateUrl: './files-tree.component.html', styleUrl: './files-tree.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class FilesTreeComponent implements OnDestroy, AfterViewInit { - @HostBinding('class') classes = 'relative'; - private dropZoneContainerRef = viewChild('dropZoneContainer'); - readonly filesService = inject(FilesService); - readonly router = inject(Router); - readonly toastService = inject(ToastService); - readonly route = inject(ActivatedRoute); - readonly customConfirmationService = inject(CustomConfirmationService); - readonly customDialogService = inject(CustomDialogService); - readonly dataciteService = inject(DataciteService); - private readonly viewOnlyService = inject(ViewOnlyLinkHelperService); - - private readonly destroyRef = inject(DestroyRef); - private readonly environment = inject(ENVIRONMENT); - private readonly platformId = inject(PLATFORM_ID); - readonly clipboard = inject(Clipboard); - - files = input.required(); - totalCount = input(0); - isLoading = input(); - currentFolder = input.required(); - storage = input.required(); - resourceId = input.required(); - viewOnly = input(true); - provider = input(); - allowedMenuActions = input({} as FileMenuFlags); - supportUpload = input(true); - selectedFiles = input([]); - scrollHeight = input('300px'); - selectionMode = input<'multiple' | null>('multiple'); - isDraftResource = input(false); - - entryFileClicked = output(); - uploadFilesConfirmed = output(); - setCurrentFolder = output(); - setMoveDialogCurrentFolder = output(); - deleteEntryAction = output(); - renameEntryAction = output<{ newName: string; link: string }>(); - loadFiles = output<{ link: string; page: number }>(); - selectFile = output(); - unselectFile = output(); - clearSelection = output(); - updateFoldersStack = output(); - resetFilesProvider = output(); - - readonly resourceMetadata = select(CurrentResourceSelectors.getCurrentResource); - - foldersStack: FileFolderModel[] = []; - lastSelectedFile: FileModel | null = null; - itemsPerPage = 10; - virtualScrollItemSize = 46; - - isDragOver = signal(false); +export class FilesTreeComponent { + readonly files = input.required(); + readonly currentFolder = input.required(); + + readonly totalCount = input(0); + readonly isLoading = input(false); + readonly storage = input(null); + readonly viewOnly = input(true); + readonly scrollHeight = input('300px'); + readonly selectionMode = input<'multiple' | null>(null); + readonly allowedMenuActions = input({} as FileMenuFlags); + + readonly fileOpened = output(); + readonly uploadFiles = output(); + readonly currentFolderChanged = output(); + readonly deleteFile = output(); + readonly loadFiles = output(); + + foldersStack = signal([] as FileFolderModel[]); isLoadingMore = signal(false); - hasViewOnly = computed(() => this.viewOnlyService.hasViewOnlyParam(this.router) || this.viewOnly()); - visibleFilesCount = computed((): number => { - const height = parseInt(this.scrollHeight(), 10); - return Math.ceil(height / this.virtualScrollItemSize); - }); - - get isSomeFileActionAllowed(): boolean { - return Object.keys(this.allowedMenuActions()).length > 0; - } + readonly itemsPerPage = 10; + readonly virtualScrollItemSize = 46; readonly nodes = computed(() => { const currentFolder = this.currentFolder(); const files = this.files(); - const hasParent = this.foldersStack.length > 0; - if (hasParent) { - return [ - { - ...currentFolder, - previousFolder: hasParent, - }, - ...files, - ] as FileModel[]; - } else { - return [...files]; - } - }); - // [NS] Temporary fix - readonly selectedNodes = computed(() => this.selectedFiles() as FileTreeNode[]); + const values = this.foldersStack().length + ? ([{ ...currentFolder, previousFolder: true }, ...files] as FileModel[]) + : files; + + return FileTreeMapper.toTreeNodes(values); + }); constructor() { effect(() => { const storageChanged = this.storage(); if (storageChanged) { - this.foldersStack = []; - this.updateFoldersStack.emit(this.foldersStack); + this.foldersStack.set([]); } }); @@ -176,260 +87,39 @@ export class FilesTreeComponent implements OnDestroy, AfterViewInit { }); } - ngAfterViewInit(): void { - if (!this.viewOnly()) { - this.dropZoneContainerRef()?.nativeElement?.addEventListener('dragenter', this.dragEnterHandler); - } - } - - ngOnDestroy(): void { - if (this.dropZoneContainerRef()?.nativeElement) { - this.dropZoneContainerRef()!.nativeElement.removeEventListener('dragenter', this.dragEnterHandler); - } - } - - private dragEnterHandler = (event: DragEvent) => { - if (event.dataTransfer?.types?.includes('Files') && !this.viewOnly()) { - this.isDragOver.set(true); - } - }; - - onDragOver(event: DragEvent) { - if (this.viewOnly()) { - return; - } - event.preventDefault(); - event.stopPropagation(); - event.dataTransfer!.dropEffect = 'copy'; - this.isDragOver.set(true); - } - - onDragLeave(event: Event) { - if (this.viewOnly()) { - return; - } - event.preventDefault(); - event.stopPropagation(); - this.isDragOver.set(false); + onDropFiles(fileArray: File[]): void { + this.uploadFiles.emit(fileArray); } - onDrop(event: DragEvent) { - event.preventDefault(); - event.stopPropagation(); - this.isDragOver.set(false); - - if (this.viewOnly()) { - return; - } - - const files = event.dataTransfer?.files; - - if (files && files.length > 0) { - const fileArray = Array.from(files); - const isMultiple = files.length > 1; - - this.customConfirmationService.confirmAccept({ - headerKey: isMultiple ? 'files.dialogs.uploadFiles.title' : 'files.dialogs.uploadFile.title', - messageParams: isMultiple ? { count: files.length } : { name: files[0].name }, - messageKey: isMultiple ? 'files.dialogs.uploadFiles.message' : 'files.dialogs.uploadFile.message', - acceptLabelKey: 'common.buttons.upload', - onConfirm: () => this.uploadFilesConfirmed.emit(fileArray), - }); - } + deleteEntry(file: FileModel): void { + this.deleteFile.emit(file); } - openEntry(event: Event, file: FileModel | FileFolderModel) { - event.stopPropagation(); + openEntry(file: FileModel) { if (file.kind === FileKind.File) { - if (file.guid) { - this.entryFileClicked.emit(file); - } else { - this.filesService.getFileGuid(file.id).subscribe((file) => { - this.entryFileClicked.emit(file); - }); - } + this.fileOpened.emit(file); } else { const current = this.currentFolder(); - if (current) { - this.foldersStack.push(current); - this.updateFoldersStack.emit(this.foldersStack); - } - const folder = FilesMapper.mapFileToFolder(file as FileModel); - this.setCurrentFolder.emit(folder); - this.clearSelection.emit(); + this.foldersStack.update((stack) => [...stack, current]); + const folder = FilesMapper.mapFileToFolder(file); + this.currentFolderChanged.emit(folder); } } openParentFolder() { - const previous = this.foldersStack.pop(); - this.updateFoldersStack.emit(this.foldersStack); - if (previous) { - this.setCurrentFolder.emit(previous); - } - this.clearSelection.emit(); - } - - onFileMenuAction(action: FileMenuAction, file: FileModel): void { - const { value, data } = action; - - switch (value) { - case FileMenuType.Download: - this.downloadFileOrFolder(file); - break; - case FileMenuType.Delete: - this.deleteEntry(file); - break; - case FileMenuType.Share: - this.handleShareAction(file, data?.type); - break; - case FileMenuType.Embed: - this.handleEmbedAction(file, data?.type); - break; - case FileMenuType.Rename: - this.confirmRename(file); - break; - case FileMenuType.Move: - this.moveFile(file, FileMenuType.Move); - break; - case FileMenuType.Copy: - this.moveFile(file, FileMenuType.Copy); - break; - } - } - - downloadFileOrFolder(file: FileModel) { - const resourceType = this.resourceMetadata()?.type ?? 'nodes'; - this.dataciteService - .logFileDownload(this.resourceId(), resourceType) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(); - if (file.kind === FileKind.File) { - this.downloadFile(file.links.download); - } else { - const folder = FilesMapper.mapFileToFolder(file as FileModel); - this.downloadFolder(folder.links.download); - } + const stack = this.foldersStack(); + const previous = stack[stack.length - 1]; + this.foldersStack.set(stack.slice(0, -1)); + this.currentFolderChanged.emit(previous); } - private handleShareAction(file: FileModel, shareType?: string): void { - const emailLink = `mailto:?subject=${file.name}&body=${file.links.html}`; - const twitterLink = `https://twitter.com/intent/tweet?url=${file.links.html}&text=${file.name}&via=OSFramework`; - const facebookLink = `https://www.facebook.com/dialog/share?app_id=${this.environment.facebookAppId}&display=popup&href=${file.links.html}&redirect_uri=${file.links.html}`; - - switch (shareType) { - case 'email': - this.openLink(emailLink); - break; - case 'twitter': - this.openLinkNewTab(twitterLink); - break; - case 'facebook': - this.openLinkNewTab(facebookLink); - break; - } - } - - private handleEmbedAction(file: FileModel, embedType?: string): void { - let embedHtml = ''; - if (embedType === 'dynamic') { - embedHtml = embedDynamicJs.replace('ENCODED_URL', file.links.render); - } else if (embedType === 'static') { - embedHtml = embedStaticHtml.replace('ENCODED_URL', file.links.render); - } - - if (embedHtml) { - this.copyToClipboard(embedHtml); - } - } - - deleteEntry(file: FileModel): void { - this.customConfirmationService.confirmDelete({ - headerKey: file.kind === FileKind.Folder ? 'files.dialogs.deleteFolder.title' : 'files.dialogs.deleteFile.title', - messageParams: { name: file.name }, - messageKey: - file.kind === FileKind.Folder ? 'files.dialogs.deleteFolder.message' : 'files.dialogs.deleteFile.message', - acceptLabelKey: 'common.buttons.remove', - onConfirm: () => this.confirmDeleteEntry(file), - }); - } - - confirmDeleteEntry(file: FileModel): void { - this.deleteEntryAction.emit(file); - } - - confirmRename(file: FileModel): void { - this.customDialogService - .open(RenameFileDialogComponent, { - header: 'files.dialogs.renameFile.title', - width: '448px', - data: { - currentName: file.name, - }, - }) - .onClose.subscribe((newName: string) => { - if (newName) { - this.renameEntry(newName, file); - } - }); - } - - renameEntry(newName: string, file: FileModel): void { - if (newName.trim() && file.links.upload) { - const link = file.links.upload; - this.renameEntryAction.emit({ newName, link }); - } - } - - downloadFile(link: string): void { - if (isPlatformBrowser(this.platformId)) { - window.open(link)?.focus(); - } - } - - openLink(link: string): void { - if (isPlatformBrowser(this.platformId)) { - window.location.href = link; - } - } - - openLinkNewTab(link: string): void { - if (isPlatformBrowser(this.platformId)) { - window.open(link, '_blank', 'noopener,noreferrer'); - } - } - - downloadFolder(downloadLink: string): void { - if (isPlatformBrowser(this.platformId) && downloadLink) { - const link = this.filesService.getFolderDownloadLink(downloadLink); - window.open(link, '_blank')?.focus(); + onLazyLoad(event: TreeLazyLoadEvent) { + const loaded = this.files().length; + if (event.last >= loaded - 1) { + this.loadNextPage(); } } - moveFile(file: FileModel, action: string): void { - this.setMoveDialogCurrentFolder.emit(this.currentFolder()); - this.customDialogService - .open(MoveFileDialogComponent, { - header: 'files.dialogs.moveFile.title', - width: '552px', - data: { - files: [file], - resourceId: this.resourceId(), - action: action, - storageProvider: this.storage()?.folder.provider, - foldersStack: structuredClone(this.foldersStack), - initialFolder: structuredClone(this.currentFolder()), - }, - }) - .onClose.subscribe(() => { - this.resetFilesProvider.emit(); - }); - } - - copyToClipboard(embedHtml: string): void { - this.clipboard.copy(embedHtml); - this.toastService.showSuccess('files.detail.toast.copiedToClipboard'); - } - private loadNextPage(): void { const total = this.totalCount(); const loaded = this.files().length; @@ -437,73 +127,7 @@ export class FilesTreeComponent implements OnDestroy, AfterViewInit { if (!this.isLoadingMore() && loaded < total) { this.isLoadingMore.set(true); - this.loadFiles.emit({ - link: this.currentFolder()?.links.filesLink ?? '', - page: nextPage, - }); - } - } - - onLazyLoad(event: TreeLazyLoadEvent) { - const loaded = this.files().length; - if (event.last >= loaded - 1) { - this.loadNextPage(); - } - } - - onNodeSelect(event: TreeNodeSelectEvent) { - const files = this.files(); - const selectedNode = event.node as FileModel; - if ((event.originalEvent as PointerEvent).shiftKey && this.lastSelectedFile) { - const lastIndex = files.indexOf(this.lastSelectedFile); - const currentIndex = files.indexOf(selectedNode); - if (lastIndex == currentIndex) { - return; - } - - const start = Math.min(lastIndex, currentIndex); - const end = Math.max(lastIndex, currentIndex); - - for (const file of files.slice(start, end)) { - this.selectFile.emit(file); - } - } - this.selectFile.emit(selectedNode); - this.lastSelectedFile = selectedNode; - } - - onNodeDrop(event: TreeNodeDropEvent) { - const dropFile = event.dropNode as FileModel; - if (dropFile.kind !== FileKind.Folder) { - return; + this.loadFiles.emit({ link: this.currentFolder().links.filesLink, page: nextPage }); } - const files = this.selectedFiles(); - const dragFile = event.dragNode as FileModel; - if (!files.includes(dragFile)) { - this.selectFile.emit(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/app/shared/components/socials-share-button/socials-share-button.component.spec.ts b/src/app/shared/components/socials-share-button/socials-share-button.component.spec.ts index 9f0f4ce96..05413c6ab 100644 --- a/src/app/shared/components/socials-share-button/socials-share-button.component.spec.ts +++ b/src/app/shared/components/socials-share-button/socials-share-button.component.spec.ts @@ -7,6 +7,7 @@ import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { SocialShareService } from '@osf/shared/services/social-share.service'; import { provideOSFCore } from '@testing/osf.testing.provider'; +import { SocialShareServiceMock, SocialShareServiceMockType } from '@testing/providers/social-share-provider.mock'; import { IconComponent } from '../icon/icon.component'; @@ -15,21 +16,22 @@ import { SocialsShareButtonComponent } from './socials-share-button.component'; describe('SocialsShareButtonComponent', () => { let component: SocialsShareButtonComponent; let fixture: ComponentFixture; - let service: SocialShareService; + let service: SocialShareServiceMockType; beforeEach(() => { + const socialShare = SocialShareServiceMock.simple(); + socialShare.createPreprintUrl.mockReturnValue('https://web/preprints/providerX/id123'); + socialShare.createGuidUrl.mockReturnValue('https://web/guid-id999'); + socialShare.generateSocialActionItems.mockReturnValue([]); + TestBed.configureTestingModule({ imports: [SocialsShareButtonComponent, MockComponent(IconComponent), MockPipe(TranslatePipe)], - providers: [provideOSFCore(), MockProvider(SocialShareService)], + providers: [provideOSFCore(), MockProvider(SocialShareService, socialShare)], }); fixture = TestBed.createComponent(SocialsShareButtonComponent); component = fixture.componentInstance; - service = TestBed.inject(SocialShareService); - - vi.spyOn(service, 'createPreprintUrl').mockReturnValue('https://web/preprints/providerX/id123'); - vi.spyOn(service, 'createGuidUrl').mockReturnValue('https://web/guid-id999'); - vi.spyOn(service, 'generateSocialActionItems').mockReturnValue([]); + service = socialShare; }); it('should create', () => { diff --git a/src/app/shared/components/socials-share-button/socials-share-button.component.ts b/src/app/shared/components/socials-share-button/socials-share-button.component.ts index 23ed332eb..f51128d45 100644 --- a/src/app/shared/components/socials-share-button/socials-share-button.component.ts +++ b/src/app/shared/components/socials-share-button/socials-share-button.component.ts @@ -33,11 +33,7 @@ export class SocialsShareButtonComponent { ? this.socialShareService.createPreprintUrl(this.resourceId(), this.resourceProvider()) : this.socialShareService.createGuidUrl(this.resourceId()); - const shareableContent: SocialShareContentModel = { - id: this.resourceId(), - title: this.resourceTitle(), - url: resourceUrl, - }; + const shareableContent: SocialShareContentModel = { title: this.resourceTitle(), url: resourceUrl }; return this.socialShareService.generateSocialActionItems(shareableContent); }); diff --git a/src/app/shared/components/status-badge/status-badge.component.html b/src/app/shared/components/status-badge/status-badge.component.html index 0f2b18722..261413749 100644 --- a/src/app/shared/components/status-badge/status-badge.component.html +++ b/src/app/shared/components/status-badge/status-badge.component.html @@ -1,3 +1,3 @@ -@if (label) { - +@if (label()) { + } diff --git a/src/app/shared/components/status-badge/status-badge.component.spec.ts b/src/app/shared/components/status-badge/status-badge.component.spec.ts index 6afef014e..929db9184 100644 --- a/src/app/shared/components/status-badge/status-badge.component.spec.ts +++ b/src/app/shared/components/status-badge/status-badge.component.spec.ts @@ -10,162 +10,47 @@ describe('StatusBadgeComponent', () => { let component: StatusBadgeComponent; let fixture: ComponentFixture; + function setup(status: RegistryStatus): void { + fixture = TestBed.createComponent(StatusBadgeComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('status', status); + fixture.detectChanges(); + } + beforeEach(() => { TestBed.configureTestingModule({ imports: [StatusBadgeComponent], providers: [provideOSFCore()], }); - - fixture = TestBed.createComponent(StatusBadgeComponent); - component = fixture.componentInstance; }); it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should set status input correctly', () => { - fixture.componentRef.setInput('status', RegistryStatus.Accepted); - expect(component.status()).toBe(RegistryStatus.Accepted); - }); - - it('should get label for Accepted status', () => { - fixture.componentRef.setInput('status', RegistryStatus.Accepted); - expect(component.label).toBe('shared.statuses.accepted'); - }); - - it('should get label for Pending status', () => { - fixture.componentRef.setInput('status', RegistryStatus.Pending); - expect(component.label).toBe('shared.statuses.pending'); - }); - - it('should get label for Unapproved status', () => { - fixture.componentRef.setInput('status', RegistryStatus.Unapproved); - expect(component.label).toBe('shared.statuses.unapproved'); - }); - - it('should get label for Withdrawn status', () => { - fixture.componentRef.setInput('status', RegistryStatus.Withdrawn); - expect(component.label).toBe('shared.statuses.withdrawn'); - }); - - it('should get label for InProgress status', () => { - fixture.componentRef.setInput('status', RegistryStatus.InProgress); - expect(component.label).toBe('shared.statuses.inProgress'); - }); - - it('should get label for PendingModeration status', () => { - fixture.componentRef.setInput('status', RegistryStatus.PendingModeration); - expect(component.label).toBe('shared.statuses.pendingModeration'); - }); - - it('should get label for PendingRegistrationApproval status', () => { - fixture.componentRef.setInput('status', RegistryStatus.PendingRegistrationApproval); - expect(component.label).toBe('shared.statuses.pendingRegistrationApproval'); - }); - - it('should get label for PendingEmbargoApproval status', () => { - fixture.componentRef.setInput('status', RegistryStatus.PendingEmbargoApproval); - expect(component.label).toBe('shared.statuses.pendingEmbargoApproval'); - }); - - it('should get label for Embargo status', () => { - fixture.componentRef.setInput('status', RegistryStatus.Embargo); - expect(component.label).toBe('shared.statuses.embargo'); - }); + setup(RegistryStatus.Accepted); - it('should get label for PendingEmbargoTerminationApproval status', () => { - fixture.componentRef.setInput('status', RegistryStatus.PendingEmbargoTerminationApproval); - expect(component.label).toBe('shared.statuses.pendingEmbargoTerminationApproval'); - }); - - it('should get label for PendingWithdrawRequest status', () => { - fixture.componentRef.setInput('status', RegistryStatus.PendingWithdrawRequest); - expect(component.label).toBe('shared.statuses.pendingWithdrawRequest'); - }); - - it('should get label for PendingWithdraw status', () => { - fixture.componentRef.setInput('status', RegistryStatus.PendingWithdraw); - expect(component.label).toBe('shared.statuses.pendingWithdraw'); - }); - - it('should get label for UpdatePendingApproval status', () => { - fixture.componentRef.setInput('status', RegistryStatus.UpdatePendingApproval); - expect(component.label).toBe('shared.statuses.updatePendingApproval'); - }); - - it('should get label for None status', () => { - fixture.componentRef.setInput('status', RegistryStatus.None); - expect(component.label).toBe(''); - }); - - it('should get severity for Accepted status', () => { - fixture.componentRef.setInput('status', RegistryStatus.Accepted); - expect(component.severity).toBe('success'); - }); - - it('should get severity for Pending status', () => { - fixture.componentRef.setInput('status', RegistryStatus.Pending); - expect(component.severity).toBe('info'); - }); - - it('should get severity for Unapproved status', () => { - fixture.componentRef.setInput('status', RegistryStatus.Unapproved); - expect(component.severity).toBe('danger'); - }); - - it('should get severity for Withdrawn status', () => { - fixture.componentRef.setInput('status', RegistryStatus.Withdrawn); - expect(component.severity).toBe('danger'); - }); - - it('should get severity for InProgress status', () => { - fixture.componentRef.setInput('status', RegistryStatus.InProgress); - expect(component.severity).toBe('info'); - }); - - it('should get severity for PendingModeration status', () => { - fixture.componentRef.setInput('status', RegistryStatus.PendingModeration); - expect(component.severity).toBe('warn'); - }); - - it('should get severity for PendingRegistrationApproval status', () => { - fixture.componentRef.setInput('status', RegistryStatus.PendingRegistrationApproval); - expect(component.severity).toBe('warn'); + expect(component).toBeTruthy(); }); - it('should get severity for PendingEmbargoApproval status', () => { - fixture.componentRef.setInput('status', RegistryStatus.PendingEmbargoApproval); - expect(component.severity).toBe('warn'); - }); + it('should map accepted status to label and severity', () => { + setup(RegistryStatus.Accepted); - it('should get severity for Embargo status', () => { - fixture.componentRef.setInput('status', RegistryStatus.Embargo); - expect(component.severity).toBe('info'); + expect(component.label()).toBe('shared.statuses.accepted'); + expect(component.severity()).toBe('success'); }); - it('should get severity for PendingEmbargoTerminationApproval status', () => { - fixture.componentRef.setInput('status', RegistryStatus.PendingEmbargoTerminationApproval); - expect(component.severity).toBe('warn'); - }); + it('should not render tag when status label is empty', () => { + setup(RegistryStatus.None); - it('should get severity for PendingWithdrawRequest status', () => { - fixture.componentRef.setInput('status', RegistryStatus.PendingWithdrawRequest); - expect(component.severity).toBe('info'); - }); + const tag = fixture.nativeElement.querySelector('p-tag'); - it('should get severity for PendingWithdraw status', () => { - fixture.componentRef.setInput('status', RegistryStatus.PendingWithdraw); - expect(component.severity).toBe('warn'); + expect(component.label()).toBe(''); + expect(component.severity()).toBe(null); + expect(tag).toBeNull(); }); - it('should get severity for UpdatePendingApproval status', () => { - fixture.componentRef.setInput('status', RegistryStatus.UpdatePendingApproval); - expect(component.severity).toBe('warn'); - }); + it('should use fallback values for unknown status', () => { + setup('unknown-status' as RegistryStatus); - it('should get severity for None status', () => { - fixture.componentRef.setInput('status', RegistryStatus.None); - expect(component.severity).toBe(null); + expect(component.label()).toBe('resourceCard.type.null'); + expect(component.severity()).toBe(null); }); }); diff --git a/src/app/shared/components/status-badge/status-badge.component.ts b/src/app/shared/components/status-badge/status-badge.component.ts index 5610e175b..1fa211862 100644 --- a/src/app/shared/components/status-badge/status-badge.component.ts +++ b/src/app/shared/components/status-badge/status-badge.component.ts @@ -2,11 +2,10 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Tag } from 'primeng/tag'; -import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; import { RegistryStatusMap } from '@osf/shared/constants/registration-statuses'; import { RegistryStatus } from '@osf/shared/enums/registry-status.enum'; -import { TagSeverityType } from '@osf/shared/models/severity.type'; @Component({ selector: 'osf-status-badge', @@ -18,11 +17,6 @@ import { TagSeverityType } from '@osf/shared/models/severity.type'; export class StatusBadgeComponent { status = input.required(); - get label(): string { - return RegistryStatusMap[this.status()]?.label ?? 'Unknown'; - } - - get severity(): TagSeverityType | null { - return RegistryStatusMap[this.status()]?.severity ?? null; - } + label = computed(() => RegistryStatusMap[this.status()]?.label ?? 'resourceCard.type.null'); + severity = computed(() => RegistryStatusMap[this.status()]?.severity ?? null); } diff --git a/src/app/shared/components/subjects/subjects.component.ts b/src/app/shared/components/subjects/subjects.component.ts index 966742a8b..f201d0fbe 100644 --- a/src/app/shared/components/subjects/subjects.component.ts +++ b/src/app/shared/components/subjects/subjects.component.ts @@ -11,7 +11,8 @@ import { Tree, TreeModule } from 'primeng/tree'; import { debounceTime, distinctUntilChanged } from 'rxjs'; -import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, DestroyRef, inject, input, output } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormControl, FormsModule } from '@angular/forms'; import { SubjectModel } from '@osf/shared/models/subject/subject.model'; @@ -27,11 +28,14 @@ import { SearchInputComponent } from '../search-input/search-input.component'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class SubjectsComponent { + readonly destroyRef = inject(DestroyRef); + subjects = select(SubjectsSelectors.getSubjects); subjectsLoading = select(SubjectsSelectors.getSubjectsLoading); searchedSubjects = select(SubjectsSelectors.getSearchedSubjects); - areSubjectsUpdating = input(false); isSearching = select(SubjectsSelectors.getSearchedSubjectsLoading); + + areSubjectsUpdating = input(false); selected = input([]); readonly = input(false); searchChanged = output(); @@ -51,9 +55,11 @@ export class SubjectsComponent { searchControl = new FormControl(''); constructor() { - this.searchControl.valueChanges.pipe(debounceTime(300), distinctUntilChanged()).subscribe((value) => { - this.searchChanged.emit(value ?? ''); - }); + this.searchControl.valueChanges + .pipe(debounceTime(300), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)) + .subscribe((value) => { + this.searchChanged.emit(value ?? ''); + }); } loadNode(event: TreeNode) { diff --git a/src/app/features/files/constants/embed-content.constants.ts b/src/app/shared/constants/file-embed.constants.ts similarity index 100% rename from src/app/features/files/constants/embed-content.constants.ts rename to src/app/shared/constants/file-embed.constants.ts diff --git a/src/app/shared/constants/social-share.config.ts b/src/app/shared/constants/social-share.config.ts index 88f7d17e2..7a0e2bfaf 100644 --- a/src/app/shared/constants/social-share.config.ts +++ b/src/app/shared/constants/social-share.config.ts @@ -1,7 +1,8 @@ export const SOCIAL_SHARE_URLS = { email: 'mailto:', - twitter: { preview_url: 'https://twitter.com/intent/tweet', viaHandle: 'OsfFramework' }, + x: { preview_url: 'https://x.com/intent/tweet', viaHandle: 'OsfFramework' }, facebook: 'https://www.facebook.com/sharer/sharer.php', + facebookShare: 'https://www.facebook.com/dialog/share', linkedIn: 'https://www.linkedin.com/sharing/share-offsite', mastodon: 'https://mastodonshare.com', bluesky: 'https://bsky.app/intent/compose', diff --git a/src/app/shared/helpers/mfr-url.helper.ts b/src/app/shared/helpers/mfr-url.helper.ts new file mode 100644 index 000000000..a3fa1093b --- /dev/null +++ b/src/app/shared/helpers/mfr-url.helper.ts @@ -0,0 +1,19 @@ +export function getMfrUrlWithVersion( + mfrUrl: string | undefined, + version?: string, + viewOnlyParam?: string | null +): string | null { + if (!mfrUrl) return null; + const mfrUrlObj = new URL(mfrUrl); + const encodedDownloadUrl = mfrUrlObj.searchParams.get('url'); + if (!encodedDownloadUrl) return mfrUrl; + + const downloadUrlObj = new URL(decodeURIComponent(encodedDownloadUrl)); + + if (version) downloadUrlObj.searchParams.set('version', version); + if (viewOnlyParam) downloadUrlObj.searchParams.set('view_only', viewOnlyParam); + + mfrUrlObj.searchParams.set('url', downloadUrlObj.toString()); + + return mfrUrlObj.toString(); +} diff --git a/src/app/shared/helpers/project-path-options.helper.ts b/src/app/shared/helpers/project-path-options.helper.ts new file mode 100644 index 000000000..f444937d6 --- /dev/null +++ b/src/app/shared/helpers/project-path-options.helper.ts @@ -0,0 +1,31 @@ +import { NodeShortInfoModel } from '@osf/shared/models/nodes/node-with-children.model'; +import { SelectOption } from '@osf/shared/models/select-option.model'; + +import { ProjectPathOptionsParams } from '../models/files/project-path-options.model'; + +export function buildProjectPathOptions({ + nodes = [], + parentPath = '..', + rootProjectId, +}: ProjectPathOptionsParams): SelectOption[] { + return nodes.reduce((acc, node) => { + const pathParts: string[] = []; + + let current: NodeShortInfoModel | undefined = node; + while (current) { + pathParts.unshift(current.title ?? ''); + current = nodes.find((n) => n.id === current?.parentId); + } + + const isRootProject = !!rootProjectId && node.id === rootProjectId; + const basePath = isRootProject ? '' : parentPath; + const fullPath = basePath ? `${basePath}/${pathParts.join('/')}` : pathParts.join('/'); + + acc.push({ + value: node.id, + label: fullPath, + }); + + return acc; + }, []); +} diff --git a/src/app/shared/helpers/storage-addon-options.helper.ts b/src/app/shared/helpers/storage-addon-options.helper.ts new file mode 100644 index 000000000..18ec482b5 --- /dev/null +++ b/src/app/shared/helpers/storage-addon-options.helper.ts @@ -0,0 +1,31 @@ +import { FileProvider } from '@osf/features/files/constants'; +import { ConfiguredAddonModel } from '@shared/models/addons/configured-addon.model'; +import { FileFolderModel } from '@shared/models/files/file-folder.model'; +import { FileLabelModel } from '@shared/models/files/file-label.model'; + +export function getConfiguredStorageAddonDisplayName( + addons: ConfiguredAddonModel[], + provider: string, + osfStorageLabel: string +): string { + if (provider === FileProvider.OsfStorage) { + return osfStorageLabel; + } + + return addons.find((addon) => addon.externalServiceName === provider)?.displayName ?? ''; +} + +export function mapRootFoldersToStorageLabels( + rootFolders: FileFolderModel[] | null | undefined, + addons: ConfiguredAddonModel[] | null | undefined, + osfStorageLabel: string +): FileLabelModel[] { + if (!rootFolders || !addons) { + return []; + } + + return rootFolders.map((folder) => ({ + label: getConfiguredStorageAddonDisplayName(addons, folder.provider, osfStorageLabel), + folder, + })); +} diff --git a/src/app/shared/mappers/files/file-tree.mapper.ts b/src/app/shared/mappers/files/file-tree.mapper.ts new file mode 100644 index 000000000..7f84211fe --- /dev/null +++ b/src/app/shared/mappers/files/file-tree.mapper.ts @@ -0,0 +1,16 @@ +import { FileModel } from '@shared/models/files/file.model'; +import { FileTreeNode } from '@shared/models/files/file-tree-node.model'; + +export class FileTreeMapper { + static toTreeNode(file: FileModel): FileTreeNode { + return { + key: file.id, + label: file.name, + data: file, + }; + } + + static toTreeNodes(files: FileModel[]): FileTreeNode[] { + return files.map((file) => this.toTreeNode(file)); + } +} diff --git a/src/app/shared/mappers/files/files.mapper.ts b/src/app/shared/mappers/files/files.mapper.ts index f792acf78..6723a3042 100644 --- a/src/app/shared/mappers/files/files.mapper.ts +++ b/src/app/shared/mappers/files/files.mapper.ts @@ -60,6 +60,8 @@ export class FilesMapper { } static getFileDetails(data: FileDetailsDataJsonApi): FileDetailsModel { + const target = data.embeds?.target?.data; + return { id: data.id, guid: data.attributes.guid, @@ -76,7 +78,7 @@ export class FilesMapper { showAsUnviewed: data.attributes.show_as_unviewed, extra: this.getFileExtra(data.attributes.extra), links: this.getFileLinks(data.links), - target: BaseNodeMapper.getNodeData(data.embeds!.target.data), + target: target ? BaseNodeMapper.getNodeData(target) : null, }; } diff --git a/src/app/shared/models/files/file-move-link.model.ts b/src/app/shared/models/files/file-move-link.model.ts new file mode 100644 index 000000000..d43827aa5 --- /dev/null +++ b/src/app/shared/models/files/file-move-link.model.ts @@ -0,0 +1,6 @@ +import { FileModel } from '@osf/shared/models/files/file.model'; + +export interface FileMoveLinkModel { + file: FileModel; + link: string; +} diff --git a/src/app/shared/models/files/file-page-link.model.ts b/src/app/shared/models/files/file-page-link.model.ts new file mode 100644 index 000000000..a5b6ed0f5 --- /dev/null +++ b/src/app/shared/models/files/file-page-link.model.ts @@ -0,0 +1,4 @@ +export interface FilePageLinkModel { + link: string; + page: number; +} diff --git a/src/app/shared/models/files/file-share-link.model.ts b/src/app/shared/models/files/file-share-link.model.ts new file mode 100644 index 000000000..60a018ea6 --- /dev/null +++ b/src/app/shared/models/files/file-share-link.model.ts @@ -0,0 +1,4 @@ +export interface FileShareLink { + link: string; + target: '_self' | '_blank'; +} diff --git a/src/app/shared/models/files/file-tree-node.model.ts b/src/app/shared/models/files/file-tree-node.model.ts new file mode 100644 index 000000000..7f35896a8 --- /dev/null +++ b/src/app/shared/models/files/file-tree-node.model.ts @@ -0,0 +1,5 @@ +import { TreeNode } from 'primeng/api'; + +import { FileModel } from './file.model'; + +export type FileTreeNode = TreeNode; diff --git a/src/app/shared/models/files/file-upload-link.model.ts b/src/app/shared/models/files/file-upload-link.model.ts new file mode 100644 index 000000000..c8530ce4b --- /dev/null +++ b/src/app/shared/models/files/file-upload-link.model.ts @@ -0,0 +1,4 @@ +export interface FileUploadLinkModel { + file: File; + link: string; +} diff --git a/src/app/shared/models/files/file.model.ts b/src/app/shared/models/files/file.model.ts index c60efe6e8..f49bc8215 100644 --- a/src/app/shared/models/files/file.model.ts +++ b/src/app/shared/models/files/file.model.ts @@ -29,7 +29,7 @@ export interface FileDetailsModel extends BaseFileModel { currentVersion: number; showAsUnviewed: boolean; links: FileLinksModel; - target: BaseNodeModel; + target: BaseNodeModel | null; } export interface FileExtraModel { diff --git a/src/app/shared/models/files/project-path-options.model.ts b/src/app/shared/models/files/project-path-options.model.ts new file mode 100644 index 000000000..32adef21d --- /dev/null +++ b/src/app/shared/models/files/project-path-options.model.ts @@ -0,0 +1,7 @@ +import { NodeShortInfoModel } from '../nodes/node-with-children.model'; + +export interface ProjectPathOptionsParams { + nodes?: NodeShortInfoModel[]; + parentPath?: string; + rootProjectId?: string; +} diff --git a/src/app/shared/models/files/renamed-file-link.model.ts b/src/app/shared/models/files/renamed-file-link.model.ts new file mode 100644 index 000000000..81876ff0e --- /dev/null +++ b/src/app/shared/models/files/renamed-file-link.model.ts @@ -0,0 +1,4 @@ +export interface RenamedFileLinkModel { + newName: string; + link: string; +} diff --git a/src/app/shared/models/socials/social-share-content.model.ts b/src/app/shared/models/socials/social-share-content.model.ts index fc5b94031..ec393ac08 100644 --- a/src/app/shared/models/socials/social-share-content.model.ts +++ b/src/app/shared/models/socials/social-share-content.model.ts @@ -1,5 +1,4 @@ export interface SocialShareContentModel { - id: string; title: string; url: string; } diff --git a/src/app/shared/services/files-share-embed.service.spec.ts b/src/app/shared/services/files-share-embed.service.spec.ts new file mode 100644 index 000000000..be6867f12 --- /dev/null +++ b/src/app/shared/services/files-share-embed.service.spec.ts @@ -0,0 +1,130 @@ +import { MockProvider } from 'ng-mocks'; + +import { Mock } from 'vitest'; + +import { Clipboard } from '@angular/cdk/clipboard'; +import { TestBed } from '@angular/core/testing'; + +import { ToastService } from '@osf/shared/services/toast.service'; + +import { FileModelMock } from '@testing/mocks/file.model.mock'; +import { + SocialShareServiceMockBuilder, + SocialShareServiceMockType, +} from '@testing/providers/social-share-provider.mock'; +import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; + +import { embedDynamicJs, embedStaticHtml } from '../constants/file-embed.constants'; + +import { FilesShareEmbedService } from './files-share-embed.service'; +import { SocialShareService } from './social-share.service'; + +describe('FilesShareEmbedService', () => { + let service: FilesShareEmbedService; + let clipboardMock: Pick; + let copyMock: Mock<(text: string) => boolean>; + let socialShareServiceMock: SocialShareServiceMockType; + let toastService: ToastServiceMockType; + + function setup() { + copyMock = vi.fn((_: string) => true); + clipboardMock = { copy: copyMock }; + socialShareServiceMock = SocialShareServiceMockBuilder.create() + .withGetEmailLink(vi.fn((_: string, __: string) => 'mailto:test')) + .withGetXLink(vi.fn((_: string, __: string) => 'https://x.test')) + .withGetFacebookLink(vi.fn((_: string) => 'https://facebook.test')) + .build(); + toastService = ToastServiceMock.simple(); + + TestBed.configureTestingModule({ + providers: [ + FilesShareEmbedService, + MockProvider(Clipboard, clipboardMock), + MockProvider(SocialShareService, socialShareServiceMock), + MockProvider(ToastService, toastService), + ], + }); + + service = TestBed.inject(FilesShareEmbedService); + } + + it('should create', () => { + setup(); + expect(service).toBeTruthy(); + }); + + it('should return null share link when file html link is missing', () => { + setup(); + const file = FileModelMock.simple({ links: { ...FileModelMock.simple().links, html: '' } }); + + const result = service.getShareLink(file, 'email'); + + expect(result).toBeNull(); + }); + + it('should build email share link', () => { + setup(); + const file = FileModelMock.simple({ name: 'Report', links: { ...FileModelMock.simple().links, html: '/html' } }); + + const result = service.getShareLink(file, 'email'); + + expect(socialShareServiceMock.getEmailLink).toHaveBeenCalledWith('Report', '/html'); + expect(result).toEqual({ link: 'mailto:test', target: '_self' }); + }); + + it('should build x and facebook links with _blank target', () => { + setup(); + const file = FileModelMock.simple({ name: 'Report', links: { ...FileModelMock.simple().links, html: '/html' } }); + + const xResult = service.getShareLink(file, 'twitter'); + const fbResult = service.getShareLink(file, 'facebook'); + + expect(socialShareServiceMock.getXLink).toHaveBeenCalledWith('Report', '/html'); + expect(socialShareServiceMock.getFacebookLink).toHaveBeenCalledWith('/html'); + expect(xResult).toEqual({ link: 'https://x.test', target: '_blank' }); + expect(fbResult).toEqual({ link: 'https://facebook.test', target: '_blank' }); + }); + + it('should return null for unknown share type', () => { + setup(); + const file = FileModelMock.simple({ links: { ...FileModelMock.simple().links, html: '/html' } }); + + const result = service.getShareLink(file, 'unknown'); + + expect(result).toBeNull(); + }); + + it('should generate dynamic and static embed html', () => { + setup(); + const url = 'https://mfr.osf.io/render?url=abc'; + + const dynamic = service.getEmbedHtml(url, 'dynamic'); + const stat = service.getEmbedHtml(url, 'static'); + const empty = service.getEmbedHtml(url, 'unknown'); + + expect(dynamic).toBe(embedDynamicJs.replace('ENCODED_URL', url)); + expect(stat).toBe(embedStaticHtml.replace('ENCODED_URL', url)); + expect(empty).toBe(''); + }); + + it('should copy embed html and show success toast when clipboard copy succeeds', () => { + setup(); + copyMock.mockReturnValue(true); + + const result = service.copyEmbedToClipboard('https://mfr.osf.io/render?url=abc', 'dynamic'); + + expect(clipboardMock.copy).toHaveBeenCalled(); + expect(toastService.showSuccess).toHaveBeenCalledWith('files.detail.toast.copiedToClipboard'); + expect(result).toBe(true); + }); + + it('should return false and skip toast when embed type is invalid', () => { + setup(); + + const result = service.copyEmbedToClipboard('https://mfr.osf.io/render?url=abc', 'invalid'); + + expect(clipboardMock.copy).not.toHaveBeenCalled(); + expect(toastService.showSuccess).not.toHaveBeenCalled(); + expect(result).toBe(false); + }); +}); diff --git a/src/app/shared/services/files-share-embed.service.ts b/src/app/shared/services/files-share-embed.service.ts new file mode 100644 index 000000000..399f81658 --- /dev/null +++ b/src/app/shared/services/files-share-embed.service.ts @@ -0,0 +1,77 @@ +import { Clipboard } from '@angular/cdk/clipboard'; +import { inject, Injectable } from '@angular/core'; + +import { embedDynamicJs, embedStaticHtml } from '@shared/constants/file-embed.constants'; + +import { FileModel } from '../models/files/file.model'; +import { FileShareLink } from '../models/files/file-share-link.model'; + +import { SocialShareService } from './social-share.service'; +import { ToastService } from './toast.service'; + +@Injectable({ + providedIn: 'root', +}) +export class FilesShareEmbedService { + private readonly clipboard = inject(Clipboard); + private readonly socialShareService = inject(SocialShareService); + private readonly toastService = inject(ToastService); + + private readonly EMBED_PLACEHOLDER = 'ENCODED_URL'; + + getShareLink(file: FileModel, shareType?: string): FileShareLink | null { + const name = file.name ?? ''; + const url = file.links?.html ?? ''; + + if (!url) { + return null; + } + + switch (shareType) { + case 'email': + return { + link: this.socialShareService.getEmailLink(name, url), + target: '_self', + }; + case 'twitter': + return { + link: this.socialShareService.getXLink(name, url), + target: '_blank', + }; + case 'facebook': + return { + link: this.socialShareService.getFacebookLink(url), + target: '_blank', + }; + default: + return null; + } + } + + getEmbedHtml(url: string, embedType?: string): string { + switch (embedType) { + case 'dynamic': + return embedDynamicJs.replace(this.EMBED_PLACEHOLDER, url); + case 'static': + return embedStaticHtml.replace(this.EMBED_PLACEHOLDER, url); + default: + return ''; + } + } + + copyEmbedToClipboard(url: string, embedType?: string): boolean { + const embedHtml = this.getEmbedHtml(url, embedType); + + if (!embedHtml) { + return false; + } + + const copied = this.clipboard.copy(embedHtml); + + if (copied) { + this.toastService.showSuccess('files.detail.toast.copiedToClipboard'); + } + + return copied; + } +} diff --git a/src/app/shared/services/files-tree-actions.service.spec.ts b/src/app/shared/services/files-tree-actions.service.spec.ts new file mode 100644 index 000000000..29a0497d6 --- /dev/null +++ b/src/app/shared/services/files-tree-actions.service.spec.ts @@ -0,0 +1,103 @@ +import { MockProvider } from 'ng-mocks'; + +import { TestBed } from '@angular/core/testing'; + +import { FileKind } from '@osf/shared/enums/file-kind.enum'; +import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; + +import { + CustomConfirmationServiceMock, + CustomConfirmationServiceMockType, +} from '@testing/providers/custom-confirmation-provider.mock'; + +import { FilesTreeActionsService } from './files-tree-actions.service'; + +describe('FilesTreeActionsService', () => { + let service: FilesTreeActionsService; + let confirmationService: CustomConfirmationServiceMockType; + + function setup() { + confirmationService = CustomConfirmationServiceMock.simple(); + + TestBed.configureTestingModule({ + providers: [FilesTreeActionsService, MockProvider(CustomConfirmationService, confirmationService)], + }); + + service = TestBed.inject(FilesTreeActionsService); + } + + it('should create', () => { + setup(); + expect(service).toBeTruthy(); + }); + + it('should not open upload confirmation when dropped files are empty', () => { + setup(); + + service.confirmDropFiles([], vi.fn()); + + expect(confirmationService.confirmAccept).not.toHaveBeenCalled(); + }); + + it('should confirm single file upload with file name params', () => { + setup(); + const onConfirm = vi.fn(); + const file = new File(['body'], 'single.txt'); + + service.confirmDropFiles([file], onConfirm); + + expect(confirmationService.confirmAccept).toHaveBeenCalledWith({ + headerKey: 'files.dialogs.uploadFile.title', + messageParams: { name: 'single.txt' }, + messageKey: 'files.dialogs.uploadFile.message', + acceptLabelKey: 'common.buttons.upload', + onConfirm, + }); + }); + + it('should confirm multiple file upload with count params', () => { + setup(); + const onConfirm = vi.fn(); + const files = [new File(['a'], 'a.txt'), new File(['b'], 'b.txt')]; + + service.confirmDropFiles(files, onConfirm); + + expect(confirmationService.confirmAccept).toHaveBeenCalledWith({ + headerKey: 'files.dialogs.uploadFiles.title', + messageParams: { count: 2 }, + messageKey: 'files.dialogs.uploadFiles.message', + acceptLabelKey: 'common.buttons.upload', + onConfirm, + }); + }); + + it('should confirm delete with folder keys for folder kind', () => { + setup(); + const onConfirm = vi.fn(); + + service.confirmDeleteEntry({ kind: FileKind.Folder, name: 'Docs' }, onConfirm); + + expect(confirmationService.confirmDelete).toHaveBeenCalledWith({ + headerKey: 'files.dialogs.deleteFolder.title', + messageParams: { name: 'Docs' }, + messageKey: 'files.dialogs.deleteFolder.message', + acceptLabelKey: 'common.buttons.remove', + onConfirm, + }); + }); + + it('should confirm delete with file keys for file kind', () => { + setup(); + const onConfirm = vi.fn(); + + service.confirmDeleteEntry({ kind: FileKind.File, name: 'report.pdf' }, onConfirm); + + expect(confirmationService.confirmDelete).toHaveBeenCalledWith({ + headerKey: 'files.dialogs.deleteFile.title', + messageParams: { name: 'report.pdf' }, + messageKey: 'files.dialogs.deleteFile.message', + acceptLabelKey: 'common.buttons.remove', + onConfirm, + }); + }); +}); diff --git a/src/app/shared/services/files-tree-actions.service.ts b/src/app/shared/services/files-tree-actions.service.ts new file mode 100644 index 000000000..2edbdd522 --- /dev/null +++ b/src/app/shared/services/files-tree-actions.service.ts @@ -0,0 +1,38 @@ +import { inject, Injectable } from '@angular/core'; + +import { FileKind } from '@osf/shared/enums/file-kind.enum'; +import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; + +@Injectable({ + providedIn: 'root', +}) +export class FilesTreeActionsService { + private readonly customConfirmationService = inject(CustomConfirmationService); + + confirmDropFiles(fileArray: File[], onConfirm: () => void): void { + if (!fileArray.length) { + return; + } + + const isMultiple = fileArray.length > 1; + + this.customConfirmationService.confirmAccept({ + headerKey: isMultiple ? 'files.dialogs.uploadFiles.title' : 'files.dialogs.uploadFile.title', + messageParams: isMultiple ? { count: fileArray.length } : { name: fileArray[0].name }, + messageKey: isMultiple ? 'files.dialogs.uploadFiles.message' : 'files.dialogs.uploadFile.message', + acceptLabelKey: 'common.buttons.upload', + onConfirm, + }); + } + + confirmDeleteEntry(file: { kind: FileKind; name: string }, onConfirm: () => void): void { + this.customConfirmationService.confirmDelete({ + headerKey: file.kind === FileKind.Folder ? 'files.dialogs.deleteFolder.title' : 'files.dialogs.deleteFile.title', + messageParams: { name: file.name }, + messageKey: + file.kind === FileKind.Folder ? 'files.dialogs.deleteFolder.message' : 'files.dialogs.deleteFile.message', + acceptLabelKey: 'common.buttons.remove', + onConfirm, + }); + } +} diff --git a/src/app/shared/services/files.service.spec.ts b/src/app/shared/services/files.service.spec.ts index cfe06fb7b..6f8a355ab 100644 --- a/src/app/shared/services/files.service.spec.ts +++ b/src/app/shared/services/files.service.spec.ts @@ -1,78 +1,187 @@ -import { HttpTestingController } from '@angular/common/http/testing'; -import { inject, TestBed } from '@angular/core/testing'; +import { MockProvider } from 'ng-mocks'; -import { getConfiguredAddonsData } from '@testing/data/addons/addons.configured.data'; -import { getResourceReferencesData } from '@testing/data/files/resource-references.data'; -import { provideOSFCore, provideOSFHttp } from '@testing/osf.testing.provider'; +import { firstValueFrom, of } from 'rxjs'; + +import { HttpResponse } from '@angular/common/http'; +import { TestBed } from '@angular/core/testing'; + +import { ENVIRONMENT } from '@core/provider/environment.provider'; +import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { AddonMapper } from '@osf/shared/mappers/addon.mapper'; +import { FilesMapper } from '@osf/shared/mappers/files/files.mapper'; +import { FileModel } from '@osf/shared/models/files/file.model'; + +import { FileModelMock } from '@testing/mocks/file.model.mock'; +import { JsonApiServiceMock, JsonApiServiceMockType } from '@testing/providers/json-api.service.mock'; import { FilesService } from './files.service'; +import { JsonApiService } from './json-api.service'; -describe.skip('Service: Files', () => { +describe('FilesService', () => { let service: FilesService; + let jsonApiService: JsonApiServiceMockType; + + function setup() { + jsonApiService = JsonApiServiceMock.simple(); - beforeEach(() => { TestBed.configureTestingModule({ - providers: [provideOSFCore(), provideOSFHttp(), FilesService], + providers: [ + FilesService, + MockProvider(JsonApiService, jsonApiService), + MockProvider(ENVIRONMENT, { + apiDomainUrl: 'https://api.test', + addonsApiUrl: 'https://addons.test', + webUrl: 'https://web.test', + }), + ], }); service = TestBed.inject(FilesService); + } + + it('should create', () => { + setup(); + expect(service).toBeTruthy(); + expect(service.apiUrl).toBe('https://api.test/v2'); + expect(service.addonsApiUrl).toBe('https://addons.test'); }); - it('should test getResourceReferences', inject([HttpTestingController], (httpMock: HttpTestingController) => { - let results!: string; - service.getResourceReferences('reference-url').subscribe({ - next: (result) => { - results = result; - }, + it('should request files with filtering params and map response', async () => { + setup(); + const mappedFile = FileModelMock.simple({ id: 'mapped' }); + const mapperSpy = vi.spyOn(FilesMapper, 'getFiles').mockReturnValue([mappedFile]); + jsonApiService.get.mockReturnValue(of({ data: [{ id: 'raw' }], meta: { total: 1 } })); + + const response = await firstValueFrom(service.getFiles('/files', 'term', '-name', 2)); + + expect(jsonApiService.get).toHaveBeenCalledWith('/files', { + sort: '-name', + page: '2', + 'fields[files]': 'name,guid,kind,extra,size,path,materialized_path,date_modified,parent_folder,files', + 'filter[name]': 'term', }); + expect(mapperSpy).toHaveBeenCalledWith([{ id: 'raw' }]); + expect(response.files).toEqual([mappedFile]); + expect(response.meta).toEqual({ total: 1 }); + }); - const request = httpMock.expectOne( - 'http://addons.localhost:8000/resource-references?filter%5Bresource_uri%5D=reference-url' - ); - expect(request.request.method).toBe('GET'); - request.flush(getResourceReferencesData()); + it('should map resource type to root folders url', () => { + setup(); + const getFoldersSpy = vi.spyOn(service, 'getFolders').mockReturnValue(of({ files: [] })); - expect(results).toBe('http://addons.localhost:8000/resource-references/3193f97c-e6d8-41a4-8312-b73483442086'); - expect(httpMock.verify).toBeTruthy(); - })); + service.getRootFolders('abc', ResourceType.Project).subscribe(); + service.getRootFolders('abc', ResourceType.Registration).subscribe(); + service.getRootFolders('abc', ResourceType.Preprint).subscribe(); - it('should test getConfiguredStorageAddons', inject([HttpTestingController], (httpMock: HttpTestingController) => { - let results: any[] = []; - service.getConfiguredStorageAddons('reference-url').subscribe((result) => { - results = result; + expect(getFoldersSpy).toHaveBeenNthCalledWith(1, 'https://api.test/v2/nodes/abc/files/'); + expect(getFoldersSpy).toHaveBeenNthCalledWith(2, 'https://api.test/v2/registrations/abc/files/'); + expect(getFoldersSpy).toHaveBeenNthCalledWith(3, 'https://api.test/v2/preprints/abc/files/'); + }); + + it('should upload file with create params and without params for update', () => { + setup(); + const file = new File(['body'], 'a.txt'); + jsonApiService.putFile.mockReturnValue(of(new HttpResponse({ status: 200 }))); + + service.uploadFile(file, '/upload').subscribe(); + service.uploadFile(file, '/upload', true).subscribe(); + + expect(jsonApiService.putFile).toHaveBeenNthCalledWith(1, '/upload', file, { + kind: 'file', + name: 'a.txt', }); + expect(jsonApiService.putFile).toHaveBeenNthCalledWith(2, '/upload', file, undefined); + }); - let request = httpMock.expectOne( - 'http://addons.localhost:8000/resource-references?filter%5Bresource_uri%5D=reference-url' - ); - expect(request.request.method).toBe('GET'); - request.flush(getResourceReferencesData()); + it('should post move file payload with optional replace conflict', () => { + setup(); + jsonApiService.post.mockReturnValue(of({})); - request = httpMock.expectOne( - 'http://addons.localhost:8000/resource-references/3193f97c-e6d8-41a4-8312-b73483442086/configured_storage_addons' - ); - expect(request.request.method).toBe('GET'); - request.flush(getConfiguredAddonsData()); - - expect(results[0]).toEqual( - Object({ - baseAccountId: '62ed6dd7-f7b7-4003-b7b4-855789c1f991', - baseAccountType: 'authorized-storage-accounts', - connectedCapabilities: ['ACCESS', 'UPDATE'], - connectedOperationNames: ['list_child_items', 'list_root_items', 'get_item_info'], - currentUserIsOwner: true, - displayName: 'Google Drive', - externalServiceName: 'googledrive', - externalStorageServiceId: '8aeb85e9-3a73-426f-a89b-5624b4b9d418', - id: '756579dc-3a24-4849-8866-698a60846ac3', - resourceType: undefined, - rootFolderId: '0AIl0aR4C9JAFUk9PVA', - selectedStorageItemId: '0AIl0aR4C9JAFUk9PVA', - targetUrl: undefined, - type: 'configured-storage-addons', - }) + service.moveFile('/move', '/dest', 'node-1', 'osfstorage', 'move').subscribe(); + service.moveFile('/move', '/dest', 'node-1', 'osfstorage', 'move', true).subscribe(); + + expect(jsonApiService.post).toHaveBeenNthCalledWith(1, '/move', { + action: 'move', + path: '/dest', + provider: 'osfstorage', + resource: 'node-1', + conflict: undefined, + }); + expect(jsonApiService.post).toHaveBeenNthCalledWith(2, '/move', { + action: 'move', + path: '/dest', + provider: 'osfstorage', + resource: 'node-1', + conflict: 'replace', + }); + }); + + it('should build folder download link with correct separator', () => { + setup(); + expect(service.getFolderDownloadLink('/files/1')).toBe('/files/1?zip='); + expect(service.getFolderDownloadLink('/files/1?foo=bar')).toBe('/files/1?foo=bar&zip='); + }); + + it('should return empty reference when addons api response has no data', async () => { + setup(); + jsonApiService.get.mockReturnValue(of({ data: [] })); + + const link = await firstValueFrom(service.getResourceReferences('https://web.test/resource')); + expect(link).toBe(''); + }); + + it('should return empty configured addons when reference url is empty', async () => { + setup(); + vi.spyOn(service, 'getResourceReferences').mockReturnValue(of('')); + + const addons = await firstValueFrom(service.getConfiguredStorageAddons('node-1')); + expect(addons).toEqual([]); + }); + + it('should fetch configured addons and map response', async () => { + setup(); + vi.spyOn(service, 'getResourceReferences').mockReturnValue(of('https://addons.test/resource-ref')); + const addonSpy = vi.spyOn(AddonMapper, 'fromConfiguredAddonResponse').mockReturnValue({ id: 'addon-1' } as never); + jsonApiService.get.mockReturnValue(of({ data: [{ id: 'raw-addon' }] })); + + const addons = await firstValueFrom(service.getConfiguredStorageAddons('node-1')); + + expect(jsonApiService.get).toHaveBeenCalledWith('https://addons.test/resource-ref/configured_storage_addons'); + expect(addonSpy).toHaveBeenCalledWith({ id: 'raw-addon' }); + expect(addons).toEqual([{ id: 'addon-1' }]); + }); + + it('should fetch external storage service and map addon', async () => { + setup(); + const addon = { id: 'service-1' }; + const addonSpy = vi.spyOn(AddonMapper, 'fromResponse').mockReturnValue(addon as never); + jsonApiService.get.mockReturnValue(of({ data: { id: 'raw' } })); + + const result = await firstValueFrom(service.getExternalStorageService('service-1')); + + expect(jsonApiService.get).toHaveBeenCalledWith( + 'https://addons.test/configured-storage-addons/service-1/external_storage_service/' ); + expect(addonSpy).toHaveBeenCalledWith({ id: 'raw' }); + expect(result).toEqual(addon); + }); - expect(httpMock.verify).toBeTruthy(); - })); + it('should call updateTags with correct patch payload', () => { + setup(); + const fileDetails = FileModelMock.simple() as unknown as FileModel; + const fileDetailsSpy = vi.spyOn(FilesMapper, 'getFileDetails').mockReturnValue(fileDetails as never); + jsonApiService.patch.mockReturnValue(of({ id: 'file-1' })); + + service.updateTags(['one', 'two'], 'file-1').subscribe(); + + expect(jsonApiService.patch).toHaveBeenCalledWith('https://api.test/v2/files/file-1/', { + data: { + id: 'file-1', + type: 'files', + relationships: {}, + attributes: { tags: ['one', 'two'] }, + }, + }); + expect(fileDetailsSpy).toHaveBeenCalledWith({ id: 'file-1' }); + }); }); diff --git a/src/app/shared/services/files.service.ts b/src/app/shared/services/files.service.ts index ad78cb023..5d5becd79 100644 --- a/src/app/shared/services/files.service.ts +++ b/src/app/shared/services/files.service.ts @@ -5,20 +5,22 @@ import { HttpEvent } from '@angular/common/http'; import { inject, Injectable } from '@angular/core'; import { ENVIRONMENT } from '@core/provider/environment.provider'; -import { MapFileCustomMetadata, MapFileRevision } from '@osf/features/files/mappers'; +import { MapFileCustomMetadata } from '@osf/features/files/mappers/file-custom-metadata.mapper'; +import { MapFileRevision } from '@osf/features/files/mappers/file-revision.mapper'; +import { OsfFileCustomMetadata } from '@osf/features/files/models/file-custom-metadata.model'; +import { OsfFileRevision } from '@osf/features/files/models/file-revisions.model'; +import { GetCustomMetadataResponse } from '@osf/features/files/models/get-custom-metadata-response.model'; import { FileCustomMetadata, - GetCustomMetadataResponse, GetFileMetadataResponse, - GetFileRevisionsResponse, - GetShortInfoResponse, - OsfFileCustomMetadata, - OsfFileRevision, - PatchFileMetadata, -} from '@osf/features/files/models'; +} from '@osf/features/files/models/get-file-metadata-response.model'; +import { GetFileRevisionsResponse } from '@osf/features/files/models/get-file-revisions-response.model'; +import { GetShortInfoResponse } from '@osf/features/files/models/get-short-info-response.model'; +import { PatchFileMetadata } from '@osf/features/files/models/patch-file-metadata.model'; import { PaginatedData } from '@osf/shared/models/paginated-data.model'; import { FileKind } from '../enums/file-kind.enum'; +import { ResourceType } from '../enums/resource-type.enum'; import { AddonMapper } from '../mappers/addon.mapper'; import { ContributorsMapper } from '../mappers/contributors'; import { FilesMapper } from '../mappers/files/files.mapper'; @@ -36,6 +38,7 @@ import { FileFoldersResponseJsonApi, } from '../models/files/file-folder-json-api.model'; import { + FileDetailsDataJsonApi, FileDetailsResponseJsonApi, FileResponseJsonApi, FilesResponseJsonApi, @@ -60,7 +63,13 @@ export class FilesService { return this.environment.addonsApiUrl; } - filesFields = 'name,guid,kind,extra,size,path,materialized_path,date_modified,parent_folder,files'; + private readonly filesFields = 'name,guid,kind,extra,size,path,materialized_path,date_modified,parent_folder,files'; + + private readonly resourcePathMap: Record = { + [ResourceType.Project]: 'nodes', + [ResourceType.Registration]: 'registrations', + [ResourceType.Preprint]: 'preprints', + }; getFiles( filesLink: string, @@ -86,10 +95,17 @@ export class FilesService { .pipe(map((response) => ({ files: FilesMapper.getFileFolders(response.data), meta: response.meta }))); } + getRootFolders( + resourceId: string, + resourceType: ResourceType + ): Observable<{ files: FileFolderModel[]; meta?: MetaJsonApi }> { + const resourcePath = this.resourcePathMap[resourceType]; + return this.getFolders(`${this.apiUrl}/${resourcePath}/${resourceId}/files/`); + } + getFilesWithoutFiltering(filesLink: string, page = 1): Observable> { - const params: Record = { - page: page.toString(), - }; + const params: Record = { page: page.toString() }; + return this.jsonApiService.get(filesLink, params).pipe( map((response) => ({ data: FilesMapper.getFiles(response.data), @@ -169,9 +185,7 @@ export class FilesService { } getFileGuid(id: string): Observable { - const params = { - create_guid: 'true', - }; + const params = { create_guid: 'true' }; return this.jsonApiService .get(`${this.apiUrl}/files/${id}/`, params) @@ -252,15 +266,13 @@ export class FilesService { id: fileGuid, type: 'files', relationships: {}, - attributes: { - tags: tags, - }, + attributes: { tags: tags }, }, }; return this.jsonApiService - .patch(`${this.apiUrl}/files/${fileGuid}/`, payload) - .pipe(map((response) => FilesMapper.getFileDetails(response.data))); + .patch(`${this.apiUrl}/files/${fileGuid}/`, payload) + .pipe(map((response) => FilesMapper.getFileDetails(response))); } copyFileToAnotherLocation(moveLink: string, provider: string, resourceId: string) { @@ -278,9 +290,7 @@ export class FilesService { } getResourceReferences(resourceUri: string): Observable { - const params = { - 'filter[resource_uri]': resourceUri, - }; + const params = { 'filter[resource_uri]': resourceUri }; return this.jsonApiService .get< @@ -289,7 +299,9 @@ export class FilesService { .pipe(map((response) => response.data?.[0]?.links?.self ?? '')); } - getConfiguredStorageAddons(resourceUri: string): Observable { + getConfiguredStorageAddons(resourceId: string): Observable { + const resourceUri = `${this.environment.webUrl}/${resourceId}`; + return this.getResourceReferences(resourceUri).pipe( switchMap((referenceUrl: string) => { if (!referenceUrl) return of([]); diff --git a/src/app/shared/services/meta-tags-builder.service.spec.ts b/src/app/shared/services/meta-tags-builder.service.spec.ts index 14644b63d..59745be75 100644 --- a/src/app/shared/services/meta-tags-builder.service.spec.ts +++ b/src/app/shared/services/meta-tags-builder.service.spec.ts @@ -4,7 +4,7 @@ import { MockProvider } from 'ng-mocks'; import { LOCALE_ID } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { OsfFileCustomMetadata } from '@osf/features/files/models'; +import { OsfFileCustomMetadata } from '@osf/features/files/models/file-custom-metadata.model'; import { FileKind } from '@osf/shared/enums/file-kind.enum'; import { FileDetailsModel } from '@osf/shared/models/files/file.model'; diff --git a/src/app/shared/services/meta-tags-builder.service.ts b/src/app/shared/services/meta-tags-builder.service.ts index 28dd524f4..31e8f8db7 100644 --- a/src/app/shared/services/meta-tags-builder.service.ts +++ b/src/app/shared/services/meta-tags-builder.service.ts @@ -4,7 +4,7 @@ import { formatDate } from '@angular/common'; import { inject, Injectable, LOCALE_ID } from '@angular/core'; import { ENVIRONMENT } from '@core/provider/environment.provider'; -import { OsfFileCustomMetadata } from '@osf/features/files/models'; +import { OsfFileCustomMetadata } from '@osf/features/files/models/file-custom-metadata.model'; import { PreprintModel } from '@osf/features/preprints/models'; import { ProjectOverviewModel } from '@osf/features/project/overview/models'; import { RegistrationOverviewModel } from '@osf/features/registry/models'; diff --git a/src/app/shared/services/social-share.service.spec.ts b/src/app/shared/services/social-share.service.spec.ts new file mode 100644 index 000000000..04060b9f7 --- /dev/null +++ b/src/app/shared/services/social-share.service.spec.ts @@ -0,0 +1,96 @@ +import { MockProvider } from 'ng-mocks'; + +import { TestBed } from '@angular/core/testing'; + +import { ENVIRONMENT } from '@core/provider/environment.provider'; + +import { SOCIAL_PLATFORMS } from '../constants/social-platforms.const'; +import { SOCIAL_SHARE_URLS } from '../constants/social-share.config'; +import { SocialShareContentModel } from '../models/socials/social-share-content.model'; + +import { SocialShareService } from './social-share.service'; + +describe('SocialShareService', () => { + let service: SocialShareService; + + const content: SocialShareContentModel = { + title: 'My Title', + url: 'https://osf.io/abcd1', + }; + + function setup() { + TestBed.configureTestingModule({ + providers: [ + SocialShareService, + MockProvider(ENVIRONMENT, { + webUrl: 'https://osf.test', + facebookAppId: 'fb-app-id', + }), + ], + }); + + service = TestBed.inject(SocialShareService); + } + + it('should create', () => { + setup(); + expect(service).toBeTruthy(); + }); + + it('should build email share link', () => { + setup(); + const link = service.getEmailLink(content.title, content.url); + expect(link).toBe( + `${SOCIAL_SHARE_URLS.email}?subject=${encodeURIComponent(content.title)}&body=${encodeURIComponent(content.url)}` + ); + }); + + it('should build x share link', () => { + setup(); + const link = service.getXLink(content.title, content.url); + expect(link).toBe( + `${SOCIAL_SHARE_URLS.x.preview_url}?url=${encodeURIComponent(content.url)}&text=${encodeURIComponent(content.title)}&via=${SOCIAL_SHARE_URLS.x.viaHandle}` + ); + }); + + it('should build facebook links', () => { + setup(); + const link = service.getFacebookLink(content.url); + const custom = service.getFacebookCustomLink(content.url); + + expect(link).toBe(`${SOCIAL_SHARE_URLS.facebook}?u=${encodeURIComponent(content.url)}`); + expect(custom).toBe( + `${SOCIAL_SHARE_URLS.facebookShare}?app_id=fb-app-id&display=popup&href=${encodeURIComponent(content.url)}&redirect_uri=${encodeURIComponent(content.url)}` + ); + }); + + it('should generate all sharing links', () => { + setup(); + const links = service.generateAllSharingLinks(content); + + expect(links.email).toContain('mailto:'); + expect(links.twitter).toContain(SOCIAL_SHARE_URLS.x.preview_url); + expect(links.facebook).toContain(SOCIAL_SHARE_URLS.facebook); + expect(links.linkedIn).toContain(SOCIAL_SHARE_URLS.linkedIn); + expect(links.mastodon).toContain(SOCIAL_SHARE_URLS.mastodon); + expect(links.bluesky).toContain(SOCIAL_SHARE_URLS.bluesky); + }); + + it('should create web urls', () => { + setup(); + + expect(service.createPreprintUrl('pp-1', 'osf')).toBe('https://osf.test/preprints/osf/pp-1'); + expect(service.createGuidUrl('abc12')).toBe('https://osf.test/abc12'); + expect(service.createDownloadUrl('res-1')).toBe('https://osf.test/download/res-1'); + }); + + it('should generate social action items from platform config', () => { + setup(); + const items = service.generateSocialActionItems(content); + + expect(items.length).toBe(SOCIAL_PLATFORMS.length); + expect(items[0].label).toBe(SOCIAL_PLATFORMS[0].label); + expect(items[0].icon).toBe(SOCIAL_PLATFORMS[0].icon); + expect(items[0].url).toContain('mailto:'); + }); +}); diff --git a/src/app/shared/services/social-share.service.ts b/src/app/shared/services/social-share.service.ts index 7b821619a..d8c9832f2 100644 --- a/src/app/shared/services/social-share.service.ts +++ b/src/app/shared/services/social-share.service.ts @@ -18,10 +18,29 @@ export class SocialShareService { return this.environment.webUrl; } + getEmailLink(title: string, url: string): string { + return this.generateEmailLink({ title, url }); + } + + getXLink(title: string, url: string): string { + return this.generateXLink({ title, url }); + } + + getFacebookLink(url: string): string { + return this.generateFacebookLink({ title: '', url }); + } + + getFacebookCustomLink(url: string): string { + const encodedUrl = encodeURIComponent(url); + const appId = this.environment.facebookAppId; + + return `${SOCIAL_SHARE_URLS.facebookShare}?app_id=${appId}&display=popup&href=${encodedUrl}&redirect_uri=${encodedUrl}`; + } + generateAllSharingLinks(content: SocialShareContentModel): SocialShareLinksModel { return { email: this.generateEmailLink(content), - twitter: this.generateTwitterLink(content), + twitter: this.generateXLink(content), facebook: this.generateFacebookLink(content), linkedIn: this.generateLinkedInLink(content), mastodon: this.generateMastodonLink(content), @@ -58,11 +77,11 @@ export class SocialShareService { return `${SOCIAL_SHARE_URLS.email}?subject=${subject}&body=${body}`; } - private generateTwitterLink(content: SocialShareContentModel): string { + private generateXLink(content: SocialShareContentModel): string { const url = encodeURIComponent(content.url); const text = encodeURIComponent(content.title); - return `${SOCIAL_SHARE_URLS.twitter.preview_url}?url=${url}&text=${text}&via=${SOCIAL_SHARE_URLS.twitter.viaHandle}`; + return `${SOCIAL_SHARE_URLS.x.preview_url}?url=${url}&text=${text}&via=${SOCIAL_SHARE_URLS.x.viaHandle}`; } private generateFacebookLink(content: SocialShareContentModel): string { diff --git a/src/app/shared/stores/current-resource/current-resource.state.ts b/src/app/shared/stores/current-resource/current-resource.state.ts index 9bceb57bc..ee681d5b7 100644 --- a/src/app/shared/stores/current-resource/current-resource.state.ts +++ b/src/app/shared/stores/current-resource/current-resource.state.ts @@ -35,10 +35,10 @@ export class CurrentResourceState { }); return this.resourceService.getResourceById(action.resourceId).pipe( - tap((resourceType) => { + tap((resource) => { ctx.patchState({ currentResource: { - data: resourceType, + data: resource, isLoading: false, error: null, }, diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index d398ed471..452a5228a 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -1121,6 +1121,7 @@ "deleteProject": "Delete Project", "descriptions": { "file_updated": { + "instant": "You'll be notified immediately when files are updated.", "daily": "You'll receive a daily summary of file updates.", "instant": "You'll be notified immediately when files are updated.", "none": "You won't receive file update notifications." diff --git a/src/styles/overrides/button.scss b/src/styles/overrides/button.scss index 01b02850f..f24af0624 100644 --- a/src/styles/overrides/button.scss +++ b/src/styles/overrides/button.scss @@ -1,5 +1,3 @@ -@use "../mixins" as mix; - :root { --p-button-padding-x: 1rem; --p-button-padding-y: 0.5625rem; @@ -31,7 +29,7 @@ } .btn-icon-only .p-button { - padding: mix.rem(13px) mix.rem(21px); + padding: 0.625rem 1.25rem; } .btn-full-width { @@ -81,3 +79,11 @@ .help-icon .p-button { height: 2.25rem; } + +.file-link-btn { + .p-button-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} diff --git a/src/styles/overrides/tree.scss b/src/styles/overrides/tree.scss index e30a4f1e8..f8cfc2a66 100644 --- a/src/styles/overrides/tree.scss +++ b/src/styles/overrides/tree.scss @@ -2,6 +2,13 @@ .p-tree { padding: 0; + .files-table-row { + .p-button { + --p-button-label-font-weight: 400; + --p-button-link-color: var(--dark-blue-1); + } + } + .p-tree-node-toggle-button { display: none; } @@ -18,7 +25,7 @@ .p-tree-node-dragover { .files-table-row { - background: var(--bg-blue-3); + background-color: var(--bg-blue-3); } } @@ -47,32 +54,27 @@ } .p-tree-empty-message { - display: none; + height: 100%; } .p-tree-node-selected { .files-table-row { color: var(--white); - background: var(--pr-blue-1); + background-color: var(--pr-blue-1); + + .p-button { + --p-button-link-color: var(--white); + --p-button-link-hover-color: var(--white); + } .blue-icon { color: var(--white); } } } - } - - .empty-state-container { - position: absolute; - inset: 0; - top: 2.75rem; - display: flex; - justify-content: center; - align-items: center; - .drop-text { - text-align: center; - margin-bottom: 2.75rem; + .p-tree-loading-icon { + color: var(--pr-blue-1); } } } diff --git a/src/testing/mocks/file-details.mock.ts b/src/testing/mocks/file-details.mock.ts new file mode 100644 index 000000000..7a7b9a8c2 --- /dev/null +++ b/src/testing/mocks/file-details.mock.ts @@ -0,0 +1,43 @@ +import { FileKind } from '@osf/shared/enums/file-kind.enum'; +import { FileDetailsModel } from '@osf/shared/models/files/file.model'; + +import { MOCK_PROJECT_OVERVIEW } from './project-overview.mock'; + +export const FileDetailsMock = { + simple(overrides: Partial = {}): FileDetailsModel { + return { + id: 'file-id', + guid: 'file-guid', + name: 'file-name.pdf', + kind: FileKind.File, + path: '/file-name.pdf', + size: 100, + materializedPath: '/file-name.pdf', + dateModified: '2024-01-05T00:00:00Z', + extra: { + hashes: { + md5: 'md5', + sha256: 'sha256', + }, + downloads: 1, + }, + lastTouched: null, + dateCreated: '2024-01-04T00:00:00Z', + tags: [], + currentVersion: 1, + showAsUnviewed: false, + links: { + info: 'info', + move: 'move', + upload: 'upload', + delete: 'delete', + download: 'download', + render: 'render', + html: 'html', + self: 'self', + }, + target: MOCK_PROJECT_OVERVIEW, + ...overrides, + }; + }, +}; diff --git a/src/testing/mocks/file.model.mock.ts b/src/testing/mocks/file.model.mock.ts new file mode 100644 index 000000000..ceb67e586 --- /dev/null +++ b/src/testing/mocks/file.model.mock.ts @@ -0,0 +1,35 @@ +import { FileKind } from '@osf/shared/enums/file-kind.enum'; +import { FileModel } from '@osf/shared/models/files/file.model'; + +export const FileModelMock = { + simple(overrides: Partial = {}): FileModel { + return { + id: 'file-id', + guid: null, + name: 'test-file', + kind: FileKind.File, + path: '/test-file', + size: 0, + materializedPath: '/test-file', + dateModified: '', + extra: { + hashes: { md5: '', sha256: '' }, + downloads: 0, + }, + links: { + info: '', + move: 'move', + upload: '', + delete: '', + download: '', + render: '', + html: '', + self: '', + }, + filesLink: null, + previousFolder: false, + provider: 'osfstorage', + ...overrides, + }; + }, +}; diff --git a/src/testing/providers/custom-dialog-provider.mock.ts b/src/testing/providers/custom-dialog-provider.mock.ts index 401126d17..352444460 100644 --- a/src/testing/providers/custom-dialog-provider.mock.ts +++ b/src/testing/providers/custom-dialog-provider.mock.ts @@ -1,5 +1,7 @@ import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; +import { Observable } from 'rxjs'; + import { Mock } from 'vitest'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; @@ -42,6 +44,12 @@ export class CustomDialogServiceMockBuilder { } export const CustomDialogServiceMock = { + dialogRefWithClose(onClose: Observable): DynamicDialogRef { + return { + onClose, + close: vi.fn(), + } as unknown as DynamicDialogRef; + }, create() { return CustomDialogServiceMockBuilder.create(); }, diff --git a/src/testing/providers/files-move-copy-service.mock.ts b/src/testing/providers/files-move-copy-service.mock.ts new file mode 100644 index 000000000..35ec95c65 --- /dev/null +++ b/src/testing/providers/files-move-copy-service.mock.ts @@ -0,0 +1,20 @@ +import { Observable, of } from 'rxjs'; + +import { Mock, vi } from 'vitest'; + +import { MoveCopyOptions } from '@osf/features/files/models/move-copy-options.model'; +import { FilesMoveCopyService } from '@osf/features/files/services/files-move-copy.service'; + +type ExecuteFn = (options: MoveCopyOptions) => Observable; + +export type FilesMoveCopyServiceMockType = Pick & { + execute: Mock; +}; + +export const FilesMoveCopyServiceMock = { + simple(): FilesMoveCopyServiceMockType { + return { + execute: vi.fn().mockReturnValue(of(true)), + } as FilesMoveCopyServiceMockType; + }, +}; diff --git a/src/testing/providers/files-service.mock.ts b/src/testing/providers/files-service.mock.ts new file mode 100644 index 000000000..37d32e6ee --- /dev/null +++ b/src/testing/providers/files-service.mock.ts @@ -0,0 +1,122 @@ +import { Observable, of } from 'rxjs'; + +import { Mock, vi } from 'vitest'; + +import { HttpEvent } from '@angular/common/http'; + +import { MetaJsonApi } from '@osf/shared/models/common/json-api.model'; +import { FileDetailsModel, FileModel } from '@osf/shared/models/files/file.model'; +import { FileFolderModel } from '@osf/shared/models/files/file-folder.model'; +import { FileVersionModel } from '@osf/shared/models/files/file-version.model'; +import { PaginatedData } from '@osf/shared/models/paginated-data.model'; +import { FilesService } from '@osf/shared/services/files.service'; + +import { FileModelMock } from '@testing/mocks/file.model.mock'; +import { OSF_FILE_MOCK } from '@testing/mocks/osf-file.mock'; + +type GetFilesFn = ( + filesLink: string, + search: string, + sort: string, + page?: number +) => Observable<{ files: FileModel[]; meta?: MetaJsonApi }>; +type GetFoldersFn = (folderLink: string) => Observable<{ files: FileFolderModel[]; meta?: MetaJsonApi }>; +type GetRootFoldersFn = ( + resourceId: string, + resourceType: number +) => Observable<{ files: FileFolderModel[]; meta?: MetaJsonApi }>; +type GetFilesWithoutFilteringFn = (filesLink: string, page?: number) => Observable>; +type UploadFileFn = (file: File, uploadLink: string, isUpdate?: boolean) => Observable>; +type GetFolderFn = (link: string) => Observable; +type DeleteEntryFn = (link: string) => Observable; +type RenameEntryFn = (link: string, name: string, conflict?: string) => Observable; +type MoveFileFn = ( + link: string, + path: string, + resourceId: string, + provider: string, + action: string, + replace?: boolean +) => Observable; +type GetFolderDownloadLinkFn = (link: string) => string; +type GetFileTargetFn = (fileGuid: string) => Observable; +type GetFileGuidFn = (id: string) => Observable; +type GetFileByIdFn = (fileGuid: string) => Observable; +type GetFileVersionsFn = (fileGuid: string) => Observable; + +export type FilesServiceMockType = Pick< + FilesService, + | 'getFiles' + | 'getFolders' + | 'getRootFolders' + | 'getFilesWithoutFiltering' + | 'uploadFile' + | 'getFolder' + | 'deleteEntry' + | 'renameEntry' + | 'moveFile' + | 'getFolderDownloadLink' + | 'getFileTarget' + | 'getFileGuid' + | 'getFileById' + | 'getFileVersions' +> & { + getFiles: Mock; + getFolders: Mock; + getRootFolders: Mock; + getFilesWithoutFiltering: Mock; + uploadFile: Mock; + getFolder: Mock; + deleteEntry: Mock; + renameEntry: Mock; + moveFile: Mock; + getFolderDownloadLink: Mock; + getFileTarget: Mock; + getFileGuid: Mock; + getFileById: Mock; + getFileVersions: Mock; +}; + +export const FilesServiceMock = { + simple(): FilesServiceMockType { + const file = FileModelMock.simple(); + const folder = { ...OSF_FILE_MOCK }; + const fileDetails: FileDetailsModel = { + id: file.id, + guid: file.guid, + name: file.name, + kind: file.kind, + path: file.path, + size: file.size, + materializedPath: file.materializedPath, + dateModified: file.dateModified, + extra: file.extra, + lastTouched: null, + dateCreated: '', + tags: [], + currentVersion: 1, + showAsUnviewed: false, + links: file.links, + target: null, + }; + + return { + getFiles: vi.fn().mockReturnValue(of({ files: [file], meta: { total: 1, per_page: 10 } as MetaJsonApi })), + getFolders: vi.fn().mockReturnValue(of({ files: [folder], meta: { total: 1, per_page: 10 } as MetaJsonApi })), + getRootFolders: vi.fn().mockReturnValue(of({ files: [folder], meta: { total: 1, per_page: 10 } as MetaJsonApi })), + getFilesWithoutFiltering: vi + .fn() + .mockReturnValue(of({ data: [file], totalCount: 1, pageSize: 10 } as PaginatedData)), + uploadFile: vi.fn().mockReturnValue(of({} as HttpEvent)), + getFolder: vi.fn().mockReturnValue(of(folder)), + deleteEntry: vi.fn().mockReturnValue(of(void 0)), + renameEntry: vi.fn().mockReturnValue(of(file)), + moveFile: vi.fn().mockReturnValue(of({})), + getFolderDownloadLink: vi.fn().mockImplementation((link: string) => `${link}?zip=`), + getFileTarget: vi.fn().mockReturnValue(of(fileDetails)), + getFileGuid: vi.fn().mockReturnValue(of(file)), + getFileById: vi.fn().mockReturnValue(of(file)), + getFileVersions: vi.fn().mockReturnValue(of([])), + } as FilesServiceMockType; + }, +}; diff --git a/src/testing/providers/files-share-embed-provider.mock.ts b/src/testing/providers/files-share-embed-provider.mock.ts new file mode 100644 index 000000000..459127635 --- /dev/null +++ b/src/testing/providers/files-share-embed-provider.mock.ts @@ -0,0 +1,57 @@ +import { Mock } from 'vitest'; + +import { FileModel } from '@osf/shared/models/files/file.model'; +import { FileShareLink } from '@osf/shared/models/files/file-share-link.model'; +import { FilesShareEmbedService } from '@osf/shared/services/files-share-embed.service'; + +export type FilesShareEmbedServiceMockType = Partial & { + getShareLink: Mock<(file: FileModel, shareType?: string) => FileShareLink | null>; + getEmbedHtml: Mock<(url: string, embedType?: string) => string>; + copyEmbedToClipboard: Mock<(url: string, embedType?: string) => boolean>; +}; + +export class FilesShareEmbedServiceMockBuilder { + private getShareLinkMock: Mock<(file: FileModel, shareType?: string) => FileShareLink | null> = vi.fn(() => null); + private getEmbedHtmlMock: Mock<(url: string, embedType?: string) => string> = vi.fn(() => ''); + private copyEmbedToClipboardMock: Mock<(url: string, embedType?: string) => boolean> = vi.fn(() => true); + + static create(): FilesShareEmbedServiceMockBuilder { + return new FilesShareEmbedServiceMockBuilder(); + } + + withGetShareLink( + mockImpl: Mock<(file: FileModel, shareType?: string) => FileShareLink | null> + ): FilesShareEmbedServiceMockBuilder { + this.getShareLinkMock = mockImpl; + return this; + } + + withGetEmbedHtml(mockImpl: Mock<(url: string, embedType?: string) => string>): FilesShareEmbedServiceMockBuilder { + this.getEmbedHtmlMock = mockImpl; + return this; + } + + withCopyEmbedToClipboard( + mockImpl: Mock<(url: string, embedType?: string) => boolean> + ): FilesShareEmbedServiceMockBuilder { + this.copyEmbedToClipboardMock = mockImpl; + return this; + } + + build(): FilesShareEmbedServiceMockType { + return { + getShareLink: this.getShareLinkMock, + getEmbedHtml: this.getEmbedHtmlMock, + copyEmbedToClipboard: this.copyEmbedToClipboardMock, + } as FilesShareEmbedServiceMockType; + } +} + +export const FilesShareEmbedServiceMock = { + create() { + return FilesShareEmbedServiceMockBuilder.create(); + }, + simple() { + return FilesShareEmbedServiceMockBuilder.create().build(); + }, +}; diff --git a/src/testing/providers/json-api.service.mock.ts b/src/testing/providers/json-api.service.mock.ts new file mode 100644 index 000000000..828c7b44e --- /dev/null +++ b/src/testing/providers/json-api.service.mock.ts @@ -0,0 +1,102 @@ +import { Observable, of } from 'rxjs'; + +import { Mock, vi } from 'vitest'; + +import { HttpContext, HttpEvent } from '@angular/common/http'; + +import { JsonApiService } from '@osf/shared/services/json-api.service'; + +type GetFn = ( + url: string, + params?: Record, + context?: HttpContext, + headers?: Record +) => Observable; +type PostFn = ( + url: string, + body?: unknown, + params?: Record, + headers?: Record +) => Observable; +type PatchFn = ( + url: string, + body: unknown, + params?: Record, + headers?: Record, + context?: HttpContext +) => Observable; +type PutFn = (url: string, body: unknown, params?: Record) => Observable; +type PutFileFn = (url: string, file: File, params?: Record) => Observable>; +type DeleteFn = (url: string, body?: unknown, headers?: Record) => Observable; + +export type JsonApiServiceMockType = Pick & { + get: Mock; + post: Mock; + patch: Mock; + put: Mock; + putFile: Mock; + delete: Mock; +}; + +export class JsonApiServiceMockBuilder { + private getMock: Mock = vi.fn().mockReturnValue(of({})); + private postMock: Mock = vi.fn().mockReturnValue(of({})); + private patchMock: Mock = vi.fn().mockReturnValue(of({})); + private putMock: Mock = vi.fn().mockReturnValue(of({})); + private putFileMock: Mock = vi.fn().mockReturnValue(of({} as HttpEvent)); + private deleteMock: Mock = vi.fn().mockReturnValue(of(void 0)); + + static create(): JsonApiServiceMockBuilder { + return new JsonApiServiceMockBuilder(); + } + + withGet(mockImpl: Mock): JsonApiServiceMockBuilder { + this.getMock = mockImpl; + return this; + } + + withPost(mockImpl: Mock): JsonApiServiceMockBuilder { + this.postMock = mockImpl; + return this; + } + + withPatch(mockImpl: Mock): JsonApiServiceMockBuilder { + this.patchMock = mockImpl; + return this; + } + + withPut(mockImpl: Mock): JsonApiServiceMockBuilder { + this.putMock = mockImpl; + return this; + } + + withPutFile(mockImpl: Mock): JsonApiServiceMockBuilder { + this.putFileMock = mockImpl; + return this; + } + + withDelete(mockImpl: Mock): JsonApiServiceMockBuilder { + this.deleteMock = mockImpl; + return this; + } + + build(): JsonApiServiceMockType { + return { + get: this.getMock, + post: this.postMock, + patch: this.patchMock, + put: this.putMock, + putFile: this.putFileMock, + delete: this.deleteMock, + } as JsonApiServiceMockType; + } +} + +export const JsonApiServiceMock = { + create() { + return JsonApiServiceMockBuilder.create(); + }, + simple() { + return JsonApiServiceMockBuilder.create().build(); + }, +}; diff --git a/src/testing/providers/signposting-provider.mock.ts b/src/testing/providers/signposting-provider.mock.ts new file mode 100644 index 000000000..4693ae66a --- /dev/null +++ b/src/testing/providers/signposting-provider.mock.ts @@ -0,0 +1,51 @@ +import { Mock } from 'vitest'; + +import { SignpostingService } from '@osf/shared/services/signposting.service'; + +export type SignpostingServiceMockType = Partial & { + addSignposting: Mock<(guid: string) => void>; + addMetadataSignposting: Mock<(guid: string) => void>; + removeSignpostingLinkTags: Mock<() => void>; +}; + +export class SignpostingServiceMockBuilder { + private addSignpostingMock: Mock<(guid: string) => void> = vi.fn(); + private addMetadataSignpostingMock: Mock<(guid: string) => void> = vi.fn(); + private removeSignpostingLinkTagsMock: Mock<() => void> = vi.fn(); + + static create(): SignpostingServiceMockBuilder { + return new SignpostingServiceMockBuilder(); + } + + withAddSignposting(mockImpl: Mock<(guid: string) => void>): SignpostingServiceMockBuilder { + this.addSignpostingMock = mockImpl; + return this; + } + + withAddMetadataSignposting(mockImpl: Mock<(guid: string) => void>): SignpostingServiceMockBuilder { + this.addMetadataSignpostingMock = mockImpl; + return this; + } + + withRemoveSignpostingLinkTags(mockImpl: Mock<() => void>): SignpostingServiceMockBuilder { + this.removeSignpostingLinkTagsMock = mockImpl; + return this; + } + + build(): SignpostingServiceMockType { + return { + addSignposting: this.addSignpostingMock, + addMetadataSignposting: this.addMetadataSignpostingMock, + removeSignpostingLinkTags: this.removeSignpostingLinkTagsMock, + } as SignpostingServiceMockType; + } +} + +export const SignpostingServiceMock = { + create() { + return SignpostingServiceMockBuilder.create(); + }, + simple() { + return SignpostingServiceMockBuilder.create().build(); + }, +}; diff --git a/src/testing/providers/social-share-provider.mock.ts b/src/testing/providers/social-share-provider.mock.ts new file mode 100644 index 000000000..5cec1c542 --- /dev/null +++ b/src/testing/providers/social-share-provider.mock.ts @@ -0,0 +1,136 @@ +import { Mock } from 'vitest'; + +import { SocialShareContentModel } from '@osf/shared/models/socials/social-share-content.model'; +import { SocialShareLinksModel } from '@osf/shared/models/socials/social-share-links.model'; +import { SocialsShareActionItem } from '@osf/shared/models/socials/socials-share-action-item.model'; +import { SocialShareService } from '@osf/shared/services/social-share.service'; + +import { SOCIAL_SHARE_LINKS_MOCK } from '@testing/mocks/social-share-links.mock'; + +export type SocialShareServiceMockType = Partial & { + getEmailLink: Mock<(title: string, url: string) => string>; + getXLink: Mock<(title: string, url: string) => string>; + getFacebookLink: Mock<(url: string) => string>; + getFacebookCustomLink: Mock<(url: string) => string>; + generateAllSharingLinks: Mock<(content: SocialShareContentModel) => SocialShareLinksModel>; + createPreprintUrl: Mock<(preprintId: string, providerId: string) => string>; + createGuidUrl: Mock<(guid: string) => string>; + createDownloadUrl: Mock<(resourceId: string) => string>; + generateSocialActionItems: Mock<(content: SocialShareContentModel) => SocialsShareActionItem[]>; +}; + +export class SocialShareServiceMockBuilder { + private webUrlValue = 'https://osf.io'; + private getEmailLinkMock: Mock<(title: string, url: string) => string> = vi.fn( + (_title: string, _url: string) => 'mailto:?subject=&body=' + ); + private getXLinkMock: Mock<(title: string, url: string) => string> = vi.fn( + (_title: string, _url: string) => 'https://twitter.com/intent/tweet' + ); + private getFacebookLinkMock: Mock<(url: string) => string> = vi.fn( + (_url: string) => 'https://www.facebook.com/sharer/sharer.php' + ); + private getFacebookCustomLinkMock: Mock<(url: string) => string> = vi.fn( + (_url: string) => 'https://www.facebook.com/dialog/share' + ); + private generateAllSharingLinksMock: Mock<(content: SocialShareContentModel) => SocialShareLinksModel> = vi.fn( + () => SOCIAL_SHARE_LINKS_MOCK + ); + private createPreprintUrlMock: Mock<(preprintId: string, providerId: string) => string> = vi.fn( + (preprintId: string, providerId: string) => `${this.webUrlValue}/preprints/${providerId}/${preprintId}` + ); + private createGuidUrlMock: Mock<(guid: string) => string> = vi.fn((guid: string) => `${this.webUrlValue}/${guid}`); + private createDownloadUrlMock: Mock<(resourceId: string) => string> = vi.fn( + (resourceId: string) => `${this.webUrlValue}/download/${resourceId}` + ); + private generateSocialActionItemsMock: Mock<(content: SocialShareContentModel) => SocialsShareActionItem[]> = vi.fn( + () => [] + ); + + static create(): SocialShareServiceMockBuilder { + return new SocialShareServiceMockBuilder(); + } + + withWebUrl(value: string): SocialShareServiceMockBuilder { + this.webUrlValue = value; + return this; + } + + withGetEmailLink(mockImpl: Mock<(title: string, url: string) => string>): SocialShareServiceMockBuilder { + this.getEmailLinkMock = mockImpl; + return this; + } + + withGetXLink(mockImpl: Mock<(title: string, url: string) => string>): SocialShareServiceMockBuilder { + this.getXLinkMock = mockImpl; + return this; + } + + withGetFacebookLink(mockImpl: Mock<(url: string) => string>): SocialShareServiceMockBuilder { + this.getFacebookLinkMock = mockImpl; + return this; + } + + withGetFacebookCustomLink(mockImpl: Mock<(url: string) => string>): SocialShareServiceMockBuilder { + this.getFacebookCustomLinkMock = mockImpl; + return this; + } + + withGenerateAllSharingLinks( + mockImpl: Mock<(content: SocialShareContentModel) => SocialShareLinksModel> + ): SocialShareServiceMockBuilder { + this.generateAllSharingLinksMock = mockImpl; + return this; + } + + withCreatePreprintUrl( + mockImpl: Mock<(preprintId: string, providerId: string) => string> + ): SocialShareServiceMockBuilder { + this.createPreprintUrlMock = mockImpl; + return this; + } + + withCreateGuidUrl(mockImpl: Mock<(guid: string) => string>): SocialShareServiceMockBuilder { + this.createGuidUrlMock = mockImpl; + return this; + } + + withCreateDownloadUrl(mockImpl: Mock<(resourceId: string) => string>): SocialShareServiceMockBuilder { + this.createDownloadUrlMock = mockImpl; + return this; + } + + withGenerateSocialActionItems( + mockImpl: Mock<(content: SocialShareContentModel) => SocialsShareActionItem[]> + ): SocialShareServiceMockBuilder { + this.generateSocialActionItemsMock = mockImpl; + return this; + } + + build(): SocialShareServiceMockType { + const webUrl = this.webUrlValue; + return { + get webUrl() { + return webUrl; + }, + getEmailLink: this.getEmailLinkMock, + getXLink: this.getXLinkMock, + getFacebookLink: this.getFacebookLinkMock, + getFacebookCustomLink: this.getFacebookCustomLinkMock, + generateAllSharingLinks: this.generateAllSharingLinksMock, + createPreprintUrl: this.createPreprintUrlMock, + createGuidUrl: this.createGuidUrlMock, + createDownloadUrl: this.createDownloadUrlMock, + generateSocialActionItems: this.generateSocialActionItemsMock, + } as SocialShareServiceMockType; + } +} + +export const SocialShareServiceMock = { + create() { + return SocialShareServiceMockBuilder.create(); + }, + simple() { + return SocialShareServiceMockBuilder.create().build(); + }, +};