diff --git a/src/app/core/components/request-access/request-access.component.html b/src/app/core/components/request-access/request-access.component.html index f8b74ca4b..1791804fc 100644 --- a/src/app/core/components/request-access/request-access.component.html +++ b/src/app/core/components/request-access/request-access.component.html @@ -1,35 +1,44 @@
-

{{ 'requestAccess.title' | translate }}

+

{{ titleTranslation() | translate }}

-

{{ 'requestAccess.message' | translate }}

+

+ + @if (isProjectReadOnly()) { + {{ supportEmail }} + } +

-
- + @if (!isProjectReadOnly()) { +
+ - -
+ +
+ }
- + @if (!isProjectReadOnly()) { + + } diff --git a/src/app/core/components/request-access/request-access.component.spec.ts b/src/app/core/components/request-access/request-access.component.spec.ts index 8051fd7f9..48a7984a7 100644 --- a/src/app/core/components/request-access/request-access.component.spec.ts +++ b/src/app/core/components/request-access/request-access.component.spec.ts @@ -9,6 +9,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute, Router } from '@angular/router'; import { AuthService } from '@core/services/auth.service'; +import { UserSelectors } from '@core/store/user'; import { InputLimits } from '@osf/shared/constants/input-limits.const'; import { RequestAccessService } from '@osf/shared/services/request-access.service'; import { ToastService } from '@osf/shared/services/toast.service'; @@ -18,10 +19,17 @@ import { AuthServiceMock, AuthServiceMockType } from '@testing/providers/auth-se import { LoaderServiceMock, provideLoaderServiceMock } from '@testing/providers/loader-service.mock'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; +import { BaseSetupOverrides, mergeSignalOverrides, provideMockStore } from '@testing/providers/store-provider.mock'; import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; import { RequestAccessComponent } from './request-access.component'; +interface SetupOverrides extends BaseSetupOverrides { + routeId?: string; + requestAccessResult?: Observable; + requestAccessError?: HttpErrorResponse; +} + describe('RequestAccessComponent', () => { let fixture: ComponentFixture; let component: RequestAccessComponent; @@ -31,12 +39,10 @@ describe('RequestAccessComponent', () => { let toastServiceMock: ToastServiceMockType; let authServiceMock: AuthServiceMockType; - function setup(overrides?: { - routeId?: string; - requestAccessResult?: Observable; - requestAccessError?: HttpErrorResponse; - }) { + function setup(overrides?: SetupOverrides) { const routeId = overrides?.routeId ?? 'project-1'; + const defaultSignals = [{ selector: UserSelectors.isProjectReadOnly, value: false }]; + const signals = mergeSignalOverrides(defaultSignals, overrides?.selectorOverrides ?? []); routerMock = RouterMockBuilder.create().withNavigate(vi.fn().mockResolvedValue(true)).build(); loaderServiceMock = new LoaderServiceMock(); toastServiceMock = ToastServiceMock.simple(); @@ -60,6 +66,7 @@ describe('RequestAccessComponent', () => { MockProvider(RequestAccessService, requestAccessServiceMock), MockProvider(ToastService, toastServiceMock), MockProvider(AuthService, authServiceMock), + provideMockStore({ signals }), ], }); @@ -86,6 +93,23 @@ describe('RequestAccessComponent', () => { expect(supportLink.textContent).toContain(component.supportEmail); }); + it('should expose title and message translations based on read-only state', () => { + setup({ selectorOverrides: [{ selector: UserSelectors.isProjectReadOnly, value: true }] }); + expect(component.titleTranslation()).toBe('requestAccess.readOnlyTitle'); + expect(component.messageTranslation()).toBe('requestAccess.messageReadOnly'); + + const buttons = fixture.nativeElement.querySelectorAll('p-button'); + expect(buttons).toHaveLength(1); + }); + + it('should expose title and message translations based on non-read-only state', () => { + setup({ selectorOverrides: [{ selector: UserSelectors.isProjectReadOnly, value: false }] }); + expect(component.titleTranslation()).toBe('requestAccess.title'); + expect(component.messageTranslation()).toBe('requestAccess.message'); + const buttons = fixture.nativeElement.querySelectorAll('p-button'); + expect(buttons.length).toBe(2); + }); + it('should request access and handle success flow', () => { setup({ routeId: 'project-123' }); component.comment.set('please grant access'); diff --git a/src/app/core/components/request-access/request-access.component.ts b/src/app/core/components/request-access/request-access.component.ts index eaa56b861..fe8002095 100644 --- a/src/app/core/components/request-access/request-access.component.ts +++ b/src/app/core/components/request-access/request-access.component.ts @@ -1,3 +1,5 @@ +import { select } from '@ngxs/store'; + import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; @@ -6,13 +8,14 @@ import { Textarea } from 'primeng/textarea'; import { map, of } from 'rxjs'; import { HttpErrorResponse } from '@angular/common/http'; -import { ChangeDetectionStrategy, Component, inject, model } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, inject, model } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import { ENVIRONMENT } from '@core/provider/environment.provider'; import { AuthService } from '@core/services/auth.service'; +import { UserSelectors } from '@osf/core/store/user/user.selectors'; import { InputLimits } from '@osf/shared/constants/input-limits.const'; import { LoaderService } from '@osf/shared/services/loader.service'; import { RequestAccessService } from '@osf/shared/services/request-access.service'; @@ -41,6 +44,16 @@ export class RequestAccessComponent { private readonly toastService = inject(ToastService); private readonly authService = inject(AuthService); + readonly isProjectReadOnly = select(UserSelectors.isProjectReadOnly); + + readonly titleTranslation = computed(() => + this.isProjectReadOnly() ? 'requestAccess.readOnlyTitle' : 'requestAccess.title' + ); + + readonly messageTranslation = computed(() => + this.isProjectReadOnly() ? 'requestAccess.messageReadOnly' : 'requestAccess.message' + ); + requestAccess() { this.loaderService.show(); this.requestAccessService.requestAccessToProject(this.id(), this.comment()).subscribe({ diff --git a/src/app/core/store/user/user.selectors.ts b/src/app/core/store/user/user.selectors.ts index 311d3eec1..f8bf086fb 100644 --- a/src/app/core/store/user/user.selectors.ts +++ b/src/app/core/store/user/user.selectors.ts @@ -58,4 +58,14 @@ export class UserSelectors { static getActiveFlags(state: UserStateModel): string[] { return state.activeFlags || []; } + + @Selector([UserState]) + static isProjectCreationDisabled(state: UserStateModel): boolean { + return state.activeFlags?.includes('prevent_project_creation') || false; + } + + @Selector([UserState]) + static isProjectReadOnly(state: UserStateModel): boolean { + return state.activeFlags?.includes('project_read_only') || false; + } } diff --git a/src/app/features/analytics/components/view-duplicates/view-duplicates.component.html b/src/app/features/analytics/components/view-duplicates/view-duplicates.component.html index 0b19ebb0f..6e4fbcfae 100644 --- a/src/app/features/analytics/components/view-duplicates/view-duplicates.component.html +++ b/src/app/features/analytics/components/view-duplicates/view-duplicates.component.html @@ -2,6 +2,8 @@ [title]="'project.analytics.kpi.forks' | translate" [showButton]="isAuthenticated()" [buttonLabel]="'project.overview.actions.forkProjectLabel' | translate" + [isButtonDisabled]="preventDuplicateCreation()" + [buttonTooltip]="duplicateButtonTooltip() | translate" (buttonClick)="handleForkResource()" /> diff --git a/src/app/features/analytics/components/view-duplicates/view-duplicates.component.spec.ts b/src/app/features/analytics/components/view-duplicates/view-duplicates.component.spec.ts index 5fc67205b..83d466144 100644 --- a/src/app/features/analytics/components/view-duplicates/view-duplicates.component.spec.ts +++ b/src/app/features/analytics/components/view-duplicates/view-duplicates.component.spec.ts @@ -7,6 +7,7 @@ import { of } from 'rxjs'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute, Router } from '@angular/router'; +import { UserSelectors } from '@osf/core/store/user/user.selectors'; import { ProjectOverviewSelectors } from '@osf/features/project/overview/store'; import { RegistrySelectors } from '@osf/features/registry/store/registry'; import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component'; @@ -24,10 +25,14 @@ 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 } 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 { ViewDuplicatesComponent } from './view-duplicates.component'; +interface SetupOverrides extends BaseSetupOverrides { + selectors?: any[]; +} + describe('Component: View Duplicates', () => { let component: ViewDuplicatesComponent; let fixture: ComponentFixture; @@ -35,7 +40,7 @@ describe('Component: View Duplicates', () => { let activatedRouteMock: ReturnType; let mockCustomDialogService: ReturnType; - beforeEach(() => { + function setup(overrides: SetupOverrides = {}) { mockCustomDialogService = CustomDialogServiceMockBuilder.create().build(); routerMock = RouterMockBuilder.create().build(); activatedRouteMock = ActivatedRouteMockBuilder.create() @@ -43,6 +48,18 @@ describe('Component: View Duplicates', () => { .withData({ resourceType: ResourceType.Project }) .build(); + const defaultSelectors = [ + { selector: DuplicatesSelectors.getDuplicates, value: [] }, + { selector: DuplicatesSelectors.getDuplicatesLoading, value: false }, + { selector: DuplicatesSelectors.getDuplicatesTotalCount, value: 0 }, + { selector: ProjectOverviewSelectors.getProject, value: MOCK_PROJECT_OVERVIEW }, + { selector: ProjectOverviewSelectors.isProjectAnonymous, value: false }, + { selector: RegistrySelectors.getRegistry, value: undefined }, + { selector: RegistrySelectors.isRegistryAnonymous, value: false }, + { selector: UserSelectors.isProjectCreationDisabled, value: false }, + ]; + const signals = mergeSignalOverrides(defaultSelectors, overrides.selectors || []); + TestBed.configureTestingModule({ imports: [ ViewDuplicatesComponent, @@ -58,15 +75,7 @@ describe('Component: View Duplicates', () => { providers: [ provideOSFCore(), provideMockStore({ - signals: [ - { selector: DuplicatesSelectors.getDuplicates, value: [] }, - { selector: DuplicatesSelectors.getDuplicatesLoading, value: false }, - { selector: DuplicatesSelectors.getDuplicatesTotalCount, value: 0 }, - { selector: ProjectOverviewSelectors.getProject, value: MOCK_PROJECT_OVERVIEW }, - { selector: ProjectOverviewSelectors.isProjectAnonymous, value: false }, - { selector: RegistrySelectors.getRegistry, value: undefined }, - { selector: RegistrySelectors.isRegistryAnonymous, value: false }, - ], + signals, }), MockProvider(CustomDialogService, mockCustomDialogService), MockProvider(Router, routerMock), @@ -78,13 +87,23 @@ describe('Component: View Duplicates', () => { component = fixture.componentInstance; fixture.detectChanges(); - }); + } it('should create', () => { + setup(); expect(component).toBeTruthy(); }); + it('should disable fork button and show tooltip when isProjectCreationDisabled is true', () => { + setup({ + selectors: [{ selector: UserSelectors.isProjectCreationDisabled, value: true }], + }); + expect(component.preventDuplicateCreation()).toBe(true); + expect(component.duplicateButtonTooltip()).toBe('project.overview.actions.duplicatingProjectsNotAllowed'); + }); + it('should open ForkDialog with width 450px when small and not refresh on failure', () => { + setup(); (component as any).actions = { ...component.actions, getDuplicates: vi.fn() }; const openSpy = vi @@ -98,12 +117,14 @@ describe('Component: View Duplicates', () => { }); it('should update currentPage when page is defined', () => { + setup(); const event: PaginatorState = { page: 1 } as PaginatorState; component.onPageChange(event); expect(component.currentPage()).toBe(2); }); it('should not update currentPage when page is undefined', () => { + setup(); component.currentPage.set(5); const event: PaginatorState = { page: undefined } as PaginatorState; component.onPageChange(event); diff --git a/src/app/features/analytics/components/view-duplicates/view-duplicates.component.ts b/src/app/features/analytics/components/view-duplicates/view-duplicates.component.ts index 458fdb4c6..11ecd3444 100644 --- a/src/app/features/analytics/components/view-duplicates/view-duplicates.component.ts +++ b/src/app/features/analytics/components/view-duplicates/view-duplicates.component.ts @@ -77,11 +77,15 @@ export class ViewDuplicatesComponent { isDuplicatesLoading = select(DuplicatesSelectors.getDuplicatesLoading); totalDuplicates = select(DuplicatesSelectors.getDuplicatesTotalCount); isAuthenticated = select(UserSelectors.isAuthenticated); + preventDuplicateCreation = select(UserSelectors.isProjectCreationDisabled); readonly pageSize = 10; currentPage = signal(1); firstIndex = computed(() => (this.currentPage() - 1) * this.pageSize); + duplicateButtonTooltip = computed(() => + this.preventDuplicateCreation() ? 'project.overview.actions.duplicatingProjectsNotAllowed' : '' + ); readonly forkActionItems = (resourceId: string) => [ { diff --git a/src/app/features/collections/components/add-to-collection/add-to-collection.component.html b/src/app/features/collections/components/add-to-collection/add-to-collection.component.html index 15486dc58..a4cd6f026 100644 --- a/src/app/features/collections/components/add-to-collection/add-to-collection.component.html +++ b/src/app/features/collections/components/add-to-collection/add-to-collection.component.html @@ -19,6 +19,7 @@

{{ collectionProvider()? [stepperActiveValue]="stepperActiveValue()" [collectionId]="primaryCollectionId() ?? ''" [targetStepValue]="AddToCollectionSteps.SelectProject" + [isProjectReadOnly]="isProjectReadOnly()" (projectSelected)="handleProjectSelected()" (stepChange)="handleChangeStep($event)" /> @@ -68,7 +69,8 @@

{{ collectionProvider()?

diff --git a/src/app/features/collections/components/add-to-collection/add-to-collection.component.spec.ts b/src/app/features/collections/components/add-to-collection/add-to-collection.component.spec.ts index 0d7056142..8062f0dde 100644 --- a/src/app/features/collections/components/add-to-collection/add-to-collection.component.spec.ts +++ b/src/app/features/collections/components/add-to-collection/add-to-collection.component.spec.ts @@ -50,6 +50,8 @@ const DEFAULT_SIGNALS: SignalOverride[] = [ { selector: CollectionsSelectors.getRequiredMetadataTemplate, value: null }, { selector: ProjectsSelectors.getSelectedProject, value: MOCK_PROJECT }, { selector: UserSelectors.getCurrentUser, value: MOCK_USER }, + { selector: UserSelectors.getActiveFlags, value: [] }, + { selector: UserSelectors.isProjectReadOnly, value: false }, { selector: MetadataSelectors.getCedarRecords, value: [] }, { selector: AddToCollectionSelectors.getCurrentCollectionSubmission, value: null }, ]; @@ -331,4 +333,9 @@ describe('AddToCollectionComponent', () => { expect(component.allowNavigation()).toBe(true); expect(mockRouter.navigate).toHaveBeenCalledWith(['project-1', 'overview']); }); + + it('should disable the add to collection button if isProjectReadOnly', () => { + const { component } = setup({ selectorOverrides: [{ selector: UserSelectors.isProjectReadOnly, value: true }] }); + expect(component.disabledAddButtonTooltip()).toBe('common.errorMessages.actionUnavailable'); + }); }); diff --git a/src/app/features/collections/components/add-to-collection/add-to-collection.component.ts b/src/app/features/collections/components/add-to-collection/add-to-collection.component.ts index 7c78df5cb..e9ca8c814 100644 --- a/src/app/features/collections/components/add-to-collection/add-to-collection.component.ts +++ b/src/app/features/collections/components/add-to-collection/add-to-collection.component.ts @@ -4,6 +4,7 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { Stepper } from 'primeng/stepper'; +import { Tooltip } from 'primeng/tooltip'; import { filter, finalize, map, Observable, of, switchMap } from 'rxjs'; @@ -65,6 +66,7 @@ import { SelectProjectStepComponent } from './select-project-step/select-project Button, Stepper, RouterLink, + Tooltip, TranslatePipe, LoadingSpinnerComponent, SelectProjectStepComponent, @@ -100,6 +102,7 @@ export class AddToCollectionComponent implements CanDeactivateComponent { selectedProject = select(ProjectsSelectors.getSelectedProject); currentUser = select(UserSelectors.getCurrentUser); currentCollectionSubmission = select(AddToCollectionSelectors.getCurrentCollectionSubmission); + isProjectReadOnly = select(UserSelectors.isProjectReadOnly); cedarRecords = select(MetadataSelectors.getCedarRecords); providerId = signal(''); @@ -117,6 +120,7 @@ export class AddToCollectionComponent implements CanDeactivateComponent { isCollectionMetadataDisabled = computed( () => !this.selectedProject() || !this.projectMetadataSaved() || !this.projectContributorsSaved() ); + disabledAddButtonTooltip = computed(() => (this.isProjectReadOnly() ? 'common.errorMessages.actionUnavailable' : '')); existingCedarRecord = computed(() => { const records = this.cedarRecords(); const templateId = this.requiredMetadataTemplate()?.id; diff --git a/src/app/features/collections/components/add-to-collection/select-project-step/select-project-step.component.html b/src/app/features/collections/components/add-to-collection/select-project-step/select-project-step.component.html index ceae12d81..df62da00b 100644 --- a/src/app/features/collections/components/add-to-collection/select-project-step/select-project-step.component.html +++ b/src/app/features/collections/components/add-to-collection/select-project-step/select-project-step.component.html @@ -1,4 +1,8 @@ - +
@@ -28,6 +32,7 @@

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

[excludeProjectIds]="excludedProjectIds()" [publicOnly]="true" [(selectedProject)]="currentSelectedProject" + [disabled]="isProjectReadOnly()" (projectChange)="handleProjectChange($event)" (projectsLoaded)="handleProjectsLoaded($event)" /> diff --git a/src/app/features/collections/components/add-to-collection/select-project-step/select-project-step.component.ts b/src/app/features/collections/components/add-to-collection/select-project-step/select-project-step.component.ts index 7658ab614..5e380ca53 100644 --- a/src/app/features/collections/components/add-to-collection/select-project-step/select-project-step.component.ts +++ b/src/app/features/collections/components/add-to-collection/select-project-step/select-project-step.component.ts @@ -4,6 +4,7 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { Step, StepItem, StepPanel } from 'primeng/stepper'; +import { Tooltip } from 'primeng/tooltip'; import { ChangeDetectionStrategy, Component, computed, input, output, signal } from '@angular/core'; @@ -16,7 +17,7 @@ import { ProjectsSelectors } from '@shared/stores/projects/projects.selectors'; @Component({ selector: 'osf-select-project-step', - imports: [Button, TranslatePipe, ProjectSelectorComponent, Step, StepItem, StepPanel], + imports: [Button, Tooltip, TranslatePipe, ProjectSelectorComponent, Step, StepItem, StepPanel], templateUrl: './select-project-step.component.html', styleUrl: './select-project-step.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -28,6 +29,7 @@ export class SelectProjectStepComponent { stepperActiveValue = input.required(); targetStepValue = input.required(); collectionId = input.required(); + isProjectReadOnly = input.required(); stepChange = output(); projectSelected = output(); diff --git a/src/app/features/collections/components/collections-discover/collections-discover.component.html b/src/app/features/collections/components/collections-discover/collections-discover.component.html index ea5c5cd98..35c0cf3d9 100644 --- a/src/app/features/collections/components/collections-discover/collections-discover.component.html +++ b/src/app/features/collections/components/collections-discover/collections-discover.component.html @@ -20,7 +20,12 @@

{{ collectionProvider()? }

- +
diff --git a/src/app/features/collections/components/collections-discover/collections-discover.component.spec.ts b/src/app/features/collections/components/collections-discover/collections-discover.component.spec.ts index 42e4f6c3c..8e03a9d20 100644 --- a/src/app/features/collections/components/collections-discover/collections-discover.component.spec.ts +++ b/src/app/features/collections/components/collections-discover/collections-discover.component.spec.ts @@ -8,6 +8,7 @@ import { TestBed } from '@angular/core/testing'; import { ActivatedRoute } from '@angular/router'; import { ENVIRONMENT } from '@core/provider/environment.provider'; +import { UserSelectors } from '@core/store/user'; import { GlobalSearchComponent } from '@osf/shared/components/global-search/global-search.component'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; import { SearchInputComponent } from '@osf/shared/components/search-input/search-input.component'; @@ -20,7 +21,7 @@ import { MOCK_PROVIDER } from '@testing/mocks/provider.mock'; 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 { provideMockStore } from '@testing/providers/store-provider.mock'; +import { mergeSignalOverrides, provideMockStore } from '@testing/providers/store-provider.mock'; import { ToastServiceMock } from '@testing/providers/toast-provider.mock'; import { CollectionsDiscoverComponent } from './collections-discover.component'; @@ -76,15 +77,24 @@ const MOCK_COLLECTION_PROVIDER_WITH_TEMPLATE = { interface SetupOptions { provider?: typeof MOCK_COLLECTION_PROVIDER | typeof MOCK_COLLECTION_PROVIDER_WITH_TEMPLATE; + selectorOverrides?: { selector: any; value: any }[]; } function setup(options: SetupOptions = {}) { - const { provider = MOCK_COLLECTION_PROVIDER } = options; + const { provider = MOCK_COLLECTION_PROVIDER, selectorOverrides = [] } = options; const toastServiceMock = ToastServiceMock.simple(); const mockCustomDialogService = CustomDialogServiceMockBuilder.create().build(); const mockRoute = ActivatedRouteMockBuilder.create().withParams({ providerId: 'provider-1' }).build(); + const defaultSignals = [ + { selector: CollectionsSelectors.getCollectionProvider, value: provider }, + { selector: CollectionsSelectors.getCollectionProviderLoading, value: false }, + { selector: UserSelectors.isProjectReadOnly, value: false }, + ]; + + const signals = mergeSignalOverrides(defaultSignals, selectorOverrides || []); + TestBed.configureTestingModule({ imports: [ CollectionsDiscoverComponent, @@ -97,10 +107,7 @@ function setup(options: SetupOptions = {}) { MockProvider(CustomDialogService, mockCustomDialogService), MockProvider(ActivatedRoute, mockRoute), provideMockStore({ - signals: [ - { selector: CollectionsSelectors.getCollectionProvider, value: provider }, - { selector: CollectionsSelectors.getCollectionProviderLoading, value: false }, - ], + signals, }), ], }); @@ -161,4 +168,9 @@ describe('CollectionsDiscoverComponent', () => { const el = fixture.nativeElement as HTMLElement; expect(el.querySelector('osf-global-search')).toBeTruthy(); }); + + it('should disable add button when user has isProjectReadOnly', () => { + const { component } = setup({ selectorOverrides: [{ selector: UserSelectors.isProjectReadOnly, value: true }] }); + expect(component.disableAddButtonTooltip()).toBe('common.errorMessages.actionUnavailable'); + }); }); diff --git a/src/app/features/collections/components/collections-discover/collections-discover.component.ts b/src/app/features/collections/components/collections-discover/collections-discover.component.ts index 39fc8245d..dfbff4975 100644 --- a/src/app/features/collections/components/collections-discover/collections-discover.component.ts +++ b/src/app/features/collections/components/collections-discover/collections-discover.component.ts @@ -3,6 +3,7 @@ import { createDispatchMap, select } from '@ngxs/store'; import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; +import { Tooltip } from 'primeng/tooltip'; import { isPlatformBrowser } from '@angular/common'; import { @@ -18,6 +19,7 @@ import { import { FormControl } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { UserSelectors } from '@osf/core/store/user'; import { GlobalSearchComponent } from '@osf/shared/components/global-search/global-search.component'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; import { SearchInputComponent } from '@osf/shared/components/search-input/search-input.component'; @@ -32,7 +34,15 @@ import { CollectionsHelpDialogComponent } from '../collections-help-dialog/colle @Component({ selector: 'osf-collections-discover', - imports: [Button, RouterLink, SearchInputComponent, GlobalSearchComponent, LoadingSpinnerComponent, TranslatePipe], + imports: [ + Button, + RouterLink, + SearchInputComponent, + GlobalSearchComponent, + LoadingSpinnerComponent, + Tooltip, + TranslatePipe, + ], templateUrl: './collections-discover.component.html', styleUrl: './collections-discover.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -53,8 +63,10 @@ export class CollectionsDiscoverComponent { collectionProvider = select(CollectionsSelectors.getCollectionProvider); isProviderLoading = select(CollectionsSelectors.getCollectionProviderLoading); + disableAddButton = select(UserSelectors.isProjectReadOnly); primaryCollectionId = computed(() => this.collectionProvider()?.primaryCollection?.id); + disableAddButtonTooltip = computed(() => (this.disableAddButton() ? 'common.errorMessages.actionUnavailable' : '')); actions = createDispatchMap({ getCollectionProvider: GetCollectionProvider, diff --git a/src/app/features/contributors/contributors.component.html b/src/app/features/contributors/contributors.component.html index fbcd27799..a25969495 100644 --- a/src/app/features/contributors/contributors.component.html +++ b/src/app/features/contributors/contributors.component.html @@ -4,8 +4,9 @@

{{ 'navigation.contributors' | translate } @if (hasAdminAccess()) { } diff --git a/src/app/features/contributors/contributors.component.spec.ts b/src/app/features/contributors/contributors.component.spec.ts index 37e11e236..0e3da7e90 100644 --- a/src/app/features/contributors/contributors.component.spec.ts +++ b/src/app/features/contributors/contributors.component.spec.ts @@ -78,6 +78,7 @@ describe('ContributorsComponent', () => { { selector: UserSelectors.getCurrentUser, value: { id: 'user-1' } }, { selector: ContributorsSelectors.getContributorsPageSize, value: 10 }, { selector: ContributorsSelectors.isContributorsLoadingMore, value: false }, + { selector: UserSelectors.isProjectReadOnly, value: false }, ]; function setup(overrides: BaseSetupOverrides = {}) { @@ -243,4 +244,46 @@ describe('ContributorsComponent', () => { expect(store.dispatch).toHaveBeenCalledWith(new ResetContributorsState()); }); + + it('should disable add contributor button when loading, read-only, or no admin access', () => { + setup({ + routeParams: { id: 'resource-id' }, + selectorOverrides: [ + { selector: ContributorsSelectors.isContributorsLoading, value: true }, + { selector: UserSelectors.isProjectReadOnly, value: false }, + { selector: CurrentResourceSelectors.hasResourceAdminAccess, value: true }, + ], + }); + expect(component.disableAddButton()).toBe(true); + + setup({ + routeParams: { id: 'resource-id' }, + selectorOverrides: [ + { selector: ContributorsSelectors.isContributorsLoading, value: false }, + { selector: UserSelectors.isProjectReadOnly, value: true }, + { selector: CurrentResourceSelectors.hasResourceAdminAccess, value: true }, + ], + }); + expect(component.disableAddButton()).toBe(true); + + setup({ + routeParams: { id: 'resource-id' }, + selectorOverrides: [ + { selector: ContributorsSelectors.isContributorsLoading, value: false }, + { selector: UserSelectors.isProjectReadOnly, value: false }, + { selector: CurrentResourceSelectors.hasResourceAdminAccess, value: false }, + ], + }); + expect(component.disableAddButton()).toBe(true); + + setup({ + routeParams: { id: 'resource-id' }, + selectorOverrides: [ + { selector: ContributorsSelectors.isContributorsLoading, value: false }, + { selector: UserSelectors.isProjectReadOnly, value: false }, + { selector: CurrentResourceSelectors.hasResourceAdminAccess, value: true }, + ], + }); + expect(component.disableAddButton()).toBe(false); + }); }); diff --git a/src/app/features/contributors/contributors.component.ts b/src/app/features/contributors/contributors.component.ts index 2fab14f2c..6618e7101 100644 --- a/src/app/features/contributors/contributors.component.ts +++ b/src/app/features/contributors/contributors.component.ts @@ -5,6 +5,7 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { Select } from 'primeng/select'; import { TableModule } from 'primeng/table'; +import { Tooltip } from 'primeng/tooltip'; import { debounceTime, distinctUntilChanged, filter, map, of, switchMap } from 'rxjs'; @@ -94,6 +95,7 @@ import { ResourceInfoModel } from './models'; RequestAccessTableComponent, ViewOnlyTableComponent, TranslatePipe, + Tooltip, ], templateUrl: './contributors.component.html', styleUrl: './contributors.component.scss', @@ -138,6 +140,7 @@ export class ContributorsComponent implements OnInit, OnDestroy { readonly hasAdminAccess = select(CurrentResourceSelectors.hasResourceAdminAccess); readonly resourceAccessRequestEnabled = select(CurrentResourceSelectors.resourceAccessRequestEnabled); readonly currentUser = select(UserSelectors.getCurrentUser); + readonly isProjectReadOnly = select(UserSelectors.isProjectReadOnly); readonly tableParams = computed(() => ({ ...DEFAULT_TABLE_PARAMS, @@ -148,6 +151,7 @@ export class ContributorsComponent implements OnInit, OnDestroy { rows: this.pageSize(), })); + disableAddButton = computed(() => this.isContributorsLoading() || this.isProjectReadOnly() || !this.hasAdminAccess()); canCreateViewLink = computed(() => !!this.resourceDetails() && !!this.resourceId()); searchPlaceholder = computed(() => this.resourceType() === ResourceType.Project 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 c29b33428..a63f8511b 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 @@ -18,6 +18,8 @@
@if (canUpdateFiles()) { @if (canUpdateFiles()) { { expect(component.selectedFilesCount()).toBe(0); expect(component.canUpdateFiles()).toBe(true); expect(component.hasViewOnly()).toBe(false); + expect(component.isProjectReadOnly()).toBe(false); }); it('should update selected files count input', () => { @@ -50,7 +51,14 @@ describe('FilesSelectionActionsComponent', () => { expect(component.hasViewOnly()).toBe(true); }); - it('should emit copySelected output', () => { + it('should handle isProjectReadOnly input', () => { + fixture.componentRef.setInput('isProjectReadOnly', true); + fixture.detectChanges(); + + expect(component.isProjectReadOnly()).toBe(true); + }); + + it('should emit copySelected event', () => { const copySelectedSpy = vi.spyOn(component.copySelected, 'emit'); component.copySelected.emit(); 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 54f281165..2d48328b4 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 @@ -1,12 +1,13 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; +import { Tooltip } from 'primeng/tooltip'; import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; @Component({ selector: 'osf-files-selection-actions', - imports: [Button, TranslatePipe], + imports: [Button, Tooltip, TranslatePipe], templateUrl: './files-selection-actions.component.html', styleUrl: './files-selection-actions.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -15,6 +16,7 @@ export class FilesSelectionActionsComponent { selectedFilesCount = input(0); canUpdateFiles = input(true); hasViewOnly = input(false); + isProjectReadOnly = input(false); copySelected = output(); moveSelected = output(); deleteSelected = output(); diff --git a/src/app/features/files/pages/files/files.component.html b/src/app/features/files/pages/files/files.component.html index 0b80d588c..b7739aa2e 100644 --- a/src/app/features/files/pages/files/files.component.html +++ b/src/app/features/files/pages/files/files.component.html @@ -27,6 +27,7 @@ [canUpdateFiles]="canUploadFiles()" [selectedFilesCount]="filesSelection.length" [hasViewOnly]="hasViewOnly()" + [isProjectReadOnly]="isProjectReadOnly()" (deleteSelected)="onDeleteSelected()" (moveSelected)="onMoveSelected()" (copySelected)="onCopySelected()" @@ -72,7 +73,8 @@ @if (canUploadFiles() && !hasViewOnly()) { ; resourceId?: string; + fileProvider?: string; + hasViewOnlyParam?: boolean; + withResourceType?: ResourceType; } describe('FilesComponent', () => { @@ -139,10 +144,11 @@ describe('FilesComponent', () => { }; const resourceRoute = ActivatedRouteMockBuilder.create() + .withData({ resourceType: overrides.withResourceType ?? ResourceType.Project }) .withParams({ id: overrides.resourceId ?? 'node-1' }) .build(); const dataRoute = ActivatedRouteMockBuilder.create() - .withData({ resourceType: ResourceType.Project }) + .withData({ resourceType: overrides.withResourceType ?? ResourceType.Project }) .withParentRoute(resourceRoute) .build(); const routeMock = ActivatedRouteMockBuilder.create() @@ -166,10 +172,11 @@ describe('FilesComponent', () => { { selector: FilesSelectors.isConfiguredStorageAddonsLoading, value: false }, { selector: FilesSelectors.getStorageSupportedFeatures, - value: { [FileProvider.OsfStorage]: [SupportedFeature.AddUpdateFiles] }, + value: { [FileProvider.OsfStorage]: [...Object.values(SupportedFeature)] }, }, { selector: CurrentResourceSelectors.hasResourceWriteAccess, value: true }, { selector: CurrentResourceSelectors.hasResourceAdminAccess, value: false }, + { selector: UserSelectors.isProjectReadOnly, value: false }, ]; TestBed.configureTestingModule({ @@ -232,6 +239,114 @@ describe('FilesComponent', () => { expect(calls).toContainEqual(new GetConfiguredStorageAddons('node-1')); }); + it('should compute isProjectReadOnly true when readOnlyFlagActive is true and resourceType is Project', () => { + setup({ + withResourceType: ResourceType.Project, + selectorOverrides: [{ selector: UserSelectors.isProjectReadOnly, value: true }], + }); + expect(component.isProjectReadOnly()).toBe(true); + + setup({ + withResourceType: ResourceType.ProjectComponent, + selectorOverrides: [{ selector: UserSelectors.isProjectReadOnly, value: true }], + }); + expect(component.isProjectReadOnly()).toBe(true); + + setup({ + withResourceType: ResourceType.Project, + selectorOverrides: [{ selector: UserSelectors.isProjectReadOnly, value: false }], + }); + expect(component.isProjectReadOnly()).toBe(false); + + setup({ + withResourceType: ResourceType.ProjectComponent, + selectorOverrides: [{ selector: UserSelectors.isProjectReadOnly, value: false }], + }); + expect(component.isProjectReadOnly()).toBe(false); + + setup({ + withResourceType: ResourceType.Registration, + selectorOverrides: [{ selector: UserSelectors.isProjectReadOnly, value: true }], + }); + expect(component.isProjectReadOnly()).toBe(false); + }); + + it('should compute allowedMenuActions based on view only, registration, edit access and project read only flag', () => { + const editableMenu = { + [FileMenuType.Download]: true, + [FileMenuType.Embed]: true, + [FileMenuType.Share]: true, + [FileMenuType.Move]: true, + [FileMenuType.Copy]: true, + [FileMenuType.Rename]: true, + [FileMenuType.Delete]: true, + }; + const readonlyMenu = { + [FileMenuType.Download]: true, + [FileMenuType.Embed]: true, + [FileMenuType.Share]: true, + [FileMenuType.Move]: false, + [FileMenuType.Copy]: false, + [FileMenuType.Rename]: false, + [FileMenuType.Delete]: false, + }; + + setup({ + selectorOverrides: [ + { selector: CurrentResourceSelectors.hasResourceWriteAccess, value: false }, + { selector: CurrentResourceSelectors.hasResourceAdminAccess, value: false }, + { selector: UserSelectors.isProjectReadOnly, value: false }, + ], + }); + expect(component.allowedMenuActions()).toEqual(readonlyMenu); + + setup({ + selectorOverrides: [ + { selector: CurrentResourceSelectors.hasResourceWriteAccess, value: true }, + { selector: CurrentResourceSelectors.hasResourceAdminAccess, value: true }, + { selector: UserSelectors.isProjectReadOnly, value: true }, + ], + }); + expect(component.allowedMenuActions()).toEqual(readonlyMenu); + + setup({ + withResourceType: ResourceType.Registration, + selectorOverrides: [ + { selector: CurrentResourceSelectors.hasResourceWriteAccess, value: true }, + { selector: CurrentResourceSelectors.hasResourceAdminAccess, value: true }, + { selector: UserSelectors.isProjectReadOnly, value: false }, + ], + }); + expect(component.allowedMenuActions()).toEqual(readonlyMenu); + + setup({ + selectorOverrides: [ + { selector: CurrentResourceSelectors.hasResourceWriteAccess, value: true }, + { selector: CurrentResourceSelectors.hasResourceAdminAccess, value: false }, + { selector: UserSelectors.isProjectReadOnly, value: false }, + ], + }); + expect(component.allowedMenuActions()).toEqual(editableMenu); + + setup({ + selectorOverrides: [ + { selector: CurrentResourceSelectors.hasResourceWriteAccess, value: false }, + { selector: CurrentResourceSelectors.hasResourceAdminAccess, value: true }, + { selector: UserSelectors.isProjectReadOnly, value: false }, + ], + }); + expect(component.allowedMenuActions()).toEqual(editableMenu); + + setup({ + selectorOverrides: [ + { selector: CurrentResourceSelectors.hasResourceWriteAccess, value: true }, + { selector: CurrentResourceSelectors.hasResourceAdminAccess, value: true }, + { selector: UserSelectors.isProjectReadOnly, value: false }, + ], + }); + expect(component.allowedMenuActions()).toEqual(editableMenu); + }); + it('should call uploadFiles from tree upload confirm callback', () => { setup(); const uploadSpy = vi.spyOn(component, 'uploadFiles').mockImplementation(() => {}); diff --git a/src/app/features/files/pages/files/files.component.ts b/src/app/features/files/pages/files/files.component.ts index 7b22779dd..81b68d112 100644 --- a/src/app/features/files/pages/files/files.component.ts +++ b/src/app/features/files/pages/files/files.component.ts @@ -4,6 +4,7 @@ import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { Select } from 'primeng/select'; +import { Tooltip } from 'primeng/tooltip'; import { debounceTime, distinctUntilChanged, finalize, map, of, switchMap, tap } from 'rxjs'; @@ -24,6 +25,7 @@ import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; +import { UserSelectors } from '@osf/core/store/user'; import { FileUploadDialogComponent } from '@osf/shared/components/file-upload-dialog/file-upload-dialog.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'; @@ -94,6 +96,7 @@ import { ViewOnlyLinkMessageComponent, FilesSelectionActionsComponent, TranslatePipe, + Tooltip, ], templateUrl: './files.component.html', styleUrl: './files.component.scss', @@ -147,6 +150,7 @@ export class FilesComponent { readonly supportedFeatures = select(FilesSelectors.getStorageSupportedFeatures); readonly hasWriteAccess = select(CurrentResourceSelectors.hasResourceWriteAccess); readonly hasAdminAccess = select(CurrentResourceSelectors.hasResourceAdminAccess); + readonly readOnlyFlagActive = select(UserSelectors.isProjectReadOnly); readonly currentResourceType = computed( () => (this.resourceMetadata()?.type as CurrentResourceType) ?? CurrentResourceType.Projects ); @@ -181,11 +185,12 @@ export class FilesComponent { const supportedFeatures = this.supportedFeatures()[provider] || []; const hasViewOnly = this.hasViewOnly(); const isRegistration = this.resourceType() === ResourceType.Registration; + const isProjectReadOnly = this.isProjectReadOnly(); const menuMap = mapMenuActions(supportedFeatures); const result: Record = { ...menuMap }; - if (hasViewOnly || isRegistration || !this.canEdit()) { + if (hasViewOnly || isRegistration || !this.canEdit() || isProjectReadOnly) { const allowed = new Set([FileMenuType.Download, FileMenuType.Embed, FileMenuType.Share]); (Object.keys(result) as FileMenuType[]).forEach((key) => { @@ -207,6 +212,12 @@ export class FilesComponent { readonly hasViewOnly = computed(() => this.viewOnlyService.hasViewOnlyParam(this.router)); readonly canEdit = computed(() => this.hasWriteAccess() || this.hasAdminAccess()); + + readonly isProjectReadOnly = computed( + () => + this.readOnlyFlagActive() && [ResourceType.Project, ResourceType.ProjectComponent].includes(this.resourceType()) + ); + readonly isRegistration = computed(() => this.resourceType() === ResourceType.Registration); canUploadFiles = computed( diff --git a/src/app/features/home/pages/dashboard/dashboard.component.html b/src/app/features/home/pages/dashboard/dashboard.component.html index 4a005698d..9a5645c48 100644 --- a/src/app/features/home/pages/dashboard/dashboard.component.html +++ b/src/app/features/home/pages/dashboard/dashboard.component.html @@ -7,6 +7,8 @@ [title]="subHeaderTitle() | translate" [icon]="'fas fa-home'" [buttonLabel]="'home.loggedIn.dashboard.createProject' | translate" + [isButtonDisabled]="projectCreationDisabled()" + [buttonTooltip]="buttonTooltip() | translate" (buttonClick)="createProject()" /> @@ -64,7 +66,7 @@

{{ 'home.loggedIn.latestResearch.title' | translate }}

} @else {
-

{{ 'home.loggedIn.dashboard.noCreatedProject' | translate }}

+

{{ noProjectsMessage() | translate }}

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 c68654ccc..360640c9b 100644 --- a/src/app/features/home/pages/dashboard/dashboard.component.spec.ts +++ b/src/app/features/home/pages/dashboard/dashboard.component.spec.ts @@ -72,6 +72,7 @@ describe('DashboardComponent', () => { { selector: MyResourcesSelectors.getProjects, value: [] }, { selector: MyResourcesSelectors.getTotalProjects, value: 0 }, { selector: MyResourcesSelectors.getProjectsLoading, value: false }, + { selector: UserSelectors.isProjectCreationDisabled, value: false }, { selector: UserSelectors.getActiveFlags, value: [] }, ]; @@ -131,6 +132,14 @@ describe('DashboardComponent', () => { ); }); + it('should disable project creation and show tooltip when isProjectCreationDisabled is true', () => { + setup({ + selectorOverrides: [{ selector: UserSelectors.isProjectCreationDisabled, value: true }], + }); + + expect(component.buttonTooltip()).toBe('home.loggedIn.dashboard.createProjectDisabledTooltip'); + }); + it('should read query params and fetch projects on init', () => { setup({ routeQueryParams: { diff --git a/src/app/features/home/pages/dashboard/dashboard.component.ts b/src/app/features/home/pages/dashboard/dashboard.component.ts index 70dbaaf5f..dff7125c6 100644 --- a/src/app/features/home/pages/dashboard/dashboard.component.ts +++ b/src/app/features/home/pages/dashboard/dashboard.component.ts @@ -69,6 +69,7 @@ export class DashboardComponent implements OnInit { readonly projects = select(MyResourcesSelectors.getProjects); readonly totalProjectsCount = select(MyResourcesSelectors.getTotalProjects); readonly areProjectsLoading = select(MyResourcesSelectors.getProjectsLoading); + readonly projectCreationDisabled = select(UserSelectors.isProjectCreationDisabled); readonly activeFlags = select(UserSelectors.getActiveFlags); readonly actions = createDispatchMap({ getMyProjects: GetMyProjects, clearMyResources: ClearMyResources }); @@ -82,7 +83,17 @@ export class DashboardComponent implements OnInit { return this.projects().filter((project) => project.title.toLowerCase().includes(search)); }); + readonly buttonTooltip = computed(() => { + return this.projectCreationDisabled() ? 'home.loggedIn.dashboard.createProjectDisabledTooltip' : ''; + }); + readonly existsProjects = computed(() => this.projects().length || !!this.searchControl.value?.length); + readonly noProjectsMessage = computed(() => { + if (this.projectCreationDisabled()) { + return 'home.loggedIn.dashboard.noCreatedProjectAndCreateProjectDisabled'; + } + return 'home.loggedIn.dashboard.noCreatedProject'; + }); readonly subHeaderTitle = computed(() => this.existsProjects() ? 'home.loggedIn.dashboard.title' : 'home.loggedIn.dashboard.welcome' ); diff --git a/src/app/features/metadata/components/base-metadata.component.ts b/src/app/features/metadata/components/base-metadata.component.ts new file mode 100644 index 000000000..720b7109f --- /dev/null +++ b/src/app/features/metadata/components/base-metadata.component.ts @@ -0,0 +1,9 @@ +import { Component, input } from '@angular/core'; + +@Component({ + template: '', +}) +export abstract class BaseMetadataComponent { + disabled = input(false); + disabledButtonTooltip = input(''); +} diff --git a/src/app/features/metadata/components/metadata-affiliated-institutions/metadata-affiliated-institutions.component.html b/src/app/features/metadata/components/metadata-affiliated-institutions/metadata-affiliated-institutions.component.html index b8d7488e4..e41430815 100644 --- a/src/app/features/metadata/components/metadata-affiliated-institutions/metadata-affiliated-institutions.component.html +++ b/src/app/features/metadata/components/metadata-affiliated-institutions/metadata-affiliated-institutions.component.html @@ -6,6 +6,9 @@

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

diff --git a/src/app/features/metadata/components/metadata-affiliated-institutions/metadata-affiliated-institutions.component.spec.ts b/src/app/features/metadata/components/metadata-affiliated-institutions/metadata-affiliated-institutions.component.spec.ts index 044300924..225ec12c7 100644 --- a/src/app/features/metadata/components/metadata-affiliated-institutions/metadata-affiliated-institutions.component.spec.ts +++ b/src/app/features/metadata/components/metadata-affiliated-institutions/metadata-affiliated-institutions.component.spec.ts @@ -42,4 +42,13 @@ describe('MetadataAffiliatedInstitutionsComponent', () => { expect(component.readonly()).toBe(true); }); + + it('should set disabled inputs', () => { + fixture.componentRef.setInput('disabled', true); + fixture.componentRef.setInput('disabledButtonTooltip', 'Editing is disabled'); + fixture.detectChanges(); + + expect(component.disabled()).toBe(true); + expect(component.disabledButtonTooltip()).toBe('Editing is disabled'); + }); }); diff --git a/src/app/features/metadata/components/metadata-affiliated-institutions/metadata-affiliated-institutions.component.ts b/src/app/features/metadata/components/metadata-affiliated-institutions/metadata-affiliated-institutions.component.ts index bcf1badf8..d1dd547da 100644 --- a/src/app/features/metadata/components/metadata-affiliated-institutions/metadata-affiliated-institutions.component.ts +++ b/src/app/features/metadata/components/metadata-affiliated-institutions/metadata-affiliated-institutions.component.ts @@ -2,19 +2,22 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { Card } from 'primeng/card'; +import { Tooltip } from 'primeng/tooltip'; import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; import { AffiliatedInstitutionsViewComponent } from '@osf/shared/components/affiliated-institutions-view/affiliated-institutions-view.component'; import { Institution } from '@osf/shared/models/institutions/institutions.model'; +import { BaseMetadataComponent } from '../base-metadata.component'; + @Component({ selector: 'osf-metadata-affiliated-institutions', - imports: [Button, Card, TranslatePipe, AffiliatedInstitutionsViewComponent], + imports: [Button, Card, Tooltip, TranslatePipe, AffiliatedInstitutionsViewComponent], templateUrl: './metadata-affiliated-institutions.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class MetadataAffiliatedInstitutionsComponent { +export class MetadataAffiliatedInstitutionsComponent extends BaseMetadataComponent { openEditAffiliatedInstitutionsDialog = output(); affiliatedInstitutions = input([]); diff --git a/src/app/features/metadata/components/metadata-contributors/metadata-contributors.component.html b/src/app/features/metadata/components/metadata-contributors/metadata-contributors.component.html index d086e52c4..af19d1d4e 100644 --- a/src/app/features/metadata/components/metadata-contributors/metadata-contributors.component.html +++ b/src/app/features/metadata/components/metadata-contributors/metadata-contributors.component.html @@ -7,6 +7,9 @@

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

(onClick)="openEditContributorDialog.emit()" severity="secondary" [label]="'common.buttons.edit' | translate" + [disabled]="disabled()" + [pTooltip]="disabledButtonTooltip()" + tooltipPosition="left" data-test-edit-contributors-button > } diff --git a/src/app/features/metadata/components/metadata-contributors/metadata-contributors.component.spec.ts b/src/app/features/metadata/components/metadata-contributors/metadata-contributors.component.spec.ts index f7506031b..cf4e5f6da 100644 --- a/src/app/features/metadata/components/metadata-contributors/metadata-contributors.component.spec.ts +++ b/src/app/features/metadata/components/metadata-contributors/metadata-contributors.component.spec.ts @@ -53,6 +53,15 @@ describe('MetadataContributorsComponent', () => { expect(component.readonly()).toBe(true); }); + it('should set disabled inputs', () => { + fixture.componentRef.setInput('disabled', true); + fixture.componentRef.setInput('disabledButtonTooltip', 'Editing is disabled'); + fixture.detectChanges(); + + expect(component.disabled()).toBe(true); + expect(component.disabledButtonTooltip()).toBe('Editing is disabled'); + }); + it('should emit openEditContributorDialog event', () => { const emitSpy = vi.spyOn(component.openEditContributorDialog, 'emit'); diff --git a/src/app/features/metadata/components/metadata-contributors/metadata-contributors.component.ts b/src/app/features/metadata/components/metadata-contributors/metadata-contributors.component.ts index abfa69571..39a2a4056 100644 --- a/src/app/features/metadata/components/metadata-contributors/metadata-contributors.component.ts +++ b/src/app/features/metadata/components/metadata-contributors/metadata-contributors.component.ts @@ -2,19 +2,22 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { Card } from 'primeng/card'; +import { Tooltip } from 'primeng/tooltip'; import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component'; import { ContributorModel } from '@osf/shared/models/contributors/contributor.model'; +import { BaseMetadataComponent } from '../base-metadata.component'; + @Component({ selector: 'osf-metadata-contributors', - imports: [Button, Card, TranslatePipe, ContributorsListComponent], + imports: [Button, Card, Tooltip, TranslatePipe, ContributorsListComponent], templateUrl: './metadata-contributors.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class MetadataContributorsComponent { +export class MetadataContributorsComponent extends BaseMetadataComponent { contributors = input([]); isLoading = input(false); hasMoreContributors = input(false); diff --git a/src/app/features/metadata/components/metadata-description/metadata-description.component.html b/src/app/features/metadata/components/metadata-description/metadata-description.component.html index 8aa659b6b..5f9f8d793 100644 --- a/src/app/features/metadata/components/metadata-description/metadata-description.component.html +++ b/src/app/features/metadata/components/metadata-description/metadata-description.component.html @@ -7,6 +7,9 @@

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

severity="secondary" [label]="'common.buttons.edit' | translate" (onClick)="openEditDescriptionDialog.emit()" + [disabled]="disabled()" + [pTooltip]="disabledButtonTooltip()" + tooltipPosition="left" data-test-edit-description-button > } diff --git a/src/app/features/metadata/components/metadata-description/metadata-description.component.spec.ts b/src/app/features/metadata/components/metadata-description/metadata-description.component.spec.ts index 326a5a411..5c2e3db13 100644 --- a/src/app/features/metadata/components/metadata-description/metadata-description.component.spec.ts +++ b/src/app/features/metadata/components/metadata-description/metadata-description.component.spec.ts @@ -31,6 +31,16 @@ describe('MetadataDescriptionComponent', () => { expect(component.description()).toEqual(mockDescription); }); + it('should set disabled inputs', () => { + fixture.componentRef.setInput('description', mockDescription); + fixture.componentRef.setInput('disabled', true); + fixture.componentRef.setInput('disabledButtonTooltip', 'Editing is disabled'); + fixture.detectChanges(); + + expect(component.disabled()).toBe(true); + expect(component.disabledButtonTooltip()).toBe('Editing is disabled'); + }); + it('should emit openEditDescriptionDialog event', () => { const emitSpy = vi.spyOn(component.openEditDescriptionDialog, 'emit'); diff --git a/src/app/features/metadata/components/metadata-description/metadata-description.component.ts b/src/app/features/metadata/components/metadata-description/metadata-description.component.ts index 27a06c164..d0f5b5168 100644 --- a/src/app/features/metadata/components/metadata-description/metadata-description.component.ts +++ b/src/app/features/metadata/components/metadata-description/metadata-description.component.ts @@ -2,16 +2,18 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { Card } from 'primeng/card'; +import { Tooltip } from 'primeng/tooltip'; import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; +import { BaseMetadataComponent } from '../base-metadata.component'; @Component({ selector: 'osf-metadata-description', - imports: [Card, Button, TranslatePipe], + imports: [Card, Button, Tooltip, TranslatePipe], templateUrl: './metadata-description.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class MetadataDescriptionComponent { +export class MetadataDescriptionComponent extends BaseMetadataComponent { openEditDescriptionDialog = output(); description = input.required(); readonly = input(false); diff --git a/src/app/features/metadata/components/metadata-funding/metadata-funding.component.html b/src/app/features/metadata/components/metadata-funding/metadata-funding.component.html index d02ec5408..5f7e0d4c0 100644 --- a/src/app/features/metadata/components/metadata-funding/metadata-funding.component.html +++ b/src/app/features/metadata/components/metadata-funding/metadata-funding.component.html @@ -6,6 +6,9 @@

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

diff --git a/src/app/features/metadata/components/metadata-funding/metadata-funding.component.spec.ts b/src/app/features/metadata/components/metadata-funding/metadata-funding.component.spec.ts index dfeaa309f..538df4b69 100644 --- a/src/app/features/metadata/components/metadata-funding/metadata-funding.component.spec.ts +++ b/src/app/features/metadata/components/metadata-funding/metadata-funding.component.spec.ts @@ -41,6 +41,15 @@ describe('MetadataFundingComponent', () => { expect(component.readonly()).toBe(true); }); + it('should set disabled inputs', () => { + fixture.componentRef.setInput('disabled', true); + fixture.componentRef.setInput('disabledButtonTooltip', 'Editing is disabled'); + fixture.detectChanges(); + + expect(component.disabled()).toBe(true); + expect(component.disabledButtonTooltip()).toBe('Editing is disabled'); + }); + it('should emit openEditFundingDialog event', () => { const emitSpy = vi.spyOn(component.openEditFundingDialog, 'emit'); diff --git a/src/app/features/metadata/components/metadata-funding/metadata-funding.component.ts b/src/app/features/metadata/components/metadata-funding/metadata-funding.component.ts index c0d6e7081..0fa62940f 100644 --- a/src/app/features/metadata/components/metadata-funding/metadata-funding.component.ts +++ b/src/app/features/metadata/components/metadata-funding/metadata-funding.component.ts @@ -2,19 +2,21 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { Card } from 'primeng/card'; +import { Tooltip } from 'primeng/tooltip'; import { NgClass } from '@angular/common'; import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; import { Funder } from '../../models'; +import { BaseMetadataComponent } from '../base-metadata.component'; @Component({ selector: 'osf-metadata-funding', - imports: [NgClass, Button, Card, TranslatePipe], + imports: [NgClass, Button, Card, Tooltip, TranslatePipe], templateUrl: './metadata-funding.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class MetadataFundingComponent { +export class MetadataFundingComponent extends BaseMetadataComponent { openEditFundingDialog = output(); funders = input(); diff --git a/src/app/features/metadata/components/metadata-license/metadata-license.component.html b/src/app/features/metadata/components/metadata-license/metadata-license.component.html index 77aa11ece..12728c341 100644 --- a/src/app/features/metadata/components/metadata-license/metadata-license.component.html +++ b/src/app/features/metadata/components/metadata-license/metadata-license.component.html @@ -7,6 +7,9 @@

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

severity="secondary" [label]="'common.buttons.edit' | translate" (onClick)="openEditLicenseDialog.emit()" + [disabled]="disabled()" + [pTooltip]="disabledButtonTooltip()" + tooltipPosition="left" data-test-edit-license-button /> } diff --git a/src/app/features/metadata/components/metadata-license/metadata-license.component.spec.ts b/src/app/features/metadata/components/metadata-license/metadata-license.component.spec.ts index 573b993f4..d014c807f 100644 --- a/src/app/features/metadata/components/metadata-license/metadata-license.component.spec.ts +++ b/src/app/features/metadata/components/metadata-license/metadata-license.component.spec.ts @@ -44,6 +44,15 @@ describe('MetadataLicenseComponent', () => { expect(component.readonly()).toBe(true); }); + it('should set disabled inputs', () => { + fixture.componentRef.setInput('disabled', true); + fixture.componentRef.setInput('disabledButtonTooltip', 'Editing is disabled'); + fixture.detectChanges(); + + expect(component.disabled()).toBe(true); + expect(component.disabledButtonTooltip()).toBe('Editing is disabled'); + }); + it('should emit openEditLicenseDialog event', () => { const emitSpy = vi.spyOn(component.openEditLicenseDialog, 'emit'); diff --git a/src/app/features/metadata/components/metadata-license/metadata-license.component.ts b/src/app/features/metadata/components/metadata-license/metadata-license.component.ts index 9fc0c98d6..7639ce9be 100644 --- a/src/app/features/metadata/components/metadata-license/metadata-license.component.ts +++ b/src/app/features/metadata/components/metadata-license/metadata-license.component.ts @@ -2,18 +2,21 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { Card } from 'primeng/card'; +import { Tooltip } from 'primeng/tooltip'; import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; import { LicenseModel } from '@osf/shared/models/license/license.model'; +import { BaseMetadataComponent } from '../base-metadata.component'; + @Component({ selector: 'osf-metadata-license', - imports: [Button, Card, TranslatePipe], + imports: [Button, Card, Tooltip, TranslatePipe], templateUrl: './metadata-license.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class MetadataLicenseComponent { +export class MetadataLicenseComponent extends BaseMetadataComponent { openEditLicenseDialog = output(); readonly = input(false); license = input(null); diff --git a/src/app/features/metadata/components/metadata-publication-doi/metadata-publication-doi.component.html b/src/app/features/metadata/components/metadata-publication-doi/metadata-publication-doi.component.html index 44341e63e..f29ede29d 100644 --- a/src/app/features/metadata/components/metadata-publication-doi/metadata-publication-doi.component.html +++ b/src/app/features/metadata/components/metadata-publication-doi/metadata-publication-doi.component.html @@ -10,6 +10,9 @@

diff --git a/src/app/features/metadata/components/metadata-publication-doi/metadata-publication-doi.component.spec.ts b/src/app/features/metadata/components/metadata-publication-doi/metadata-publication-doi.component.spec.ts index f3ed0c6d5..fb910678b 100644 --- a/src/app/features/metadata/components/metadata-publication-doi/metadata-publication-doi.component.spec.ts +++ b/src/app/features/metadata/components/metadata-publication-doi/metadata-publication-doi.component.spec.ts @@ -46,6 +46,15 @@ describe('MetadataPublicationDoiComponent', () => { expect(component.hideEditDoi()).toBe(true); }); + it('should set disabled inputs', () => { + fixture.componentRef.setInput('disabled', true); + fixture.componentRef.setInput('disabledButtonTooltip', 'Editing is disabled'); + fixture.detectChanges(); + + expect(component.disabled()).toBe(true); + expect(component.disabledButtonTooltip()).toBe('Editing is disabled'); + }); + it('should emit openEditPublicationDoiDialog event', () => { const emitSpy = vi.spyOn(component.openEditPublicationDoiDialog, 'emit'); diff --git a/src/app/features/metadata/components/metadata-publication-doi/metadata-publication-doi.component.ts b/src/app/features/metadata/components/metadata-publication-doi/metadata-publication-doi.component.ts index 10231c8b2..74f579b1d 100644 --- a/src/app/features/metadata/components/metadata-publication-doi/metadata-publication-doi.component.ts +++ b/src/app/features/metadata/components/metadata-publication-doi/metadata-publication-doi.component.ts @@ -2,19 +2,22 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { Card } from 'primeng/card'; +import { Tooltip } from 'primeng/tooltip'; import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { IdentifierModel } from '@osf/shared/models/identifiers/identifier.model'; +import { BaseMetadataComponent } from '../base-metadata.component'; + @Component({ selector: 'osf-metadata-publication-doi', - imports: [Button, Card, TranslatePipe], + imports: [Button, Card, Tooltip, TranslatePipe], templateUrl: './metadata-publication-doi.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class MetadataPublicationDoiComponent { +export class MetadataPublicationDoiComponent extends BaseMetadataComponent { openEditPublicationDoiDialog = output(); identifiers = input([]); 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 9b2e95482..078aa4f40 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 @@ -15,6 +15,9 @@

diff --git a/src/app/features/metadata/components/metadata-resource-information/metadata-resource-information.component.spec.ts b/src/app/features/metadata/components/metadata-resource-information/metadata-resource-information.component.spec.ts index 6eebd9418..8d4d8885b 100644 --- a/src/app/features/metadata/components/metadata-resource-information/metadata-resource-information.component.spec.ts +++ b/src/app/features/metadata/components/metadata-resource-information/metadata-resource-information.component.spec.ts @@ -51,6 +51,15 @@ describe('MetadataResourceInformationComponent', () => { expect(component.readonly()).toBe(true); }); + it('should set disabled inputs', () => { + fixture.componentRef.setInput('disabled', true); + fixture.componentRef.setInput('disabledButtonTooltip', 'Editing is disabled'); + fixture.detectChanges(); + + expect(component.disabled()).toBe(true); + expect(component.disabledButtonTooltip()).toBe('Editing is disabled'); + }); + it('should emit openEditResourceInformationDialog event', () => { const emitSpy = vi.spyOn(component.openEditResourceInformationDialog, 'emit'); 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 4659ab242..3c1625878 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 @@ -2,6 +2,7 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { Card } from 'primeng/card'; +import { Tooltip } from 'primeng/tooltip'; import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; @@ -9,13 +10,15 @@ import { CustomItemMetadataRecord } from '@osf/features/metadata/models'; import { LanguageLabelPipe } from '@osf/shared/pipes/language-label.pipe'; import { ResourceTypeGeneralLabelPipe } from '@osf/shared/pipes/resource-type-general-label.pipe'; +import { BaseMetadataComponent } from '../base-metadata.component'; + @Component({ selector: 'osf-metadata-resource-information', - imports: [Button, Card, TranslatePipe, LanguageLabelPipe, ResourceTypeGeneralLabelPipe], + imports: [Button, Card, Tooltip, TranslatePipe, LanguageLabelPipe, ResourceTypeGeneralLabelPipe], templateUrl: './metadata-resource-information.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class MetadataResourceInformationComponent { +export class MetadataResourceInformationComponent extends BaseMetadataComponent { openEditResourceInformationDialog = output(); customItemMetadata = input.required(); diff --git a/src/app/features/metadata/components/metadata-title/metadata-title.component.html b/src/app/features/metadata/components/metadata-title/metadata-title.component.html index 9f1e06f1a..ad4a44316 100644 --- a/src/app/features/metadata/components/metadata-title/metadata-title.component.html +++ b/src/app/features/metadata/components/metadata-title/metadata-title.component.html @@ -7,6 +7,9 @@

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

severity="secondary" [label]="'common.buttons.edit' | translate" (onClick)="openEditTitleDialog.emit()" + [disabled]="disabled()" + [pTooltip]="disabledButtonTooltip()" + tooltipPosition="left" data-test-edit-title-button > } diff --git a/src/app/features/metadata/components/metadata-title/metadata-title.component.spec.ts b/src/app/features/metadata/components/metadata-title/metadata-title.component.spec.ts index 07600066a..841d9f40d 100644 --- a/src/app/features/metadata/components/metadata-title/metadata-title.component.spec.ts +++ b/src/app/features/metadata/components/metadata-title/metadata-title.component.spec.ts @@ -31,6 +31,16 @@ describe('MetadataTitleComponent', () => { expect(component.title()).toEqual(mockTitle); }); + it('should set disabled inputs', () => { + fixture.componentRef.setInput('title', mockTitle); + fixture.componentRef.setInput('disabled', true); + fixture.componentRef.setInput('disabledButtonTooltip', 'Editing is disabled'); + fixture.detectChanges(); + + expect(component.disabled()).toBe(true); + expect(component.disabledButtonTooltip()).toBe('Editing is disabled'); + }); + it('should emit openEditTitleDialog event', () => { const emitSpy = vi.spyOn(component.openEditTitleDialog, 'emit'); diff --git a/src/app/features/metadata/components/metadata-title/metadata-title.component.ts b/src/app/features/metadata/components/metadata-title/metadata-title.component.ts index b1864575c..1c02d5acb 100644 --- a/src/app/features/metadata/components/metadata-title/metadata-title.component.ts +++ b/src/app/features/metadata/components/metadata-title/metadata-title.component.ts @@ -2,16 +2,19 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { Card } from 'primeng/card'; +import { Tooltip } from 'primeng/tooltip'; import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; +import { BaseMetadataComponent } from '../base-metadata.component'; + @Component({ selector: 'osf-metadata-title', - imports: [Card, Button, TranslatePipe], + imports: [Card, Button, Tooltip, TranslatePipe], templateUrl: './metadata-title.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class MetadataTitleComponent { +export class MetadataTitleComponent extends BaseMetadataComponent { title = input.required(); readonly = input(false); openEditTitleDialog = output(); diff --git a/src/app/features/metadata/metadata.component.html b/src/app/features/metadata/metadata.component.html index 6c0872d5d..873a2f665 100644 --- a/src/app/features/metadata/metadata.component.html +++ b/src/app/features/metadata/metadata.component.html @@ -2,7 +2,8 @@ @@ -13,7 +14,7 @@ [selectedCedarTemplate]="selectedCedarTemplate()!" [selectedCedarRecord]="selectedCedarRecord()!" [cedarFormReadonly]="cedarFormReadonly()" - [canEdit]="hasWriteAccess()" + [canEdit]="hasWriteAccess() && !isProjectReadOnly()" (changeTab)="onTabChange($event)" (formSubmit)="onCedarFormSubmit($event)" (cedarFormChangeTemplate)="onCedarFormChangeTemplate()" @@ -25,12 +26,16 @@ (openEditTitleDialog)="openEditTitleDialog()" [title]="metadata()?.title!" [readonly]="!hasWriteAccess()" + [disabled]="isProjectReadOnly()" + [disabledButtonTooltip]="disabledButtonTooltip() | translate" /> @if (isRegistrationType()) { @@ -46,6 +51,8 @@ [hasMoreContributors]="hasMoreContributors()" [readonly]="!hasWriteAccess()" (loadMoreContributors)="handleLoadMoreContributors()" + [disabled]="isProjectReadOnly()" + [disabledButtonTooltip]="disabledButtonTooltip() | translate" /> @if (isProjectType()) { @@ -82,6 +95,8 @@ (openEditLicenseDialog)="openEditLicenseDialog()" [license]="metadata()?.license!" [readonly]="!hasWriteAccess()" + [disabled]="isProjectReadOnly()" + [disabledButtonTooltip]="disabledButtonTooltip() | translate" /> @if (isRegistrationType()) { @@ -99,7 +116,7 @@

diff --git a/src/app/features/metadata/metadata.component.spec.ts b/src/app/features/metadata/metadata.component.spec.ts index 30d951f5f..1f6cfb69d 100644 --- a/src/app/features/metadata/metadata.component.spec.ts +++ b/src/app/features/metadata/metadata.component.spec.ts @@ -3,6 +3,7 @@ import { MockComponents, MockProvider } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute, Router } from '@angular/router'; +import { UserSelectors } from '@osf/core/store/user/user.selectors'; import { MetadataTabsComponent } from '@osf/shared/components/metadata-tabs/metadata-tabs.component'; import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; @@ -17,7 +18,7 @@ import { CustomConfirmationServiceMockBuilder } from '@testing/providers/custom- import { CustomDialogServiceMockBuilder } 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 { mergeSignalOverrides, provideMockStore, SignalOverride } from '@testing/providers/store-provider.mock'; import { ToastServiceMockBuilder } from '@testing/providers/toast-provider.mock'; import { MetadataAffiliatedInstitutionsComponent } from './components/metadata-affiliated-institutions/metadata-affiliated-institutions.component'; @@ -47,12 +48,23 @@ describe('MetadataComponent', () => { const mockMetadata = MOCK_PROJECT_METADATA; const mockResourceId = 'test-resource-id'; - beforeEach(() => { + function setup(selectorOverrides?: SignalOverride[]) { activatedRouteMock = ActivatedRouteMockBuilder.create() .withId(mockResourceId) .withData({ resourceType: ResourceType.Project }) .build(); + const defaultSignals: SignalOverride[] = [ + { selector: MetadataSelectors.getResourceMetadata, value: mockMetadata }, + { selector: MetadataSelectors.getLoading, value: false }, + { selector: MetadataSelectors.getSubmitting, value: false }, + { selector: MetadataSelectors.getCedarRecords, value: [] }, + { selector: MetadataSelectors.getCedarTemplates, value: null }, + { selector: RegistrationProviderSelectors.getBrandedProvider, value: null }, + { selector: UserSelectors.isProjectReadOnly, value: false }, + ]; + const signals = mergeSignalOverrides(defaultSignals, selectorOverrides); + Object.defineProperty(activatedRouteMock, 'parent', { value: { snapshot: { @@ -99,27 +111,22 @@ describe('MetadataComponent', () => { MockProvider(ToastService, toastServiceMock), MockProvider(CustomConfirmationService, customConfirmationServiceMock), provideMockStore({ - selectors: [ - { selector: MetadataSelectors.getResourceMetadata, value: mockMetadata }, - { selector: MetadataSelectors.getLoading, value: false }, - { selector: MetadataSelectors.getSubmitting, value: false }, - { selector: MetadataSelectors.getCedarRecords, value: [] }, - { selector: MetadataSelectors.getCedarTemplates, value: null }, - { selector: RegistrationProviderSelectors.getBrandedProvider, value: null }, - ], + signals: signals, }), ], }); fixture = TestBed.createComponent(MetadataComponent); component = fixture.componentInstance; - }); + } it('should create', () => { + setup(); expect(component).toBeTruthy(); }); it('should handle tab change for OSF tab', () => { + setup(); const tabId = 'osf'; const navigateSpy = vi.spyOn(routerMock, 'navigate'); @@ -130,6 +137,7 @@ describe('MetadataComponent', () => { }); it('should toggle edit mode', () => { + setup(); const initialReadonly = component.cedarFormReadonly(); component.toggleEditMode(); @@ -138,12 +146,14 @@ describe('MetadataComponent', () => { }); it('should handle tags changed', () => { + setup(); const tags = ['tag1', 'tag2']; expect(() => component.onTagsChanged(tags)).not.toThrow(); }); it('should open edit contributor dialog', () => { + setup(); const openSpy = vi.spyOn(customDialogServiceMock, 'open'); expect(openSpy).toHaveBeenCalledTimes(0); @@ -152,6 +162,7 @@ describe('MetadataComponent', () => { }); it('should open edit title dialog', () => { + setup(); const openSpy = vi.spyOn(customDialogServiceMock, 'open'); component.openEditTitleDialog(); @@ -160,6 +171,7 @@ describe('MetadataComponent', () => { }); it('should open edit description dialog', () => { + setup(); const openSpy = vi.spyOn(customDialogServiceMock, 'open'); component.openEditDescriptionDialog(); @@ -168,6 +180,7 @@ describe('MetadataComponent', () => { }); it('should open edit resource information dialog', () => { + setup(); const openSpy = vi.spyOn(customDialogServiceMock, 'open'); component.openEditResourceInformationDialog(); @@ -176,6 +189,7 @@ describe('MetadataComponent', () => { }); it('should show resource info tooltip', () => { + setup(); const openSpy = vi.spyOn(customDialogServiceMock, 'open'); component.onShowResourceInfo(); @@ -184,6 +198,7 @@ describe('MetadataComponent', () => { }); it('should open edit license dialog', () => { + setup(); const openSpy = vi.spyOn(customDialogServiceMock, 'open'); component.openEditLicenseDialog(); @@ -192,6 +207,7 @@ describe('MetadataComponent', () => { }); it('should open edit funding dialog', () => { + setup(); const openSpy = vi.spyOn(customDialogServiceMock, 'open'); component.openEditFundingDialog(); @@ -200,6 +216,7 @@ describe('MetadataComponent', () => { }); it('should open edit affiliated institutions dialog', () => { + setup(); const openSpy = vi.spyOn(customDialogServiceMock, 'open'); component.openEditAffiliatedInstitutionsDialog(); @@ -208,18 +225,21 @@ describe('MetadataComponent', () => { }); it('should handle subject children fetch', () => { + setup(); const parentId = 'parent-subject-id'; expect(() => component.getSubjectChildren(parentId)).not.toThrow(); }); it('should handle subject search', () => { + setup(); const searchTerm = 'test search'; expect(() => component.searchSubjects(searchTerm)).not.toThrow(); }); it('should handle edit DOI for project', () => { + setup(); const confirmSpy = vi.spyOn(customConfirmationServiceMock, 'confirmDelete'); component.handleEditDoi(); @@ -228,6 +248,7 @@ describe('MetadataComponent', () => { }); it('should open add record', () => { + setup(); const navigateSpy = vi.spyOn(routerMock, 'navigate'); component.openAddRecord(); @@ -236,10 +257,19 @@ describe('MetadataComponent', () => { }); it('should handle cedar form change template', () => { + setup(); const navigateSpy = vi.spyOn(routerMock, 'navigate'); component.onCedarFormChangeTemplate(); expect(navigateSpy).toHaveBeenCalled(); }); + + it('should handle isProjectReadOnly', () => { + setup([{ selector: UserSelectors.isProjectReadOnly, value: true }]); + + expect(component.isTagsReadOnly()).toBe(true); + expect(component.isSubjectsReadOnly()).toBe(true); + expect(component.disabledButtonTooltip()).toBe('common.errorMessages.actionUnavailable'); + }); }); diff --git a/src/app/features/metadata/metadata.component.ts b/src/app/features/metadata/metadata.component.ts index f82c3fec4..7503ba551 100644 --- a/src/app/features/metadata/metadata.component.ts +++ b/src/app/features/metadata/metadata.component.ts @@ -19,6 +19,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router } from '@angular/router'; import { ENVIRONMENT } from '@core/provider/environment.provider'; +import { UserSelectors } from '@osf/core/store/user/user.selectors'; import { MetadataTabsComponent } from '@osf/shared/components/metadata-tabs/metadata-tabs.component'; import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; import { MetadataResourceEnum } from '@osf/shared/enums/metadata-resource.enum'; @@ -160,6 +161,8 @@ export class MetadataComponent implements OnInit, OnDestroy { hasWriteAccess = select(MetadataSelectors.hasWriteAccess); hasAdminAccess = select(MetadataSelectors.hasAdminAccess); + isProjectReadOnly = select(UserSelectors.isProjectReadOnly); + provider = this.environment.defaultProvider; private readonly resourceNameMap = new Map([ @@ -207,8 +210,23 @@ export class MetadataComponent implements OnInit, OnDestroy { (!!this.metadata()?.identifiers?.length || !this.metadata()?.public) ); + isTagsReadOnly = computed(() => { + if (this.isProjectReadOnly()) { + return true; + } + return this.isRegistrationType() ? !this.hasAdminAccess() : !this.hasWriteAccess(); + }); + + isSubjectsReadOnly = computed(() => { + if (this.isProjectReadOnly()) { + return true; + } + return !this.hasAdminAccess(); + }); + isProjectType = computed(() => this.resourceType() === ResourceType.Project); isRegistrationType = computed(() => this.resourceType() === ResourceType.Registration); + disabledButtonTooltip = computed(() => (this.isProjectReadOnly() ? 'common.errorMessages.actionUnavailable' : '')); constructor() { effect(() => { diff --git a/src/app/features/my-projects/my-projects.component.html b/src/app/features/my-projects/my-projects.component.html index fcb4ca2c3..b35cc624a 100644 --- a/src/app/features/my-projects/my-projects.component.html +++ b/src/app/features/my-projects/my-projects.component.html @@ -3,6 +3,8 @@ [showButton]="true" [buttonLabel]="'myProjects.header.createProject' | translate" [title]="'myProjects.header.title' | translate" + [isButtonDisabled]="projectCreationDisabled()" + [buttonTooltip]="buttonTooltip() | translate" [icon]="'custom-icon-projects'" (buttonClick)="createProject()" /> 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 db1259d33..3144bb074 100644 --- a/src/app/features/my-projects/my-projects.component.spec.ts +++ b/src/app/features/my-projects/my-projects.component.spec.ts @@ -9,6 +9,7 @@ import { Mock } from 'vitest'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute, Router } from '@angular/router'; +import { UserSelectors } from '@osf/core/store/user/user.selectors'; import { MyProjectsTableComponent } from '@osf/shared/components/my-projects-table/my-projects-table.component'; import { SearchInputComponent } from '@osf/shared/components/search-input/search-input.component'; import { SelectComponent } from '@osf/shared/components/select/select.component'; @@ -76,6 +77,7 @@ describe('MyProjectsComponent', () => { { selector: BookmarksSelectors.getBookmarks, value: [] }, { selector: BookmarksSelectors.getBookmarksCollectionId, value: 'bookmark-collection-id' }, { selector: BookmarksSelectors.getBookmarksTotalCount, value: 0 }, + { selector: UserSelectors.isProjectCreationDisabled, value: false }, ]; function setup( @@ -133,6 +135,13 @@ describe('MyProjectsComponent', () => { expect(component).toBeTruthy(); }); + it('should disable project creation and show tooltip when isProjectCreationDisabled is true', () => { + setup([{ selector: UserSelectors.isProjectCreationDisabled, value: true }]); + + expect(component.projectCreationDisabled()).toBe(true); + expect(component.buttonTooltip()).toBe('myProjects.header.createProjectDisabledTooltip'); + }); + it('should dispatch get bookmarks collection id on init', () => { setup(); expect(store.dispatch).toHaveBeenCalledWith(new GetBookmarksCollectionId()); diff --git a/src/app/features/my-projects/my-projects.component.ts b/src/app/features/my-projects/my-projects.component.ts index 8fd1df271..6013c48e5 100644 --- a/src/app/features/my-projects/my-projects.component.ts +++ b/src/app/features/my-projects/my-projects.component.ts @@ -25,6 +25,7 @@ import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { FormControl, FormsModule } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; +import { UserSelectors } from '@osf/core/store/user'; import { MyProjectsTableComponent } from '@osf/shared/components/my-projects-table/my-projects-table.component'; import { SearchInputComponent } from '@osf/shared/components/search-input/search-input.component'; import { SelectComponent } from '@osf/shared/components/select/select.component'; @@ -89,6 +90,7 @@ export class MyProjectsComponent implements OnInit { readonly downloadOptionsService = inject(ProjectDownloadOptionsService); readonly platformId = inject(PLATFORM_ID); readonly isBrowser = isPlatformBrowser(this.platformId); + readonly projectCreationDisabled = select(UserSelectors.isProjectCreationDisabled); readonly isLoading = signal(false); readonly isMedium = toSignal(inject(IS_MEDIUM)); @@ -134,6 +136,9 @@ export class MyProjectsComponent implements OnInit { readonly bookmarksCollectionId = select(BookmarksSelectors.getBookmarksCollectionId); readonly totalBookmarksCount = select(BookmarksSelectors.getBookmarksTotalCount); readonly isBookmarks = computed(() => this.selectedTab() === MyProjectsTab.Bookmarks); + readonly buttonTooltip = computed(() => + this.projectCreationDisabled() ? 'myProjects.header.createProjectDisabledTooltip' : '' + ); readonly actions = createDispatchMap({ getBookmarksCollectionId: GetBookmarksCollectionId, diff --git a/src/app/features/preprints/components/stepper/review-step/review-step.component.html b/src/app/features/preprints/components/stepper/review-step/review-step.component.html index ae47fd780..325ee602d 100644 --- a/src/app/features/preprints/components/stepper/review-step/review-step.component.html +++ b/src/app/features/preprints/components/stepper/review-step/review-step.component.html @@ -220,16 +220,18 @@

} - -
-

{{ 'preprints.preprintStepper.review.sections.supplements.title' | translate }}

- @if (preprintProject()) { -

{{ preprintProject()?.name }}

- } @else { -

{{ 'preprints.preprintStepper.review.sections.supplements.noSupplements' | translate }}

- } -
-
+@if (!isProjectCreationDisabled()) { + +
+

{{ 'preprints.preprintStepper.review.sections.supplements.title' | translate }}

+ @if (preprintProject()) { +

{{ preprintProject()?.name }}

+ } @else { +

{{ 'preprints.preprintStepper.review.sections.supplements.noSupplements' | translate }}

+ } +
+
+}
(this.preprint()?.licenseOptions ?? {}) as Record); readonly ApplicabilityStatus = ApplicabilityStatus; diff --git a/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.html b/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.html index be6d06524..9c3beb310 100644 --- a/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.html +++ b/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.html @@ -25,6 +25,8 @@

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

styleClass="w-full" [label]="'preprints.preprintStepper.supplements.options.createNew' | translate" severity="secondary" + [disabled]="createProjectDisabled()" + [pTooltip]="createProjectTooltip() | translate" (onClick)="selectSupplementOption(SupplementOptions.CreateNewProject)" />
diff --git a/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.spec.ts b/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.spec.ts index 6275e16a1..40fece834 100644 --- a/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.spec.ts +++ b/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.spec.ts @@ -6,6 +6,7 @@ import { Mock } from 'vitest'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { UserSelectors } from '@osf/core/store/user/user.selectors'; import { SupplementOptions } from '@osf/features/preprints/enums'; import { ConnectProject, @@ -46,6 +47,7 @@ describe('SupplementsStepComponent', () => { { selector: PreprintStepperSelectors.areAvailableProjectsLoading, value: false }, { selector: PreprintStepperSelectors.getPreprintProject, value: null }, { selector: PreprintStepperSelectors.isPreprintProjectLoading, value: false }, + { selector: UserSelectors.isProjectCreationDisabled, value: false }, ]; function setup(overrides?: { selectorOverrides?: SignalOverride[]; detectChanges?: boolean }) { @@ -359,4 +361,13 @@ describe('SupplementsStepComponent', () => { component.selectedSupplementOption.set(SupplementOptions.ConnectExistingProject); expect(component.isNextButtonDisabled()).toBe(false); }); + + it('should compute create project disabled state based on isProjectCreationDisabled', () => { + setup({ + selectorOverrides: [{ selector: UserSelectors.isProjectCreationDisabled, value: true }], + detectChanges: false, + }); + + expect(component.createProjectTooltip()).toBe('preprints.preprintStepper.supplements.projectCreationDisabled'); + }); }); diff --git a/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.ts b/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.ts index 4eaf843ee..7d910efa6 100644 --- a/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.ts +++ b/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.ts @@ -6,6 +6,7 @@ import { Button } from 'primeng/button'; import { Card } from 'primeng/card'; import { Select, SelectChangeEvent } from 'primeng/select'; import { Skeleton } from 'primeng/skeleton'; +import { Tooltip } from 'primeng/tooltip'; import { debounceTime, distinctUntilChanged, map } from 'rxjs'; @@ -26,6 +27,7 @@ import { import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { UserSelectors } from '@osf/core/store/user'; import { SupplementOptions } from '@osf/features/preprints/enums'; import { ConnectProject, @@ -45,7 +47,17 @@ import { ProjectForm } from '@shared/models/projects/create-project-form.model'; @Component({ selector: 'osf-supplements-step', - imports: [Button, NgClass, Card, Select, AddProjectFormComponent, ReactiveFormsModule, Skeleton, TranslatePipe], + imports: [ + Button, + NgClass, + Card, + Select, + AddProjectFormComponent, + ReactiveFormsModule, + Skeleton, + Tooltip, + TranslatePipe, + ], templateUrl: './supplements-step.component.html', styleUrl: './supplements-step.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -69,6 +81,7 @@ export class SupplementsStepComponent implements OnInit { readonly areAvailableProjectsLoading = select(PreprintStepperSelectors.areAvailableProjectsLoading); readonly preprintProject = select(PreprintStepperSelectors.getPreprintProject); readonly isPreprintProjectLoading = select(PreprintStepperSelectors.isPreprintProjectLoading); + readonly createProjectDisabled = select(UserSelectors.isProjectCreationDisabled); selectedSupplementOption = signal(SupplementOptions.None); selectedProjectId = signal(null); @@ -113,6 +126,10 @@ export class SupplementsStepComponent implements OnInit { return false; }); + createProjectTooltip = computed(() => + this.createProjectDisabled() ? 'preprints.preprintStepper.supplements.projectCreationDisabled' : '' + ); + constructor() { effect(() => { const preprint = this.createdPreprint(); diff --git a/src/app/features/preprints/pages/submit-preprint-stepper/submit-preprint-stepper.component.spec.ts b/src/app/features/preprints/pages/submit-preprint-stepper/submit-preprint-stepper.component.spec.ts index cb16d3bc0..43e6ed972 100644 --- a/src/app/features/preprints/pages/submit-preprint-stepper/submit-preprint-stepper.component.spec.ts +++ b/src/app/features/preprints/pages/submit-preprint-stepper/submit-preprint-stepper.component.spec.ts @@ -7,6 +7,7 @@ import { of } from 'rxjs'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute } from '@angular/router'; +import { UserSelectors } from '@osf/core/store/user/user.selectors'; import { StepperComponent } from '@osf/shared/components/stepper/stepper.component'; import { IS_WEB } from '@osf/shared/helpers/breakpoints.tokens'; import { BrandService } from '@osf/shared/services/brand.service'; @@ -56,6 +57,7 @@ describe('SubmitPreprintStepperComponent', () => { { selector: PreprintProvidersSelectors.getPreprintProviderDetails(mockProviderId), value: mockProvider }, { selector: PreprintProvidersSelectors.isPreprintProviderDetailsLoading, value: false }, { selector: PreprintStepperSelectors.hasBeenSubmitted, value: false }, + { selector: UserSelectors.isProjectCreationDisabled, value: false }, ]; function setup(overrides?: { selectorOverrides?: SignalOverride[] }) { @@ -168,6 +170,22 @@ describe('SubmitPreprintStepperComponent', () => { expect(stepValues).toContain(PreprintSteps.AuthorAssertions); }); + it('should filter out Supplements step when supplements are disabled via isProjectCreationDisabled', () => { + setup({ + selectorOverrides: [{ selector: UserSelectors.isProjectCreationDisabled, value: true }], + }); + + const stepValues = component.steps().map((s) => s.value); + expect(stepValues).not.toContain(PreprintSteps.Supplements); + }); + + it('should include Supplements step when supplements are enabled via isProjectCreationDisabled', () => { + setup(); + + const stepValues = component.steps().map((s) => s.value); + expect(stepValues).toContain(PreprintSteps.Supplements); + }); + it('should re-index steps sequentially', () => { setup(); diff --git a/src/app/features/preprints/pages/submit-preprint-stepper/submit-preprint-stepper.component.ts b/src/app/features/preprints/pages/submit-preprint-stepper/submit-preprint-stepper.component.ts index 65a608c74..a9f4188e9 100644 --- a/src/app/features/preprints/pages/submit-preprint-stepper/submit-preprint-stepper.component.ts +++ b/src/app/features/preprints/pages/submit-preprint-stepper/submit-preprint-stepper.component.ts @@ -21,6 +21,7 @@ import { import { toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute } from '@angular/router'; +import { UserSelectors } from '@osf/core/store/user/user.selectors'; import { StepperComponent } from '@osf/shared/components/stepper/stepper.component'; import { IS_WEB } from '@osf/shared/helpers/breakpoints.tokens'; import { CanDeactivateComponent } from '@osf/shared/models/can-deactivate.interface'; @@ -79,6 +80,7 @@ export class SubmitPreprintStepperComponent implements OnDestroy, CanDeactivateC preprintProvider = select(PreprintProvidersSelectors.getPreprintProviderDetails(this.providerId())); isPreprintProviderLoading = select(PreprintProvidersSelectors.isPreprintProviderDetailsLoading); hasBeenSubmitted = select(PreprintStepperSelectors.hasBeenSubmitted); + supplementsDisabled = select(UserSelectors.isProjectCreationDisabled); currentStep = signal(submitPreprintSteps[0]); @@ -94,7 +96,12 @@ export class SubmitPreprintStepperComponent implements OnDestroy, CanDeactivateC } return submitPreprintSteps - .filter((step) => step.value !== PreprintSteps.AuthorAssertions || provider.assertionsEnabled) + .filter((step) => { + return ( + (step.value !== PreprintSteps.AuthorAssertions || provider.assertionsEnabled) && + (step.value !== PreprintSteps.Supplements || !this.supplementsDisabled()) + ); + }) .map((step, index) => ({ ...step, index })); }); 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 e35e056c7..be59800d6 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 @@ -7,6 +7,7 @@ import { of } from 'rxjs'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute } from '@angular/router'; +import { UserSelectors } from '@osf/core/store/user/user.selectors'; import { StepperComponent } from '@osf/shared/components/stepper/stepper.component'; import { IS_WEB } from '@osf/shared/helpers/breakpoints.tokens'; import { BrandService } from '@osf/shared/services/brand.service'; @@ -55,6 +56,7 @@ describe('UpdatePreprintStepperComponent', () => { { selector: PreprintStepperSelectors.getPreprint, value: mockPreprint }, { selector: PreprintStepperSelectors.hasBeenSubmitted, value: false }, { selector: PreprintStepperSelectors.hasAdminAccess, value: false }, + { selector: UserSelectors.isProjectCreationDisabled, value: false }, ]; function setup(overrides?: { selectorOverrides?: SignalOverride[] }) { @@ -150,6 +152,26 @@ describe('UpdatePreprintStepperComponent', () => { expect(stepValues).toContain(PreprintSteps.Review); }); + it('should filter out Supplements step when isProjectCreationDisabled is true', () => { + setup({ + selectorOverrides: [{ selector: UserSelectors.isProjectCreationDisabled, value: true }], + }); + + const steps = component.updateSteps(); + const stepValues = steps.map((s) => s.value); + + expect(stepValues).not.toContain(PreprintSteps.Supplements); + }); + + it('should include Supplements step when isProjectCreationDisabled is false', () => { + setup(); + + const steps = component.updateSteps(); + const stepValues = steps.map((s) => s.value); + + expect(stepValues).toContain(PreprintSteps.Supplements); + }); + it('should re-index steps sequentially', () => { setup(); 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 c288af3f4..5be1a80b7 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 @@ -20,6 +20,7 @@ import { import { toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute } from '@angular/router'; +import { UserSelectors } from '@osf/core/store/user/user.selectors'; import { StepperComponent } from '@osf/shared/components/stepper/stepper.component'; import { IS_WEB } from '@osf/shared/helpers/breakpoints.tokens'; import { BrandService } from '@osf/shared/services/brand.service'; @@ -78,6 +79,7 @@ export class UpdatePreprintStepperComponent implements OnDestroy, CanDeactivateC readonly isPreprintProviderLoading = select(PreprintProvidersSelectors.isPreprintProviderDetailsLoading); readonly hasBeenSubmitted = select(PreprintStepperSelectors.hasBeenSubmitted); readonly hasAdminAccess = select(PreprintStepperSelectors.hasAdminAccess); + readonly supplementsDisabled = select(UserSelectors.isProjectCreationDisabled); readonly isWeb = toSignal(inject(IS_WEB)); @@ -108,6 +110,9 @@ export class UpdatePreprintStepperComponent implements OnDestroy, CanDeactivateC if (step.value === PreprintSteps.AuthorAssertions) { return provider.assertionsEnabled && this.hasAdminAccess(); } + if (step.value === PreprintSteps.Supplements) { + return !this.supplementsDisabled(); + } return true; }) .map((step, index) => ({ ...step, index })); diff --git a/src/app/features/project/overview/components/fork-dialog/fork-dialog.component.spec.ts b/src/app/features/project/overview/components/fork-dialog/fork-dialog.component.spec.ts index 78ef81dc7..6389d4f8a 100644 --- a/src/app/features/project/overview/components/fork-dialog/fork-dialog.component.spec.ts +++ b/src/app/features/project/overview/components/fork-dialog/fork-dialog.component.spec.ts @@ -4,10 +4,11 @@ import { MockProvider } from 'ng-mocks'; import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; -import { EMPTY } from 'rxjs'; +import { throwError } from 'rxjs'; import { Mock } from 'vitest'; +import { HttpErrorResponse } from '@angular/common/http'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; @@ -105,18 +106,21 @@ describe('ForkDialogComponent', () => { component.handleForkConfirm(); expect(store.dispatch).toHaveBeenCalledWith(new ForkResource('project-1', ResourceType.Project)); - expect(dialogRef.close).toHaveBeenCalledWith({ success: true }); + expect(dialogRef.close).toHaveBeenCalledWith(); expect(toastService.showSuccess).toHaveBeenCalledWith('project.overview.dialog.toast.fork.success'); }); - it('should still close dialog and show toast when fork action errors', () => { + it('should keep dialog open and show toast when fork action errors', () => { + const errorDetail = 'Fork creation failed'; setup({ resourceId: 'project-1', resourceType: ResourceType.Project }); (store.dispatch as Mock).mockClear(); - (store.dispatch as Mock).mockReturnValueOnce(EMPTY); + (store.dispatch as Mock).mockReturnValueOnce( + throwError(() => new HttpErrorResponse({ status: 405, error: { errors: [{ detail: errorDetail }] } })) + ); component.handleForkConfirm(); expect(store.dispatch).toHaveBeenCalledWith(new ForkResource('project-1', ResourceType.Project)); - expect(dialogRef.close).toHaveBeenCalledWith({ success: true }); - expect(toastService.showSuccess).toHaveBeenCalledWith('project.overview.dialog.toast.fork.success'); + expect(dialogRef.close).callCount(0); + expect(toastService.showError).toHaveBeenCalledWith(errorDetail); }); }); diff --git a/src/app/features/project/overview/components/fork-dialog/fork-dialog.component.ts b/src/app/features/project/overview/components/fork-dialog/fork-dialog.component.ts index da9e17296..495600c66 100644 --- a/src/app/features/project/overview/components/fork-dialog/fork-dialog.component.ts +++ b/src/app/features/project/overview/components/fork-dialog/fork-dialog.component.ts @@ -5,7 +5,8 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; -import { finalize } from 'rxjs'; +import { EMPTY } from 'rxjs'; +import { catchError } from 'rxjs/operators'; import { ChangeDetectionStrategy, Component, DestroyRef, inject } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @@ -42,11 +43,16 @@ export class ForkDialogComponent { .forkResource(resourceId, resourceType) .pipe( takeUntilDestroyed(this.destroyRef), - finalize(() => { - this.dialogRef.close({ success: true }); - this.toastService.showSuccess('project.overview.dialog.toast.fork.success'); + catchError((e) => { + this.toastService.showError(e.error.errors[0].detail); + return EMPTY; }) ) - .subscribe(); + .subscribe({ + next: () => { + this.dialogRef.close(); + this.toastService.showSuccess('project.overview.dialog.toast.fork.success'); + }, + }); } } diff --git a/src/app/features/project/overview/components/linked-resources/linked-resources.component.html b/src/app/features/project/overview/components/linked-resources/linked-resources.component.html index dd7f7b2c1..552509480 100644 --- a/src/app/features/project/overview/components/linked-resources/linked-resources.component.html +++ b/src/app/features/project/overview/components/linked-resources/linked-resources.component.html @@ -6,6 +6,8 @@

{{ 'project.overview.linkedProjects.title' | translate }}

severity="secondary" [label]="'project.overview.components.linkProjectsButton' | translate" (onClick)="openLinkProjectModal()" + [disabled]="isProjectReadOnly()" + [pTooltip]="disabledButtonTooltip() | translate" /> } diff --git a/src/app/features/project/overview/components/linked-resources/linked-resources.component.spec.ts b/src/app/features/project/overview/components/linked-resources/linked-resources.component.spec.ts index a316b29da..00a0a030f 100644 --- a/src/app/features/project/overview/components/linked-resources/linked-resources.component.spec.ts +++ b/src/app/features/project/overview/components/linked-resources/linked-resources.component.spec.ts @@ -2,6 +2,7 @@ import { MockComponents, MockProvider } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { UserSelectors } from '@osf/core/store/user/user.selectors'; import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component'; import { IconComponent } from '@osf/shared/components/icon/icon.component'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; @@ -10,7 +11,7 @@ import { NodeLinksSelectors } from '@osf/shared/stores/node-links'; import { MOCK_NODE_WITH_ADMIN } from '@testing/mocks/node.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; import { CustomDialogServiceMockBuilder } from '@testing/providers/custom-dialog-provider.mock'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { mergeSignalOverrides, provideMockStore, SignalOverride } from '@testing/providers/store-provider.mock'; import { ProjectOverviewSelectors } from '../../store'; import { DeleteNodeLinkDialogComponent } from '../delete-node-link-dialog/delete-node-link-dialog.component'; @@ -29,21 +30,24 @@ describe('LinkedProjectsComponent', () => { { ...MOCK_NODE_WITH_ADMIN, id: 'resource-3', title: 'Linked Resource 3' }, ]; - beforeEach(() => { + function setup(selectorOverrides?: SignalOverride[]) { customDialogServiceMock = CustomDialogServiceMockBuilder.create().withDefaultOpen().build(); + const defaultSignals: SignalOverride[] = [ + { selector: NodeLinksSelectors.getLinkedResources, value: mockLinkedResources }, + { selector: NodeLinksSelectors.getLinkedResourcesLoading, value: false }, + { selector: NodeLinksSelectors.hasMoreLinkedResources, value: false }, + { selector: NodeLinksSelectors.isLoadingMoreLinkedResources, value: false }, + { selector: ProjectOverviewSelectors.getProject, value: MOCK_NODE_WITH_ADMIN }, + { selector: UserSelectors.isProjectReadOnly, value: false }, + ]; + const signals = mergeSignalOverrides(defaultSignals, selectorOverrides); TestBed.configureTestingModule({ imports: [LinkedResourcesComponent, ...MockComponents(IconComponent, ContributorsListComponent)], providers: [ provideOSFCore(), provideMockStore({ - signals: [ - { selector: NodeLinksSelectors.getLinkedResources, value: mockLinkedResources }, - { selector: NodeLinksSelectors.getLinkedResourcesLoading, value: false }, - { selector: NodeLinksSelectors.hasMoreLinkedResources, value: false }, - { selector: NodeLinksSelectors.isLoadingMoreLinkedResources, value: false }, - { selector: ProjectOverviewSelectors.getProject, value: MOCK_NODE_WITH_ADMIN }, - ], + signals: signals, }), MockProvider(CustomDialogService, customDialogServiceMock), ], @@ -53,9 +57,10 @@ describe('LinkedProjectsComponent', () => { component = fixture.componentInstance; fixture.componentRef.setInput('canEdit', true); fixture.detectChanges(); - }); + } it('should open LinkResourceDialogComponent with correct config', () => { + setup(); component.openLinkProjectModal(); expect(customDialogServiceMock.open).toHaveBeenCalledWith(LinkResourceDialogComponent, { @@ -66,6 +71,7 @@ describe('LinkedProjectsComponent', () => { }); it('should find resource by id and open DeleteNodeLinkDialogComponent with correct config when resource exists', () => { + setup(); component.openDeleteResourceModal('resource-2'); expect(customDialogServiceMock.open).toHaveBeenCalledWith(DeleteNodeLinkDialogComponent, { @@ -76,10 +82,19 @@ describe('LinkedProjectsComponent', () => { }); it('should return early and not open dialog when resource is not found', () => { + setup(); customDialogServiceMock.open.mockClear(); component.openDeleteResourceModal('non-existent-id'); expect(customDialogServiceMock.open).not.toHaveBeenCalled(); }); + + it('should return disabledButtonTooltip based on isProjectReadOnly', () => { + setup(); + expect(component.disabledButtonTooltip()).toBe(''); + + setup([{ selector: UserSelectors.isProjectReadOnly, value: true }]); + expect(component.disabledButtonTooltip()).toBe('common.errorMessages.actionUnavailable'); + }); }); diff --git a/src/app/features/project/overview/components/linked-resources/linked-resources.component.ts b/src/app/features/project/overview/components/linked-resources/linked-resources.component.ts index cd1d88944..f78fe4801 100644 --- a/src/app/features/project/overview/components/linked-resources/linked-resources.component.ts +++ b/src/app/features/project/overview/components/linked-resources/linked-resources.component.ts @@ -4,12 +4,14 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { Skeleton } from 'primeng/skeleton'; +import { Tooltip } from 'primeng/tooltip'; import { filter } from 'rxjs'; -import { ChangeDetectionStrategy, Component, DestroyRef, inject, input } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, DestroyRef, inject, input } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { UserSelectors } from '@osf/core/store/user/user.selectors'; import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component'; import { IconComponent } from '@osf/shared/components/icon/icon.component'; import { TruncatedTextComponent } from '@osf/shared/components/truncated-text/truncated-text.component'; @@ -22,7 +24,7 @@ import { LinkResourceDialogComponent } from '../link-resource-dialog/link-resour @Component({ selector: 'osf-linked-resources', - imports: [Button, Skeleton, TranslatePipe, TruncatedTextComponent, IconComponent, ContributorsListComponent], + imports: [Button, Skeleton, Tooltip, TranslatePipe, TruncatedTextComponent, IconComponent, ContributorsListComponent], templateUrl: './linked-resources.component.html', styleUrl: './linked-resources.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -38,6 +40,11 @@ export class LinkedResourcesComponent { hasMoreLinkedResources = select(NodeLinksSelectors.hasMoreLinkedResources); isLoadingMoreLinkedResources = select(NodeLinksSelectors.isLoadingMoreLinkedResources); currentProject = select(ProjectOverviewSelectors.getProject); + isProjectReadOnly = select(UserSelectors.isProjectReadOnly); + + readonly disabledButtonTooltip = computed(() => + this.isProjectReadOnly() ? 'common.errorMessages.actionUnavailable' : '' + ); private readonly actions = createDispatchMap({ getLinkedResources: GetLinkedResources, diff --git a/src/app/features/project/overview/components/overview-components/overview-components.component.html b/src/app/features/project/overview/components/overview-components/overview-components.component.html index ff16cb4f4..3a0712b54 100644 --- a/src/app/features/project/overview/components/overview-components/overview-components.component.html +++ b/src/app/features/project/overview/components/overview-components/overview-components.component.html @@ -7,6 +7,8 @@

{{ 'project.overview.components.title' | translate }}

(onClick)="handleAddComponent()" severity="secondary" [label]="'project.overview.components.addComponentButton' | translate" + [disabled]="preventComponentCreation()" + [pTooltip]="createComponentTooltip() | translate" /> } diff --git a/src/app/features/project/overview/components/overview-components/overview-components.component.spec.ts b/src/app/features/project/overview/components/overview-components/overview-components.component.spec.ts index 40dfea920..0b84dfb28 100644 --- a/src/app/features/project/overview/components/overview-components/overview-components.component.spec.ts +++ b/src/app/features/project/overview/components/overview-components/overview-components.component.spec.ts @@ -8,6 +8,7 @@ import { CdkDragDrop } from '@angular/cdk/drag-drop'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { Router } from '@angular/router'; +import { UserSelectors } from '@osf/core/store/user/user.selectors'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { NodeModel } from '@osf/shared/models/nodes/base-node.model'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; @@ -20,7 +21,7 @@ import { provideOSFCore } from '@testing/osf.testing.provider'; import { CustomDialogServiceMockBuilder } from '@testing/providers/custom-dialog-provider.mock'; import { LoaderServiceMock } from '@testing/providers/loader-service.mock'; import { RouterMockBuilder, RouterMockType } 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, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; import { LoadMoreComponents, ProjectOverviewSelectors, ReorderComponents } from '../../store'; @@ -30,6 +31,10 @@ import { DeleteComponentDialogComponent } from '../delete-component-dialog/delet import { OverviewComponentsComponent } from './overview-components.component'; +interface SetupOverrides extends BaseSetupOverrides { + selectors?: any[]; +} + describe('OverviewComponentsComponent', () => { let component: OverviewComponentsComponent; let fixture: ComponentFixture; @@ -48,12 +53,22 @@ describe('OverviewComponentsComponent', () => { rootParentId: 'root-1', }; - beforeEach(() => { + function setup(overrides: SetupOverrides = {}) { routerMock = RouterMockBuilder.create().build(); customDialogService = CustomDialogServiceMockBuilder.create().build(); loaderService = new LoaderServiceMock(); toastService = ToastServiceMock.simple(); + const defaultSelectors = [ + { selector: ProjectOverviewSelectors.getComponents, value: components }, + { selector: ProjectOverviewSelectors.getComponentsLoading, value: false }, + { selector: ProjectOverviewSelectors.getComponentsSubmitting, value: false }, + { selector: ProjectOverviewSelectors.hasMoreComponents, value: true }, + { selector: ProjectOverviewSelectors.getProject, value: project }, + { selector: UserSelectors.isProjectCreationDisabled, value: false }, + ]; + const signals = mergeSignalOverrides(defaultSelectors, overrides.selectors); + TestBed.configureTestingModule({ imports: [OverviewComponentsComponent, MockComponent(ComponentCardComponent)], providers: [ @@ -63,13 +78,7 @@ describe('OverviewComponentsComponent', () => { MockProvider(LoaderService, loaderService), MockProvider(ToastService, toastService), provideMockStore({ - signals: [ - { selector: ProjectOverviewSelectors.getComponents, value: components }, - { selector: ProjectOverviewSelectors.getComponentsLoading, value: false }, - { selector: ProjectOverviewSelectors.getComponentsSubmitting, value: false }, - { selector: ProjectOverviewSelectors.hasMoreComponents, value: true }, - { selector: ProjectOverviewSelectors.getProject, value: project }, - ], + signals, }), ], }); @@ -79,17 +88,20 @@ describe('OverviewComponentsComponent', () => { component = fixture.componentInstance; fixture.componentRef.setInput('canEdit', true); fixture.detectChanges(); - }); + } it('should create', () => { + setup(); expect(component).toBeTruthy(); }); it('should initialize reorderedComponents from components selector', () => { + setup(); expect(component.reorderedComponents()).toEqual(components); }); it('should open add component dialog', () => { + setup(); component.handleAddComponent(); expect(customDialogService.open).toHaveBeenCalledWith(AddComponentDialogComponent, { @@ -99,18 +111,21 @@ describe('OverviewComponentsComponent', () => { }); it('should navigate for manageContributors action', () => { + setup(); component.handleMenuAction('manageContributors', 'comp-a'); expect(routerMock.navigate).toHaveBeenCalledWith(['comp-a', 'contributors']); }); it('should navigate for settings action', () => { + setup(); component.handleMenuAction('settings', 'comp-a'); expect(routerMock.navigate).toHaveBeenCalledWith(['comp-a', 'settings']); }); it('should open delete component dialog through delete menu action', () => { + setup(); component.handleMenuAction('delete', 'comp-a'); expect(loaderService.show).toHaveBeenCalled(); @@ -124,6 +139,7 @@ describe('OverviewComponentsComponent', () => { }); it('should open component url in same tab on navigate', () => { + setup(); const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null); vi.spyOn(routerMock, 'createUrlTree').mockReturnValue({} as any); vi.spyOn(routerMock, 'serializeUrl').mockReturnValue('/comp-a'); @@ -135,6 +151,7 @@ describe('OverviewComponentsComponent', () => { }); it('should dispatch load more components when project exists', () => { + setup(); (store.dispatch as Mock).mockClear(); component.loadMoreComponents(); @@ -143,6 +160,7 @@ describe('OverviewComponentsComponent', () => { }); it('should reorder components and dispatch reorder action', () => { + setup(); (store.dispatch as Mock).mockClear(); const event = { previousIndex: 0, currentIndex: 1 } as CdkDragDrop; @@ -154,6 +172,7 @@ describe('OverviewComponentsComponent', () => { }); it('should not reorder when canEdit is false', () => { + setup(); fixture.componentRef.setInput('canEdit', false); fixture.detectChanges(); (store.dispatch as Mock).mockClear(); @@ -163,4 +182,13 @@ describe('OverviewComponentsComponent', () => { expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(ReorderComponents)); }); + + it('should disable add component button and show tooltip when isProjectCreationDisabled flag is true', () => { + setup({ + selectors: [{ selector: UserSelectors.isProjectCreationDisabled, value: true }], + }); + + expect(component.preventComponentCreation()).toBe(true); + expect(component.createComponentTooltip()).toBe('project.overview.components.addComponentDisabled'); + }); }); diff --git a/src/app/features/project/overview/components/overview-components/overview-components.component.ts b/src/app/features/project/overview/components/overview-components/overview-components.component.ts index 0ab0bdfd4..e7b7bafd4 100644 --- a/src/app/features/project/overview/components/overview-components/overview-components.component.ts +++ b/src/app/features/project/overview/components/overview-components/overview-components.component.ts @@ -4,11 +4,13 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { Skeleton } from 'primeng/skeleton'; +import { Tooltip } from 'primeng/tooltip'; import { CdkDrag, CdkDragDrop, CdkDropList, moveItemInArray } from '@angular/cdk/drag-drop'; import { ChangeDetectionStrategy, Component, computed, effect, inject, input, signal } from '@angular/core'; import { Router } from '@angular/router'; +import { UserSelectors } from '@osf/core/store/user'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { NodeModel } from '@osf/shared/models/nodes/base-node.model'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; @@ -23,7 +25,7 @@ import { DeleteComponentDialogComponent } from '../delete-component-dialog/delet @Component({ selector: 'osf-project-components', - imports: [Button, CdkDrag, CdkDropList, Skeleton, TranslatePipe, ComponentCardComponent], + imports: [Button, CdkDrag, CdkDropList, Skeleton, Tooltip, TranslatePipe, ComponentCardComponent], templateUrl: './overview-components.component.html', styleUrl: './overview-components.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -42,6 +44,7 @@ export class OverviewComponentsComponent { isComponentsSubmitting = select(ProjectOverviewSelectors.getComponentsSubmitting); hasMoreComponents = select(ProjectOverviewSelectors.hasMoreComponents); project = select(ProjectOverviewSelectors.getProject); + preventComponentCreation = select(UserSelectors.isProjectCreationDisabled); reorderedComponents = signal([]); @@ -55,6 +58,10 @@ export class OverviewComponentsComponent { () => this.isComponentsSubmitting() || (!this.canEdit() && this.reorderedComponents().length <= 1) ); + createComponentTooltip = computed(() => + this.preventComponentCreation() ? 'project.overview.components.addComponentDisabled' : '' + ); + constructor() { effect(() => { const componentsData = this.components(); @@ -77,6 +84,8 @@ export class OverviewComponentsComponent { } handleAddComponent(): void { + if (this.preventComponentCreation()) return; + this.customDialogService.open(AddComponentDialogComponent, { header: 'project.overview.dialog.addComponent.header', width: '850px', diff --git a/src/app/features/project/overview/components/overview-wiki/overview-wiki.component.html b/src/app/features/project/overview/components/overview-wiki/overview-wiki.component.html index c4b0bad38..d48afc456 100644 --- a/src/app/features/project/overview/components/overview-wiki/overview-wiki.component.html +++ b/src/app/features/project/overview/components/overview-wiki/overview-wiki.component.html @@ -7,6 +7,8 @@

{{ 'project.overview.wiki.title' | translate }}

severity="secondary" [label]="'common.buttons.edit' | translate" (onClick)="navigateToWiki()" + [disabled]="isProjectReadOnly()" + [pTooltip]="disabledButtonTooltip() | translate" data-test-edit-wiki-button > } diff --git a/src/app/features/project/overview/components/overview-wiki/overview-wiki.component.spec.ts b/src/app/features/project/overview/components/overview-wiki/overview-wiki.component.spec.ts index 06fff3e1e..72e3232fa 100644 --- a/src/app/features/project/overview/components/overview-wiki/overview-wiki.component.spec.ts +++ b/src/app/features/project/overview/components/overview-wiki/overview-wiki.component.spec.ts @@ -3,13 +3,14 @@ import { MockComponents, MockProvider } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { Router } from '@angular/router'; +import { UserSelectors } from '@osf/core/store/user/user.selectors'; import { MarkdownComponent } from '@osf/shared/components/markdown/markdown.component'; import { TruncatedTextComponent } from '@osf/shared/components/truncated-text/truncated-text.component'; import { WikiSelectors } from '@osf/shared/stores/wiki'; import { provideOSFCore } from '@testing/osf.testing.provider'; import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { mergeSignalOverrides, provideMockStore, SignalOverride } from '@testing/providers/store-provider.mock'; import { OverviewWikiComponent } from './overview-wiki.component'; @@ -20,18 +21,21 @@ describe('OverviewWikiComponent', () => { const mockResourceId = 'project-123'; - beforeEach(() => { + function setup(signalOverrides?: SignalOverride[]) { routerMock = RouterMockBuilder.create().build(); + const defaultSignals = [ + { selector: WikiSelectors.getHomeWikiLoading, value: false }, + { selector: WikiSelectors.getHomeWikiContent, value: null }, + { selector: UserSelectors.isProjectReadOnly, value: false }, + ]; + const signals = mergeSignalOverrides(defaultSignals, signalOverrides); TestBed.configureTestingModule({ imports: [OverviewWikiComponent, ...MockComponents(TruncatedTextComponent, MarkdownComponent)], providers: [ provideOSFCore(), provideMockStore({ - signals: [ - { selector: WikiSelectors.getHomeWikiLoading, value: false }, - { selector: WikiSelectors.getHomeWikiContent, value: null }, - ], + signals: signals, }), MockProvider(Router, routerMock), ], @@ -39,18 +43,21 @@ describe('OverviewWikiComponent', () => { fixture = TestBed.createComponent(OverviewWikiComponent); component = fixture.componentInstance; - }); + } it('should create', () => { + setup(); expect(component).toBeTruthy(); }); it('should default resourceId to empty string', () => { + setup(); fixture.detectChanges(); expect(component.resourceId()).toBe(''); }); it('should set resourceId input correctly', () => { + setup(); fixture.componentRef.setInput('resourceId', mockResourceId); fixture.detectChanges(); @@ -58,11 +65,13 @@ describe('OverviewWikiComponent', () => { }); it('should default canEdit to false', () => { + setup(); fixture.detectChanges(); expect(component.canEdit()).toBe(false); }); it('should set canEdit input correctly', () => { + setup(); fixture.componentRef.setInput('canEdit', true); fixture.detectChanges(); @@ -70,16 +79,19 @@ describe('OverviewWikiComponent', () => { }); it('should get isWikiLoading from store', () => { + setup(); fixture.detectChanges(); expect(component.isWikiLoading).toBeDefined(); }); it('should get wikiContent from store', () => { + setup(); fixture.detectChanges(); expect(component.wikiContent).toBeDefined(); }); it('should compute wiki link with resourceId', () => { + setup(); fixture.componentRef.setInput('resourceId', mockResourceId); fixture.detectChanges(); @@ -87,12 +99,14 @@ describe('OverviewWikiComponent', () => { }); it('should compute wiki link with empty resourceId', () => { + setup(); fixture.detectChanges(); expect(component.wikiLink()).toEqual(['/', '', 'wiki']); }); it('should navigate to wiki link', () => { + setup(); fixture.componentRef.setInput('resourceId', mockResourceId); fixture.detectChanges(); @@ -102,10 +116,21 @@ describe('OverviewWikiComponent', () => { }); it('should navigate with empty resourceId', () => { + setup(); fixture.detectChanges(); component.navigateToWiki(); expect(routerMock.navigate).toHaveBeenCalledWith(['/', '', 'wiki']); }); + + it('should compute disabledButtonTooltip based on isProjectReadOnly', () => { + setup([{ selector: UserSelectors.isProjectReadOnly, value: true }]); + fixture.detectChanges(); + expect(component.disabledButtonTooltip()).toBe('common.errorMessages.actionUnavailable'); + + setup([{ selector: UserSelectors.isProjectReadOnly, value: false }]); + fixture.detectChanges(); + expect(component.disabledButtonTooltip()).toBe(''); + }); }); diff --git a/src/app/features/project/overview/components/overview-wiki/overview-wiki.component.ts b/src/app/features/project/overview/components/overview-wiki/overview-wiki.component.ts index f7bcd30c4..fcb1ae647 100644 --- a/src/app/features/project/overview/components/overview-wiki/overview-wiki.component.ts +++ b/src/app/features/project/overview/components/overview-wiki/overview-wiki.component.ts @@ -4,17 +4,19 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { Skeleton } from 'primeng/skeleton'; +import { Tooltip } from 'primeng/tooltip'; import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'; import { Router } from '@angular/router'; +import { UserSelectors } from '@osf/core/store/user'; import { MarkdownComponent } from '@osf/shared/components/markdown/markdown.component'; import { TruncatedTextComponent } from '@osf/shared/components/truncated-text/truncated-text.component'; import { WikiSelectors } from '@osf/shared/stores/wiki'; @Component({ selector: 'osf-overview-wiki', - imports: [Skeleton, TranslatePipe, TruncatedTextComponent, MarkdownComponent, Button], + imports: [Skeleton, Tooltip, TranslatePipe, TruncatedTextComponent, MarkdownComponent, Button], templateUrl: './overview-wiki.component.html', styleUrl: './overview-wiki.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -24,11 +26,13 @@ export class OverviewWikiComponent { isWikiLoading = select(WikiSelectors.getHomeWikiLoading); wikiContent = select(WikiSelectors.getHomeWikiContent); + isProjectReadOnly = select(UserSelectors.isProjectReadOnly); resourceId = input(''); canEdit = input(false); wikiLink = computed(() => ['/', this.resourceId(), 'wiki']); + disabledButtonTooltip = computed(() => (this.isProjectReadOnly() ? 'common.errorMessages.actionUnavailable' : '')); navigateToWiki() { this.router.navigate(this.wikiLink()); 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 463704e67..4ebbd829b 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 @@ -21,6 +21,8 @@

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

[routerLink]="'../metadata'" severity="secondary" [label]="'common.buttons.edit' | translate" + [disabled]="isProjectReadOnly()" + [pTooltip]="disabledButtonTooltip() | translate" > } diff --git a/src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.spec.ts b/src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.spec.ts index 7bf51cdd0..846a7b0a8 100644 --- a/src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.spec.ts +++ b/src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.spec.ts @@ -7,6 +7,7 @@ import { Mock } from 'vitest'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { Router } from '@angular/router'; +import { UserSelectors } from '@osf/core/store/user'; import { GetCedarMetadataRecords, GetCedarMetadataTemplates, @@ -34,7 +35,7 @@ import { FetchSelectedSubjects, SubjectsSelectors } from '@osf/shared/stores/sub import { MOCK_PROJECT_OVERVIEW } from '@testing/mocks/project-overview.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { mergeSignalOverrides, provideMockStore } from '@testing/providers/store-provider.mock'; import { GetProjectIdentifiers, @@ -59,6 +60,7 @@ describe('ProjectOverviewMetadataComponent', () => { interface SetupOverrides { project?: typeof MOCK_PROJECT_OVERVIEW | null; + selectorOverrides?: { selector: any; value: any }[]; } function setup(overrides: SetupOverrides = {}) { @@ -66,6 +68,34 @@ describe('ProjectOverviewMetadataComponent', () => { mockRouter = RouterMockBuilder.create().withUrl('/project/project-1/overview').build(); metadataRecordsService = { downloadMetadata: vi.fn() }; + const defaultSignals = [ + { selector: ProjectOverviewSelectors.getProject, value: project }, + { selector: ProjectOverviewSelectors.isProjectAnonymous, value: false }, + { selector: ProjectOverviewSelectors.hasWriteAccess, value: true }, + { selector: ProjectOverviewSelectors.getInstitutions, value: [] }, + { selector: ProjectOverviewSelectors.isInstitutionsLoading, value: false }, + { selector: ProjectOverviewSelectors.getIdentifiers, value: [] }, + { selector: ProjectOverviewSelectors.isIdentifiersLoading, value: false }, + { selector: ProjectOverviewSelectors.getLicense, value: null }, + { selector: ProjectOverviewSelectors.isLicenseLoading, value: false }, + { selector: ProjectOverviewSelectors.getPreprints, value: [] }, + { selector: ProjectOverviewSelectors.isPreprintsLoading, value: false }, + { selector: SubjectsSelectors.getSelectedSubjects, value: [] }, + { selector: SubjectsSelectors.areSelectedSubjectsLoading, value: false }, + { selector: ContributorsSelectors.getBibliographicContributors, value: [] }, + { selector: ContributorsSelectors.isBibliographicContributorsLoading, value: false }, + { selector: ContributorsSelectors.hasMoreBibliographicContributors, value: false }, + { selector: CollectionsSelectors.getCurrentProjectSubmissions, value: [] }, + { selector: CollectionsSelectors.getCurrentProjectSubmissionsLoading, value: false }, + { selector: UserSelectors.getActiveFlags, value: [] }, + { selector: UserSelectors.isProjectReadOnly, value: false }, + { selector: MetadataSelectors.getCedarRecords, value: [] }, + { selector: MetadataSelectors.getCedarTemplates, value: null }, + { selector: MetadataSelectors.getCustomItemMetadata, value: null }, + { selector: MetadataSelectors.isCustomItemMetadataLoading, value: false }, + ]; + const signals = mergeSignalOverrides(defaultSignals, overrides.selectorOverrides || []); + TestBed.configureTestingModule({ imports: [ ProjectOverviewMetadataComponent, @@ -87,30 +117,7 @@ describe('ProjectOverviewMetadataComponent', () => { MockProvider(MetadataRecordsService, metadataRecordsService), MockProvider(Router, mockRouter), provideMockStore({ - signals: [ - { selector: ProjectOverviewSelectors.getProject, value: project }, - { selector: ProjectOverviewSelectors.isProjectAnonymous, value: false }, - { selector: ProjectOverviewSelectors.hasWriteAccess, value: true }, - { selector: ProjectOverviewSelectors.getInstitutions, value: [] }, - { selector: ProjectOverviewSelectors.isInstitutionsLoading, value: false }, - { selector: ProjectOverviewSelectors.getIdentifiers, value: [] }, - { selector: ProjectOverviewSelectors.isIdentifiersLoading, value: false }, - { selector: ProjectOverviewSelectors.getLicense, value: null }, - { selector: ProjectOverviewSelectors.isLicenseLoading, value: false }, - { selector: ProjectOverviewSelectors.getPreprints, value: [] }, - { selector: ProjectOverviewSelectors.isPreprintsLoading, value: false }, - { selector: SubjectsSelectors.getSelectedSubjects, value: [] }, - { selector: SubjectsSelectors.areSelectedSubjectsLoading, value: false }, - { selector: ContributorsSelectors.getBibliographicContributors, value: [] }, - { selector: ContributorsSelectors.isBibliographicContributorsLoading, value: false }, - { selector: ContributorsSelectors.hasMoreBibliographicContributors, value: false }, - { selector: CollectionsSelectors.getCurrentProjectSubmissions, value: [] }, - { selector: CollectionsSelectors.getCurrentProjectSubmissionsLoading, value: false }, - { selector: MetadataSelectors.getCedarRecords, value: [] }, - { selector: MetadataSelectors.getCedarTemplates, value: null }, - { selector: MetadataSelectors.getCustomItemMetadata, value: null }, - { selector: MetadataSelectors.isCustomItemMetadataLoading, value: false }, - ], + signals: signals, }), ], }); @@ -208,4 +215,14 @@ describe('ProjectOverviewMetadataComponent', () => { expect(component.resourceType).toBe(CurrentResourceType.Projects); expect(component.dateFormat).toBe('MMM d, y, h:mm a'); }); + + it('should compute disabledButtonTooltip based on isProjectReadOnly', () => { + setup({ selectorOverrides: [{ selector: UserSelectors.isProjectReadOnly, value: true }] }); + fixture.detectChanges(); + expect(component.disabledButtonTooltip()).toBe('common.errorMessages.actionUnavailable'); + + setup({ selectorOverrides: [{ selector: UserSelectors.isProjectReadOnly, value: false }] }); + fixture.detectChanges(); + expect(component.disabledButtonTooltip()).toBe(''); + }); }); 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 6cd5128d6..4ab4f60a5 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 @@ -3,11 +3,13 @@ import { createDispatchMap, select } from '@ngxs/store'; import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; +import { Tooltip } from 'primeng/tooltip'; import { DatePipe } from '@angular/common'; import { ChangeDetectionStrategy, Component, computed, effect, inject } from '@angular/core'; import { Router, RouterLink } from '@angular/router'; +import { UserSelectors } from '@osf/core/store/user/user.selectors'; import { GetCedarMetadataRecords, GetCedarMetadataTemplates, @@ -53,6 +55,7 @@ import { OverviewSupplementsComponent } from '../overview-supplements/overview-s TranslatePipe, RouterLink, DatePipe, + Tooltip, TruncatedTextComponent, ResourceCitationsComponent, OverviewCollectionsComponent, @@ -95,9 +98,13 @@ export class ProjectOverviewMetadataComponent { readonly hasMoreBibliographicContributors = select(ContributorsSelectors.hasMoreBibliographicContributors); readonly projectSubmissions = select(CollectionsSelectors.getCurrentProjectSubmissions); readonly isProjectSubmissionsLoading = select(CollectionsSelectors.getCurrentProjectSubmissionsLoading); + readonly isProjectReadOnly = select(UserSelectors.isProjectReadOnly); readonly cedarRecords = select(MetadataSelectors.getCedarRecords); - private readonly cedarTemplatesResponse = select(MetadataSelectors.getCedarTemplates); readonly cedarTemplates = computed(() => this.cedarTemplatesResponse()?.data ?? null); + private readonly cedarTemplatesResponse = select(MetadataSelectors.getCedarTemplates); + readonly disabledButtonTooltip = computed(() => + this.isProjectReadOnly() ? 'common.errorMessages.actionUnavailable' : '' + ); readonly resourceType = CurrentResourceType.Projects; readonly dateFormat = 'MMM d, y, h:mm a'; diff --git a/src/app/features/project/overview/components/project-overview-toolbar/project-overview-toolbar.component.html b/src/app/features/project/overview/components/project-overview-toolbar/project-overview-toolbar.component.html index cf3d39e99..9eb6149d4 100644 --- a/src/app/features/project/overview/components/project-overview-toolbar/project-overview-toolbar.component.html +++ b/src/app/features/project/overview/components/project-overview-toolbar/project-overview-toolbar.component.html @@ -9,12 +9,15 @@

{{ 'project.overview.header.privateProject' | translate }}

- + + +
@@ -59,23 +62,36 @@ } @if (!viewOnly()) { - - {{ resource.forksCount }} - - - - - {{ item.label | translate }} - - - - + @if (preventDuplicateCreation()) { + + {{ resource.forksCount }} + + + } @else { + + {{ resource.forksCount }} + + + + + {{ item.label | translate }} + + + + + } } @if (!viewOnly()) { diff --git a/src/app/features/project/overview/components/project-overview-toolbar/project-overview-toolbar.component.spec.ts b/src/app/features/project/overview/components/project-overview-toolbar/project-overview-toolbar.component.spec.ts index f405eccae..d31f29dbf 100644 --- a/src/app/features/project/overview/components/project-overview-toolbar/project-overview-toolbar.component.spec.ts +++ b/src/app/features/project/overview/components/project-overview-toolbar/project-overview-toolbar.component.spec.ts @@ -21,7 +21,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 } 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, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; import { ProjectOverviewModel } from '../../models'; @@ -29,6 +29,10 @@ import { TogglePublicityDialogComponent } from '../toggle-publicity-dialog/toggl import { ProjectOverviewToolbarComponent } from './project-overview-toolbar.component'; +interface SetupOverrides extends BaseSetupOverrides { + selectors?: any[]; +} + describe('ProjectOverviewToolbarComponent', () => { let component: ProjectOverviewToolbarComponent; let fixture: ComponentFixture; @@ -51,25 +55,29 @@ describe('ProjectOverviewToolbarComponent', () => { storageUsage: '500MB', }; - beforeEach(() => { + function setup(overrides: SetupOverrides = {}) { routerMock = RouterMockBuilder.create().build(); activatedRouteMock = ActivatedRouteMockBuilder.create().build(); customDialogServiceMock = CustomDialogServiceMockBuilder.create().withDefaultOpen().build(); toastService = ToastServiceMock.simple(); + const defaultSelectors = [ + { selector: BookmarksSelectors.getBookmarksCollectionId, value: 'bookmarks-123' }, + { selector: BookmarksSelectors.getBookmarks, value: [] }, + { selector: BookmarksSelectors.areBookmarksLoading, value: false }, + { selector: BookmarksSelectors.getBookmarksCollectionIdSubmitting, value: false }, + { selector: ProjectOverviewSelectors.getDuplicatedProject, value: null }, + { selector: UserSelectors.isAuthenticated, value: true }, + { selector: UserSelectors.isProjectCreationDisabled, value: false }, + { selector: UserSelectors.isProjectReadOnly, value: false }, + ]; + const signals = mergeSignalOverrides(defaultSelectors, overrides.selectors); TestBed.configureTestingModule({ imports: [ProjectOverviewToolbarComponent, ...MockComponents(SocialsShareButtonComponent)], providers: [ provideOSFCore(), provideMockStore({ - signals: [ - { selector: BookmarksSelectors.getBookmarksCollectionId, value: 'bookmarks-123' }, - { selector: BookmarksSelectors.getBookmarks, value: [] }, - { selector: BookmarksSelectors.areBookmarksLoading, value: false }, - { selector: BookmarksSelectors.getBookmarksCollectionIdSubmitting, value: false }, - { selector: ProjectOverviewSelectors.getDuplicatedProject, value: null }, - { selector: UserSelectors.isAuthenticated, value: true }, - ], + signals, }), MockProvider(Router, routerMock), MockProvider(ActivatedRoute, activatedRouteMock), @@ -87,14 +95,16 @@ describe('ProjectOverviewToolbarComponent', () => { fixture.componentRef.setInput('currentResource', mockResource); fixture.componentRef.setInput('storage', mockStorage); fixture.componentRef.setInput('viewOnly', false); - }); + } it('should create', () => { + setup(); expect(component).toBeTruthy(); }); describe('Input Bindings', () => { it('should set canEdit input correctly', () => { + setup(); fixture.componentRef.setInput('canEdit', false); fixture.detectChanges(); @@ -102,18 +112,22 @@ describe('ProjectOverviewToolbarComponent', () => { }); it('should set currentResource input correctly', () => { + setup(); expect(component.currentResource()).toEqual(mockResource); }); it('should set storage input correctly', () => { + setup(); expect(component.storage()).toEqual(mockStorage); }); it('should default viewOnly to false', () => { + setup(); expect(component.viewOnly()).toBe(false); }); it('should set viewOnly input correctly', () => { + setup(); fixture.componentRef.setInput('viewOnly', true); fixture.detectChanges(); @@ -123,12 +137,14 @@ describe('ProjectOverviewToolbarComponent', () => { describe('Effects', () => { it('should set isPublic from currentResource', () => { + setup(); fixture.detectChanges(); expect(component.isPublic()).toBe(true); }); it('should dispatch getResourceBookmark when bookmarksId and resource exist', () => { + setup(); fixture.detectChanges(); expect(store.dispatch).toHaveBeenCalledWith(expect.any(GetResourceBookmark)); @@ -137,6 +153,9 @@ describe('ProjectOverviewToolbarComponent', () => { describe('handleToggleProjectPublicity', () => { it('should open TogglePublicityDialogComponent with makePrivate header when project is public', () => { + setup(); + fixture.detectChanges(); + component.handleToggleProjectPublicity(); expect(customDialogServiceMock.open).toHaveBeenCalledWith(TogglePublicityDialogComponent, { @@ -150,6 +169,7 @@ describe('ProjectOverviewToolbarComponent', () => { }); it('should open TogglePublicityDialogComponent with makePublic header when project is private', () => { + setup(); fixture.componentRef.setInput('currentResource', { ...mockResource, isPublic: false }); fixture.detectChanges(); @@ -166,6 +186,7 @@ describe('ProjectOverviewToolbarComponent', () => { }); it('should not open dialog when resource is null', () => { + setup(); fixture.componentRef.setInput('currentResource', null as any); fixture.detectChanges(); @@ -173,15 +194,83 @@ describe('ProjectOverviewToolbarComponent', () => { expect(customDialogServiceMock.open).not.toHaveBeenCalled(); }); + + it('should compute disableProjectPrivacyToggle when isProjectReadOnly is false', () => { + setup(); + fixture.detectChanges(); + + expect(component.isPublic()).toBe(true); + expect(component.disableProjectPrivacyToggle()).toBe(false); + + fixture.componentRef.setInput('currentResource', { ...mockResource, isPublic: false }); + fixture.detectChanges(); + + expect(component.isPublic()).toBe(false); + expect(component.disableProjectPrivacyToggle()).toBe(false); + }); + + it('should compute disableProjectPrivacyToggle when isProjectReadOnly is true', () => { + setup({ selectors: [{ selector: UserSelectors.isProjectReadOnly, value: true }] }); + fixture.detectChanges(); + + expect(component.isPublic()).toBe(true); + expect(component.disableProjectPrivacyToggle()).toBe(true); + + fixture.componentRef.setInput('currentResource', { ...mockResource, isPublic: false }); + fixture.detectChanges(); + + expect(component.isPublic()).toBe(false); + expect(component.disableProjectPrivacyToggle()).toBe(false); + }); }); describe('Properties', () => { it('should have ResourceType property', () => { + setup(); expect(component.ResourceType).toBe(ResourceType); }); it('should have resourceType set to Project', () => { + setup(); expect(component.resourceType).toBe(ResourceType.Project); }); }); + + describe('preventDuplicateCreation', () => { + it('should return false when isProjectCreationDisabled is false', () => { + setup(); + expect(component.preventDuplicateCreation()).toBe(false); + }); + + it('should return true when isProjectCreationDisabled is true', () => { + setup({ + selectors: [{ selector: UserSelectors.isProjectCreationDisabled, value: true }], + }); + fixture.detectChanges(); + expect(component.preventDuplicateCreation()).toBe(true); + }); + }); + + describe('projectReadOnlyTooltip', () => { + it('should return empty string when isProjectReadOnly is false', () => { + setup(); + expect(component.projectReadOnlyTooltip()).toBe(''); + + fixture.componentRef.setInput('currentResource', { ...mockResource, isPublic: false }); + fixture.detectChanges(); + expect(component.projectReadOnlyTooltip()).toBe(''); + }); + + it('should return tooltip message when isProjectReadOnly is true', () => { + setup({ + selectors: [{ selector: UserSelectors.isProjectReadOnly, value: true }], + }); + fixture.detectChanges(); + expect(component.projectReadOnlyTooltip()).toBe('common.errorMessages.actionUnavailable'); + + fixture.componentRef.setInput('currentResource', { ...mockResource, isPublic: false }); + fixture.detectChanges(); + expect(component.projectReadOnlyTooltip()).toBe(''); + }); + }); }); diff --git a/src/app/features/project/overview/components/project-overview-toolbar/project-overview-toolbar.component.ts b/src/app/features/project/overview/components/project-overview-toolbar/project-overview-toolbar.component.ts index 812e20ef2..01a7d2710 100644 --- a/src/app/features/project/overview/components/project-overview-toolbar/project-overview-toolbar.component.ts +++ b/src/app/features/project/overview/components/project-overview-toolbar/project-overview-toolbar.component.ts @@ -9,7 +9,7 @@ import { Tooltip } from 'primeng/tooltip'; import { timer } from 'rxjs'; -import { ChangeDetectionStrategy, Component, DestroyRef, effect, inject, input, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, DestroyRef, effect, inject, input, signal } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; @@ -75,6 +75,13 @@ export class ProjectOverviewToolbarComponent { duplicatedProject = select(ProjectOverviewSelectors.getDuplicatedProject); isAuthenticated = select(UserSelectors.isAuthenticated); + preventDuplicateCreation = select(UserSelectors.isProjectCreationDisabled); + isProjectReadOnly = select(UserSelectors.isProjectReadOnly); + + disableProjectPrivacyToggle = computed(() => this.isProjectReadOnly() && this.isPublic()); + projectReadOnlyTooltip = computed(() => + this.disableProjectPrivacyToggle() ? 'common.errorMessages.actionUnavailable' : '' + ); actions = createDispatchMap({ getResourceBookmark: GetResourceBookmark, @@ -96,9 +103,7 @@ export class ProjectOverviewToolbarComponent { }, { label: 'project.overview.actions.viewDuplication', - command: () => { - this.router.navigate(['../analytics/duplicates'], { relativeTo: this.route }); - }, + command: () => this.navigateToDuplicatesView(), }, ]; @@ -205,4 +210,8 @@ export class ProjectOverviewToolbarComponent { complete: () => this.actions.clearDuplicatedProject(), }); } + + navigateToDuplicatesView(): void { + this.router.navigate(['../analytics/duplicates'], { relativeTo: this.route }); + } } diff --git a/src/app/features/project/project-addons/project-addons.component.html b/src/app/features/project/project-addons/project-addons.component.html index 3fcf71ec0..4d4691b15 100644 --- a/src/app/features/project/project-addons/project-addons.component.html +++ b/src/app/features/project/project-addons/project-addons.component.html @@ -1,4 +1,4 @@ - +
diff --git a/src/app/features/project/settings/components/settings-project-form-card/settings-project-form-card.component.spec.ts b/src/app/features/project/settings/components/settings-project-form-card/settings-project-form-card.component.spec.ts index 027dbea4e..dcfbbcb63 100644 --- a/src/app/features/project/settings/components/settings-project-form-card/settings-project-form-card.component.spec.ts +++ b/src/app/features/project/settings/components/settings-project-form-card/settings-project-form-card.component.spec.ts @@ -9,6 +9,7 @@ import { ProjectFormControls } from '@osf/shared/enums/create-project-form-contr import { MOCK_NODE_DETAILS } from '@testing/mocks/node-details.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; import { NodeDetailsModel } from '../../models'; @@ -23,7 +24,7 @@ describe('SettingsProjectFormCardComponent', () => { beforeEach(() => { TestBed.configureTestingModule({ imports: [SettingsProjectFormCardComponent, MockComponent(TextInputComponent), MockDirective(Textarea)], - providers: [provideOSFCore()], + providers: [provideOSFCore(), provideMockStore()], }); fixture = TestBed.createComponent(SettingsProjectFormCardComponent); diff --git a/src/app/features/project/settings/components/settings-project-form-card/settings-project-form-card.component.ts b/src/app/features/project/settings/components/settings-project-form-card/settings-project-form-card.component.ts index 06621b004..c04d69a7e 100644 --- a/src/app/features/project/settings/components/settings-project-form-card/settings-project-form-card.component.ts +++ b/src/app/features/project/settings/components/settings-project-form-card/settings-project-form-card.component.ts @@ -1,12 +1,16 @@ +import { select } from '@ngxs/store'; + import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { Card } from 'primeng/card'; import { Textarea } from 'primeng/textarea'; +import { Tooltip } from 'primeng/tooltip'; import { ChangeDetectionStrategy, Component, computed, effect, input, output } from '@angular/core'; import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { UserSelectors } from '@osf/core/store/user/user.selectors'; import { TextInputComponent } from '@osf/shared/components/text-input/text-input.component'; import { InputLimits } from '@osf/shared/constants/input-limits.const'; import { ProjectFormControls } from '@osf/shared/enums/create-project-form-controls.enum'; @@ -16,7 +20,7 @@ import { NodeDetailsModel, ProjectDetailsModel } from '../../models'; @Component({ selector: 'osf-settings-project-form-card', - imports: [Button, Card, Textarea, TranslatePipe, ReactiveFormsModule, TextInputComponent], + imports: [Button, Card, Textarea, TranslatePipe, ReactiveFormsModule, TextInputComponent, Tooltip], templateUrl: './settings-project-form-card.component.html', styleUrl: 'settings-project-form-card.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -30,6 +34,8 @@ export class SettingsProjectFormCardComponent { readonly ProjectFormControls = ProjectFormControls; readonly inputLimits = InputLimits; + readonly isProjectReadonly = select(UserSelectors.isProjectReadOnly); + projectForm = new FormGroup({ [ProjectFormControls.Title]: new FormControl('', CustomValidators.requiredTrimmed()), [ProjectFormControls.Description]: new FormControl(''), diff --git a/src/app/features/project/settings/components/settings-wiki-card/settings-wiki-card.component.html b/src/app/features/project/settings/components/settings-wiki-card/settings-wiki-card.component.html index e563c4ba9..54cb69241 100644 --- a/src/app/features/project/settings/components/settings-wiki-card/settings-wiki-card.component.html +++ b/src/app/features/project/settings/components/settings-wiki-card/settings-wiki-card.component.html @@ -6,30 +6,37 @@

{{ 'myProjects.settings.wiki' | translate }}

[binary]="true" [ngModel]="wikiEnabled()" (ngModelChange)="wikiChangeEmit.emit($event)" + [disabled]="isProjectReadOnly()" inputId="wiki" name="ongoing" > -

- -

{{ 'registries.new.steps.title' | translate }} 1

-

{{ 'registries.new.steps.existingProjectQuestion' | translate }}

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

{{ 'registries.new.steps.title' | translate }} 1

+

{{ 'registries.new.steps.existingProjectQuestion' | translate }}

+
+ + +
+
+ }
@if (fromProject()) { @@ -58,7 +60,9 @@

{{ 'registries.new.steps.title' | translate }} 2

} -

{{ 'registries.new.steps.title' | translate }} {{ fromProject() ? '3' : '2' }}

+ @if (!isProjectReadOnly()) { +

{{ 'registries.new.steps.title' | translate }} {{ fromProject() ? '3' : '2' }}

+ }

{{ 'registries.new.steps.registrationTypeQuestion' | translate }}

{ { selector: RegistriesSelectors.isProvidersLoading, value: false }, { selector: RegistriesSelectors.isProjectsLoading, value: false }, { selector: UserSelectors.getCurrentUser, value: { id: 'user-1' } }, + { selector: UserSelectors.isProjectReadOnly, value: false }, { selector: RegistrationProviderSelectors.getBrandedProvider, value: { id: 'prov-1', allowSubmissions: true } }, ]; @@ -129,6 +130,13 @@ describe('NewRegistrationComponent', () => { expect(component.fromProject()).toBe(true); }); + it('should not show project panel when projectId is present, but isProjectReadOnly is true', () => { + setup({ + selectorOverrides: [{ selector: UserSelectors.isProjectReadOnly, value: true }], + }); + expect(component.fromProject()).toBe(false); + }); + it('should init form with project id from route', () => { setup(); expect(component.draftForm.get('project')?.value).toBe('proj-1'); diff --git a/src/app/features/registries/components/new-registration/new-registration.component.ts b/src/app/features/registries/components/new-registration/new-registration.component.ts index 8fc36948b..95cdc1ae6 100644 --- a/src/app/features/registries/components/new-registration/new-registration.component.ts +++ b/src/app/features/registries/components/new-registration/new-registration.component.ts @@ -42,6 +42,7 @@ export class NewRegistrationComponent { readonly isDraftSubmitting = select(RegistriesSelectors.isDraftSubmitting); readonly isProvidersLoading = select(RegistriesSelectors.isProvidersLoading); readonly isProjectsLoading = select(RegistriesSelectors.isProjectsLoading); + readonly isProjectReadOnly = select(UserSelectors.isProjectReadOnly); private readonly draftRegistration = select(RegistriesSelectors.getDraftRegistration); readonly canShowForm = computed(() => !this.isProvidersLoading() && !!this.provider()?.allowSubmissions); @@ -53,7 +54,7 @@ export class NewRegistrationComponent { createDraft: CreateDraft, }); private readonly providerId = this.route.snapshot.params['providerId']; - private readonly projectId = this.route.snapshot.queryParams['projectId']; + private readonly projectId = this.isProjectReadOnly() ? undefined : this.route.snapshot.queryParams['projectId']; private readonly filter$ = new Subject(); readonly fromProject = signal(this.projectId !== undefined); 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 725d9005b..5efca47aa 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 @@ -8,6 +8,7 @@

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

[isLoading]="isContributorsLoading()" [showLoadMore]="hasMoreContributors()" [isLoadingMore]="isLoadingMore()" + [resourceType]="8" (remove)="removeContributor($event)" (loadMore)="loadMoreContributors()" /> 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 b28e15dc8..4d1002687 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 @@ -62,6 +62,20 @@

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

{{ registryProvider()?.name }}

+ @if (showAssociatedProject()) { +
+

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

+ + + {{ webUrl + '/' + resource.associatedProjectId }} + +
+ } +

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

@@ -124,7 +138,7 @@

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

- @if (resource.associatedProjectId) { + @if (showAssociatedProject()) {

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

diff --git a/src/app/features/registry/components/registry-overview-metadata/registry-overview-metadata.component.spec.ts b/src/app/features/registry/components/registry-overview-metadata/registry-overview-metadata.component.spec.ts index 1f4035420..61eb29f5c 100644 --- a/src/app/features/registry/components/registry-overview-metadata/registry-overview-metadata.component.spec.ts +++ b/src/app/features/registry/components/registry-overview-metadata/registry-overview-metadata.component.spec.ts @@ -156,4 +156,30 @@ describe('RegistryOverviewMetadataComponent', () => { expect(mockRouter.navigate).toHaveBeenCalledWith(['/search'], { queryParams: { search: 'test-tag' } }); }); + + it('should show associated project section if registry has associated project and is not a project registration', () => { + const { fixture } = setup({ + registry: { ...MOCK_REGISTRY, associatedProjectId: 'project-123', hasProject: true }, + }); + const associatedProjectSection = fixture.nativeElement.querySelector( + '[data-test-registry-overview-metadata-associated-project-link]' + ); + expect(associatedProjectSection).not.toBeNull(); + }); + + it('should hide associated project section if registry has no associated project', () => { + const { fixture } = setup({ registry: { ...MOCK_REGISTRY, associatedProjectId: 'abc123', hasProject: false } }); + const associatedProjectSection = fixture.nativeElement.querySelector( + '[data-test-registry-overview-metadata-associated-project-link]' + ); + expect(associatedProjectSection).toBeNull(); + + const { fixture: fixture2 } = setup({ + registry: { ...MOCK_REGISTRY, associatedProjectId: undefined, hasProject: true }, + }); + const associatedProjectSection2 = fixture2.nativeElement.querySelector( + '[data-test-registry-overview-metadata-associated-project-link]' + ); + expect(associatedProjectSection2).toBeNull(); + }); }); 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 38a43b958..803b56e9b 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 @@ -5,7 +5,7 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { DatePipe } from '@angular/common'; -import { ChangeDetectionStrategy, Component, effect, inject } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, effect, inject } from '@angular/core'; import { Router, RouterLink } from '@angular/router'; import { ENVIRONMENT } from '@core/provider/environment.provider'; @@ -84,6 +84,9 @@ export class RegistryOverviewMetadataComponent { readonly currentResourceType = CurrentResourceType.Registrations; readonly dateFormat = 'MMM d, y, h:mm a'; readonly webUrl = this.environment.webUrl; + readonly showAssociatedProject = computed( + () => !!this.registry()?.associatedProjectId && this.registry()?.hasProject + ); private readonly actions = createDispatchMap({ getSubjects: FetchSelectedSubjects, diff --git a/src/app/features/registry/models/registry-overview.model.ts b/src/app/features/registry/models/registry-overview.model.ts index 957feafb5..d100a6384 100644 --- a/src/app/features/registry/models/registry-overview.model.ts +++ b/src/app/features/registry/models/registry-overview.model.ts @@ -5,7 +5,7 @@ import { RegistrationNodeModel } from '@shared/models/registration/registration- export interface RegistrationOverviewModel extends RegistrationNodeModel { registrationSchemaLink: string; licenseId: string; - associatedProjectId: string; + associatedProjectId?: string; providerId: string; status: RegistryStatus; forksCount: number; diff --git a/src/app/shared/components/addons/addon-card/addon-card.component.html b/src/app/shared/components/addons/addon-card/addon-card.component.html index 4d8f35d18..2840f1e75 100644 --- a/src/app/shared/components/addons/addon-card/addon-card.component.html +++ b/src/app/shared/components/addons/addon-card/addon-card.component.html @@ -21,7 +21,8 @@

{{ actualAddon()?.displayName diff --git a/src/app/shared/components/addons/addon-card/addon-card.component.spec.ts b/src/app/shared/components/addons/addon-card/addon-card.component.spec.ts index e3405245e..cd939eabb 100644 --- a/src/app/shared/components/addons/addon-card/addon-card.component.spec.ts +++ b/src/app/shared/components/addons/addon-card/addon-card.component.spec.ts @@ -3,6 +3,7 @@ import { MockProvider } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { Router } from '@angular/router'; +import { UserSelectors } from '@osf/core/store/user'; import { CredentialsFormat } from '@osf/shared/enums/addons-credentials-format.enum'; import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; import { AddonModel } from '@shared/models/addons/addon.model'; @@ -10,15 +11,25 @@ import { AddonModel } from '@shared/models/addons/addon.model'; import { provideOSFCore } from '@testing/osf.testing.provider'; import { CustomConfirmationServiceMockBuilder } from '@testing/providers/custom-confirmation-provider.mock'; import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { + BaseSetupOverrides, + mergeSignalOverrides, + provideMockStore, + SignalOverride, +} from '@testing/providers/store-provider.mock'; import { AddonCardComponent } from './addon-card.component'; +interface SetupOverrides extends BaseSetupOverrides { + selectorOverrides?: SignalOverride[]; +} + describe('AddonCardComponent', () => { let component: AddonCardComponent; let fixture: ComponentFixture; let mockRouter: ReturnType; let customConfirmationServiceMock: ReturnType; + const defaultSignals: SignalOverride[] = [{ selector: UserSelectors.isProjectReadOnly, value: false }]; const mockAddon: AddonModel = { id: 'test-addon-id', @@ -31,7 +42,7 @@ describe('AddonCardComponent', () => { externalServiceName: 'test-service', }; - beforeEach(() => { + const setup = function (overrides?: SetupOverrides) { mockRouter = RouterMockBuilder.create().withUrl('/settings/addons').build(); customConfirmationServiceMock = CustomConfirmationServiceMockBuilder.create().build(); @@ -42,6 +53,9 @@ describe('AddonCardComponent', () => { provideMockStore(), MockProvider(Router, mockRouter), MockProvider(CustomConfirmationService, customConfirmationServiceMock), + provideMockStore({ + signals: mergeSignalOverrides(defaultSignals, overrides?.selectorOverrides), + }), ], }); @@ -50,13 +64,41 @@ describe('AddonCardComponent', () => { fixture.componentRef.setInput('card', mockAddon); fixture.detectChanges(); - }); + }; it('should create', () => { + setup(); expect(component).toBeTruthy(); }); + it('should compute shouldDisableConnect when isProjectReadOnly false', () => { + expect(component.shouldDisableConnect()).toBe(false); + + fixture.componentRef.setInput('isConnected', true); + fixture.detectChanges(); + expect(component.shouldDisableConnect()).toBe(false); + + fixture.componentRef.setInput('card', { ...mockAddon, type: 'external-citation-services' }); + fixture.detectChanges(); + expect(component.shouldDisableConnect()).toBe(false); + }); + + it('should compute shouldDisableConnect when isProjectReadOnly true', () => { + setup({ selectorOverrides: [{ selector: UserSelectors.isProjectReadOnly, value: true }] }); + expect(component.shouldDisableConnect()).toBe(true); + + fixture.componentRef.setInput('isConnected', true); + fixture.detectChanges(); + expect(component.shouldDisableConnect()).toBe(false); + + fixture.componentRef.setInput('card', { ...mockAddon, type: 'external-citation-services' }); + fixture.componentRef.setInput('isConnected', false); + fixture.detectChanges(); + expect(component.shouldDisableConnect()).toBe(false); + }); + it('should navigate to connect-addon route when addon exists', () => { + setup(); component.onConnectAddon(); expect(mockRouter.navigate).toHaveBeenCalledWith(['/settings/addons/connect-addon'], { @@ -65,6 +107,7 @@ describe('AddonCardComponent', () => { }); it('should navigate to configure-addon route when addon exists', () => { + setup(); component.onConfigureAddon(); expect(mockRouter.navigate).toHaveBeenCalledWith(['/settings/addons/configure-addon'], { @@ -73,6 +116,7 @@ describe('AddonCardComponent', () => { }); it('should call confirmDelete on customConfirmationService', () => { + setup(); component.showDisableDialog(); expect(customConfirmationServiceMock.confirmDelete).toHaveBeenCalledWith({ diff --git a/src/app/shared/components/addons/addon-card/addon-card.component.ts b/src/app/shared/components/addons/addon-card/addon-card.component.ts index 16ca526b4..686bc5ffe 100644 --- a/src/app/shared/components/addons/addon-card/addon-card.component.ts +++ b/src/app/shared/components/addons/addon-card/addon-card.component.ts @@ -1,13 +1,15 @@ -import { createDispatchMap } from '@ngxs/store'; +import { createDispatchMap, select } from '@ngxs/store'; import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; +import { Tooltip } from 'primeng/tooltip'; import { Component, computed, inject, input } from '@angular/core'; import { Router } from '@angular/router'; -import { getAddonTypeString, isConfiguredAddon } from '@osf/shared/helpers/addon-type.helper'; +import { UserSelectors } from '@osf/core/store/user'; +import { getAddonTypeString, isConfiguredAddon, isStorageAddon } from '@osf/shared/helpers/addon-type.helper'; import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; import { LoaderService } from '@osf/shared/services/loader.service'; import { AddonModel } from '@shared/models/addons/addon.model'; @@ -18,7 +20,7 @@ import { DeleteAuthorizedAddon } from '@shared/stores/addons'; @Component({ selector: 'osf-addon-card', - imports: [Button, TranslatePipe], + imports: [Button, Tooltip, TranslatePipe], templateUrl: './addon-card.component.html', styleUrl: './addon-card.component.scss', }) @@ -32,6 +34,8 @@ export class AddonCardComponent { readonly isConnected = input(false); readonly hasAdminAccess = input(false); + readonly isProjectReadOnly = select(UserSelectors.isProjectReadOnly); + readonly actualAddon = computed(() => { const actualCard = this.card(); if (!actualCard) return null; @@ -74,6 +78,20 @@ export class AddonCardComponent { return hasAdmin || isOwner; }); + readonly shouldDisableConnect = computed(() => { + if (this.isConfiguredAddon() || this.isConnected()) { + return false; + } + if (this.isProjectReadOnly() && !this.isConnected() && isStorageAddon(this.actualAddon())) { + return true; + } + return false; + }); + + readonly buttonTooltip = computed(() => { + return this.shouldDisableConnect() ? 'common.errorMessages.actionUnavailable' : ''; + }); + readonly buttonLabel = computed(() => { const isConfigured = this.isConfiguredAddon(); const isConnected = this.isConnected(); 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 36b516a90..14e33b64b 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 @@ -1,7 +1,7 @@ - @if (!deactivatedContributors() && hasAdminAccess()) { + @if (!deactivatedContributors() && canEditContributors()) {
@@ -75,6 +75,8 @@ @if (hasAdminAccess() && !contributor.deactivated) {
- + + +
@if (showCurator()) { @@ -160,6 +164,12 @@ icon="fas fa-trash" severity="danger" text + [disabled]="!canEditContributors() && contributor.userId !== currentUserId()" + [pTooltip]=" + (!canEditContributors() && contributor.userId !== currentUserId() ? controlDisabledTooltip() : '') + | translate + " + tooltipPosition="left" [ariaLabel]="'common.buttons.delete' | translate" (onClick)="removeContributor(contributor)" data-test-remove-contributor-button @@ -206,61 +216,27 @@

{{ 'project.contributors.permissionInfo.title' | translate }}

{{ 'project.contributors.permissions.read' | translate }}

    -
  • - {{ - (isProject() - ? 'project.contributors.permissionInfo.viewProjectContent' - : 'project.contributors.permissionInfo.viewRegistrationContent' - ) | translate - }} -
  • + @for (permission of readOnlyPermissionInfo(); track $index) { +
  • {{ permission | translate }}
  • + }

{{ 'project.contributors.permissions.readAndWrite' | translate }}

    -
  • {{ 'project.contributors.permissionInfo.read' | translate }}
  • -
  • - {{ - (isProject() - ? 'project.contributors.permissionInfo.addComponents' - : 'project.contributors.permissionInfo.editMetadata' - ) | translate - }} -
  • -
  • - {{ - (isProject() - ? 'project.contributors.permissionInfo.editContent' - : 'project.contributors.permissionInfo.addResourcesLinks' - ) | translate - }} -
  • + @for (permission of writePermissionInfo(); track $index) { +
  • {{ permission | translate }}
  • + }

{{ 'project.contributors.permissions.administrator' | translate }}

    -
  • {{ 'project.contributors.permissionInfo.readWrite' | translate }}
  • -
  • {{ 'project.contributors.permissionInfo.manageContributors' | translate }}
  • -
  • - {{ - (isProject() - ? 'project.contributors.permissionInfo.deleteRegister' - : 'project.contributors.permissionInfo.withdrawRegistration' - ) | translate - }} -
  • -
  • - {{ - (isProject() - ? 'project.contributors.permissionInfo.publicPrivate' - : 'project.contributors.permissionInfo.endEmbargoEarly' - ) | translate - }} -
  • + @for (permission of adminPermissionInfo(); track $index) { +
  • {{ permission | translate }}
  • + }
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 b3a4ccac0..fe972e217 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 @@ -2,6 +2,7 @@ import { MockComponents, MockProvider } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { UserSelectors } from '@osf/core/store/user/user.selectors'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { ContributorModel } from '@shared/models/contributors/contributor.model'; import { TableParameters } from '@shared/models/table-parameters.model'; @@ -10,6 +11,7 @@ import { CustomDialogService } from '@shared/services/custom-dialog.service'; import { MOCK_CONTRIBUTOR, MOCK_CONTRIBUTOR_WITHOUT_HISTORY } from '@testing/mocks/contributors.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; import { CustomDialogServiceMockBuilder } from '@testing/providers/custom-dialog-provider.mock'; +import { BaseSetupOverrides, mergeSignalOverrides, provideMockStore } from '@testing/providers/store-provider.mock'; import { EducationHistoryDialogComponent } from '../../education-history-dialog/education-history-dialog.component'; import { EmploymentHistoryDialogComponent } from '../../employment-history-dialog/employment-history-dialog.component'; @@ -36,37 +38,47 @@ describe('ContributorsTableComponent', () => { let fixture: ComponentFixture; let mockCustomDialogService: ReturnType; - beforeEach(() => { + function setup(overrides: BaseSetupOverrides = {}) { mockCustomDialogService = CustomDialogServiceMockBuilder.create().build(); + const defaultSignals = [{ selector: UserSelectors.isProjectReadOnly, value: false }]; + const signals = mergeSignalOverrides(defaultSignals, overrides.selectorOverrides); TestBed.configureTestingModule({ imports: [ContributorsTableComponent, ...MockComponents(SelectComponent, IconComponent, InfoIconComponent)], - providers: [provideOSFCore(), MockProvider(CustomDialogService, mockCustomDialogService)], + providers: [ + provideOSFCore(), + MockProvider(CustomDialogService, mockCustomDialogService), + provideMockStore({ signals }), + ], }); fixture = TestBed.createComponent(ContributorsTableComponent); component = fixture.componentInstance; fixture.componentRef.setInput('tableParams', makeTableParams()); fixture.detectChanges(); - }); + } it('should create', () => { + setup(); expect(component).toBeTruthy(); }); it('should return true from isProject when resourceType is Project', () => { + setup(); fixture.componentRef.setInput('resourceType', ResourceType.Project); fixture.detectChanges(); expect(component.isProject()).toBe(true); }); it('should return false from isProject when resourceType is Registration', () => { + setup(); fixture.componentRef.setInput('resourceType', ResourceType.Registration); fixture.detectChanges(); expect(component.isProject()).toBe(false); }); it('should return true from deactivatedContributors when at least one contributor is deactivated', () => { + setup(); fixture.componentRef.setInput('contributors', [ { ...MOCK_CONTRIBUTOR, id: '1', deactivated: false }, { ...MOCK_CONTRIBUTOR_WITHOUT_HISTORY, id: '2', deactivated: true }, @@ -76,6 +88,7 @@ describe('ContributorsTableComponent', () => { }); it('should return false from deactivatedContributors when all contributors are active', () => { + setup(); fixture.componentRef.setInput('contributors', [ { ...MOCK_CONTRIBUTOR, id: '1', deactivated: false }, { ...MOCK_CONTRIBUTOR_WITHOUT_HISTORY, id: '2', deactivated: false }, @@ -85,23 +98,131 @@ describe('ContributorsTableComponent', () => { }); it('should return false from deactivatedContributors when contributor list is empty', () => { + setup(); fixture.componentRef.setInput('contributors', []); fixture.detectChanges(); expect(component.deactivatedContributors()).toBe(false); }); it('should default showLoadMore to false', () => { + setup(); expect(component.showLoadMore()).toBe(false); }); it('should reflect showLoadMore as true when set by parent', () => { + setup(); 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' }; + it('should compute readOnlyPermissionInfo correctly', () => { + setup({ selectorOverrides: [{ selector: UserSelectors.isProjectReadOnly, value: true }] }); + fixture.detectChanges(); + expect(component.readOnlyPermissionInfo()).toEqual([ + 'project.contributors.permissionInfo.readOnlyViewProjectContent', + ]); + + setup({ selectorOverrides: [{ selector: UserSelectors.isProjectReadOnly, value: false }] }); + fixture.componentRef.setInput('resourceType', ResourceType.Project); + fixture.detectChanges(); + expect(component.readOnlyPermissionInfo()).toEqual(['project.contributors.permissionInfo.viewProjectContent']); + + setup({ selectorOverrides: [{ selector: UserSelectors.isProjectReadOnly, value: true }] }); + fixture.componentRef.setInput('resourceType', ResourceType.Registration); + fixture.detectChanges(); + expect(component.readOnlyPermissionInfo()).toEqual(['project.contributors.permissionInfo.viewRegistrationContent']); + }); + + it('should compute writePermissionInfo correctly', () => { + setup({ selectorOverrides: [{ selector: UserSelectors.isProjectReadOnly, value: true }] }); + fixture.detectChanges(); + expect(component.writePermissionInfo()).toEqual(['project.contributors.permissionInfo.readOnlyViewProjectContent']); + + setup({ selectorOverrides: [{ selector: UserSelectors.isProjectReadOnly, value: false }] }); + fixture.componentRef.setInput('resourceType', ResourceType.Project); + fixture.detectChanges(); + expect(component.writePermissionInfo()).toEqual([ + 'project.contributors.permissionInfo.read', + 'project.contributors.permissionInfo.addComponents', + 'project.contributors.permissionInfo.editContent', + ]); + + setup({ selectorOverrides: [{ selector: UserSelectors.isProjectReadOnly, value: true }] }); + fixture.componentRef.setInput('resourceType', ResourceType.Registration); + fixture.detectChanges(); + expect(component.writePermissionInfo()).toEqual([ + 'project.contributors.permissionInfo.read', + 'project.contributors.permissionInfo.editMetadata', + 'project.contributors.permissionInfo.addResourcesLinks', + ]); + }); + + it('should compute adminPermissionInfo correctly', () => { + setup({ selectorOverrides: [{ selector: UserSelectors.isProjectReadOnly, value: true }] }); + fixture.detectChanges(); + expect(component.adminPermissionInfo()).toEqual([ + 'project.contributors.permissionInfo.manageViewOnlyLinks', + 'project.contributors.permissionInfo.deleteProject', + ]); + + setup({ selectorOverrides: [{ selector: UserSelectors.isProjectReadOnly, value: false }] }); + fixture.componentRef.setInput('resourceType', ResourceType.Project); + fixture.detectChanges(); + expect(component.adminPermissionInfo()).toEqual([ + 'project.contributors.permissionInfo.readWrite', + 'project.contributors.permissionInfo.manageContributors', + 'project.contributors.permissionInfo.deleteRegister', + 'project.contributors.permissionInfo.publicPrivate', + ]); + + setup({ selectorOverrides: [{ selector: UserSelectors.isProjectReadOnly, value: true }] }); + fixture.componentRef.setInput('resourceType', ResourceType.Registration); + fixture.detectChanges(); + expect(component.adminPermissionInfo()).toEqual([ + 'project.contributors.permissionInfo.readWrite', + 'project.contributors.permissionInfo.manageContributors', + 'project.contributors.permissionInfo.withdrawRegistration', + 'project.contributors.permissionInfo.endEmbargoEarly', + ]); + }); + + it('should compute properties when hasAdminAccess is true and isProjectReadonly is false', () => { + setup({ selectorOverrides: [{ selector: UserSelectors.isProjectReadOnly, value: false }] }); + fixture.componentRef.setInput('hasAdminAccess', true); + fixture.detectChanges(); + expect(component.canEditContributors()).toBe(true); + expect(component.controlDisabledTooltip()).toBe(''); + }); + + it('should compute properties when hasAdminAccess is true and isProjectReadonly is true', () => { + setup({ selectorOverrides: [{ selector: UserSelectors.isProjectReadOnly, value: true }] }); + fixture.componentRef.setInput('hasAdminAccess', true); + fixture.detectChanges(); + expect(component.canEditContributors()).toBe(false); + expect(component.controlDisabledTooltip()).toBe('common.errorMessages.actionUnavailable'); + }); + + it('should compute properties when hasAdminAccess is false and isProjectReadonly is false', () => { + setup({ selectorOverrides: [{ selector: UserSelectors.isProjectReadOnly, value: false }] }); + fixture.componentRef.setInput('hasAdminAccess', false); + fixture.detectChanges(); + expect(component.canEditContributors()).toBe(false); + expect(component.controlDisabledTooltip()).toBe(''); + }); + + it('should compute properties when hasAdminAccess is true, isProjectReadonly is true, and isProject is false', () => { + setup({ selectorOverrides: [{ selector: UserSelectors.isProjectReadOnly, value: true }] }); + fixture.componentRef.setInput('hasAdminAccess', true); + fixture.componentRef.setInput('resourceType', ResourceType.DraftRegistration); + fixture.detectChanges(); + expect(component.canEditContributors()).toBe(true); + expect(component.controlDisabledTooltip()).toBe(''); + }); + + it('should emit remove event when removeContributor is called', () => { + setup(); + const contributor = { ...MOCK_CONTRIBUTOR, id: 'remove-id' }; vi.spyOn(component.remove, 'emit'); component.removeContributor(contributor); @@ -110,6 +231,7 @@ describe('ContributorsTableComponent', () => { }); it('should emit loadMore event when loadMoreItems is called', () => { + setup(); vi.spyOn(component.loadMore, 'emit'); component.loadMoreItems(); @@ -118,6 +240,7 @@ describe('ContributorsTableComponent', () => { }); it('should open EducationHistoryDialogComponent with contributor education data', () => { + setup(); const contributor: ContributorModel = { ...MOCK_CONTRIBUTOR, id: 'education-id', @@ -145,6 +268,7 @@ describe('ContributorsTableComponent', () => { }); it('should open EducationHistoryDialogComponent with an empty education array', () => { + setup(); const contributor: ContributorModel = { ...MOCK_CONTRIBUTOR, id: 'no-education-id', education: [] }; component.openEducationHistory(contributor); @@ -157,6 +281,7 @@ describe('ContributorsTableComponent', () => { }); it('should open EmploymentHistoryDialogComponent with contributor employment data', () => { + setup(); const contributor: ContributorModel = { ...MOCK_CONTRIBUTOR, id: 'employment-id', @@ -184,6 +309,7 @@ describe('ContributorsTableComponent', () => { }); it('should open EmploymentHistoryDialogComponent with an empty employment array', () => { + setup(); const contributor: ContributorModel = { ...MOCK_CONTRIBUTOR, id: 'no-employment-id', employment: [] }; component.openEmploymentHistory(contributor); @@ -196,6 +322,7 @@ describe('ContributorsTableComponent', () => { }); it('should reindex contributors starting from tableParams.firstRowIndex on row reorder', () => { + setup(); fixture.componentRef.setInput('tableParams', makeTableParams({ firstRowIndex: 10 })); fixture.componentRef.setInput('contributors', [ { ...MOCK_CONTRIBUTOR, id: '1', index: 0 }, @@ -210,6 +337,7 @@ describe('ContributorsTableComponent', () => { }); it('should reindex contributors from 0 when firstRowIndex is 0 on row reorder', () => { + setup(); fixture.componentRef.setInput('tableParams', makeTableParams({ firstRowIndex: 0 })); fixture.componentRef.setInput('contributors', [ { ...MOCK_CONTRIBUTOR, id: '1', index: 5 }, 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 800c35066..aee7652bc 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 @@ -1,3 +1,5 @@ +import { select } from '@ngxs/store'; + import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; @@ -9,6 +11,7 @@ import { Tooltip } from 'primeng/tooltip'; import { ChangeDetectionStrategy, Component, computed, inject, input, model, output } from '@angular/core'; import { FormsModule } from '@angular/forms'; +import { UserSelectors } from '@osf/core/store/user/user.selectors'; import { SelectComponent } from '@osf/shared/components/select/select.component'; import { PERMISSION_OPTIONS } from '@osf/shared/constants/contributors.constants'; import { ContributorPermission } from '@osf/shared/enums/contributors/contributor-permission.enum'; @@ -58,6 +61,8 @@ export class ContributorsTableComponent { remove = output(); loadMore = output(); + isProjectReadonly = select(UserSelectors.isProjectReadOnly); + customDialogService = inject(CustomDialogService); readonly permissionsOptions = PERMISSION_OPTIONS; @@ -69,6 +74,65 @@ export class ContributorsTableComponent { deactivatedContributors = computed(() => this.contributors().some((contributor) => contributor.deactivated)); + canEditContributors = computed(() => this.hasAdminAccess() && !(this.isProjectReadonly() && this.isProject())); + + controlDisabledTooltip = computed(() => + this.isProjectReadonly() && this.isProject() ? 'common.errorMessages.actionUnavailable' : '' + ); + + readOnlyPermissionInfo = computed(() => { + const translationPrefix = 'project.contributors.permissionInfo.'; + if (!this.isProject()) { + return [translationPrefix + 'viewRegistrationContent']; + } + return this.isProjectReadonly() + ? [translationPrefix + 'readOnlyViewProjectContent'] + : [translationPrefix + 'viewProjectContent']; + }); + + writePermissionInfo = computed(() => { + const translationPrefix = 'project.contributors.permissionInfo.'; + const projectPermissions = [ + translationPrefix + 'read', + translationPrefix + 'addComponents', + translationPrefix + 'editContent', + ]; + const registrationPermissions = [ + translationPrefix + 'read', + translationPrefix + 'editMetadata', + translationPrefix + 'addResourcesLinks', + ]; + + if (!this.isProject()) { + return registrationPermissions; + } + + return this.isProjectReadonly() ? [translationPrefix + 'readOnlyViewProjectContent'] : projectPermissions; + }); + + adminPermissionInfo = computed(() => { + const translationPrefix = 'project.contributors.permissionInfo.'; + const projectPermissions = [ + translationPrefix + 'readWrite', + translationPrefix + 'manageContributors', + translationPrefix + 'deleteRegister', + translationPrefix + 'publicPrivate', + ]; + const registrationPermissions = [ + translationPrefix + 'readWrite', + translationPrefix + 'manageContributors', + translationPrefix + 'withdrawRegistration', + translationPrefix + 'endEmbargoEarly', + ]; + + if (!this.isProject()) { + return registrationPermissions; + } + return this.isProjectReadonly() + ? [translationPrefix + 'manageViewOnlyLinks', translationPrefix + 'deleteProject'] + : projectPermissions; + }); + removeContributor(contributor: ContributorModel) { this.remove.emit(contributor); } diff --git a/src/app/shared/components/project-selector/project-selector.component.html b/src/app/shared/components/project-selector/project-selector.component.html index 5d9ffdcfc..b10a31e21 100644 --- a/src/app/shared/components/project-selector/project-selector.component.html +++ b/src/app/shared/components/project-selector/project-selector.component.html @@ -3,6 +3,7 @@ [loading]="isProjectsLoading()" [options]="projectsOptions()" [filter]="true" + [disabled]="disabled()" optionLabel="label" optionValue="value" appendTo="body" diff --git a/src/app/shared/components/project-selector/project-selector.component.ts b/src/app/shared/components/project-selector/project-selector.component.ts index 2b7597ec8..ae9e4b0fe 100644 --- a/src/app/shared/components/project-selector/project-selector.component.ts +++ b/src/app/shared/components/project-selector/project-selector.component.ts @@ -25,7 +25,6 @@ import { UserSelectors } from '@core/store/user'; import { ProjectModel } from '@osf/shared/models/projects/projects.model'; import { CustomOption } from '@shared/models/select-option.model'; import { GetProjects, ProjectsSelectors } from '@shared/stores/projects'; - @Component({ selector: 'osf-project-selector', imports: [Select, TranslatePipe, FormsModule], @@ -44,6 +43,7 @@ export class ProjectSelectorComponent { placeholder = input('common.buttons.select'); showClear = input(true); excludeProjectIds = input([]); + disabled = input(false); publicOnly = input(false); selectedProject = model(null); diff --git a/src/app/shared/components/sub-header/sub-header.component.html b/src/app/shared/components/sub-header/sub-header.component.html index 13eee31c5..c198c9fb0 100644 --- a/src/app/shared/components/sub-header/sub-header.component.html +++ b/src/app/shared/components/sub-header/sub-header.component.html @@ -32,6 +32,7 @@

[loading]="isSubmitting()" [disabled]="isButtonDisabled()" data-test-sub-header-button + [pTooltip]="buttonTooltip()" >

} diff --git a/src/app/shared/components/sub-header/sub-header.component.spec.ts b/src/app/shared/components/sub-header/sub-header.component.spec.ts index 74c875fea..a479eb79e 100644 --- a/src/app/shared/components/sub-header/sub-header.component.spec.ts +++ b/src/app/shared/components/sub-header/sub-header.component.spec.ts @@ -128,6 +128,11 @@ describe('SubHeaderComponent', () => { expect(component.isButtonDisabled()).toBe(true); }); + it('should set buttonTooltip input correctly', () => { + fixture.componentRef.setInput('buttonTooltip', 'Test button tooltip'); + expect(component.buttonTooltip()).toBe('Test button tooltip'); + }); + it('should emit buttonClick event', () => { const emitSpy = vi.spyOn(component.buttonClick, 'emit'); @@ -155,12 +160,14 @@ describe('SubHeaderComponent', () => { fixture.componentRef.setInput('description', 'Description with special chars: <>&"\''); fixture.componentRef.setInput('buttonLabel', 'Button with special chars: !@#$%'); fixture.componentRef.setInput('tooltip', 'Tooltip with special chars: [{}]|\\'); + fixture.componentRef.setInput('buttonTooltip', 'Button tooltip with special chars: @#$%()<>'); fixture.componentRef.setInput('icon', 'pi-icon-with-special-chars'); expect(component.title()).toBe('Title with special chars: @#$%^&*()'); expect(component.description()).toBe('Description with special chars: <>&"\''); expect(component.buttonLabel()).toBe('Button with special chars: !@#$%'); expect(component.tooltip()).toBe('Tooltip with special chars: [{}]|\\'); + expect(component.buttonTooltip()).toBe('Button tooltip with special chars: @#$%()<>'); expect(component.icon()).toBe('pi-icon-with-special-chars'); }); @@ -169,12 +176,14 @@ describe('SubHeaderComponent', () => { fixture.componentRef.setInput('description', ''); fixture.componentRef.setInput('buttonLabel', ''); fixture.componentRef.setInput('tooltip', ''); + fixture.componentRef.setInput('buttonTooltip', ''); fixture.componentRef.setInput('icon', ''); expect(component.title()).toBe(''); expect(component.description()).toBe(''); expect(component.buttonLabel()).toBe(''); expect(component.tooltip()).toBe(''); + expect(component.buttonTooltip()).toBe(''); expect(component.icon()).toBe(''); }); @@ -193,9 +202,11 @@ describe('SubHeaderComponent', () => { fixture.componentRef.setInput('showButton', true); fixture.componentRef.setInput('isButtonDisabled', true); fixture.componentRef.setInput('buttonLabel', 'Disabled Button'); + fixture.componentRef.setInput('buttonTooltip', 'Disabled Button Tooltip'); expect(component.showButton()).toBe(true); expect(component.isButtonDisabled()).toBe(true); expect(component.buttonLabel()).toBe('Disabled Button'); + expect(component.buttonTooltip()).toBe('Disabled Button Tooltip'); }); }); diff --git a/src/app/shared/components/sub-header/sub-header.component.ts b/src/app/shared/components/sub-header/sub-header.component.ts index e0150cc8f..559f76924 100644 --- a/src/app/shared/components/sub-header/sub-header.component.ts +++ b/src/app/shared/components/sub-header/sub-header.component.ts @@ -25,5 +25,6 @@ export class SubHeaderComponent { isLoading = input(false); isSubmitting = input(false); isButtonDisabled = input(false); + buttonTooltip = input(''); buttonClick = output(); } diff --git a/src/app/shared/components/wiki/edit-section/edit-section.component.html b/src/app/shared/components/wiki/edit-section/edit-section.component.html index 5b31ad66a..dda2c48ae 100644 --- a/src/app/shared/components/wiki/edit-section/edit-section.component.html +++ b/src/app/shared/components/wiki/edit-section/edit-section.component.html @@ -7,7 +7,8 @@

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

[label]="'common.buttons.save' | translate" severity="success" class="mr-2" - [disabled]="!currentContent()" + [disabled]="!currentContent() || disableSaveButton()" + [pTooltip]="disableSaveButton() ? ('common.errorMessages.actionUnavailable' | translate) : ''" (onClick)="save()" [loading]="isSaving()" > diff --git a/src/app/shared/components/wiki/edit-section/edit-section.component.spec.ts b/src/app/shared/components/wiki/edit-section/edit-section.component.spec.ts index e4e005871..8b82765b6 100644 --- a/src/app/shared/components/wiki/edit-section/edit-section.component.spec.ts +++ b/src/app/shared/components/wiki/edit-section/edit-section.component.spec.ts @@ -244,4 +244,16 @@ describe('EditSectionComponent', () => { expect(component.content).toBe(''); expect(component.initialContent).toBe(''); }); + + it('should handle disableSaveButton input', () => { + fixture.componentRef.setInput('disableSaveButton', true); + fixture.detectChanges(); + + expect(component.disableSaveButton()).toBe(true); + + fixture.componentRef.setInput('disableSaveButton', false); + fixture.detectChanges(); + + expect(component.disableSaveButton()).toBe(false); + }); }); diff --git a/src/app/shared/components/wiki/edit-section/edit-section.component.ts b/src/app/shared/components/wiki/edit-section/edit-section.component.ts index a62bf469f..2a255ef50 100644 --- a/src/app/shared/components/wiki/edit-section/edit-section.component.ts +++ b/src/app/shared/components/wiki/edit-section/edit-section.component.ts @@ -4,6 +4,7 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { Checkbox } from 'primeng/checkbox'; import { Panel } from 'primeng/panel'; +import { Tooltip } from 'primeng/tooltip'; import { ChangeDetectionStrategy, Component, effect, inject, input, output } from '@angular/core'; import { FormsModule } from '@angular/forms'; @@ -14,7 +15,7 @@ import { WikiSyntaxHelpDialogComponent } from '../wiki-syntax-help-dialog/wiki-s @Component({ selector: 'osf-edit-section', - imports: [Checkbox, Panel, Button, TranslatePipe, FormsModule, LMarkdownEditorModule], + imports: [Checkbox, Panel, Button, Tooltip, TranslatePipe, FormsModule, LMarkdownEditorModule], templateUrl: './edit-section.component.html', styleUrl: './edit-section.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -25,6 +26,7 @@ export class EditSectionComponent { readonly currentContent = input.required(); readonly versionContent = input.required(); readonly isSaving = input(false); + readonly disableSaveButton = input(false); readonly contentChange = output(); readonly saveContent = output(); // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/app/shared/components/wiki/wiki-list/wiki-list.component.html b/src/app/shared/components/wiki/wiki-list/wiki-list.component.html index b6d16d796..09a2cd66b 100644 --- a/src/app/shared/components/wiki/wiki-list/wiki-list.component.html +++ b/src/app/shared/components/wiki/wiki-list/wiki-list.component.html @@ -18,6 +18,8 @@ @if (canEdit()) { {{ item.label | translate }} {{ item.label | translate }} {{ item.label | translate }} raised outlined severity="danger" + [disabled]="isWikiReadonly()" + [pTooltip]="disabledButtonTooltip() | translate" (onClick)="openDeleteWikiDialog()" /> } diff --git a/src/app/shared/components/wiki/wiki-list/wiki-list.component.spec.ts b/src/app/shared/components/wiki/wiki-list/wiki-list.component.spec.ts index 0a9ac2f66..377d00ff3 100644 --- a/src/app/shared/components/wiki/wiki-list/wiki-list.component.spec.ts +++ b/src/app/shared/components/wiki/wiki-list/wiki-list.component.spec.ts @@ -5,6 +5,7 @@ import { MenuItem } from 'primeng/api'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { Router } from '@angular/router'; +import { UserSelectors } from '@osf/core/store/user/user.selectors'; import { WikiModel } from '@osf/shared/models/wiki/wiki.model'; import { WikiItemType } from '@osf/shared/models/wiki/wiki-type.model'; import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; @@ -14,6 +15,7 @@ import { ComponentWiki } from '@osf/shared/stores/wiki'; import { provideOSFCore } from '@testing/osf.testing.provider'; import { CustomConfirmationServiceMockBuilder } from '@testing/providers/custom-confirmation-provider.mock'; import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; +import { mergeSignalOverrides, provideMockStore, SignalOverride } from '@testing/providers/store-provider.mock'; import { WikiListComponent } from './wiki-list.component'; @@ -42,10 +44,13 @@ describe('WikiListComponent', () => { }, ]; - beforeEach(() => { + const defaultSignals: SignalOverride[] = [{ selector: UserSelectors.isProjectReadOnly, value: false }]; + + function setup({ selectorOverrides = defaultSignals } = {}) { mockCustomConfirmationService = CustomConfirmationServiceMockBuilder.create().build(); mockRouter = RouterMockBuilder.create().withUrl('/project/abc123/wiki').build(); + const signals = mergeSignalOverrides(defaultSignals, selectorOverrides ?? []); TestBed.configureTestingModule({ imports: [WikiListComponent], providers: [ @@ -53,14 +58,16 @@ describe('WikiListComponent', () => { MockProvider(CustomDialogService), MockProvider(CustomConfirmationService, mockCustomConfirmationService), MockProvider(Router, mockRouter), + provideMockStore({ signals }), ], }); fixture = TestBed.createComponent(WikiListComponent); component = fixture.componentInstance; - }); + } it('should create', () => { + setup(); fixture.componentRef.setInput('list', mockWikiList); fixture.componentRef.setInput('resourceId', 'resource-123'); fixture.componentRef.setInput('currentWikiId', 'wiki1'); @@ -71,6 +78,7 @@ describe('WikiListComponent', () => { }); it('should have all required inputs', () => { + setup(); fixture.componentRef.setInput('list', mockWikiList); fixture.componentRef.setInput('resourceId', 'resource-123'); fixture.componentRef.setInput('currentWikiId', 'wiki1'); @@ -84,6 +92,7 @@ describe('WikiListComponent', () => { }); it('should have default values for optional inputs', () => { + setup(); fixture.componentRef.setInput('list', mockWikiList); fixture.componentRef.setInput('resourceId', 'resource-123'); fixture.componentRef.setInput('currentWikiId', 'wiki1'); @@ -95,6 +104,7 @@ describe('WikiListComponent', () => { }); it('should have WikiItemType enum available', () => { + setup(); fixture.componentRef.setInput('list', mockWikiList); fixture.componentRef.setInput('resourceId', 'resource-123'); fixture.componentRef.setInput('currentWikiId', 'wiki1'); @@ -105,6 +115,7 @@ describe('WikiListComponent', () => { }); it('should have expanded signal initialized to true', () => { + setup(); fixture.componentRef.setInput('list', mockWikiList); fixture.componentRef.setInput('resourceId', 'resource-123'); fixture.componentRef.setInput('currentWikiId', 'wiki1'); @@ -115,6 +126,7 @@ describe('WikiListComponent', () => { }); it('should compute hasComponentsWikis correctly', () => { + setup(); fixture.componentRef.setInput('list', mockWikiList); fixture.componentRef.setInput('resourceId', 'resource-123'); fixture.componentRef.setInput('currentWikiId', 'wiki1'); @@ -125,6 +137,7 @@ describe('WikiListComponent', () => { }); it('should compute hasComponentsWikis as false when empty', () => { + setup(); fixture.componentRef.setInput('list', mockWikiList); fixture.componentRef.setInput('resourceId', 'resource-123'); fixture.componentRef.setInput('currentWikiId', 'wiki1'); @@ -135,6 +148,7 @@ describe('WikiListComponent', () => { }); it('should compute isHomeWikiSelected correctly when home wiki is selected', () => { + setup(); fixture.componentRef.setInput('list', mockWikiList); fixture.componentRef.setInput('resourceId', 'resource-123'); fixture.componentRef.setInput('currentWikiId', 'wiki1'); @@ -145,6 +159,7 @@ describe('WikiListComponent', () => { }); it('should compute isHomeWikiSelected as false when other wiki is selected', () => { + setup(); fixture.componentRef.setInput('list', mockWikiList); fixture.componentRef.setInput('resourceId', 'resource-123'); fixture.componentRef.setInput('currentWikiId', 'wiki2'); @@ -155,6 +170,7 @@ describe('WikiListComponent', () => { }); it('should compute homeWikiId correctly', () => { + setup(); fixture.componentRef.setInput('list', mockWikiList); fixture.componentRef.setInput('resourceId', 'resource-123'); fixture.componentRef.setInput('currentWikiId', 'wiki1'); @@ -165,6 +181,7 @@ describe('WikiListComponent', () => { }); it('should return true for canEditName when user can edit and item is not home', () => { + setup(); fixture.componentRef.setInput('list', mockWikiList); fixture.componentRef.setInput('resourceId', 'resource-123'); fixture.componentRef.setInput('currentWikiId', 'wiki1'); @@ -177,6 +194,7 @@ describe('WikiListComponent', () => { }); it('should return false for canEditName when item is home wiki', () => { + setup(); fixture.componentRef.setInput('list', mockWikiList); fixture.componentRef.setInput('resourceId', 'resource-123'); fixture.componentRef.setInput('currentWikiId', 'wiki1'); @@ -189,6 +207,7 @@ describe('WikiListComponent', () => { }); it('should return false for canEditName when user cannot edit', () => { + setup(); fixture.componentRef.setInput('list', mockWikiList); fixture.componentRef.setInput('resourceId', 'resource-123'); fixture.componentRef.setInput('currentWikiId', 'wiki1'); @@ -201,6 +220,7 @@ describe('WikiListComponent', () => { }); it('should compute wikiMenu with main wikis', () => { + setup(); fixture.componentRef.setInput('list', mockWikiList); fixture.componentRef.setInput('resourceId', 'resource-123'); fixture.componentRef.setInput('currentWikiId', 'wiki1'); @@ -215,6 +235,7 @@ describe('WikiListComponent', () => { }); it('should compute wikiMenu with components wikis when present', () => { + setup(); fixture.componentRef.setInput('list', mockWikiList); fixture.componentRef.setInput('resourceId', 'resource-123'); fixture.componentRef.setInput('currentWikiId', 'wiki1'); @@ -229,6 +250,7 @@ describe('WikiListComponent', () => { }); it('should open delete confirmation dialog when openDeleteWikiDialog is called', () => { + setup(); fixture.componentRef.setInput('list', mockWikiList); fixture.componentRef.setInput('resourceId', 'resource-123'); fixture.componentRef.setInput('currentWikiId', 'wiki1'); @@ -241,6 +263,7 @@ describe('WikiListComponent', () => { }); it('should emit deleteWiki when delete is confirmed', () => { + setup(); fixture.componentRef.setInput('list', mockWikiList); fixture.componentRef.setInput('resourceId', 'resource-123'); fixture.componentRef.setInput('currentWikiId', 'wiki1'); @@ -258,6 +281,7 @@ describe('WikiListComponent', () => { }); it('should toggle expanded state when collapseNavigation is called', () => { + setup(); fixture.componentRef.setInput('list', mockWikiList); fixture.componentRef.setInput('resourceId', 'resource-123'); fixture.componentRef.setInput('currentWikiId', 'wiki1'); @@ -276,6 +300,7 @@ describe('WikiListComponent', () => { }); it('should handle empty wiki list', () => { + setup(); fixture.componentRef.setInput('list', []); fixture.componentRef.setInput('resourceId', 'resource-123'); fixture.componentRef.setInput('currentWikiId', ''); @@ -289,6 +314,7 @@ describe('WikiListComponent', () => { }); it('should handle empty components list', () => { + setup(); fixture.componentRef.setInput('list', mockWikiList); fixture.componentRef.setInput('resourceId', 'resource-123'); fixture.componentRef.setInput('currentWikiId', 'wiki1'); @@ -300,4 +326,21 @@ describe('WikiListComponent', () => { const menu = component.wikiMenu(); expect(menu.length).toBe(1); }); + + it('should compute disabledButtonTooltip when wiki is read-only', () => { + const selectorOverrides: SignalOverride[] = [ + { + selector: UserSelectors.isProjectReadOnly, + value: true, + }, + ]; + setup({ selectorOverrides }); + fixture.componentRef.setInput('list', mockWikiList); + fixture.componentRef.setInput('resourceId', 'resource-123'); + fixture.componentRef.setInput('currentWikiId', 'wiki1'); + fixture.componentRef.setInput('componentsList', []); + fixture.detectChanges(); + + expect(component.disabledButtonTooltip()).toBe('common.errorMessages.actionUnavailable'); + }); }); diff --git a/src/app/shared/components/wiki/wiki-list/wiki-list.component.ts b/src/app/shared/components/wiki/wiki-list/wiki-list.component.ts index d353a9893..050e0464d 100644 --- a/src/app/shared/components/wiki/wiki-list/wiki-list.component.ts +++ b/src/app/shared/components/wiki/wiki-list/wiki-list.component.ts @@ -1,3 +1,5 @@ +import { select } from '@ngxs/store'; + import { TranslatePipe } from '@ngx-translate/core'; import { MenuItem } from 'primeng/api'; @@ -5,11 +7,13 @@ import { Button } from 'primeng/button'; import { Panel } from 'primeng/panel'; import { PanelMenu } from 'primeng/panelmenu'; import { Skeleton } from 'primeng/skeleton'; +import { Tooltip } from 'primeng/tooltip'; import { ChangeDetectionStrategy, Component, computed, DestroyRef, inject, input, output, signal } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { Router } from '@angular/router'; +import { UserSelectors } from '@osf/core/store/user/user.selectors'; import { WikiModel } from '@osf/shared/models/wiki/wiki.model'; import { WikiMenuItem } from '@osf/shared/models/wiki/wiki-menu.model'; import { WikiItemType } from '@osf/shared/models/wiki/wiki-type.model'; @@ -22,7 +26,7 @@ import { RenameWikiDialogComponent } from '../rename-wiki-dialog/rename-wiki-dia @Component({ selector: 'osf-wiki-list', - imports: [Button, Panel, PanelMenu, Skeleton, TranslatePipe], + imports: [Button, Panel, PanelMenu, Skeleton, Tooltip, TranslatePipe], templateUrl: './wiki-list.component.html', styleUrl: './wiki-list.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -45,12 +49,15 @@ export class WikiListComponent { private readonly router = inject(Router); private readonly destroyRef = inject(DestroyRef); + readonly isWikiReadonly = select(UserSelectors.isProjectReadOnly); + wikiItemType = WikiItemType; expanded = signal(true); hasComponentsWikis = computed(() => this.componentsList().length > 0); homeWikiId = computed(() => this.list()?.find((wiki) => wiki.name.toLowerCase() === 'home')?.id); isHomeWikiSelected = computed(() => this.currentWikiId() === this.homeWikiId()); + disabledButtonTooltip = computed(() => (this.isWikiReadonly() ? 'common.errorMessages.actionUnavailable' : '')); wikiMenu = computed(() => { const menu: WikiMenuItem[] = [ diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 4a49a62a0..31068f4c1 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -472,7 +472,8 @@ "message": "Are you sure you want to discard your unsaved changes?" }, "errorMessages": { - "serverError": "An unexpected error occurred. Please try again later." + "serverError": "An unexpected error occurred. Please try again later.", + "actionUnavailable": "This action is no longer available. Contact support if you have any questions." }, "hint": { "tagSeparators": "Use enter or comma to create a tag.", @@ -787,6 +788,7 @@ "loggedIn": { "dashboard": { "createProject": "Create New Project", + "createProjectDisabledTooltip": "Project creation is no longer available. OSF is transitioning away from Projects as part of a platform update. Your existing projects remain accessible.", "getStartedHelp": "Visit Get Started Help Guides", "images": { "osfCollectionsImageAltText": "OSF Collections", @@ -794,6 +796,8 @@ "osfPreprintsImageAltTest": "OSF Preprints", "osfRegistriesImageAltTest": "OSF Registries" }, + "noCreatedProject": "You haven’t created a project yet.", + "noCreatedProjectAndCreateProjectDisabled": "You don't have any projects.", "workflowLauncher": { "title": "Plan and document the lifecycle of your research", "description1": "OSF is designed to support your research process — planning your study, registering your methods, and sharing your findings as preprints. ", @@ -821,7 +825,6 @@ } } }, - "noCreatedProject": "You haven’t created a project yet.", "projectTransition": "New projects can no longer be created after November 16th, and all existing projects will become read-only on February 19th. You can read more about the upcoming transition here.", "quickSearch": { "goTo": "Go to", @@ -1094,6 +1097,7 @@ }, "header": { "createProject": "Create Project", + "createProjectDisabledTooltip": "Project creation is no longer available. OSF is transitioning away from Projects as part of a platform update. Your existing projects remain accessible.", "title": "My Projects" }, "redirectDialog": { @@ -1118,12 +1122,15 @@ "deleteProject": "Delete Project", "descriptions": { "file_updated": { + "instantly": "You'll be notified immediately when files are updated.", "daily": "You'll receive a daily summary of file updates.", "none": "You won't receive file update notifications." } }, + "disabledForWiki": "This feature is disabled for wikis of private projects.", "emailNotifications": "Email Notifications", "emailNotificationsText": "These notification settings only apply to you. They do NOT affect any other contributor on this project.", + "enabledForWiki": "This feature is enabled for wikis of private projects.", "ensureNoInformation": "Ensure the wiki pages, files, registration forms and add-ons do not contain identifying information.", "faq": "FAQ", "institutionalLogos": "institutional logos to be displayed on public projects", @@ -1588,6 +1595,7 @@ "connectExisting": "Connect An Existing OSF Project", "createNew": "Create A New OSF Project" }, + "projectCreationDisabled": "Project creation is no longer available. OSF is transitioning away from Projects as part of a platform update. Your existing projects remain accessible.", "successMessages": { "projectConnected": "Project connected", "projectCreated": "Project created", @@ -1704,13 +1712,16 @@ "permissionInfo": { "addComponents": "Add and configure components", "addResourcesLinks": "Add resources links", + "deleteProject": "Delete project and components", "deleteRegister": "Delete and register project", "editContent": "Add and edit content", "editMetadata": "Edit metadata", "endEmbargoEarly": "End embargo early", "manageContributors": "Manage contributor", + "manageViewOnlyLinks": "Manage view-only links", "publicPrivate": "Public private settings", "read": "Read privileges", + "readOnlyViewProjectContent": "View project content", "readWrite": "Read and write privileges", "title": "Permission Information", "viewProjectContent": "View project content and comment", @@ -1877,7 +1888,8 @@ "forkProjectLabel": "Duplicate project", "manageContributors": "Manage Contributors", "settings": "Settings", - "viewDuplication": "View duplicates" + "viewDuplication": "View duplicates", + "duplicatingProjectsNotAllowed": "Duplicating projects is no longer available. OSF is transitioning away from Projects as part of a platform update." }, "citations": { "copyCitation": "Copy citation", @@ -1891,6 +1903,7 @@ }, "components": { "addComponentButton": "Add Component", + "addComponentDisabled": "Component creation is no longer available. OSF is transitioning away from Projects as part of a platform update.", "linkProjectsButton": "Link Projects", "noComponentsMessage": "Add components to organize your project.", "title": "Components" @@ -2316,6 +2329,8 @@ "commentLabel": "Comment (Optional)", "helpMessage": "If this should not have occurred, please contact", "message": "Ask for access, or switch to an account with permission.", + "messageReadOnly": "This project is in a read-only state, and new access can no longer be granted. If you are already a contributor, please switch to the account that has access to this project.

If you need assistance locating the correct account or have questions, please contact support at", + "readOnlyTitle": "You Don't Have Access", "requestAccess": "Request Access", "requestedSuccessMessage": "Your request for access has been sent.", "switchAccount": "Switch Account", @@ -2864,6 +2879,7 @@ }, "files": { "description": "Uploaded files will automatically be archived in this registration. They will also be added to a related project that will be created for this registration.", + "descriptionNoProject": "Uploaded files will automatically be archived in this registration.", "limitText": "You may attach up to 5 file(s) to this question. Files cannot total over 5GB in size." }, "license": {