diff --git a/package-lock.json b/package-lock.json index dfe544253..343846a54 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "osf", - "version": "26.7.0", + "version": "26.10.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "osf", - "version": "26.7.0", + "version": "26.10.1", "dependencies": { "@angular/animations": "^21.2.7", "@angular/cdk": "^21.2.6", @@ -840,9 +840,9 @@ } }, "node_modules/@angular/ssr": { - "version": "21.2.7", - "resolved": "https://registry.npmjs.org/@angular/ssr/-/ssr-21.2.7.tgz", - "integrity": "sha512-NhrkeD32s3H/jU9yJLqDy2JBNNatFyzqNkwieJw0waEvBRNbxXlcg5+g6rilcg2nHlH5hyzMQUzs7ZwZH9wCqg==", + "version": "21.2.12", + "resolved": "https://registry.npmjs.org/@angular/ssr/-/ssr-21.2.12.tgz", + "integrity": "sha512-g3GZXWlS73TX87awmFUBuviWALida9t5g0iWZ2KZF+e3oaDFOp4JeA5eOsrHlNfd6CBIJItRTiy5SRXUuV1fjA==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -6238,9 +6238,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { @@ -7726,13 +7726,13 @@ } }, "node_modules/express-rate-limit": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz", - "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==", + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", "dev": true, "license": "MIT", "dependencies": { - "ip-address": "10.1.0" + "ip-address": "^10.2.0" }, "engines": { "node": ">= 16" @@ -7794,9 +7794,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "dev": true, "funding": [ { @@ -8227,9 +8227,9 @@ } }, "node_modules/hono": { - "version": "4.12.14", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz", - "integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==", + "version": "4.12.23", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz", + "integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==", "dev": true, "license": "MIT", "engines": { @@ -8526,9 +8526,9 @@ } }, "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "dev": true, "license": "MIT", "engines": { @@ -10878,9 +10878,9 @@ } }, "node_modules/qs": { - "version": "6.15.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", - "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 711ff4387..a227f8e5a 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -182,6 +182,11 @@ export const routes: Routes = [ import('./core/components/page-not-found/page-not-found.component').then((mod) => mod.PageNotFoundComponent), data: { skipBreadcrumbs: true }, }, + { + path: ':id/files/:fileGuid/preview', + loadComponent: () => + import('./features/files/pages/file-preview/file-preview.component').then((m) => m.FilePreviewComponent), + }, { path: 'spam-content', loadComponent: () => diff --git a/src/app/core/components/layout/layout.component.html b/src/app/core/components/layout/layout.component.html index c587f4ef4..5f872d40f 100644 --- a/src/app/core/components/layout/layout.component.html +++ b/src/app/core/components/layout/layout.component.html @@ -19,6 +19,13 @@ + + @if (isMaintenanceMode()) { +
+

{{ 'maintenance.title' | translate }}

+

{{ 'maintenance.message' | translate }}

+
+ } { provideOSFCore(), MockProvider(IS_WEB, isWebSubject), MockProvider(IS_MEDIUM, isMediumSubject), - MockProvider(ConfirmationService), + MockProvider(MaintenanceModeService, MaintenanceModeServiceMock.simple()), ], }); diff --git a/src/app/core/components/layout/layout.component.ts b/src/app/core/components/layout/layout.component.ts index 63601d382..62befef60 100644 --- a/src/app/core/components/layout/layout.component.ts +++ b/src/app/core/components/layout/layout.component.ts @@ -6,6 +6,7 @@ import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { RouterOutlet } from '@angular/router'; +import { MaintenanceModeService } from '@core/services/maintenance-mode.service'; import { ScrollTopOnRouteChangeDirective } from '@osf/shared/directives/scroll-top.directive'; import { IS_MEDIUM, IS_WEB } from '@osf/shared/helpers/breakpoints.tokens'; @@ -35,6 +36,9 @@ import { TopnavComponent } from '../topnav/topnav.component'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class LayoutComponent { + private readonly maintenanceModeService = inject(MaintenanceModeService); + isWeb = toSignal(inject(IS_WEB)); isMedium = toSignal(inject(IS_MEDIUM)); + isMaintenanceMode = this.maintenanceModeService.isActive; } diff --git a/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.html b/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.html index 9dd9ed582..f3687af5c 100644 --- a/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.html +++ b/src/app/core/components/osf-banners/maintenance-banner/maintenance-banner.component.html @@ -4,9 +4,9 @@ styleClass="w-full" icon="pi pi-info-circle" [severity]="maintenance()?.severity" - [text]="maintenance()?.message" [closable]="true" (onClose)="dismiss()" > + {{ maintenance()?.message }} } diff --git a/src/app/core/interceptors/error.interceptor.spec.ts b/src/app/core/interceptors/error.interceptor.spec.ts index 8d977ad30..a082c289d 100644 --- a/src/app/core/interceptors/error.interceptor.spec.ts +++ b/src/app/core/interceptors/error.interceptor.spec.ts @@ -9,6 +9,7 @@ import { Router } from '@angular/router'; import { SENTRY_TOKEN } from '@core/provider/sentry.provider'; import { AuthService } from '@core/services/auth.service'; +import { MaintenanceModeService } from '@core/services/maintenance-mode.service'; import { UserSelectors } from '@core/store/user'; import { ToastService } from '@osf/shared/services/toast.service'; import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; @@ -16,6 +17,10 @@ import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-h import { provideOSFCore } from '@testing/osf.testing.provider'; import { AuthServiceMock, AuthServiceMockType } from '@testing/providers/auth-service.mock'; import { LoaderServiceMock, provideLoaderServiceMock } from '@testing/providers/loader-service.mock'; +import { + MaintenanceModeServiceMock, + MaintenanceModeServiceMockType, +} from '@testing/providers/maintenance-mode.service.mock'; import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; import { SentryMock, SentryMockType } from '@testing/providers/sentry-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; @@ -30,6 +35,7 @@ describe('errorInterceptor', () => { let toastServiceMock: ToastServiceMockType; let loaderServiceMock: LoaderServiceMock; let authServiceMock: AuthServiceMockType; + let maintenanceModeServiceMock: MaintenanceModeServiceMockType; let viewOnlyHelperMock: ViewOnlyLinkHelperMockType; let sentryMock: SentryMockType; @@ -43,6 +49,7 @@ describe('errorInterceptor', () => { toastServiceMock = ToastServiceMock.simple(); loaderServiceMock = new LoaderServiceMock(); authServiceMock = AuthServiceMock.simple(); + maintenanceModeServiceMock = MaintenanceModeServiceMock.simple(); viewOnlyHelperMock = ViewOnlyLinkHelperMock.simple(viewOnly); sentryMock = SentryMock.simple(); @@ -56,6 +63,7 @@ describe('errorInterceptor', () => { MockProvider(Router, router), MockProvider(ToastService, toastServiceMock), MockProvider(AuthService, authServiceMock), + MockProvider(MaintenanceModeService, maintenanceModeServiceMock), MockProvider(ViewOnlyLinkHelperService, viewOnlyHelperMock), MockProvider(PLATFORM_ID, platformId), { provide: SENTRY_TOKEN, useValue: sentryMock }, @@ -181,4 +189,21 @@ describe('errorInterceptor', () => { expect(loaderServiceMock.hide).toHaveBeenCalled(); expect(toastServiceMock.showError).not.toHaveBeenCalled(); }); + + it('should activate maintenance mode on 503 maintenance response', async () => { + setup('browser', false); + const request = createRequest('/api/v2/'); + const error = new HttpErrorResponse({ + status: 503, + error: { meta: { maintenance_mode: true } }, + url: request.url, + }); + + const caught = await runInterceptor(request, error); + + expect(caught?.status).toBe(503); + expect(maintenanceModeServiceMock.activate).toHaveBeenCalled(); + expect(loaderServiceMock.hide).toHaveBeenCalled(); + expect(toastServiceMock.showError).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/core/interceptors/error.interceptor.ts b/src/app/core/interceptors/error.interceptor.ts index 7a914a208..4a620df46 100644 --- a/src/app/core/interceptors/error.interceptor.ts +++ b/src/app/core/interceptors/error.interceptor.ts @@ -9,8 +9,10 @@ import { inject, PLATFORM_ID } from '@angular/core'; import { Router } from '@angular/router'; import { ERROR_MESSAGES } from '@core/constants/error-messages'; +import { MaintenanceResponse } from '@core/models/maintenance-response.model'; import { SENTRY_TOKEN } from '@core/provider/sentry.provider'; import { AuthService } from '@core/services/auth.service'; +import { MaintenanceModeService } from '@core/services/maintenance-mode.service'; import { UserSelectors } from '@core/store/user'; import { LoaderService } from '@osf/shared/services/loader.service'; import { ToastService } from '@osf/shared/services/toast.service'; @@ -23,6 +25,7 @@ export const errorInterceptor: HttpInterceptorFn = (req, next) => { const loaderService = inject(LoaderService); const router = inject(Router); const authService = inject(AuthService); + const maintenanceModeService = inject(MaintenanceModeService); const sentry = inject(SENTRY_TOKEN); const platformId = inject(PLATFORM_ID); const viewOnlyHelper = inject(ViewOnlyLinkHelperService); @@ -47,6 +50,17 @@ export const errorInterceptor: HttpInterceptorFn = (req, next) => { } const serverErrorRegex = /5\d{2}/; + const maintenanceResponse = error.error as MaintenanceResponse | null; + + const maintenanceMode = error.status === 503 && maintenanceResponse?.meta?.maintenance_mode === true; + + if (maintenanceMode) { + loaderService.hide(); + if (isPlatformBrowser(platformId)) { + maintenanceModeService.activate(); + } + return throwError(() => error); + } if (serverErrorRegex.test(error.status.toString())) { errorMessage = error.error.message || 'common.errorMessages.serverError'; diff --git a/src/app/core/models/maintenance-response.model.ts b/src/app/core/models/maintenance-response.model.ts new file mode 100644 index 000000000..88c5fea94 --- /dev/null +++ b/src/app/core/models/maintenance-response.model.ts @@ -0,0 +1,5 @@ +export interface MaintenanceResponse { + meta?: { + maintenance_mode?: boolean; + }; +} diff --git a/src/app/core/services/maintenance-mode.service.ts b/src/app/core/services/maintenance-mode.service.ts new file mode 100644 index 000000000..2059b710f --- /dev/null +++ b/src/app/core/services/maintenance-mode.service.ts @@ -0,0 +1,66 @@ +import { catchError, map, Observable, of, Subscription, switchMap, timer } from 'rxjs'; + +import { HttpClient, HttpContext } from '@angular/common/http'; +import { inject, Injectable, OnDestroy, signal } from '@angular/core'; + +import { MaintenanceResponse } from '@core/models/maintenance-response.model'; +import { ENVIRONMENT } from '@core/provider/environment.provider'; + +import { BYPASS_ERROR_INTERCEPTOR } from '../interceptors/error-interceptor.tokens'; + +@Injectable({ + providedIn: 'root', +}) +export class MaintenanceModeService implements OnDestroy { + private readonly http = inject(HttpClient); + private readonly environment = inject(ENVIRONMENT); + + private readonly POLL_INTERVAL_MS = 5 * 60 * 1_000; + private readonly _isActive = signal(false); + private readonly bypassContext = new HttpContext().set(BYPASS_ERROR_INTERCEPTOR, true); + + private pollingSubscription: Subscription | null = null; + + readonly isActive = this._isActive.asReadonly(); + + activate(): void { + this._isActive.set(true); + if (this.pollingSubscription) { + return; + } + this.startPolling(); + } + + deactivate(): void { + this._isActive.set(false); + this.stopPolling(); + } + + ngOnDestroy(): void { + this.stopPolling(); + } + + private startPolling(): void { + this.pollingSubscription = timer(0, this.POLL_INTERVAL_MS) + .pipe(switchMap(() => this.checkMaintenanceStatus())) + .subscribe((isMaintenance) => { + if (!isMaintenance) { + this.deactivate(); + } + }); + } + + private stopPolling(): void { + this.pollingSubscription?.unsubscribe(); + this.pollingSubscription = null; + } + + private checkMaintenanceStatus(): Observable { + return this.http + .get(`${this.environment.apiDomainUrl}/v2/`, { context: this.bypassContext }) + .pipe( + map((response) => response.meta?.maintenance_mode === true), + catchError(() => of(true)) + ); + } +} 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/collections/components/add-to-collection/project-contributors-step/project-contributors-step.component.html b/src/app/features/collections/components/add-to-collection/project-contributors-step/project-contributors-step.component.html index bc0195260..43d53e657 100644 --- a/src/app/features/collections/components/add-to-collection/project-contributors-step/project-contributors-step.component.html +++ b/src/app/features/collections/components/add-to-collection/project-contributors-step/project-contributors-step.component.html @@ -42,6 +42,7 @@

{{ 'collections.addToCollection.projectContributors' | translate }}

[(contributors)]="projectContributors" [tableParams]="tableParams()" [isLoading]="isContributorsLoading()" + [showLoadMore]="hasMoreContributors()" [isLoadingMore]="isLoadingMore()" (remove)="handleRemoveContributor($event)" (loadMore)="loadMoreContributors()" diff --git a/src/app/features/collections/components/add-to-collection/project-contributors-step/project-contributors-step.component.ts b/src/app/features/collections/components/add-to-collection/project-contributors-step/project-contributors-step.component.ts index f8b32192c..8a41cde7a 100644 --- a/src/app/features/collections/components/add-to-collection/project-contributors-step/project-contributors-step.component.ts +++ b/src/app/features/collections/components/add-to-collection/project-contributors-step/project-contributors-step.component.ts @@ -60,15 +60,16 @@ export class ProjectContributorsStepComponent { private readonly customConfirmationService = inject(CustomConfirmationService); private readonly router = inject(Router); + readonly currentUser = select(UserSelectors.getCurrentUser); readonly isContributorsLoading = select(ContributorsSelectors.isContributorsLoading); readonly contributorsTotalCount = select(ContributorsSelectors.getContributorsTotalCount); readonly selectedProject = select(ProjectsSelectors.getSelectedProject); - readonly currentUser = select(UserSelectors.getCurrentUser); - isLoadingMore = select(ContributorsSelectors.isContributorsLoadingMore); + readonly isLoadingMore = select(ContributorsSelectors.isContributorsLoadingMore); + readonly hasMoreContributors = select(ContributorsSelectors.hasMoreContributors); + readonly pageSize = select(ContributorsSelectors.getContributorsPageSize); private initialContributors = select(ContributorsSelectors.getContributors); readonly projectContributors = signal([]); - pageSize = select(ContributorsSelectors.getContributorsPageSize); readonly tableParams = computed(() => ({ ...DEFAULT_TABLE_PARAMS, diff --git a/src/app/features/contributors/contributors.component.html b/src/app/features/contributors/contributors.component.html index 2f43f5b60..fbcd27799 100644 --- a/src/app/features/contributors/contributors.component.html +++ b/src/app/features/contributors/contributors.component.html @@ -67,6 +67,7 @@

{{ 'navigation.contributors' | translate } [tableParams]="tableParams()" [hasAdminAccess]="hasAdminAccess()" [currentUserId]="currentUser()?.id" + [showLoadMore]="hasMoreContributors()" [showCurator]="true" [showInfo]="true" [resourceType]="resourceType()" diff --git a/src/app/features/contributors/contributors.component.ts b/src/app/features/contributors/contributors.component.ts index 1cf411bbc..2fab14f2c 100644 --- a/src/app/features/contributors/contributors.component.ts +++ b/src/app/features/contributors/contributors.component.ts @@ -132,11 +132,12 @@ export class ContributorsComponent implements OnInit, OnDestroy { readonly isContributorsLoading = select(ContributorsSelectors.isContributorsLoading); readonly contributorsTotalCount = select(ContributorsSelectors.getContributorsTotalCount); readonly isViewOnlyLinksLoading = select(ViewOnlyLinkSelectors.isViewOnlyLinksLoading); + readonly isLoadingMore = select(ContributorsSelectors.isContributorsLoadingMore); + readonly pageSize = select(ContributorsSelectors.getContributorsPageSize); + readonly hasMoreContributors = select(ContributorsSelectors.hasMoreContributors); readonly hasAdminAccess = select(CurrentResourceSelectors.hasResourceAdminAccess); readonly resourceAccessRequestEnabled = select(CurrentResourceSelectors.resourceAccessRequestEnabled); readonly currentUser = select(UserSelectors.getCurrentUser); - pageSize = select(ContributorsSelectors.getContributorsPageSize); - isLoadingMore = select(ContributorsSelectors.isContributorsLoadingMore); readonly tableParams = computed(() => ({ ...DEFAULT_TABLE_PARAMS, 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 a47be2ff6..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,30 +4,32 @@ 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'; -import { languageCodes } from '@osf/shared/constants/language.const'; +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, }) export class EditFileMetadataDialogComponent { readonly resourceTypes = resourceTypes; - readonly languages = languageCodes; + readonly languages = LANGUAGE_CODES; 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 e278aa6f0..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 { languageCodes } 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(languageCodes); 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 2b9f22052..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 { languageCodes } 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 = languageCodes; + 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 a264e749a..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(link: string) { - this.actions.deleteEntry(link).subscribe(() => { - this.toastService.showSuccess('files.dialogs.deleteFile.success'); - this.updateFilesList(); + deleteEntry(file: FileModel): void { + 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/metadata/components/metadata-registry-info/metadata-registry-info.component.spec.ts b/src/app/features/metadata/components/metadata-registry-info/metadata-registry-info.component.spec.ts index 4a0f25eb2..34f80a903 100644 --- a/src/app/features/metadata/components/metadata-registry-info/metadata-registry-info.component.spec.ts +++ b/src/app/features/metadata/components/metadata-registry-info/metadata-registry-info.component.spec.ts @@ -19,6 +19,7 @@ describe('MetadataRegistryInfoComponent', () => { iri: 'https://example.com/registry', reviewsWorkflow: 'standard', allowSubmissions: true, + allowUpdates: true, }; beforeEach(() => { diff --git a/src/app/features/metadata/components/metadata-resource-information/metadata-resource-information.component.html b/src/app/features/metadata/components/metadata-resource-information/metadata-resource-information.component.html index 6f00278b7..9b2e95482 100644 --- a/src/app/features/metadata/components/metadata-resource-information/metadata-resource-information.component.html +++ b/src/app/features/metadata/components/metadata-resource-information/metadata-resource-information.component.html @@ -26,12 +26,12 @@

{{ 'project.overview.metadata.resourceType' | translate }}: - {{ getResourceTypeName(customItemMetadata()?.resourceTypeGeneral!) }} + {{ customItemMetadata()?.resourceTypeGeneral | resourceTypeGeneralLabel }}

{{ 'project.overview.metadata.resourceLanguage' | translate }}: - {{ getLanguageName(customItemMetadata()?.language || '') }} + {{ customItemMetadata()?.language | languageLabel }}

} @else { diff --git a/src/app/features/metadata/components/metadata-resource-information/metadata-resource-information.component.ts b/src/app/features/metadata/components/metadata-resource-information/metadata-resource-information.component.ts index b5a9cb0de..4659ab242 100644 --- a/src/app/features/metadata/components/metadata-resource-information/metadata-resource-information.component.ts +++ b/src/app/features/metadata/components/metadata-resource-information/metadata-resource-information.component.ts @@ -5,14 +5,13 @@ import { Card } from 'primeng/card'; import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; -import { RESOURCE_TYPE_OPTIONS } from '@osf/features/metadata/constants'; import { CustomItemMetadataRecord } from '@osf/features/metadata/models'; -import { languageCodes } from '@osf/shared/constants/language.const'; -import { LanguageCodeModel } from '@shared/models/language-code.model'; +import { LanguageLabelPipe } from '@osf/shared/pipes/language-label.pipe'; +import { ResourceTypeGeneralLabelPipe } from '@osf/shared/pipes/resource-type-general-label.pipe'; @Component({ selector: 'osf-metadata-resource-information', - imports: [Button, Card, TranslatePipe], + imports: [Button, Card, TranslatePipe, LanguageLabelPipe, ResourceTypeGeneralLabelPipe], templateUrl: './metadata-resource-information.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) @@ -22,17 +21,4 @@ export class MetadataResourceInformationComponent { customItemMetadata = input.required(); readonly = input(false); showResourceInfo = output(); - - readonly languageCodes = languageCodes; - readonly resourceTypes = RESOURCE_TYPE_OPTIONS; - - getLanguageName(languageCode: string): string { - const language = this.languageCodes.find((lang: LanguageCodeModel) => lang.code === languageCode); - return language ? language.name : languageCode; - } - - getResourceTypeName(resourceType: string): string { - const resource = this.resourceTypes.find((res) => res.value === resourceType); - return resource ? resource.label : resourceType; - } } diff --git a/src/app/features/metadata/constants/index.ts b/src/app/features/metadata/constants/index.ts index ea28ffd12..7fd5997d1 100644 --- a/src/app/features/metadata/constants/index.ts +++ b/src/app/features/metadata/constants/index.ts @@ -1,2 +1 @@ export * from './cedar-config.const'; -export * from './resource-type-options.const'; diff --git a/src/app/features/metadata/dialogs/contributors-dialog/contributors-dialog.component.html b/src/app/features/metadata/dialogs/contributors-dialog/contributors-dialog.component.html index 4ff339220..787fa2208 100644 --- a/src/app/features/metadata/dialogs/contributors-dialog/contributors-dialog.component.html +++ b/src/app/features/metadata/dialogs/contributors-dialog/contributors-dialog.component.html @@ -20,6 +20,7 @@ [tableParams]="tableParams()" [isLoading]="isLoading()" [isLoadingMore]="isLoadingMore()" + [showLoadMore]="hasMoreContributors()" [showEmployment]="false" [showEducation]="false" [hasAdminAccess]="hasAdminAccess()" diff --git a/src/app/features/metadata/dialogs/contributors-dialog/contributors-dialog.component.ts b/src/app/features/metadata/dialogs/contributors-dialog/contributors-dialog.component.ts index 46e3a82f6..c259bee48 100644 --- a/src/app/features/metadata/dialogs/contributors-dialog/contributors-dialog.component.ts +++ b/src/app/features/metadata/dialogs/contributors-dialog/contributors-dialog.component.ts @@ -73,7 +73,9 @@ export class ContributorsDialogComponent implements OnInit { hasAdminAccess = select(MetadataSelectors.hasAdminAccess); contributors = signal([]); isLoadingMore = select(ContributorsSelectors.isContributorsLoadingMore); + hasMoreContributors = select(ContributorsSelectors.hasMoreContributors); pageSize = select(ContributorsSelectors.getContributorsPageSize); + changesMade = signal(false); currentUser = select(UserSelectors.getCurrentUser); diff --git a/src/app/features/metadata/dialogs/resource-information-dialog/resource-information-dialog.component.html b/src/app/features/metadata/dialogs/resource-information-dialog/resource-information-dialog.component.html index 22b3da58d..406d870f6 100644 --- a/src/app/features/metadata/dialogs/resource-information-dialog/resource-information-dialog.component.html +++ b/src/app/features/metadata/dialogs/resource-information-dialog/resource-information-dialog.component.html @@ -6,14 +6,14 @@

@@ -24,22 +24,19 @@ - - {{ option.label }} -
diff --git a/src/app/features/metadata/dialogs/resource-information-dialog/resource-information-dialog.component.spec.ts b/src/app/features/metadata/dialogs/resource-information-dialog/resource-information-dialog.component.spec.ts index 65b895efb..5279b776a 100644 --- a/src/app/features/metadata/dialogs/resource-information-dialog/resource-information-dialog.component.spec.ts +++ b/src/app/features/metadata/dialogs/resource-information-dialog/resource-information-dialog.component.spec.ts @@ -12,6 +12,8 @@ import { ResourceInformationDialogComponent } from './resource-information-dialo describe('ResourceInformationDialogComponent', () => { let component: ResourceInformationDialogComponent; let fixture: ComponentFixture; + let dialogRef: DynamicDialogRef; + let config: DynamicDialogConfig; beforeEach(() => { TestBed.configureTestingModule({ @@ -21,83 +23,58 @@ describe('ResourceInformationDialogComponent', () => { fixture = TestBed.createComponent(ResourceInformationDialogComponent); component = fixture.componentInstance; + dialogRef = TestBed.inject(DynamicDialogRef); + config = TestBed.inject(DynamicDialogConfig); }); it('should create', () => { expect(component).toBeTruthy(); }); - it('should have resource type options', () => { - expect(component.resourceTypeOptions).toBeDefined(); - expect(component.resourceTypeOptions.length).toBeGreaterThan(0); - }); - - it('should have language options', () => { - expect(component.languageOptions).toBeDefined(); - expect(component.languageOptions.length).toBeGreaterThan(0); - }); + it('should patch form values on init when metadata is provided', () => { + config.data = { + customItemMetadata: { + resourceTypeGeneral: 'Dataset', + language: 'eng', + }, + }; - it('should not save when form is invalid', () => { - const dialogRef = TestBed.inject(DynamicDialogRef); - const closeSpy = vi.spyOn(dialogRef, 'close'); + component.ngOnInit(); - component.resourceForm.patchValue({ - resourceType: 'dataset', - resourceLanguage: 'en', + expect(component.resourceForm.getRawValue()).toEqual({ + resourceType: 'Dataset', + resourceLanguage: 'eng', }); + }); - component.save(); + it('should keep default empty values on init when metadata is not provided', () => { + config.data = {}; - expect(closeSpy).toHaveBeenCalledWith({ - resourceTypeGeneral: 'dataset', - language: 'en', + component.ngOnInit(); + + expect(component.resourceForm.getRawValue()).toEqual({ + resourceType: '', + resourceLanguage: '', }); }); - it('should not save when resource type is missing', () => { - const dialogRef = TestBed.inject(DynamicDialogRef); - const closeSpy = vi.spyOn(dialogRef, 'close'); - - component.resourceForm.patchValue({ - resourceType: '', - resourceLanguage: 'en', + it('should close dialog with mapped payload on save when form is valid', () => { + component.resourceForm.setValue({ + resourceType: 'JournalArticle', + resourceLanguage: 'deu', }); component.save(); - expect(closeSpy).toHaveBeenCalledWith({ - resourceTypeGeneral: '', - language: 'en', + expect(dialogRef.close).toHaveBeenCalledWith({ + resourceTypeGeneral: 'JournalArticle', + language: 'deu', }); }); - it('should cancel dialog', () => { - const dialogRef = TestBed.inject(DynamicDialogRef); - const closeSpy = vi.spyOn(dialogRef, 'close'); - + it('should close dialog without payload on cancel', () => { component.cancel(); - expect(closeSpy).toHaveBeenCalled(); - }); - - it('should validate required fields', () => { - const resourceTypeControl = component.resourceForm.get('resourceType'); - - expect(resourceTypeControl?.hasError('required')).toBe(false); - - resourceTypeControl?.setValue('dataset'); - - expect(resourceTypeControl?.hasError('required')).toBe(false); - }); - - it('should handle form validation state', () => { - expect(component.resourceForm.valid).toBe(true); - - component.resourceForm.patchValue({ - resourceType: 'dataset', - resourceLanguage: 'en', - }); - - expect(component.resourceForm.valid).toBe(true); + expect(dialogRef.close).toHaveBeenCalledWith(); }); }); diff --git a/src/app/features/metadata/dialogs/resource-information-dialog/resource-information-dialog.component.ts b/src/app/features/metadata/dialogs/resource-information-dialog/resource-information-dialog.component.ts index 51866076d..1a9acf977 100644 --- a/src/app/features/metadata/dialogs/resource-information-dialog/resource-information-dialog.component.ts +++ b/src/app/features/metadata/dialogs/resource-information-dialog/resource-information-dialog.component.ts @@ -7,11 +7,10 @@ import { Select } from 'primeng/select'; import { ChangeDetectionStrategy, Component, inject, OnInit } from '@angular/core'; import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; -import { languageCodes } from '@osf/shared/constants/language.const'; -import { LanguageCodeModel } from '@shared/models/language-code.model'; +import { LANGUAGE_CODES } from '@osf/shared/constants/language.const'; +import { RESOURCE_TYPE_GENERAL_OPTIONS } from '@osf/shared/constants/resource-type-general-options.const'; -import { RESOURCE_TYPE_OPTIONS } from '../../constants'; -import { CustomItemMetadataRecord, ResourceInformationForm } from '../../models'; +import { ResourceInformationForm } from '../../models'; @Component({ selector: 'osf-resource-information-dialog', @@ -28,26 +27,12 @@ export class ResourceInformationDialogComponent implements OnInit { resourceLanguage: new FormControl(''), }); - resourceTypeOptions = RESOURCE_TYPE_OPTIONS; - languageOptions = languageCodes.map((lang: LanguageCodeModel) => ({ - label: lang.name, - value: lang.code, - })); - - get customItemMetadata(): CustomItemMetadataRecord | null { - return this.config.data?.customItemMetadata || null; - } - - get isEditMode(): boolean { - return !!this.customItemMetadata; - } - - getResourceTypeName(resourceType: string): string { - return Object.fromEntries(RESOURCE_TYPE_OPTIONS.map((item) => [item.value, item.label]))[resourceType]; - } + resourceTypeOptions = RESOURCE_TYPE_GENERAL_OPTIONS; + languageOptions = LANGUAGE_CODES; ngOnInit(): void { - const metadata = this.customItemMetadata; + const metadata = this.config.data?.customItemMetadata; + if (metadata) { this.resourceForm.patchValue({ resourceType: metadata.resourceTypeGeneral || '', @@ -57,13 +42,11 @@ export class ResourceInformationDialogComponent implements OnInit { } save(): void { - if (this.resourceForm.valid) { - const formValue = this.resourceForm.getRawValue(); - this.dialogRef.close({ - resourceTypeGeneral: formValue.resourceType, - language: formValue.resourceLanguage, - }); - } + const formValue = this.resourceForm.getRawValue(); + this.dialogRef.close({ + resourceTypeGeneral: formValue.resourceType, + language: formValue.resourceLanguage, + }); } cancel(): void { diff --git a/src/app/features/metadata/store/metadata.selectors.ts b/src/app/features/metadata/store/metadata.selectors.ts index 7ceca5945..a79fa0a61 100644 --- a/src/app/features/metadata/store/metadata.selectors.ts +++ b/src/app/features/metadata/store/metadata.selectors.ts @@ -16,6 +16,11 @@ export class MetadataSelectors { return state.customMetadata?.data ?? null; } + @Selector([MetadataState]) + static isCustomItemMetadataLoading(state: MetadataStateModel) { + return state.customMetadata?.isLoading ?? false; + } + @Selector([MetadataState]) static getLoading(state: MetadataStateModel) { return state.metadata?.isLoading || state.customMetadata?.isLoading || false; 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/general-information/general-information.component.html b/src/app/features/preprints/components/preprint-details/general-information/general-information.component.html index fc2e0539f..26d233842 100644 --- a/src/app/features/preprints/components/preprint-details/general-information/general-information.component.html +++ b/src/app/features/preprints/components/preprint-details/general-information/general-information.component.html @@ -44,18 +44,6 @@

{{ 'preprints.details.supplementalMaterials' | translate }}

} - @if (preprintProviderValue?.assertionsEnabled) { -
-

{{ 'preprints.preprintStepper.review.sections.authorAssertions.conflictOfInterest' | translate }}

- - @if (preprintValue.hasCoi) { - {{ preprintValue.coiStatement }} - } @else { -

{{ 'preprints.preprintStepper.review.sections.authorAssertions.noCoi' | translate }}

- } -
- } - { 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/components/stepper/preprints-metadata-step/preprints-contributors/preprints-contributors.component.html b/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-contributors/preprints-contributors.component.html index 76023ffe3..cee666a6f 100644 --- a/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-contributors/preprints-contributors.component.html +++ b/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-contributors/preprints-contributors.component.html @@ -12,6 +12,7 @@

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

[(contributors)]="contributors" [tableParams]="tableParams()" [isLoading]="isContributorsLoading()" + [showLoadMore]="hasMoreContributors()" [isLoadingMore]="isLoadingMore()" (remove)="removeContributor($event)" (loadMore)="loadMoreContributors()" diff --git a/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-contributors/preprints-contributors.component.ts b/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-contributors/preprints-contributors.component.ts index 5adbe6301..e36795893 100644 --- a/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-contributors/preprints-contributors.component.ts +++ b/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-contributors/preprints-contributors.component.ts @@ -62,6 +62,7 @@ export class PreprintsContributorsComponent implements OnInit { readonly contributors = signal([]); readonly contributorsTotalCount = select(ContributorsSelectors.getContributorsTotalCount); readonly isContributorsLoading = select(ContributorsSelectors.isContributorsLoading); + readonly hasMoreContributors = select(ContributorsSelectors.hasMoreContributors); readonly isLoadingMore = select(ContributorsSelectors.isContributorsLoadingMore); readonly pageSize = select(ContributorsSelectors.getContributorsPageSize); 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/preprints/pages/update-preprint-stepper/update-preprint-stepper.component.html b/src/app/features/preprints/pages/update-preprint-stepper/update-preprint-stepper.component.html index 8a8a4af30..6e19e1bd8 100644 --- a/src/app/features/preprints/pages/update-preprint-stepper/update-preprint-stepper.component.html +++ b/src/app/features/preprints/pages/update-preprint-stepper/update-preprint-stepper.component.html @@ -36,53 +36,26 @@

@switch (currentStep().value) { @case (PreprintSteps.TitleAndAbstract) { - + } @case (PreprintSteps.File) { - + } @case (PreprintSteps.Metadata) { } @case (PreprintSteps.AuthorAssertions) { - + } @case (PreprintSteps.Supplements) { - + } @case (PreprintSteps.Review) { - + } }
diff --git a/src/app/features/preprints/pages/update-preprint-stepper/update-preprint-stepper.component.spec.ts b/src/app/features/preprints/pages/update-preprint-stepper/update-preprint-stepper.component.spec.ts index 69345de2a..e35e056c7 100644 --- a/src/app/features/preprints/pages/update-preprint-stepper/update-preprint-stepper.component.spec.ts +++ b/src/app/features/preprints/pages/update-preprint-stepper/update-preprint-stepper.component.spec.ts @@ -19,10 +19,6 @@ import { provideOSFCore } from '@testing/osf.testing.provider'; import { BrandServiceMock, BrandServiceMockType } from '@testing/providers/brand-service.mock'; import { BrowserTabServiceMock, BrowserTabServiceMockType } from '@testing/providers/browser-tab-service.mock'; import { HeaderStyleServiceMock, HeaderStyleServiceMockType } from '@testing/providers/header-style-service.mock'; -import { - PreprintDraftDeletionServiceMock, - PreprintDraftDeletionServiceMockType, -} from '@testing/providers/preprint-draft-deletion-provider.mock'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; import { mergeSignalOverrides, provideMockStore, SignalOverride } from '@testing/providers/store-provider.mock'; @@ -35,14 +31,8 @@ import { TitleAndAbstractStepComponent } from '../../components/stepper/title-an import { submitPreprintSteps } from '../../constants'; import { PreprintSteps, ReviewsState } from '../../enums'; import { PreprintProviderDetails } from '../../models'; -import { PreprintDraftDeletionService } from '../../services/preprint-draft-deletion.service'; import { GetPreprintProviderById, PreprintProvidersSelectors } from '../../store/preprint-providers'; -import { - DeletePreprint, - FetchPreprintById, - PreprintStepperSelectors, - ResetPreprintStepperState, -} from '../../store/preprint-stepper'; +import { FetchPreprintById, PreprintStepperSelectors, ResetPreprintStepperState } from '../../store/preprint-stepper'; import { UpdatePreprintStepperComponent } from './update-preprint-stepper.component'; @@ -53,7 +43,6 @@ describe('UpdatePreprintStepperComponent', () => { let brandServiceMock: BrandServiceMockType; let headerStyleMock: HeaderStyleServiceMockType; let browserTabMock: BrowserTabServiceMockType; - let draftDeletionMock: PreprintDraftDeletionServiceMockType; const mockProvider: PreprintProviderDetails = PREPRINT_PROVIDER_DETAILS_MOCK; const mockPreprint = PREPRINT_MOCK; @@ -78,7 +67,6 @@ describe('UpdatePreprintStepperComponent', () => { brandServiceMock = BrandServiceMock.simple(); headerStyleMock = HeaderStyleServiceMock.simple(); browserTabMock = BrowserTabServiceMock.simple(); - draftDeletionMock = PreprintDraftDeletionServiceMock.simple(); TestBed.configureTestingModule({ imports: [ @@ -104,12 +92,6 @@ describe('UpdatePreprintStepperComponent', () => { ], }); - TestBed.overrideComponent(UpdatePreprintStepperComponent, { - set: { - providers: [{ provide: PreprintDraftDeletionService, useValue: draftDeletionMock }], - }, - }); - store = TestBed.inject(Store); fixture = TestBed.createComponent(UpdatePreprintStepperComponent); component = fixture.componentInstance; @@ -325,44 +307,4 @@ describe('UpdatePreprintStepperComponent', () => { expect(component.currentStep()).toEqual(firstStep); }); - - it('should return false from isPreprintRejected when preprint is not rejected', () => { - setup(); - - expect(component.isPreprintRejected()).toBe(false); - }); - - it('should return true from isPreprintRejected when preprint is rejected', () => { - setup({ - selectorOverrides: [ - { - selector: PreprintStepperSelectors.getPreprint, - value: { ...mockPreprint, reviewsState: ReviewsState.Rejected }, - }, - ], - }); - - expect(component.isPreprintRejected()).toBe(true); - }); - - it('should request draft deletion with update redirect and action callbacks', () => { - setup(); - - component.requestDeletePreprint(); - - expect(draftDeletionMock.confirmDeleteDraft).toHaveBeenCalledWith( - expect.objectContaining({ - redirectUrl: '/my-preprints', - onDelete: expect.any(Function), - onReset: expect.any(Function), - }) - ); - - const { onDelete, onReset } = draftDeletionMock.confirmDeleteDraft.mock.calls[0][0]; - onDelete(); - onReset(); - - expect(store.dispatch).toHaveBeenCalledWith(new DeletePreprint()); - expect(store.dispatch).toHaveBeenCalledWith(new ResetPreprintStepperState()); - }); }); diff --git a/src/app/features/preprints/pages/update-preprint-stepper/update-preprint-stepper.component.ts b/src/app/features/preprints/pages/update-preprint-stepper/update-preprint-stepper.component.ts index 16b98abb5..3ea582ae1 100644 --- a/src/app/features/preprints/pages/update-preprint-stepper/update-preprint-stepper.component.ts +++ b/src/app/features/preprints/pages/update-preprint-stepper/update-preprint-stepper.component.ts @@ -36,7 +36,6 @@ import { SupplementsStepComponent } from '../../components/stepper/supplements-s import { TitleAndAbstractStepComponent } from '../../components/stepper/title-and-abstract-step/title-and-abstract-step.component'; import { submitPreprintSteps } from '../../constants'; import { PreprintSteps, ProviderReviewsWorkflow, ReviewsState } from '../../enums'; -import { PreprintDraftDeletionService } from '../../services/preprint-draft-deletion.service'; import { GetPreprintProviderById, PreprintProvidersSelectors } from '../../store/preprint-providers'; import { DeletePreprint, @@ -61,7 +60,6 @@ import { templateUrl: './update-preprint-stepper.component.html', styleUrl: './update-preprint-stepper.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, - providers: [PreprintDraftDeletionService], }) export class UpdatePreprintStepperComponent implements OnDestroy, CanDeactivateComponent { @HostBinding('class') classes = 'flex-1 flex flex-column w-full'; @@ -70,39 +68,36 @@ export class UpdatePreprintStepperComponent implements OnDestroy, CanDeactivateC private readonly brandService = inject(BrandService); private readonly headerStyleHelper = inject(HeaderStyleService); private readonly browserTabHelper = inject(BrowserTabService); - private readonly draftDeletionService = inject(PreprintDraftDeletionService); - private providerId = toSignal(this.route.params.pipe(map((params) => params['providerId']))); - private preprintId = toSignal(this.route.params.pipe(map((params) => params['preprintId']))); + private readonly providerId = toSignal(this.route.params.pipe(map((params) => params['providerId']))); + private readonly preprintId = toSignal(this.route.params.pipe(map((params) => params['preprintId']))); - private actions = createDispatchMap({ + private readonly actions = createDispatchMap({ getPreprintProviderById: GetPreprintProviderById, resetState: ResetPreprintStepperState, fetchPreprint: FetchPreprintById, deletePreprint: DeletePreprint, }); - preprintProvider = select(PreprintProvidersSelectors.getPreprintProviderDetails(this.providerId())); - preprint = select(PreprintStepperSelectors.getPreprint); - isPreprintProviderLoading = select(PreprintProvidersSelectors.isPreprintProviderDetailsLoading); - hasBeenSubmitted = select(PreprintStepperSelectors.hasBeenSubmitted); - hasAdminAccess = select(PreprintStepperSelectors.hasAdminAccess); + readonly preprintProvider = select(PreprintProvidersSelectors.getPreprintProviderDetails(this.providerId())); + readonly preprint = select(PreprintStepperSelectors.getPreprint); + readonly isPreprintProviderLoading = select(PreprintProvidersSelectors.isPreprintProviderDetailsLoading); + readonly hasBeenSubmitted = select(PreprintStepperSelectors.hasBeenSubmitted); + readonly hasAdminAccess = select(PreprintStepperSelectors.hasAdminAccess); - isWeb = toSignal(inject(IS_WEB)); + readonly isWeb = toSignal(inject(IS_WEB)); - currentStep = signal(submitPreprintSteps[0]); + readonly currentStep = signal(submitPreprintSteps[0]); readonly PreprintSteps = PreprintSteps; - editAndResubmitMode = computed(() => { + readonly editAndResubmitMode = computed(() => { const providerIsPremod = this.preprintProvider()?.reviewsWorkflow === ProviderReviewsWorkflow.PreModeration; const preprintIsRejected = this.preprint()?.reviewsState === ReviewsState.Rejected; return providerIsPremod && preprintIsRejected; }); - isPreprintRejected = computed(() => this.preprint()?.reviewsState === ReviewsState.Rejected); - readonly updateSteps = computed(() => { const provider = this.preprintProvider(); const preprint = this.preprint(); @@ -184,12 +179,4 @@ export class UpdatePreprintStepperComponent implements OnDestroy, CanDeactivateC this.currentStep.set(prevStep); } } - - requestDeletePreprint(): void { - this.draftDeletionService.confirmDeleteDraft({ - onDelete: () => this.actions.deletePreprint(), - onReset: () => this.actions.resetState(), - redirectUrl: '/my-preprints', - }); - } } 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/components/project-overview-metadata/project-overview-metadata.component.html b/src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.html index 8c9b34ff6..dcb6c35d8 100644 --- a/src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.html +++ b/src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.html @@ -1,6 +1,8 @@ @let resource = currentProject(); @if (resource) { + @let customMetadata = customItemMetadata(); + +
+

{{ 'project.overview.metadata.resourceType' | translate }}

+ +

+ {{ + customMetadata?.resourceTypeGeneral + ? (customMetadata?.resourceTypeGeneral | resourceTypeGeneralLabel) + : ('project.overview.metadata.noResourceType' | translate) + }} +

+
+

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

@@ -37,15 +51,6 @@

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

/>
-
-

{{ 'project.overview.metadata.supplements' | translate }}

- - -
-

{{ 'project.overview.metadata.dateCreated' | translate }}

@@ -59,18 +64,16 @@

{{ 'project.overview.metadata.dateUpdated' | translate }}

-

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

+

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

- +
- @if (!isAnonymous()) { -
-

{{ 'project.overview.metadata.projectDOI' | translate }}

+
+

{{ 'shared.tags.title' | translate }}

- -
- } + +
@if (!isAnonymous()) {
@@ -86,17 +89,46 @@

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

>
-

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

+

{{ 'project.overview.metadata.supplements' | translate }}

- +
-

{{ 'shared.tags.title' | translate }}

+

{{ 'project.overview.metadata.funderNames' | translate }}

- + +
+ +
+

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

+ +

+ {{ + customMetadata?.language + ? (customMetadata?.language | languageLabel) + : ('project.overview.metadata.noLanguage' | translate) + }} +

+
+ +
+

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

+ +
+ @if (!isAnonymous()) { +
+

{{ 'project.overview.metadata.projectDOI' | translate }}

+ + +
+ } + @if (!isAnonymous()) { { ResourceLicenseComponent, SubjectsListComponent, TagsListComponent, - OverviewSupplementsComponent + OverviewSupplementsComponent, + FundersListComponent ), ], providers: [ @@ -94,6 +98,8 @@ describe('ProjectOverviewMetadataComponent', () => { { selector: ContributorsSelectors.hasMoreBibliographicContributors, value: false }, { selector: CollectionsSelectors.getCurrentProjectSubmissions, value: [] }, { selector: CollectionsSelectors.getCurrentProjectSubmissionsLoading, value: false }, + { selector: MetadataSelectors.getCustomItemMetadata, value: null }, + { selector: MetadataSelectors.isCustomItemMetadataLoading, value: false }, ], }), ], @@ -121,6 +127,7 @@ describe('ProjectOverviewMetadataComponent', () => { expect(dispatchMock).toHaveBeenCalledWith(new GetProjectPreprints('project-1')); expect(dispatchMock).toHaveBeenCalledWith(new FetchSelectedSubjects('project-1', ResourceType.Project)); expect(dispatchMock).toHaveBeenCalledWith(new GetProjectSubmissions('project-1')); + expect(dispatchMock).toHaveBeenCalledWith(new GetCustomItemMetadata('project-1')); expect(dispatchMock).toHaveBeenCalledWith(new GetProjectLicense(MOCK_PROJECT_OVERVIEW.licenseId)); }); diff --git a/src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.ts b/src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.ts index 40e0507ae..bb5d2571c 100644 --- a/src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.ts +++ b/src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.ts @@ -8,8 +8,10 @@ import { DatePipe } from '@angular/common'; import { ChangeDetectionStrategy, Component, effect, inject } from '@angular/core'; import { Router, RouterLink } from '@angular/router'; +import { GetCustomItemMetadata, MetadataSelectors } from '@osf/features/metadata/store'; import { AffiliatedInstitutionsViewComponent } from '@osf/shared/components/affiliated-institutions-view/affiliated-institutions-view.component'; import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component'; +import { FundersListComponent } from '@osf/shared/components/funders-list/funders-list.component'; import { ResourceCitationsComponent } from '@osf/shared/components/resource-citations/resource-citations.component'; import { ResourceDoiComponent } from '@osf/shared/components/resource-doi/resource-doi.component'; import { ResourceLicenseComponent } from '@osf/shared/components/resource-license/resource-license.component'; @@ -17,6 +19,8 @@ import { SubjectsListComponent } from '@osf/shared/components/subjects-list/subj import { TagsListComponent } from '@osf/shared/components/tags-list/tags-list.component'; import { TruncatedTextComponent } from '@osf/shared/components/truncated-text/truncated-text.component'; import { CurrentResourceType, ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { LanguageLabelPipe } from '@osf/shared/pipes/language-label.pipe'; +import { ResourceTypeGeneralLabelPipe } from '@osf/shared/pipes/resource-type-general-label.pipe'; import { CollectionsSelectors, GetProjectSubmissions } from '@osf/shared/stores/collections'; import { ContributorsSelectors, @@ -48,11 +52,14 @@ import { OverviewSupplementsComponent } from '../overview-supplements/overview-s OverviewCollectionsComponent, AffiliatedInstitutionsViewComponent, ContributorsListComponent, + FundersListComponent, ResourceDoiComponent, ResourceLicenseComponent, SubjectsListComponent, TagsListComponent, OverviewSupplementsComponent, + LanguageLabelPipe, + ResourceTypeGeneralLabelPipe, ], templateUrl: './project-overview-metadata.component.html', styleUrl: './project-overview-metadata.component.scss', @@ -64,6 +71,8 @@ export class ProjectOverviewMetadataComponent { readonly currentProject = select(ProjectOverviewSelectors.getProject); readonly isAnonymous = select(ProjectOverviewSelectors.isProjectAnonymous); readonly canEdit = select(ProjectOverviewSelectors.hasWriteAccess); + readonly customItemMetadata = select(MetadataSelectors.getCustomItemMetadata); + readonly isCustomItemMetadataLoading = select(MetadataSelectors.isCustomItemMetadataLoading); readonly institutions = select(ProjectOverviewSelectors.getInstitutions); readonly isInstitutionsLoading = select(ProjectOverviewSelectors.isInstitutionsLoading); readonly identifiers = select(ProjectOverviewSelectors.getIdentifiers); @@ -91,6 +100,7 @@ export class ProjectOverviewMetadataComponent { setCustomCitation: SetProjectCustomCitation, getSubjects: FetchSelectedSubjects, getProjectSubmissions: GetProjectSubmissions, + getCustomItemMetadata: GetCustomItemMetadata, getBibliographicContributors: GetBibliographicContributors, loadMoreBibliographicContributors: LoadMoreBibliographicContributors, }); @@ -107,6 +117,7 @@ export class ProjectOverviewMetadataComponent { this.actions.getSubjects(project.id, ResourceType.Project); this.actions.getProjectSubmissions(project.id); this.actions.getLicense(project.licenseId); + this.actions.getCustomItemMetadata(project.id); } }); } 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 3424906dd..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 @@ -168,7 +168,7 @@

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

[label]="file.name" severity="info" removable="true" - (onRemove)="removeFromAttachedFiles(file, q.responseKey!)" + (onRemove)="removeFromAttachedFiles(file.file_id, q.responseKey!)" /> }
@@ -177,9 +177,10 @@

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

[attachedFiles]="attachedFiles[q.responseKey!]" [filesLink]="filesLink()" [projectId]="projectId()" - [provider]="provider()" - (attachFile)="onAttachFile($event, q.responseKey!)" [filesViewOnly]="filesViewOnly()" + (attachFile)="onAttachFile($event, q.responseKey!)" + (openFile)="onOpenFile($event)" + (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 b17f69381..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 }; } @@ -213,17 +217,42 @@ describe('CustomStepComponent', () => { { file_id: 'f2', name: 'b' }, ]; - component.removeFromAttachedFiles({ file_id: 'f1', name: 'a' }, 'field1'); + component.removeFromAttachedFiles('f1', 'field1'); expect(component.attachedFiles['field1'].length).toBe(1); expect(component.attachedFiles['field1'][0].file_id).toBe('f2'); 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'); - component.removeFromAttachedFiles({ file_id: 'f1' }, 'nonexistent'); + component.removeFromAttachedFiles('f1', 'nonexistent'); expect(emitSpy).not.toHaveBeenCalled(); }); 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 357bc71b5..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 @@ -98,6 +97,7 @@ export class CustomStepComponent implements OnDestroy { readonly INPUT_VALIDATION_MESSAGES = INPUT_VALIDATION_MESSAGES; step = signal(this.route.snapshot.params['step']); + draftId = signal(this.route.snapshot.params['id']); currentPage = computed(() => this.pages()[this.step() - 1]); stepForm: FormGroup = this.fb.group({}); @@ -135,12 +135,19 @@ export class CustomStepComponent implements OnDestroy { }); } - removeFromAttachedFiles(file: AttachedFile, questionKey: string): void { + onOpenFile(file: FileModel): void { + if (this.draftId() && file.guid) { + const url = this.router.serializeUrl(this.router.createUrlTree([this.draftId(), 'files', file.guid, 'preview'])); + window.open(url, '_blank'); + } + } + + removeFromAttachedFiles(fileId: string | undefined, questionKey: string): void { if (!this.attachedFiles[questionKey]) { return; } - this.attachedFiles[questionKey] = this.attachedFiles[questionKey].filter((f) => f.file_id !== file.file_id); + this.attachedFiles[questionKey] = this.attachedFiles[questionKey].filter((f) => f.file_id !== fileId); this.stepForm.patchValue({ [questionKey]: this.attachedFiles[questionKey] }); this.updateAction.emit({ [questionKey]: this.mapFilesToPayload(this.attachedFiles[questionKey]), 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 8d3350ae2..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,21 +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 874ea26d7..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); @@ -228,4 +218,19 @@ describe('FilesControlComponent', () => { expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(SetFilesIsLoading)); expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(GetFiles)); }); + + it('should delete entry, show success toast, refresh files, and emit removal', () => { + const file = { id: 'file-1', links: { delete: '/delete-link' } } as FileModel; + const emitSpy = vi.spyOn(component.removeFromAttachedFiles, 'emit'); + const toastSpy = vi.spyOn(toastService, 'showSuccess'); + (store.dispatch as Mock).mockClear(); + + component.deleteFile(file); + + expect(store.dispatch).toHaveBeenCalledWith(new DeleteDraftRegistrationFiles('/delete-link')); + expect(toastSpy).toHaveBeenCalledWith('files.dialogs.deleteFile.success'); + 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 423a65d45..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,20 +10,25 @@ 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'; import { CreateFolder, + DeleteDraftRegistrationFiles, GetFiles, GetRootFolders, RegistriesSelectors, @@ -45,17 +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); @@ -68,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, @@ -78,6 +83,7 @@ export class FilesControlComponent { setFilesIsLoading: SetFilesIsLoading, setCurrentFolder: SetRegistriesCurrentFolder, getRootFolders: GetRootFolders, + deleteDraftRegistrationFiles: DeleteDraftRegistrationFiles, }); constructor() { @@ -85,6 +91,14 @@ export class FilesControlComponent { this.setupCurrentFolderWatcher(); } + deleteFile(file: FileModel): void { + this.actions.deleteDraftRegistrationFiles(file?.links.delete).subscribe(() => { + this.toastService.showSuccess('files.dialogs.deleteFile.success'); + this.refreshFilesList(); + this.removeFromAttachedFiles.emit(file.id); + }); + } + onFileSelected(event: Event): void { const input = event.target as HTMLInputElement; const file = input.files?.[0]; @@ -117,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; @@ -153,17 +172,17 @@ export class FilesControlComponent { }); } + onEntryFileClicked(file: FileModel): void { + this.selectFile(file); + this.openFile.emit(file); + } + selectFile(file: FileModel): void { if (this.filesViewOnly()) return; 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/components/new-registration/new-registration.component.scss b/src/app/features/registries/components/new-registration/new-registration.component.scss index f22ca8981..ab9f13057 100644 --- a/src/app/features/registries/components/new-registration/new-registration.component.scss +++ b/src/app/features/registries/components/new-registration/new-registration.component.scss @@ -3,4 +3,5 @@ flex-direction: column; flex: 1; background-color: var(--white); + height: 100%; } diff --git a/src/app/features/registries/components/registries-metadata-step/registries-contributors/registries-contributors.component.html b/src/app/features/registries/components/registries-metadata-step/registries-contributors/registries-contributors.component.html index 6006754c0..725d9005b 100644 --- a/src/app/features/registries/components/registries-metadata-step/registries-contributors/registries-contributors.component.html +++ b/src/app/features/registries/components/registries-metadata-step/registries-contributors/registries-contributors.component.html @@ -6,6 +6,7 @@

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

[(contributors)]="contributors" [tableParams]="tableParams()" [isLoading]="isContributorsLoading()" + [showLoadMore]="hasMoreContributors()" [isLoadingMore]="isLoadingMore()" (remove)="removeContributor($event)" (loadMore)="loadMoreContributors()" diff --git a/src/app/features/registries/components/registries-metadata-step/registries-contributors/registries-contributors.component.ts b/src/app/features/registries/components/registries-metadata-step/registries-contributors/registries-contributors.component.ts index a6fce3850..833e2dea5 100644 --- a/src/app/features/registries/components/registries-metadata-step/registries-contributors/registries-contributors.component.ts +++ b/src/app/features/registries/components/registries-metadata-step/registries-contributors/registries-contributors.component.ts @@ -66,6 +66,7 @@ export class RegistriesContributorsComponent implements OnInit, OnDestroy { isContributorsLoading = select(ContributorsSelectors.isContributorsLoading); contributorsTotalCount = select(ContributorsSelectors.getContributorsTotalCount); + hasMoreContributors = select(ContributorsSelectors.hasMoreContributors); isLoadingMore = select(ContributorsSelectors.isContributorsLoadingMore); pageSize = select(ContributorsSelectors.getContributorsPageSize); diff --git a/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.spec.ts b/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.spec.ts index abdc2a725..8fdba2575 100644 --- a/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.spec.ts +++ b/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.spec.ts @@ -37,6 +37,7 @@ describe('RegistryProviderHeroComponent', () => { iri: '', reviewsWorkflow: '', allowSubmissions: false, + allowUpdates: true, }; beforeEach(() => { 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/justification/justification.component.ts b/src/app/features/registries/pages/justification/justification.component.ts index 610dfd668..96ddf6e12 100644 --- a/src/app/features/registries/pages/justification/justification.component.ts +++ b/src/app/features/registries/pages/justification/justification.component.ts @@ -165,23 +165,20 @@ export class JustificationComponent implements OnDestroy { private initStepValidation(): void { effect(() => { - const currentIndex = this.currentStepIndex(); - const pages = this.pages(); - const revisionData = this.schemaResponseRevisionData(); const stepState = untracked(() => this.stepsState()); - if (currentIndex > 0) { + if (this.currentStepIndex() > 0) { this.actions.updateStepState('0', true, stepState?.[0]?.touched || false); } - if (pages.length && currentIndex > 0 && revisionData) { - for (let i = 1; i < currentIndex; i++) { - const pageStep = pages[i - 1]; + if (this.pages().length && this.currentStepIndex() > 0 && this.schemaResponseRevisionData()) { + for (let i = 1; i < this.currentStepIndex(); i++) { + const pageStep = this.pages()[i - 1]; const isStepInvalid = pageStep?.questions?.some((question) => { - const questionData = revisionData[question.responseKey!]; + const questionData = this.schemaResponseRevisionData()[question.responseKey!]; return question.required && (Array.isArray(questionData) ? !questionData.length : !questionData); - }) || false; + }) ?? false; this.actions.updateStepState(i.toString(), isStepInvalid, stepState?.[i]?.touched || false); } } diff --git a/src/app/features/registries/pages/my-registrations/my-registrations.component.html b/src/app/features/registries/pages/my-registrations/my-registrations.component.html index 234d32b0d..b389c5af8 100644 --- a/src/app/features/registries/pages/my-registrations/my-registrations.component.html +++ b/src/app/features/registries/pages/my-registrations/my-registrations.component.html @@ -58,7 +58,7 @@ - +
@if (isSubmittedRegistrationsLoading()) { @for (item of skeletons; track $index) { diff --git a/src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.spec.ts b/src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.spec.ts index e30b7d3c0..2e2fe0908 100644 --- a/src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.spec.ts +++ b/src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.spec.ts @@ -36,6 +36,7 @@ const MOCK_PROVIDER: RegistryProviderDetails = { iri: 'http://iri.example.com', reviewsWorkflow: 'pre-moderation', allowSubmissions: true, + allowUpdates: true, }; describe('RegistriesProviderSearchComponent', () => { 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/registries/store/handlers/files.handlers.ts b/src/app/features/registries/store/handlers/files.handlers.ts index e6b5d4f38..615386a72 100644 --- a/src/app/features/registries/store/handlers/files.handlers.ts +++ b/src/app/features/registries/store/handlers/files.handlers.ts @@ -7,7 +7,7 @@ import { inject, Injectable } from '@angular/core'; import { handleSectionError } from '@osf/shared/helpers/state-error.handler'; import { FilesService } from '@osf/shared/services/files.service'; -import { CreateFolder, GetFiles, GetRootFolders } from '../registries.actions'; +import { CreateFolder, DeleteDraftRegistrationFiles, GetFiles, GetRootFolders } from '../registries.actions'; import { RegistriesStateModel } from '../registries.model'; @Injectable() @@ -70,4 +70,13 @@ export class FilesHandlers { .createFolder(action.newFolderLink, action.folderName) .pipe(finalize(() => ctx.patchState({ files: { ...state.files, isLoading: false, error: null } }))); } + + deleteDraftRegistrationFiles(ctx: StateContext, action: DeleteDraftRegistrationFiles) { + const state = ctx.getState(); + ctx.patchState({ files: { ...state.files, isLoading: true, error: null } }); + + return this.filesService + .deleteEntry(action.link) + .pipe(finalize(() => ctx.patchState({ files: { ...state.files, isLoading: false, error: null } }))); + } } diff --git a/src/app/features/registries/store/registries.actions.ts b/src/app/features/registries/store/registries.actions.ts index 45db0f8f9..25a553773 100644 --- a/src/app/features/registries/store/registries.actions.ts +++ b/src/app/features/registries/store/registries.actions.ts @@ -135,6 +135,12 @@ export class GetFiles { ) {} } +export class DeleteDraftRegistrationFiles { + static readonly type = '[Registries] Delete Draft Registration Files'; + + constructor(public link: string) {} +} + export class SetFilesIsLoading { static readonly type = '[Registries] Set Files Loading'; diff --git a/src/app/features/registries/store/registries.state.ts b/src/app/features/registries/store/registries.state.ts index d24602bbf..c2373159c 100644 --- a/src/app/features/registries/store/registries.state.ts +++ b/src/app/features/registries/store/registries.state.ts @@ -23,6 +23,7 @@ import { CreateFolder, CreateSchemaResponse, DeleteDraft, + DeleteDraftRegistrationFiles, DeleteSchemaResponse, FetchAllSchemaResponses, FetchDraft, @@ -351,6 +352,11 @@ export class RegistriesState { return this.filesHandlers.getProjectFiles(ctx, { filesLink, page }); } + @Action(DeleteDraftRegistrationFiles) + deleteDraftRegistrationFiles(ctx: StateContext, action: DeleteDraftRegistrationFiles) { + return this.filesHandlers.deleteDraftRegistrationFiles(ctx, action); + } + @Action(GetRootFolders) getRootFolders(ctx: StateContext, action: GetRootFolders) { return this.filesHandlers.getRootFolders(ctx, action); diff --git a/src/app/features/registry/components/registry-overview-metadata/registry-overview-metadata.component.html b/src/app/features/registry/components/registry-overview-metadata/registry-overview-metadata.component.html index 6e9a32ebc..b28e15dc8 100644 --- a/src/app/features/registry/components/registry-overview-metadata/registry-overview-metadata.component.html +++ b/src/app/features/registry/components/registry-overview-metadata/registry-overview-metadata.component.html @@ -30,6 +30,18 @@

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

+
+

{{ 'project.overview.metadata.resourceType' | translate }}

+ +

+ {{ + customItemMetadata()?.resourceTypeGeneral + ? (customItemMetadata()?.resourceTypeGeneral | resourceTypeGeneralLabel) + : ('project.overview.metadata.noResourceType' | translate) + }} +

+
+

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

@@ -50,6 +62,68 @@

{{ 'registry.overview.metadata.registry' | translate }}

{{ registryProvider()?.name }}

+
+
+

{{ 'project.overview.metadata.dateCreated' | translate }}

+

{{ resource.dateCreated | date: dateFormat }}

+
+ +
+

{{ 'registry.overview.metadata.registeredDate' | translate }}

+

{{ resource.dateRegistered | date: dateFormat }}

+
+
+ +
+

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

+ + +
+ +
+

{{ 'shared.tags.title' | translate }}

+ + +
+ + @if (!isAnonymous()) { +
+

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

+ + +
+ } + +
+

{{ 'project.overview.metadata.funderNames' | translate }}

+ + +
+ +
+

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

+ +

+ {{ + customItemMetadata()?.language + ? (customItemMetadata()?.language | languageLabel) + : ('project.overview.metadata.noLanguage' | translate) + }} +

+
+ @if (resource.associatedProjectId) {

{{ 'registry.overview.metadata.associatedProject' | translate }}

@@ -64,17 +138,13 @@

{{ 'registry.overview.metadata.associatedProject' | translate }}

} -
+ @if (resource.iaUrl) {
-

{{ 'project.overview.metadata.dateCreated' | translate }}

-

{{ resource.dateCreated | date: dateFormat }}

-
+

{{ 'project.overview.metadata.internetArchiveLink' | translate }}

-
-

{{ 'registry.overview.metadata.registeredDate' | translate }}

-

{{ resource.dateRegistered | date: dateFormat }}

+
{{ resource.iaUrl }}
-
+ }

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

@@ -86,14 +156,6 @@

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

>
- @if (resource.iaUrl) { -
-

{{ 'project.overview.metadata.internetArchiveLink' | translate }}

- -
{{ resource.iaUrl }}
-
- } - @if (!isAnonymous()) {

{{ 'registry.overview.metadata.doi' | translate }}

@@ -106,38 +168,6 @@

{{ 'registry.overview.metadata.doi' | translate }}

} - @if (!isAnonymous()) { -
-

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

- - -
- } - -
-

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

- - -
- -
-

{{ 'shared.tags.title' | translate }}

- - -
- @if (!isAnonymous()) { { expect(store.dispatch).toHaveBeenCalledWith(expect.any(FetchSelectedSubjects)); expect(store.dispatch).toHaveBeenCalledWith(expect.any(GetRegistryLicense)); expect(store.dispatch).toHaveBeenCalledWith(expect.any(GetRegistryIdentifiers)); + expect(store.dispatch).toHaveBeenCalledWith(expect.any(GetCustomItemMetadata)); }); it('should not dispatch init actions when registry is null', () => { diff --git a/src/app/features/registry/components/registry-overview-metadata/registry-overview-metadata.component.ts b/src/app/features/registry/components/registry-overview-metadata/registry-overview-metadata.component.ts index 0fa1ff01a..38a43b958 100644 --- a/src/app/features/registry/components/registry-overview-metadata/registry-overview-metadata.component.ts +++ b/src/app/features/registry/components/registry-overview-metadata/registry-overview-metadata.component.ts @@ -9,8 +9,10 @@ import { ChangeDetectionStrategy, Component, effect, inject } from '@angular/cor import { Router, RouterLink } from '@angular/router'; import { ENVIRONMENT } from '@core/provider/environment.provider'; +import { GetCustomItemMetadata, MetadataSelectors } from '@osf/features/metadata/store'; import { AffiliatedInstitutionsViewComponent } from '@osf/shared/components/affiliated-institutions-view/affiliated-institutions-view.component'; import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component'; +import { FundersListComponent } from '@osf/shared/components/funders-list/funders-list.component'; import { ResourceCitationsComponent } from '@osf/shared/components/resource-citations/resource-citations.component'; import { ResourceDoiComponent } from '@osf/shared/components/resource-doi/resource-doi.component'; import { ResourceLicenseComponent } from '@osf/shared/components/resource-license/resource-license.component'; @@ -18,6 +20,8 @@ import { SubjectsListComponent } from '@osf/shared/components/subjects-list/subj import { TagsListComponent } from '@osf/shared/components/tags-list/tags-list.component'; import { TruncatedTextComponent } from '@osf/shared/components/truncated-text/truncated-text.component'; import { CurrentResourceType, ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { LanguageLabelPipe } from '@osf/shared/pipes/language-label.pipe'; +import { ResourceTypeGeneralLabelPipe } from '@osf/shared/pipes/resource-type-general-label.pipe'; import { ContributorsSelectors, LoadMoreBibliographicContributors } from '@osf/shared/stores/contributors'; import { RegistrationProviderSelectors } from '@osf/shared/stores/registration-provider'; import { FetchSelectedSubjects, SubjectsSelectors } from '@osf/shared/stores/subjects'; @@ -43,8 +47,11 @@ import { ResourceLicenseComponent, AffiliatedInstitutionsViewComponent, ContributorsListComponent, + FundersListComponent, SubjectsListComponent, TagsListComponent, + LanguageLabelPipe, + ResourceTypeGeneralLabelPipe, ], templateUrl: './registry-overview-metadata.component.html', styleUrl: './registry-overview-metadata.component.scss', @@ -57,6 +64,8 @@ export class RegistryOverviewMetadataComponent { readonly registry = select(RegistrySelectors.getRegistry); readonly registryProvider = select(RegistrationProviderSelectors.getBrandedProvider); readonly isAnonymous = select(RegistrySelectors.isRegistryAnonymous); + readonly customItemMetadata = select(MetadataSelectors.getCustomItemMetadata); + readonly isCustomItemMetadataLoading = select(MetadataSelectors.isCustomItemMetadataLoading); canEdit = select(RegistrySelectors.hasWriteAccess); license = select(RegistrySelectors.getLicense); @@ -81,6 +90,7 @@ export class RegistryOverviewMetadataComponent { getInstitutions: GetRegistryInstitutions, getIdentifiers: GetRegistryIdentifiers, getLicense: GetRegistryLicense, + getCustomItemMetadata: GetCustomItemMetadata, setCustomCitation: SetRegistryCustomCitation, loadMoreBibliographicContributors: LoadMoreBibliographicContributors, }); @@ -94,6 +104,7 @@ export class RegistryOverviewMetadataComponent { this.actions.getSubjects(registry.id, ResourceType.Registration); this.actions.getLicense(registry.licenseId); this.actions.getIdentifiers(registry.id); + this.actions.getCustomItemMetadata(registry.id); } }); } diff --git a/src/app/features/registry/components/registry-revisions/registry-revisions.component.html b/src/app/features/registry/components/registry-revisions/registry-revisions.component.html index 794707631..6a50baf07 100644 --- a/src/app/features/registry/components/registry-revisions/registry-revisions.component.html +++ b/src/app/features/registry/components/registry-revisions/registry-revisions.component.html @@ -31,7 +31,7 @@ diff --git a/src/app/features/registry/components/registry-revisions/registry-revisions.component.spec.ts b/src/app/features/registry/components/registry-revisions/registry-revisions.component.spec.ts index 24b0310b9..47d0b876a 100644 --- a/src/app/features/registry/components/registry-revisions/registry-revisions.component.spec.ts +++ b/src/app/features/registry/components/registry-revisions/registry-revisions.component.spec.ts @@ -168,6 +168,28 @@ describe('RegistryRevisionsComponent', () => { expect(spy).toHaveBeenCalledWith(1); }); + it('should emit updateRegistration with registry id on startUpdateRegistration', () => { + const { component } = setup(); + const spy = vi.fn(); + component.updateRegistration.subscribe(spy); + + component.startUpdateRegistration(); + + expect(spy).toHaveBeenCalledWith(MOCK_REGISTRY.id); + }); + + it('should not emit updateRegistration when registry id is missing on startUpdateRegistration', () => { + const { fixture, component } = setup(); + const spy = vi.fn(); + component.updateRegistration.subscribe(spy); + fixture.componentRef.setInput('registry', { ...MOCK_REGISTRY, id: '' }); + fixture.detectChanges(); + + component.startUpdateRegistration(); + + expect(spy).not.toHaveBeenCalled(); + }); + it('should emit continueUpdate on continueUpdateHandler', () => { const { component } = setup(); const spy = vi.fn(); diff --git a/src/app/features/registry/components/registry-revisions/registry-revisions.component.ts b/src/app/features/registry/components/registry-revisions/registry-revisions.component.ts index 46560bbcf..1a88fab9e 100644 --- a/src/app/features/registry/components/registry-revisions/registry-revisions.component.ts +++ b/src/app/features/registry/components/registry-revisions/registry-revisions.component.ts @@ -75,6 +75,16 @@ export class RegistryRevisionsComponent { }); }); + startUpdateRegistration() { + const registryId = this.registry()?.id; + + if (!registryId) { + return; + } + + this.updateRegistration.emit(registryId); + } + emitOpenRevision(index: number) { this.openRevision.emit(index); } diff --git a/src/app/features/registry/pages/registry-overview/registry-overview.component.html b/src/app/features/registry/pages/registry-overview/registry-overview.component.html index b7d67b3ca..4712f0918 100644 --- a/src/app/features/registry/pages/registry-overview/registry-overview.component.html +++ b/src/app/features/registry/pages/registry-overview/registry-overview.component.html @@ -84,7 +84,7 @@

(continueUpdate)="onContinueUpdateRegistration()" [isModeration]="isModeration()" [isSubmitting]="isSchemaResponsesLoading()" - [canEdit]="hasAdminAccess()" + [canEdit]="canUpdate()" >

diff --git a/src/app/features/registry/pages/registry-overview/registry-overview.component.spec.ts b/src/app/features/registry/pages/registry-overview/registry-overview.component.spec.ts index d4626ee13..d8833dd28 100644 --- a/src/app/features/registry/pages/registry-overview/registry-overview.component.spec.ts +++ b/src/app/features/registry/pages/registry-overview/registry-overview.component.spec.ts @@ -20,6 +20,7 @@ import { SchemaResponse } from '@osf/shared/models/registration/schema-response. import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { ToastService } from '@osf/shared/services/toast.service'; import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; +import { RegistrationProviderSelectors } from '@osf/shared/stores/registration-provider'; import { MOCK_REGISTRATION_OVERVIEW_MODEL } from '@testing/mocks/registration-overview-model.mock'; import { createMockSchemaResponse } from '@testing/mocks/schema-response.mock'; @@ -28,7 +29,7 @@ import { CustomDialogServiceMock } from '@testing/providers/custom-dialog-provid import { LoaderServiceMock, provideLoaderServiceMock } from '@testing/providers/loader-service.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 { ToastServiceMock } from '@testing/providers/toast-provider.mock'; import { ViewOnlyLinkHelperMock } from '@testing/providers/view-only-link-helper.mock'; @@ -44,7 +45,7 @@ import { RegistrySelectors } from '../../store/registry'; import { RegistryOverviewComponent } from './registry-overview.component'; -interface SetupOverrides { +interface SetupOverrides extends BaseSetupOverrides { registry?: RegistrationOverviewModel | null; schemaResponses?: SchemaResponse[]; queryParams?: Record; @@ -67,6 +68,19 @@ function setup(overrides: SetupOverrides = {}) { const mockLoaderService = new LoaderServiceMock(); const mockToastService = ToastServiceMock.simple(); const mockViewOnlyHelper = ViewOnlyLinkHelperMock.simple(overrides.hasViewOnly); + const signalDefaults = [ + { selector: RegistrySelectors.getRegistry, value: registry }, + { selector: RegistrySelectors.isRegistryLoading, value: false }, + { selector: RegistrySelectors.isRegistryAnonymous, value: false }, + { selector: RegistrySelectors.getSchemaResponses, value: schemaResponses }, + { selector: RegistrySelectors.isSchemaResponsesLoading, value: false }, + { selector: RegistrySelectors.getSchemaBlocks, value: [] }, + { selector: RegistrySelectors.isSchemaBlocksLoading, value: false }, + { selector: RegistrySelectors.areReviewActionsLoading, value: false }, + { selector: RegistrySelectors.getSchemaResponse, value: schemaResponses[0] ?? null }, + { selector: RegistrySelectors.hasAdminAccess, value: false }, + { selector: RegistrationProviderSelectors.allowUpdates, value: false }, + ]; TestBed.configureTestingModule({ imports: [ @@ -94,18 +108,7 @@ function setup(overrides: SetupOverrides = {}) { MockProvider(ToastService, mockToastService), MockProvider(ViewOnlyLinkHelperService, mockViewOnlyHelper), provideMockStore({ - signals: [ - { selector: RegistrySelectors.getRegistry, value: registry }, - { selector: RegistrySelectors.isRegistryLoading, value: false }, - { selector: RegistrySelectors.isRegistryAnonymous, value: false }, - { selector: RegistrySelectors.getSchemaResponses, value: schemaResponses }, - { selector: RegistrySelectors.isSchemaResponsesLoading, value: false }, - { selector: RegistrySelectors.getSchemaBlocks, value: [] }, - { selector: RegistrySelectors.isSchemaBlocksLoading, value: false }, - { selector: RegistrySelectors.areReviewActionsLoading, value: false }, - { selector: RegistrySelectors.getSchemaResponse, value: schemaResponses[0] ?? null }, - { selector: RegistrySelectors.hasAdminAccess, value: false }, - ], + signals: mergeSignalOverrides(signalDefaults, overrides.selectorOverrides), }), ], }); @@ -181,6 +184,30 @@ describe('RegistryOverviewComponent', () => { expect(component.canMakeDecision()).toBe(false); }); + it('should compute canUpdate as true when admin access and provider updates are allowed', () => { + const { component } = setup({ + registry: MOCK_REGISTRATION_OVERVIEW_MODEL, + selectorOverrides: [ + { selector: RegistrySelectors.hasAdminAccess, value: true }, + { selector: RegistrationProviderSelectors.allowUpdates, value: true }, + ], + }); + + expect(component.canUpdate()).toBe(true); + }); + + it('should compute canUpdate as false when provider updates are not allowed', () => { + const { component } = setup({ + registry: MOCK_REGISTRATION_OVERVIEW_MODEL, + selectorOverrides: [ + { selector: RegistrySelectors.hasAdminAccess, value: true }, + { selector: RegistrationProviderSelectors.allowUpdates, value: false }, + ], + }); + + expect(component.canUpdate()).toBe(false); + }); + it('should compute isInitialState from reviewsState', () => { const { component } = setup({ registry: { ...MOCK_REGISTRATION_OVERVIEW_MODEL, reviewsState: RegistrationReviewStates.Initial }, diff --git a/src/app/features/registry/pages/registry-overview/registry-overview.component.ts b/src/app/features/registry/pages/registry-overview/registry-overview.component.ts index 927fc222a..fcf3bbac3 100644 --- a/src/app/features/registry/pages/registry-overview/registry-overview.component.ts +++ b/src/app/features/registry/pages/registry-overview/registry-overview.component.ts @@ -37,6 +37,7 @@ import { ToastService } from '@osf/shared/services/toast.service'; import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; import { GetBookmarksCollectionId } from '@osf/shared/stores/bookmarks'; import { GetBibliographicContributors } from '@osf/shared/stores/contributors'; +import { RegistrationProviderSelectors } from '@osf/shared/stores/registration-provider'; import { ArchivingMessageComponent } from '../../components/archiving-message/archiving-message.component'; import { RegistrationOverviewToolbarComponent } from '../../components/registration-overview-toolbar/registration-overview-toolbar.component'; @@ -98,6 +99,7 @@ export class RegistryOverviewComponent implements OnInit, OnDestroy { readonly areReviewActionsLoading = select(RegistrySelectors.areReviewActionsLoading); readonly currentRevision = select(RegistrySelectors.getSchemaResponse); readonly hasAdminAccess = select(RegistrySelectors.hasAdminAccess); + readonly allowUpdates = select(RegistrationProviderSelectors.allowUpdates); readonly selectedRevisionIndex = signal(0); @@ -112,6 +114,8 @@ export class RegistryOverviewComponent implements OnInit, OnDestroy { () => !this.registry()?.archiving && !this.registry()?.withdrawn && this.isModeration() ); + readonly canUpdate = computed(() => this.hasAdminAccess() && this.allowUpdates()); + isRootRegistration = computed(() => { const rootId = this.registry()?.rootParentId; return !rootId || rootId === this.registry()?.id; 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/features/settings/account-settings/components/two-factor-auth/two-factor-auth.component.spec.ts b/src/app/features/settings/account-settings/components/two-factor-auth/two-factor-auth.component.spec.ts index 4e96b3487..421199650 100644 --- a/src/app/features/settings/account-settings/components/two-factor-auth/two-factor-auth.component.spec.ts +++ b/src/app/features/settings/account-settings/components/two-factor-auth/two-factor-auth.component.spec.ts @@ -89,7 +89,7 @@ describe('TwoFactorAuthComponent', () => { expect(confirmationService.confirmAccept).toHaveBeenCalledWith({ headerKey: 'settings.accountSettings.twoFactorAuth.configure.title', messageKey: 'settings.accountSettings.twoFactorAuth.configure.description', - acceptLabelKey: 'settings.accountSettings.common.buttons.configure', + acceptLabelKey: 'common.buttons.configure', onConfirm: expect.any(Function), }); diff --git a/src/app/features/settings/account-settings/components/two-factor-auth/two-factor-auth.component.ts b/src/app/features/settings/account-settings/components/two-factor-auth/two-factor-auth.component.ts index 2c553c644..403896df0 100644 --- a/src/app/features/settings/account-settings/components/two-factor-auth/two-factor-auth.component.ts +++ b/src/app/features/settings/account-settings/components/two-factor-auth/two-factor-auth.component.ts @@ -55,7 +55,7 @@ export class TwoFactorAuthComponent { this.customConfirmationService.confirmAccept({ headerKey: 'settings.accountSettings.twoFactorAuth.configure.title', messageKey: 'settings.accountSettings.twoFactorAuth.configure.description', - acceptLabelKey: 'settings.accountSettings.common.buttons.configure', + acceptLabelKey: 'common.buttons.configure', onConfirm: () => { this.loaderService.show(); this.actions.enableTwoFactorAuth().subscribe(() => this.loaderService.hide()); diff --git a/src/app/shared/components/contributors/contributors-table/contributors-table.component.html b/src/app/shared/components/contributors/contributors-table/contributors-table.component.html index daaa9ba85..36b516a90 100644 --- a/src/app/shared/components/contributors/contributors-table/contributors-table.component.html +++ b/src/app/shared/components/contributors/contributors-table/contributors-table.component.html @@ -174,19 +174,19 @@ } + - @if (showLoadMore() && index === contributors().length - 1) { + + @if (showLoadMore()) { - -
- -
+ + } diff --git a/src/app/shared/components/contributors/contributors-table/contributors-table.component.spec.ts b/src/app/shared/components/contributors/contributors-table/contributors-table.component.spec.ts index f83741f0a..b3a4ccac0 100644 --- a/src/app/shared/components/contributors/contributors-table/contributors-table.component.spec.ts +++ b/src/app/shared/components/contributors/contributors-table/contributors-table.component.spec.ts @@ -1,4 +1,4 @@ -import { MockProvider } from 'ng-mocks'; +import { MockComponents, MockProvider } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; @@ -13,36 +13,40 @@ import { CustomDialogServiceMockBuilder } from '@testing/providers/custom-dialog import { EducationHistoryDialogComponent } from '../../education-history-dialog/education-history-dialog.component'; import { EmploymentHistoryDialogComponent } from '../../employment-history-dialog/employment-history-dialog.component'; +import { IconComponent } from '../../icon/icon.component'; +import { InfoIconComponent } from '../../info-icon/info-icon.component'; +import { SelectComponent } from '../../select/select.component'; import { ContributorsTableComponent } from './contributors-table.component'; +const makeTableParams = (overrides: Partial = {}): TableParameters => ({ + rows: 10, + paginator: true, + scrollable: false, + rowsPerPageOptions: [10, 25, 50], + totalRecords: 4, + firstRowIndex: 10, + defaultSortOrder: null, + defaultSortColumn: null, + ...overrides, +}); + describe('ContributorsTableComponent', () => { let component: ContributorsTableComponent; let fixture: ComponentFixture; let mockCustomDialogService: ReturnType; - const tableParams: TableParameters = { - rows: 10, - paginator: true, - scrollable: false, - rowsPerPageOptions: [10, 25, 50], - totalRecords: 4, - firstRowIndex: 10, - defaultSortOrder: null, - defaultSortColumn: null, - }; - beforeEach(() => { mockCustomDialogService = CustomDialogServiceMockBuilder.create().build(); TestBed.configureTestingModule({ - imports: [ContributorsTableComponent], + imports: [ContributorsTableComponent, ...MockComponents(SelectComponent, IconComponent, InfoIconComponent)], providers: [provideOSFCore(), MockProvider(CustomDialogService, mockCustomDialogService)], }); fixture = TestBed.createComponent(ContributorsTableComponent); component = fixture.componentInstance; - fixture.componentRef.setInput('tableParams', tableParams); + fixture.componentRef.setInput('tableParams', makeTableParams()); fixture.detectChanges(); }); @@ -50,47 +54,54 @@ describe('ContributorsTableComponent', () => { expect(component).toBeTruthy(); }); - it('should compute isProject based on resourceType', () => { + it('should return true from isProject when resourceType is Project', () => { fixture.componentRef.setInput('resourceType', ResourceType.Project); fixture.detectChanges(); expect(component.isProject()).toBe(true); + }); + it('should return false from isProject when resourceType is Registration', () => { fixture.componentRef.setInput('resourceType', ResourceType.Registration); fixture.detectChanges(); expect(component.isProject()).toBe(false); }); - it('should compute deactivatedContributors when list contains deactivated contributor', () => { - const contributors: ContributorModel[] = [ + it('should return true from deactivatedContributors when at least one contributor is deactivated', () => { + fixture.componentRef.setInput('contributors', [ { ...MOCK_CONTRIBUTOR, id: '1', deactivated: false }, { ...MOCK_CONTRIBUTOR_WITHOUT_HISTORY, id: '2', deactivated: true }, - ]; - - component.contributors.set(contributors); - + ]); + fixture.detectChanges(); expect(component.deactivatedContributors()).toBe(true); }); - it('should compute showLoadMore when loaded contributors are below total records', () => { - component.contributors.set([{ ...MOCK_CONTRIBUTOR, id: '1' }]); - - expect(component.showLoadMore()).toBe(true); + it('should return false from deactivatedContributors when all contributors are active', () => { + fixture.componentRef.setInput('contributors', [ + { ...MOCK_CONTRIBUTOR, id: '1', deactivated: false }, + { ...MOCK_CONTRIBUTOR_WITHOUT_HISTORY, id: '2', deactivated: false }, + ]); + fixture.detectChanges(); + expect(component.deactivatedContributors()).toBe(false); }); - it('should compute showLoadMore as false when contributors length matches total records', () => { - const contributors: ContributorModel[] = [ - { ...MOCK_CONTRIBUTOR, id: '1' }, - { ...MOCK_CONTRIBUTOR_WITHOUT_HISTORY, id: '2' }, - { ...MOCK_CONTRIBUTOR, id: '3' }, - { ...MOCK_CONTRIBUTOR_WITHOUT_HISTORY, id: '4' }, - ]; - component.contributors.set(contributors); + it('should return false from deactivatedContributors when contributor list is empty', () => { + fixture.componentRef.setInput('contributors', []); + fixture.detectChanges(); + expect(component.deactivatedContributors()).toBe(false); + }); + it('should default showLoadMore to false', () => { expect(component.showLoadMore()).toBe(false); }); - it('should emit remove event when removeContributor is called', () => { - const contributor = { ...MOCK_CONTRIBUTOR, id: 'remove-id' }; + it('should reflect showLoadMore as true when set by parent', () => { + fixture.componentRef.setInput('showLoadMore', true); + fixture.detectChanges(); + expect(component.showLoadMore()).toBe(true); + }); + + it('should emit remove event with the given contributor when removeContributor is called', () => { + const contributor: ContributorModel = { ...MOCK_CONTRIBUTOR, id: 'remove-id' }; vi.spyOn(component.remove, 'emit'); component.removeContributor(contributor); @@ -106,7 +117,7 @@ describe('ContributorsTableComponent', () => { expect(component.loadMore.emit).toHaveBeenCalled(); }); - it('should open education history dialog with contributor education data', () => { + it('should open EducationHistoryDialogComponent with contributor education data', () => { const contributor: ContributorModel = { ...MOCK_CONTRIBUTOR, id: 'education-id', @@ -133,7 +144,19 @@ describe('ContributorsTableComponent', () => { }); }); - it('should open employment history dialog with contributor employment data', () => { + it('should open EducationHistoryDialogComponent with an empty education array', () => { + const contributor: ContributorModel = { ...MOCK_CONTRIBUTOR, id: 'no-education-id', education: [] }; + + component.openEducationHistory(contributor); + + expect(mockCustomDialogService.open).toHaveBeenCalledWith(EducationHistoryDialogComponent, { + header: 'project.contributors.table.headers.education', + width: '552px', + data: [], + }); + }); + + it('should open EmploymentHistoryDialogComponent with contributor employment data', () => { const contributor: ContributorModel = { ...MOCK_CONTRIBUTOR, id: 'employment-id', @@ -160,16 +183,42 @@ describe('ContributorsTableComponent', () => { }); }); - it('should reorder contributors indices using table firstRowIndex', () => { - const contributors: ContributorModel[] = [ + it('should open EmploymentHistoryDialogComponent with an empty employment array', () => { + const contributor: ContributorModel = { ...MOCK_CONTRIBUTOR, id: 'no-employment-id', employment: [] }; + + component.openEmploymentHistory(contributor); + + expect(mockCustomDialogService.open).toHaveBeenCalledWith(EmploymentHistoryDialogComponent, { + header: 'project.contributors.table.headers.employment', + width: '552px', + data: [], + }); + }); + + it('should reindex contributors starting from tableParams.firstRowIndex on row reorder', () => { + fixture.componentRef.setInput('tableParams', makeTableParams({ firstRowIndex: 10 })); + fixture.componentRef.setInput('contributors', [ { ...MOCK_CONTRIBUTOR, id: '1', index: 0 }, { ...MOCK_CONTRIBUTOR_WITHOUT_HISTORY, id: '2', index: 1 }, { ...MOCK_CONTRIBUTOR, id: '3', index: 2 }, - ]; - component.contributors.set(contributors); + ]); + fixture.detectChanges(); + + component.onRowReorder(); + + expect(component.contributors().map((c) => c.index)).toEqual([10, 11, 12]); + }); + + it('should reindex contributors from 0 when firstRowIndex is 0 on row reorder', () => { + fixture.componentRef.setInput('tableParams', makeTableParams({ firstRowIndex: 0 })); + fixture.componentRef.setInput('contributors', [ + { ...MOCK_CONTRIBUTOR, id: '1', index: 5 }, + { ...MOCK_CONTRIBUTOR_WITHOUT_HISTORY, id: '2', index: 6 }, + ]); + fixture.detectChanges(); component.onRowReorder(); - expect(component.contributors().map((item) => item.index)).toEqual([10, 11, 12]); + expect(component.contributors().map((c) => c.index)).toEqual([0, 1]); }); }); diff --git a/src/app/shared/components/contributors/contributors-table/contributors-table.component.ts b/src/app/shared/components/contributors/contributors-table/contributors-table.component.ts index 80fcb5528..800c35066 100644 --- a/src/app/shared/components/contributors/contributors-table/contributors-table.component.ts +++ b/src/app/shared/components/contributors/contributors-table/contributors-table.component.ts @@ -15,7 +15,6 @@ import { ContributorPermission } from '@osf/shared/enums/contributors/contributo import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { ContributorModel } from '@shared/models/contributors/contributor.model'; -import { SelectOption } from '@shared/models/select-option.model'; import { TableParameters } from '@shared/models/table-parameters.model'; import { EducationHistoryDialogComponent } from '../../education-history-dialog/education-history-dialog.component'; @@ -50,6 +49,7 @@ export class ContributorsTableComponent { showEducation = input(true); showEmployment = input(true); showInfo = input(false); + showLoadMore = input(false); resourceType = input(ResourceType.Project); currentUserId = input(undefined); @@ -60,21 +60,15 @@ export class ContributorsTableComponent { customDialogService = inject(CustomDialogService); - readonly permissionsOptions: SelectOption[] = PERMISSION_OPTIONS; + readonly permissionsOptions = PERMISSION_OPTIONS; readonly ContributorPermission = ContributorPermission; - skeletonData: ContributorModel[] = Array.from({ length: 3 }, () => ({}) as ContributorModel); + skeletonData = Array.from({ length: 3 }, () => ({}) as ContributorModel); isProject = computed(() => this.resourceType() === ResourceType.Project); deactivatedContributors = computed(() => this.contributors().some((contributor) => contributor.deactivated)); - showLoadMore = computed(() => { - const currentLoadedItems = this.contributors().length; - const totalRecords = this.tableParams().totalRecords; - return currentLoadedItems > 0 && currentLoadedItems < totalRecords; - }); - removeContributor(contributor: ContributorModel) { this.remove.emit(contributor); } 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 fa2954b12..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,118 +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 (!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 f08df941e..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,166 +1,82 @@ -import { select } from '@ngxs/store'; - import { TranslatePipe } from '@ngx-translate/core'; -import { PrimeTemplate, TreeNode } from 'primeng/api'; -import { Tree, TreeLazyLoadEvent, TreeNodeDropEvent, TreeNodeSelectEvent } from 'primeng/tree'; +import { PrimeTemplate, TreeDragDropService } from 'primeng/api'; +import { Button } from 'primeng/button'; +import { Tooltip } from 'primeng/tooltip'; +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, + Button, Tree, + Tooltip, + PrimeTemplate, LoadingSpinnerComponent, - FileMenuComponent, - StopPropagationDirective, + 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'); - - 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([]); } }); @@ -171,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(); + 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.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); - } - } - - 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.links.delete), - }); - } - - confirmDeleteEntry(link: string): void { - this.deleteEntryAction.emit(link); - } - - 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; @@ -432,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(); + this.loadFiles.emit({ link: this.currentFolder().links.filesLink, page: nextPage }); } } - - 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; - } - 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/funders-list/funders-list.component.html b/src/app/shared/components/funders-list/funders-list.component.html new file mode 100644 index 000000000..dda761cff --- /dev/null +++ b/src/app/shared/components/funders-list/funders-list.component.html @@ -0,0 +1,15 @@ +@if (isLoading()) { +
+ +
+} @else { + @if (funders()?.length) { +
    + @for (funder of funders(); track funder.funderIdentifier) { +
  • {{ funder.funderName }}
  • + } +
+ } @else { +

{{ 'project.overview.metadata.noInformation' | translate }}

+ } +} diff --git a/src/app/shared/components/funders-list/funders-list.component.spec.ts b/src/app/shared/components/funders-list/funders-list.component.spec.ts new file mode 100644 index 000000000..49ea9362d --- /dev/null +++ b/src/app/shared/components/funders-list/funders-list.component.spec.ts @@ -0,0 +1,74 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { Funder } from '@osf/features/metadata/models'; + +import { provideOSFCore } from '@testing/osf.testing.provider'; + +import { FundersListComponent } from './funders-list.component'; + +describe('FundersListComponent', () => { + let component: FundersListComponent; + let fixture: ComponentFixture; + + const fundersMock: Funder[] = [ + { + funderName: 'National Science Foundation', + funderIdentifier: 'https://ror.org/021nxhr62', + funderIdentifierType: 'ROR', + awardNumber: 'NSF-123', + awardUri: 'https://example.org/nsf-123', + awardTitle: 'Grant 123', + }, + { + funderName: 'National Institutes of Health', + funderIdentifier: 'https://ror.org/04zaypm56', + funderIdentifierType: 'ROR', + awardNumber: 'NIH-456', + awardUri: 'https://example.org/nih-456', + awardTitle: 'Grant 456', + }, + ]; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [FundersListComponent], + providers: [provideOSFCore()], + }); + + fixture = TestBed.createComponent(FundersListComponent); + component = fixture.componentInstance; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should render loading skeleton', () => { + fixture.componentRef.setInput('isLoading', true); + fixture.componentRef.setInput('funders', fundersMock); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('p-skeleton')).not.toBeNull(); + expect(fixture.nativeElement.textContent).not.toContain('National Science Foundation'); + expect(fixture.nativeElement.textContent).not.toContain('project.overview.metadata.noInformation'); + }); + + it('should render funder names', () => { + fixture.componentRef.setInput('funders', fundersMock); + fixture.detectChanges(); + + const funderItems = fixture.nativeElement.querySelectorAll('li'); + + expect(funderItems).toHaveLength(2); + expect(fixture.nativeElement.textContent).toContain('National Science Foundation'); + expect(fixture.nativeElement.textContent).toContain('National Institutes of Health'); + }); + + it.each([undefined, []])('should render no information message when funders are %s', (funders) => { + fixture.componentRef.setInput('funders', funders); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('ul')).toBeNull(); + expect(fixture.nativeElement.textContent).toContain('project.overview.metadata.noInformation'); + }); +}); diff --git a/src/app/shared/components/funders-list/funders-list.component.ts b/src/app/shared/components/funders-list/funders-list.component.ts new file mode 100644 index 000000000..30f80090e --- /dev/null +++ b/src/app/shared/components/funders-list/funders-list.component.ts @@ -0,0 +1,18 @@ +import { TranslatePipe } from '@ngx-translate/core'; + +import { Skeleton } from 'primeng/skeleton'; + +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; + +import { Funder } from '@osf/features/metadata/models'; + +@Component({ + selector: 'osf-funders-list', + imports: [Skeleton, TranslatePipe], + templateUrl: './funders-list.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class FundersListComponent { + funders = input(undefined); + isLoading = input(false); +} diff --git a/src/app/shared/components/registration-card/registration-card.component.html b/src/app/shared/components/registration-card/registration-card.component.html index 823b0b300..6f304d0e5 100644 --- a/src/app/shared/components/registration-card/registration-card.component.html +++ b/src/app/shared/components/registration-card/registration-card.component.html @@ -5,7 +5,7 @@

- {{ (registrationData().title | fixSpecialChar) || ('project.registrations.card.noTitle' | translate) }} + {{ registrationData().title || ('project.registrations.card.noTitle' | translate) }}

@if (!isDraft()) { @@ -49,12 +49,11 @@

@if (isDraft()) { - @if (hasWriteAccess) { + @if (hasWriteAccess()) { [routerLink]="['/registries/drafts/', registrationData().id, 'metadata']" /> } - @if (hasAdminAccess) { + @if (hasAdminAccess()) { routerLinkActive="router-link-active" [label]="'common.buttons.view' | translate" > - @if (showButtons) { - @if (isApproved) { + @if (showButtons()) { + @if (isApproved()) { } - @if (isInProgress || isUnapproved) { + @if (isInProgress() || isUnapproved()) {
- @if (isApproved) { + @if (isApproved()) {

{{ 'shared.resources.title' | translate }}

{ let component: RegistrationCardComponent; let fixture: ComponentFixture; + let store: Store; + let routerMock: RouterMockType; const mockRegistrationData: RegistrationCard = MOCK_REGISTRATION; - beforeEach(() => { + const defaultSignals: SignalOverride[] = [ + { selector: RegistriesSelectors.getSchemaResponse, value: signal({ id: 'revision-id' }) }, + ]; + + type SetupOverrides = BaseSetupOverrides & { + registrationData?: RegistrationCard; + isDraft?: boolean; + }; + + function setup(overrides: SetupOverrides = {}): void { + routerMock = RouterMockBuilder.create().build(); + TestBed.configureTestingModule({ imports: [ RegistrationCardComponent, @@ -34,133 +58,165 @@ describe('RegistrationCardComponent', () => { ], providers: [ provideOSFCore(), - provideRouter([]), - provideMockStore({ - signals: [{ selector: RegistriesSelectors.getSchemaResponse, value: signal(null) }], - }), + MockProvider(Router, routerMock), + provideMockStore({ signals: mergeSignalOverrides(defaultSignals, overrides.selectorOverrides) }), ], }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(RegistrationCardComponent); component = fixture.componentInstance; - }); + fixture.componentRef.setInput('registrationData', overrides.registrationData ?? mockRegistrationData); + fixture.componentRef.setInput('isDraft', overrides.isDraft ?? false); + fixture.detectChanges(); + } it('should create', () => { - expect(component).toBeTruthy(); - }); + setup(); - it('should have registrationData as required input', () => { - fixture.componentRef.setInput('registrationData', mockRegistrationData); - expect(component.registrationData()).toEqual(mockRegistrationData); + expect(component).toBeTruthy(); }); - it('should compute isAccepted correctly when reviewsState is Accepted', () => { - const testData = { - ...mockRegistrationData, - reviewsState: RegistrationReviewStates.Accepted, - }; - fixture.componentRef.setInput('registrationData', testData); - fixture.detectChanges(); + it.each([ + [[UserPermissions.Read, UserPermissions.Write, UserPermissions.Admin], true, true], + [[UserPermissions.Write], false, true], + [[UserPermissions.Admin], true, false], + ] as [UserPermissions[], boolean, boolean][])( + 'should identify user permissions', + (currentUserPermissions, hasAdminAccess, hasWriteAccess) => { + setup({ + registrationData: { + ...mockRegistrationData, + currentUserPermissions, + }, + }); + + expect(component.hasAdminAccess()).toBe(hasAdminAccess); + expect(component.hasWriteAccess()).toBe(hasWriteAccess); + } + ); + + it.each([ + [RegistrationReviewStates.Accepted, true], + [RegistrationReviewStates.Pending, true], + [RegistrationReviewStates.Embargo, true], + [RegistrationReviewStates.Withdrawn, false], + ] as [RegistrationReviewStates, boolean][])( + 'should identify update-eligible review states', + (reviewsState, eligible) => { + setup({ + registrationData: { + ...mockRegistrationData, + reviewsState, + }, + }); + + expect(component.isAccepted()).toBe(reviewsState === RegistrationReviewStates.Accepted); + expect(component.isPending()).toBe(reviewsState === RegistrationReviewStates.Pending); + expect(component.isEmbargo()).toBe(reviewsState === RegistrationReviewStates.Embargo); + expect(component.showButtons()).toBe(eligible); + } + ); + + it.each([ + [RevisionReviewStates.Approved, true, false, false], + [RevisionReviewStates.Unapproved, false, true, false], + [RevisionReviewStates.RevisionInProgress, false, false, true], + ] as [RevisionReviewStates, boolean, boolean, boolean][])( + 'should identify revision states', + (revisionState, isApproved, isUnapproved, isInProgress) => { + setup({ + registrationData: { + ...mockRegistrationData, + revisionState, + }, + }); + + expect(component.isApproved()).toBe(isApproved); + expect(component.isUnapproved()).toBe(isUnapproved); + expect(component.isInProgress()).toBe(isInProgress); + } + ); + + it.each([ + [null, true], + [mockRegistrationData.id, true], + ['different-root-id', false], + ] as [string | null, boolean][])('should identify root registrations', (rootParentId, isRootRegistration) => { + setup({ + registrationData: { + ...mockRegistrationData, + rootParentId, + }, + }); - expect(component.isAccepted).toBe(true); + expect(component.isRootRegistration()).toBe(isRootRegistration); }); - it('should compute isPending correctly when reviewsState is Pending', () => { - const testData = { - ...mockRegistrationData, - reviewsState: RegistrationReviewStates.Pending, - }; - fixture.componentRef.setInput('registrationData', testData); - fixture.detectChanges(); + it.each([ + ['updates are disabled', { allowUpdates: false }], + ['user lacks admin access', { currentUserPermissions: [UserPermissions.Write] }], + ['registration is not root', { rootParentId: 'different-root-id' }], + ] as [string, Partial][])('should hide update buttons when %s', (_label, registrationData) => { + setup({ + registrationData: { + ...mockRegistrationData, + ...registrationData, + }, + }); - expect(component.isPending).toBe(true); + expect(component.showButtons()).toBe(false); }); - it('should compute isApproved correctly when revisionState is Approved', () => { - const testData = { - ...mockRegistrationData, - revisionState: RevisionReviewStates.Approved, - }; - fixture.componentRef.setInput('registrationData', testData); - fixture.detectChanges(); + it('should dispatch create schema response and navigate to justification page on updateRegistration', () => { + setup(); + (store.dispatch as Mock).mockClear(); - expect(component.isApproved).toBe(true); - }); + component.updateRegistration(mockRegistrationData.id); - it('should compute isUnapproved correctly when revisionState is Unapproved', () => { - const testData = { - ...mockRegistrationData, - revisionState: RevisionReviewStates.Unapproved, - }; - fixture.componentRef.setInput('registrationData', testData); - fixture.detectChanges(); - - expect(component.isUnapproved).toBe(true); + expect(store.dispatch).toHaveBeenCalledWith(new CreateSchemaResponse(mockRegistrationData.id)); + expect(routerMock.navigate).toHaveBeenCalledWith(['/registries/revisions/revision-id/justification']); }); - it('should compute isInProgress correctly when revisionState is RevisionInProgress', () => { - const testData = { - ...mockRegistrationData, - revisionState: RevisionReviewStates.RevisionInProgress, - }; - fixture.componentRef.setInput('registrationData', testData); - fixture.detectChanges(); - - expect(component.isInProgress).toBe(true); - }); + it('should dispatch fetch schema responses and navigate to review page for unapproved revision', () => { + setup({ + registrationData: { + ...mockRegistrationData, + revisionState: RevisionReviewStates.Unapproved, + }, + }); + (store.dispatch as Mock).mockClear(); - it('should compute isAccepted as false when reviewsState is not Accepted', () => { - const testData = { - ...mockRegistrationData, - reviewsState: RegistrationReviewStates.Pending, - }; - fixture.componentRef.setInput('registrationData', testData); - fixture.detectChanges(); + component.continueUpdateRegistration(mockRegistrationData.id); - expect(component.isAccepted).toBe(false); + expect(store.dispatch).toHaveBeenCalledWith(new FetchAllSchemaResponses(mockRegistrationData.id)); + expect(routerMock.navigate).toHaveBeenCalledWith(['/registries/revisions/revision-id/review']); }); - it('should compute isPending as false when reviewsState is not Pending', () => { - const testData = { - ...mockRegistrationData, - reviewsState: RegistrationReviewStates.Accepted, - }; - fixture.componentRef.setInput('registrationData', testData); - fixture.detectChanges(); - - expect(component.isPending).toBe(false); - }); + it('should dispatch fetch schema responses and navigate to justification page for non-unapproved revision', () => { + setup({ + registrationData: { + ...mockRegistrationData, + revisionState: RevisionReviewStates.RevisionInProgress, + }, + }); + (store.dispatch as Mock).mockClear(); - it('should compute isApproved as false when revisionState is not Approved', () => { - const testData = { - ...mockRegistrationData, - revisionState: RevisionReviewStates.Unapproved, - }; - fixture.componentRef.setInput('registrationData', testData); - fixture.detectChanges(); + component.continueUpdateRegistration(mockRegistrationData.id); - expect(component.isApproved).toBe(false); + expect(store.dispatch).toHaveBeenCalledWith(new FetchAllSchemaResponses(mockRegistrationData.id)); + expect(routerMock.navigate).toHaveBeenCalledWith(['/registries/revisions/revision-id/justification']); }); - it('should compute isUnapproved as false when revisionState is not Unapproved', () => { - const testData = { - ...mockRegistrationData, - revisionState: RevisionReviewStates.Approved, - }; - fixture.componentRef.setInput('registrationData', testData); - fixture.detectChanges(); - - expect(component.isUnapproved).toBe(false); - }); + it('should not navigate when schema response is not present', () => { + setup({ + selectorOverrides: [{ selector: RegistriesSelectors.getSchemaResponse, value: signal(null) }], + }); + (store.dispatch as Mock).mockClear(); - it('should compute isInProgress as false when revisionState is not RevisionInProgress', () => { - const testData = { - ...mockRegistrationData, - revisionState: RevisionReviewStates.Approved, - }; - fixture.componentRef.setInput('registrationData', testData); - fixture.detectChanges(); + component.updateRegistration(mockRegistrationData.id); - expect(component.isInProgress).toBe(false); + expect(store.dispatch).toHaveBeenCalledWith(new CreateSchemaResponse(mockRegistrationData.id)); + expect(routerMock.navigate).not.toHaveBeenCalled(); }); }); diff --git a/src/app/shared/components/registration-card/registration-card.component.ts b/src/app/shared/components/registration-card/registration-card.component.ts index e9ba0f72a..56c0bf7e5 100644 --- a/src/app/shared/components/registration-card/registration-card.component.ts +++ b/src/app/shared/components/registration-card/registration-card.component.ts @@ -8,7 +8,7 @@ import { Card } from 'primeng/card'; import { tap } from 'rxjs'; import { DatePipe } from '@angular/common'; -import { ChangeDetectionStrategy, Component, inject, input, output } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, inject, input, output } from '@angular/core'; import { Router, RouterLink } from '@angular/router'; import { CreateSchemaResponse, FetchAllSchemaResponses, RegistriesSelectors } from '@osf/features/registries/store'; @@ -16,7 +16,6 @@ import { RegistrationReviewStates } from '@osf/shared/enums/registration-review- import { RevisionReviewStates } from '@osf/shared/enums/revision-review-states.enum'; import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; import { RegistrationCard } from '@osf/shared/models/registration/registration-card.model'; -import { FixSpecialCharPipe } from '@osf/shared/pipes/fix-special-char.pipe'; import { ContributorsListComponent } from '../contributors-list/contributors-list.component'; import { DataResourcesComponent } from '../data-resources/data-resources.component'; @@ -37,7 +36,6 @@ import { TruncatedTextComponent } from '../truncated-text/truncated-text.compone IconComponent, TruncatedTextComponent, ContributorsListComponent, - FixSpecialCharPipe, ], templateUrl: './registration-card.component.html', styleUrl: './registration-card.component.scss', @@ -52,6 +50,7 @@ export class RegistrationCardComponent { readonly deleteDraft = output(); private router = inject(Router); + schemaResponse = select(RegistriesSelectors.getSchemaResponse); actions = createDispatchMap({ @@ -59,46 +58,36 @@ export class RegistrationCardComponent { createSchemaResponse: CreateSchemaResponse, }); - get hasAdminAccess(): boolean { - return this.registrationData().currentUserPermissions.includes(UserPermissions.Admin); - } - - get hasWriteAccess(): boolean { - return this.registrationData().currentUserPermissions.includes(UserPermissions.Write); - } - - get isAccepted(): boolean { - return this.registrationData().reviewsState === RegistrationReviewStates.Accepted; - } - - get isPending(): boolean { - return this.registrationData().reviewsState === RegistrationReviewStates.Pending; - } - - get isApproved(): boolean { - return this.registrationData().revisionState === RevisionReviewStates.Approved; - } + readonly hasAdminAccess = computed(() => + this.registrationData().currentUserPermissions.includes(UserPermissions.Admin) + ); - get isUnapproved(): boolean { - return this.registrationData().revisionState === RevisionReviewStates.Unapproved; - } + readonly hasWriteAccess = computed(() => + this.registrationData().currentUserPermissions.includes(UserPermissions.Write) + ); - get isInProgress(): boolean { - return this.registrationData().revisionState === RevisionReviewStates.RevisionInProgress; - } + readonly isAccepted = computed(() => this.registrationData().reviewsState === RegistrationReviewStates.Accepted); + readonly isPending = computed(() => this.registrationData().reviewsState === RegistrationReviewStates.Pending); + readonly isApproved = computed(() => this.registrationData().revisionState === RevisionReviewStates.Approved); + readonly isUnapproved = computed(() => this.registrationData().revisionState === RevisionReviewStates.Unapproved); + readonly isEmbargo = computed(() => this.registrationData().reviewsState === RegistrationReviewStates.Embargo); - get isEmbargo(): boolean { - return this.registrationData().reviewsState === RegistrationReviewStates.Embargo; - } + readonly isInProgress = computed( + () => this.registrationData().revisionState === RevisionReviewStates.RevisionInProgress + ); - get isRootRegistration(): boolean { + readonly isRootRegistration = computed(() => { const registration = this.registrationData(); return !registration.rootParentId || registration.id === registration.rootParentId; - } + }); - get showButtons(): boolean { - return this.isRootRegistration && (this.isAccepted || this.isPending || this.isEmbargo) && this.hasAdminAccess; - } + readonly showButtons = computed( + () => + this.isRootRegistration() && + (this.isAccepted() || this.isPending() || this.isEmbargo()) && + this.hasAdminAccess() && + this.registrationData().allowUpdates + ); updateRegistration(id: string): void { this.actions @@ -125,11 +114,15 @@ export class RegistrationCardComponent { private navigateToJustificationPage(): void { const revisionId = this.schemaResponse()?.id; + if (!revisionId) return; + this.router.navigate([`/registries/revisions/${revisionId}/justification`]); } private navigateToJustificationReview(): void { const revisionId = this.schemaResponse()?.id; + if (!revisionId) return; + this.router.navigate([`/registries/revisions/${revisionId}/review`]); } } diff --git a/src/app/shared/components/resource-card/components/project-secondary-metadata/project-secondary-metadata.component.ts b/src/app/shared/components/resource-card/components/project-secondary-metadata/project-secondary-metadata.component.ts index 7d3c7a568..8e62aab94 100644 --- a/src/app/shared/components/resource-card/components/project-secondary-metadata/project-secondary-metadata.component.ts +++ b/src/app/shared/components/resource-card/components/project-secondary-metadata/project-secondary-metadata.component.ts @@ -2,7 +2,7 @@ import { TranslatePipe } from '@ngx-translate/core'; import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; -import { languageCodes } from '@osf/shared/constants/language.const'; +import { LANGUAGE_CODES } from '@osf/shared/constants/language.const'; import { ResourceModel } from '@shared/models/search/resource.model'; @Component({ @@ -19,6 +19,6 @@ export class ProjectSecondaryMetadataComponent { const resourceLanguage = this.resource().language; if (!resourceLanguage) return null; - return languageCodes.find((lang) => lang.code === resourceLanguage)?.name; + return LANGUAGE_CODES.find((lang) => lang.code === resourceLanguage)?.name; }); } 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/language.const.ts b/src/app/shared/constants/language.const.ts index 30bc2c133..dc882a0df 100644 --- a/src/app/shared/constants/language.const.ts +++ b/src/app/shared/constants/language.const.ts @@ -1,4 +1,4 @@ -export const languageCodes = [ +export const LANGUAGE_CODES = [ { code: 'abk', name: 'Abkhazian', diff --git a/src/app/features/metadata/constants/resource-type-options.const.ts b/src/app/shared/constants/resource-type-general-options.const.ts similarity index 97% rename from src/app/features/metadata/constants/resource-type-options.const.ts rename to src/app/shared/constants/resource-type-general-options.const.ts index e9919c8a2..1d280087e 100644 --- a/src/app/features/metadata/constants/resource-type-options.const.ts +++ b/src/app/shared/constants/resource-type-general-options.const.ts @@ -1,6 +1,6 @@ // `value` must be from resourceTypeGeneral controlled vocab in https://schema.datacite.org/meta/kernel-4/ // see https://datacite-metadata-schema.readthedocs.io/en/4.6/appendices/appendix-1/resourceTypeGeneral/ -export const RESOURCE_TYPE_OPTIONS = [ +export const RESOURCE_TYPE_GENERAL_OPTIONS = [ { label: 'Audiovisual', value: 'Audiovisual' }, { label: 'Book', value: 'Book' }, { label: 'Book Chapter', value: 'BookChapter' }, 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/mappers/registration-provider.mapper.ts b/src/app/shared/mappers/registration-provider.mapper.ts index d97c2ebf8..8feb41303 100644 --- a/src/app/shared/mappers/registration-provider.mapper.ts +++ b/src/app/shared/mappers/registration-provider.mapper.ts @@ -35,6 +35,7 @@ export class RegistrationProviderMapper { iri: response.links.iri, reviewsWorkflow: response.attributes.reviews_workflow, allowSubmissions: response.attributes.allow_submissions, + allowUpdates: response.attributes.allow_updates, }; } } diff --git a/src/app/shared/mappers/registration/registration.mapper.ts b/src/app/shared/mappers/registration/registration.mapper.ts index 41fbe6ef4..740b6f8b1 100644 --- a/src/app/shared/mappers/registration/registration.mapper.ts +++ b/src/app/shared/mappers/registration/registration.mapper.ts @@ -73,6 +73,7 @@ export class RegistrationMapper { public: registration.attributes.public, contributors: ContributorsMapper.getContributors(registration.embeds?.bibliographic_contributors?.data), currentUserPermissions: registration.attributes.current_user_permissions, + allowUpdates: registration.embeds?.provider?.data?.attributes?.allow_updates ?? false, }; } @@ -97,6 +98,7 @@ export class RegistrationMapper { contributors: ContributorsMapper.getContributors(registration?.embeds?.bibliographic_contributors?.data), rootParentId: registration.relationships.root?.data?.id, currentUserPermissions: registration.attributes.current_user_permissions, + allowUpdates: registration.embeds?.provider?.data?.attributes?.allow_updates ?? false, }; } 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/provider/registry-provider.model.ts b/src/app/shared/models/provider/registry-provider.model.ts index 1c914acd9..9ccec98f6 100644 --- a/src/app/shared/models/provider/registry-provider.model.ts +++ b/src/app/shared/models/provider/registry-provider.model.ts @@ -11,4 +11,5 @@ export interface RegistryProviderDetails { iri: string; reviewsWorkflow: string; allowSubmissions: boolean; + allowUpdates: boolean; } diff --git a/src/app/shared/models/registration/registration-card.model.ts b/src/app/shared/models/registration/registration-card.model.ts index 65c5dd6cd..b3174e8ac 100644 --- a/src/app/shared/models/registration/registration-card.model.ts +++ b/src/app/shared/models/registration/registration-card.model.ts @@ -26,4 +26,5 @@ export interface RegistrationCard { hasSupplements?: boolean; rootParentId?: string | null; currentUserPermissions: UserPermissions[]; + allowUpdates: boolean; } diff --git a/src/app/shared/models/registration/registration-json-api.model.ts b/src/app/shared/models/registration/registration-json-api.model.ts index 1e38892af..db3e46f74 100644 --- a/src/app/shared/models/registration/registration-json-api.model.ts +++ b/src/app/shared/models/registration/registration-json-api.model.ts @@ -5,6 +5,7 @@ import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; import { ApiData, MetaJsonApi, PaginationLinksJsonApi } from '../common/json-api.model'; import { ContributorDataJsonApi } from '../contributors/contributor-response-json-api.model'; import { LicenseRecordJsonApi } from '../license/licenses-json-api.model'; +import { RegistrationProviderAttributesJsonApi } from '../provider/registration-provider-json-api.model'; export interface DraftRegistrationResponseJsonApi { data: DraftRegistrationDataJsonApi; @@ -138,9 +139,7 @@ export interface RegistrationEmbedsJsonApi { }; provider?: { data: { - attributes: { - name: string; - }; + attributes: RegistrationProviderAttributesJsonApi; }; }; } 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/pipes/language-label.pipe.ts b/src/app/shared/pipes/language-label.pipe.ts new file mode 100644 index 000000000..405e2d0e7 --- /dev/null +++ b/src/app/shared/pipes/language-label.pipe.ts @@ -0,0 +1,15 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +import { LANGUAGE_CODES } from '@osf/shared/constants/language.const'; + +const languageLabelByCode = new Map(LANGUAGE_CODES.map((item) => [item.code, item.name])); + +@Pipe({ + name: 'languageLabel', +}) +export class LanguageLabelPipe implements PipeTransform { + transform(value: string | null | undefined): string { + if (!value) return ''; + return languageLabelByCode.get(value) ?? value; + } +} diff --git a/src/app/shared/pipes/resource-type-general-label.pipe.ts b/src/app/shared/pipes/resource-type-general-label.pipe.ts new file mode 100644 index 000000000..dc8b2ea0b --- /dev/null +++ b/src/app/shared/pipes/resource-type-general-label.pipe.ts @@ -0,0 +1,15 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +import { RESOURCE_TYPE_GENERAL_OPTIONS } from '@osf/shared/constants/resource-type-general-options.const'; + +const resourceTypeGeneralLabelByValue = new Map(RESOURCE_TYPE_GENERAL_OPTIONS.map((item) => [item.value, item.label])); + +@Pipe({ + name: 'resourceTypeGeneralLabel', +}) +export class ResourceTypeGeneralLabelPipe implements PipeTransform { + transform(value: string | null | undefined): string { + if (!value) return ''; + return resourceTypeGeneralLabelByValue.get(value) ?? value; + } +} 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/app/shared/stores/registration-provider/registration-provider.selectors.ts b/src/app/shared/stores/registration-provider/registration-provider.selectors.ts index 61010f5cb..d960ebd4e 100644 --- a/src/app/shared/stores/registration-provider/registration-provider.selectors.ts +++ b/src/app/shared/stores/registration-provider/registration-provider.selectors.ts @@ -13,4 +13,9 @@ export class RegistrationProviderSelectors { static isBrandedProviderLoading(state: RegistrationProviderStateModel) { return state.currentBrandedProvider.isLoading; } + + @Selector([RegistrationProviderState]) + static allowUpdates(state: RegistrationProviderStateModel) { + return state.currentBrandedProvider.data?.allowUpdates ?? false; + } } diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 2887a045d..452a5228a 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -511,6 +511,7 @@ "emailPlaceholder": "email@example.com", "forked": "Forked", "lastUpdated": "Last Updated", + "language": "Language", "learnMore": "Learn More", "license": "License", "makePublic": "Make Public", @@ -615,7 +616,8 @@ "resourceLanguage": "Resource Language", "resourceType": "Resource Type" }, - "title": "File Metadata" + "title": "File Metadata", + "previewNotAvailable": "File or Registration metadata not available in preview mode." }, "keywords": { "title": "Keywords" @@ -1119,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." @@ -1984,6 +1987,7 @@ "customCitationPlaceholder": "Enter custom citation", "dateCreated": "Date Created", "dateUpdated": "Date Updated", + "funderNames": "Funder Names", "fundingSupport": "Funding/Support Information", "getMoreCitations": "Get More Citations", "internetArchiveLink": "internet archive link", @@ -1992,7 +1996,9 @@ "noInformation": "No information", "noProjectDoi": "No Project DOI", "noPublicationDoi": "No Publication DOI", + "noLanguage": "No language", "noResourceInformation": "No resource information available", + "noResourceType": "No resource type", "noSupplements": "No supplements", "noTags": "No tags", "placeholders": { @@ -2823,6 +2829,10 @@ } } }, + "maintenance": { + "message": "Please come back later.", + "title": "The OSF is currently down for scheduled maintenance." + }, "shared": { "affiliatedInstitutions": { "description": "This is a service provided by the OSF and is automatically applied to your registration. If you are not sure if your institution has signed up for this service, you can look for their name in this list." 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/stepper.scss b/src/styles/overrides/stepper.scss index ab10669ac..fb7b7d720 100644 --- a/src/styles/overrides/stepper.scss +++ b/src/styles/overrides/stepper.scss @@ -25,6 +25,11 @@ border: 1px solid var(--grey-2); border-radius: 0.5rem; + .p-steppanel, + .p-steppanel .p-motion { + grid-template-columns: 100%; + } + .p-steppanel-content { margin-inline-start: 0; } 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/mocks/registration.mock.ts b/src/testing/mocks/registration.mock.ts index 684c0f1c1..a774d3e5a 100644 --- a/src/testing/mocks/registration.mock.ts +++ b/src/testing/mocks/registration.mock.ts @@ -25,4 +25,5 @@ export const MOCK_REGISTRATION: RegistrationCard = { hasPapers: false, hasSupplements: true, currentUserPermissions: [UserPermissions.Admin, UserPermissions.Write, UserPermissions.Read], + allowUpdates: true, }; 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/maintenance-mode.service.mock.ts b/src/testing/providers/maintenance-mode.service.mock.ts new file mode 100644 index 000000000..7db5cdb40 --- /dev/null +++ b/src/testing/providers/maintenance-mode.service.mock.ts @@ -0,0 +1,21 @@ +import { Mock } from 'vitest'; + +import { Signal, signal } from '@angular/core'; + +import { MaintenanceModeService } from '@core/services/maintenance-mode.service'; + +export type MaintenanceModeServiceMockType = Partial & { + activate: Mock<() => void>; + deactivate: Mock<() => void>; + isActive: Signal; +}; + +export const MaintenanceModeServiceMock = { + simple() { + return { + activate: vi.fn(), + deactivate: vi.fn(), + isActive: signal(false).asReadonly(), + } as MaintenanceModeServiceMockType; + }, +}; 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(); + }, +};