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 2d9b08390..f52a209a9 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 bc5426251..9369b0817 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'; @@ -75,15 +76,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, @@ -96,10 +106,7 @@ function setup(options: SetupOptions = {}) { MockProvider(CustomDialogService, mockCustomDialogService), MockProvider(ActivatedRoute, mockRoute), provideMockStore({ - signals: [ - { selector: CollectionsSelectors.getCollectionProvider, value: provider }, - { selector: CollectionsSelectors.getCollectionProviderLoading, value: false }, - ], + signals, }), ], }); @@ -160,4 +167,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 73098fc71..7c825f700 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 d40a60984..7e207830a 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 2b4c9c025..0e8a1730b 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 f5f77f5db..ebbde85c7 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 d541a1d69..c750509d8 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/overview/components/toggle-publicity-dialog/toggle-publicity-dialog.component.html b/src/app/features/project/overview/components/toggle-publicity-dialog/toggle-publicity-dialog.component.html index 1cd9575c9..0c14f5081 100644 --- a/src/app/features/project/overview/components/toggle-publicity-dialog/toggle-publicity-dialog.component.html +++ b/src/app/features/project/overview/components/toggle-publicity-dialog/toggle-publicity-dialog.component.html @@ -10,7 +10,7 @@
  • {{ 'project.overview.dialog.makePrivate.messageItems.removedFromCollections' | translate }}
  • } @else { -

    +

    } } @else {
    diff --git a/src/app/features/project/overview/components/toggle-publicity-dialog/toggle-publicity-dialog.component.ts b/src/app/features/project/overview/components/toggle-publicity-dialog/toggle-publicity-dialog.component.ts index 876a0c7ae..db91999d4 100644 --- a/src/app/features/project/overview/components/toggle-publicity-dialog/toggle-publicity-dialog.component.ts +++ b/src/app/features/project/overview/components/toggle-publicity-dialog/toggle-publicity-dialog.component.ts @@ -17,6 +17,7 @@ import { } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { UserSelectors } from '@osf/core/store/user'; import { ComponentsSelectionListComponent } from '@osf/shared/components/components-selection-list/components-selection-list.component'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; @@ -44,6 +45,7 @@ export class TogglePublicityDialogComponent { destroyRef = inject(DestroyRef); isSubmitting = select(ProjectOverviewSelectors.getUpdatePublicStatusSubmitting); components = select(CurrentResourceSelectors.getResourceWithChildren); + isProjectReadOnly = select(UserSelectors.isProjectReadOnly); actions = createDispatchMap({ updateProjectPublicStatus: UpdateProjectPublicStatus }); @@ -54,6 +56,11 @@ export class TogglePublicityDialogComponent { componentsList: WritableSignal = signal([]); isInformationStep = computed(() => this.step() === TogglePublicityStep.Information); + makePublicMessage = computed(() => + this.isProjectReadOnly() + ? 'project.overview.dialog.makePublic.messageReadOnly' + : 'project.overview.dialog.makePublic.message' + ); constructor() { effect(() => { diff --git a/src/app/features/project/registrations/registrations.component.html b/src/app/features/project/registrations/registrations.component.html index 34300b4eb..293ce351e 100644 --- a/src/app/features/project/registrations/registrations.component.html +++ b/src/app/features/project/registrations/registrations.component.html @@ -1,6 +1,8 @@ diff --git a/src/app/features/project/registrations/registrations.component.ts b/src/app/features/project/registrations/registrations.component.ts index c40fee384..fe7cd6452 100644 --- a/src/app/features/project/registrations/registrations.component.ts +++ b/src/app/features/project/registrations/registrations.component.ts @@ -12,6 +12,7 @@ import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import { ENVIRONMENT } from '@core/provider/environment.provider'; +import { UserSelectors } from '@osf/core/store/user/user.selectors'; import { CustomPaginatorComponent } from '@osf/shared/components/custom-paginator/custom-paginator.component'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; import { RegistrationCardComponent } from '@osf/shared/components/registration-card/registration-card.component'; @@ -45,6 +46,7 @@ export class RegistrationsComponent implements OnInit { registrations = select(RegistrationsSelectors.getRegistrations); registrationsTotalCount = select(RegistrationsSelectors.getRegistrationsTotalCount); isRegistrationsLoading = select(RegistrationsSelectors.isRegistrationsLoading); + isProjectReadOnly = select(UserSelectors.isProjectReadOnly); actions = createDispatchMap({ getRegistrations: GetRegistrations }); itemsPerPage = 10; diff --git a/src/app/features/project/settings/components/project-setting-notifications/project-setting-notifications.component.html b/src/app/features/project/settings/components/project-setting-notifications/project-setting-notifications.component.html index 6217d6f0a..256064bd5 100644 --- a/src/app/features/project/settings/components/project-setting-notifications/project-setting-notifications.component.html +++ b/src/app/features/project/settings/components/project-setting-notifications/project-setting-notifications.component.html @@ -8,6 +8,7 @@

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

    (emitValueChange)="changeEmittedValue($event)" [rightControls]="allAccordionData" [title]="title()" + [disabledRightControls]="isProjectReadOnly()" >
    diff --git a/src/app/features/project/settings/components/project-setting-notifications/project-setting-notifications.component.spec.ts b/src/app/features/project/settings/components/project-setting-notifications/project-setting-notifications.component.spec.ts index 5bc3a27b3..358b8cfbb 100644 --- a/src/app/features/project/settings/components/project-setting-notifications/project-setting-notifications.component.spec.ts +++ b/src/app/features/project/settings/components/project-setting-notifications/project-setting-notifications.component.spec.ts @@ -2,11 +2,13 @@ import { MockComponent, MockPipe } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { UserSelectors } from '@osf/core/store/user/user.selectors'; import { SubscriptionEvent } from '@osf/shared/enums/subscriptions/subscription-event.enum'; import { SubscriptionFrequency } from '@osf/shared/enums/subscriptions/subscription-frequency.enum'; import { MOCK_NOTIFICATION_SUBSCRIPTIONS } from '@testing/mocks/notification-subscription.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; import { NotificationDescriptionPipe } from '../../pipes'; import { ProjectDetailSettingAccordionComponent } from '../project-detail-setting-accordion/project-detail-setting-accordion.component'; @@ -26,7 +28,17 @@ describe('ProjectSettingNotificationsComponent', () => { MockComponent(ProjectDetailSettingAccordionComponent), MockPipe(NotificationDescriptionPipe), ], - providers: [provideOSFCore()], + providers: [ + provideOSFCore(), + provideMockStore({ + signals: [ + { + selector: UserSelectors.isProjectReadOnly, + value: false, + }, + ], + }), + ], }); fixture = TestBed.createComponent(ProjectSettingNotificationsComponent); diff --git a/src/app/features/project/settings/components/project-setting-notifications/project-setting-notifications.component.ts b/src/app/features/project/settings/components/project-setting-notifications/project-setting-notifications.component.ts index db78d8652..60b08d172 100644 --- a/src/app/features/project/settings/components/project-setting-notifications/project-setting-notifications.component.ts +++ b/src/app/features/project/settings/components/project-setting-notifications/project-setting-notifications.component.ts @@ -1,9 +1,12 @@ +import { select } from '@ngxs/store'; + import { TranslatePipe } from '@ngx-translate/core'; import { Card } from 'primeng/card'; import { ChangeDetectionStrategy, Component, effect, input, output } from '@angular/core'; +import { UserSelectors } from '@osf/core/store/user/user.selectors'; import { SubscriptionEvent } from '@osf/shared/enums/subscriptions/subscription-event.enum'; import { SubscriptionFrequency } from '@osf/shared/enums/subscriptions/subscription-frequency.enum'; import { NotificationSubscription } from '@osf/shared/models/notifications/notification-subscription.model'; @@ -24,6 +27,8 @@ export class ProjectSettingNotificationsComponent { title = input(); notificationEmitValue = output(); + isProjectReadOnly = select(UserSelectors.isProjectReadOnly); + allAccordionData: RightControl[] | undefined = []; readonly subscriptionEvent = SubscriptionEvent; diff --git a/src/app/features/project/settings/components/settings-access-requests-card/settings-access-requests-card.component.html b/src/app/features/project/settings/components/settings-access-requests-card/settings-access-requests-card.component.html index 92630f256..a33e82818 100644 --- a/src/app/features/project/settings/components/settings-access-requests-card/settings-access-requests-card.component.html +++ b/src/app/features/project/settings/components/settings-access-requests-card/settings-access-requests-card.component.html @@ -6,11 +6,16 @@

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

    [binary]="true" [ngModel]="accessRequest()" (ngModelChange)="accessRequestChange.emit($event)" + [disabled]="isProjectReadOnly()" inputId="accessRequest" name="ongoing" > -
    diff --git a/src/app/features/project/settings/components/settings-access-requests-card/settings-access-requests-card.component.spec.ts b/src/app/features/project/settings/components/settings-access-requests-card/settings-access-requests-card.component.spec.ts index b304d9bab..6b1ec3d2f 100644 --- a/src/app/features/project/settings/components/settings-access-requests-card/settings-access-requests-card.component.spec.ts +++ b/src/app/features/project/settings/components/settings-access-requests-card/settings-access-requests-card.component.spec.ts @@ -1,6 +1,9 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { UserSelectors } from '@osf/core/store/user/user.selectors'; + import { provideOSFCore } from '@testing/osf.testing.provider'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; import { SettingsAccessRequestsCardComponent } from './settings-access-requests-card.component'; @@ -11,7 +14,17 @@ describe('SettingsAccessRequestsCardComponent', () => { beforeEach(() => { TestBed.configureTestingModule({ imports: [SettingsAccessRequestsCardComponent], - providers: [provideOSFCore()], + providers: [ + provideOSFCore(), + provideMockStore({ + signals: [ + { + selector: UserSelectors.isProjectReadOnly, + value: false, + }, + ], + }), + ], }); fixture = TestBed.createComponent(SettingsAccessRequestsCardComponent); diff --git a/src/app/features/project/settings/components/settings-access-requests-card/settings-access-requests-card.component.ts b/src/app/features/project/settings/components/settings-access-requests-card/settings-access-requests-card.component.ts index 546c81de0..9bb5ef12f 100644 --- a/src/app/features/project/settings/components/settings-access-requests-card/settings-access-requests-card.component.ts +++ b/src/app/features/project/settings/components/settings-access-requests-card/settings-access-requests-card.component.ts @@ -1,14 +1,19 @@ +import { select } from '@ngxs/store'; + import { TranslatePipe } from '@ngx-translate/core'; import { Card } from 'primeng/card'; import { Checkbox } from 'primeng/checkbox'; +import { Tooltip } from 'primeng/tooltip'; import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; import { FormsModule } from '@angular/forms'; +import { UserSelectors } from '@osf/core/store/user'; + @Component({ selector: 'osf-settings-access-requests-card', - imports: [Checkbox, TranslatePipe, Card, FormsModule], + imports: [Checkbox, TranslatePipe, Card, FormsModule, Tooltip], templateUrl: './settings-access-requests-card.component.html', styleUrl: './settings-access-requests-card.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -16,4 +21,5 @@ import { FormsModule } from '@angular/forms'; export class SettingsAccessRequestsCardComponent { accessRequestChange = output(); accessRequest = input.required(); + isProjectReadOnly = select(UserSelectors.isProjectReadOnly); } diff --git a/src/app/features/project/settings/components/settings-project-affiliation/settings-project-affiliation.component.html b/src/app/features/project/settings/components/settings-project-affiliation/settings-project-affiliation.component.html index 9c277665e..760a99079 100644 --- a/src/app/features/project/settings/components/settings-project-affiliation/settings-project-affiliation.component.html +++ b/src/app/features/project/settings/components/settings-project-affiliation/settings-project-affiliation.component.html @@ -28,6 +28,8 @@

    {{ 'myProjects.settings.projectAffiliation' | translate @if (canRemoveAffiliation(affiliation)) { (); userInstitutions = select(InstitutionsSelectors.getUserInstitutions); + isProjectReadOnly = select(UserSelectors.isProjectReadOnly); readonly userInstitutionIds = computed(() => new Set(this.userInstitutions().map((inst) => inst.id))); diff --git a/src/app/features/project/settings/components/settings-project-form-card/settings-project-form-card.component.html b/src/app/features/project/settings/components/settings-project-form-card/settings-project-form-card.component.html index 89a6a9a3f..8e49ab4cd 100644 --- a/src/app/features/project/settings/components/settings-project-form-card/settings-project-form-card.component.html +++ b/src/app/features/project/settings/components/settings-project-form-card/settings-project-form-card.component.html @@ -29,7 +29,8 @@

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

    type="submit" class="w-10rem btn-full-width bg-primary-blue-second" [label]="'myProjects.settings.saveChanges' | translate" - [disabled]="projectForm.invalid" + [disabled]="projectForm.invalid || isProjectReadonly()" + [pTooltip]="(isProjectReadonly() ? 'common.errorMessages.actionUnavailable' : '') | translate" >

    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" > -
    -

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

    + @if (!isProjectReadOnly()) { +

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

    -

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

    +

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

    - -
    - - - {{ 'myProjects.settings.' + (anyoneCanEditWiki() ? 'enabledForWiki' : 'disabledForWiki') | translate }} - -
    -
    + +
    + + + {{ 'myProjects.settings.' + (anyoneCanEditWiki() ? 'enabledForWiki' : 'disabledForWiki') | translate }} + +
    +
    + } diff --git a/src/app/features/project/settings/components/settings-wiki-card/settings-wiki-card.component.spec.ts b/src/app/features/project/settings/components/settings-wiki-card/settings-wiki-card.component.spec.ts index c87e90ad6..331f9b2ee 100644 --- a/src/app/features/project/settings/components/settings-wiki-card/settings-wiki-card.component.spec.ts +++ b/src/app/features/project/settings/components/settings-wiki-card/settings-wiki-card.component.spec.ts @@ -2,7 +2,10 @@ import { MockComponent } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { UserSelectors } from '@osf/core/store/user/user.selectors'; + import { provideOSFCore } from '@testing/osf.testing.provider'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; import { ProjectDetailSettingAccordionComponent } from '../project-detail-setting-accordion/project-detail-setting-accordion.component'; @@ -20,7 +23,17 @@ describe('SettingsWikiCardComponent', () => { beforeEach(() => { TestBed.configureTestingModule({ imports: [SettingsWikiCardComponent, MockComponent(ProjectDetailSettingAccordionComponent)], - providers: [provideOSFCore()], + providers: [ + provideOSFCore(), + provideMockStore({ + signals: [ + { + selector: UserSelectors.isProjectReadOnly, + value: false, + }, + ], + }), + ], }); fixture = TestBed.createComponent(SettingsWikiCardComponent); diff --git a/src/app/features/project/settings/components/settings-wiki-card/settings-wiki-card.component.ts b/src/app/features/project/settings/components/settings-wiki-card/settings-wiki-card.component.ts index 3360eccab..9c341b5fb 100644 --- a/src/app/features/project/settings/components/settings-wiki-card/settings-wiki-card.component.ts +++ b/src/app/features/project/settings/components/settings-wiki-card/settings-wiki-card.component.ts @@ -1,17 +1,22 @@ +import { select } from '@ngxs/store'; + import { TranslatePipe } from '@ngx-translate/core'; import { Card } from 'primeng/card'; import { Checkbox } from 'primeng/checkbox'; +import { Tooltip } from 'primeng/tooltip'; import { ChangeDetectionStrategy, Component, effect, input, output } from '@angular/core'; import { FormsModule } from '@angular/forms'; +import { UserSelectors } from '@osf/core/store/user/user.selectors'; + import { RightControl } from '../../models'; import { ProjectDetailSettingAccordionComponent } from '../project-detail-setting-accordion/project-detail-setting-accordion.component'; @Component({ selector: 'osf-settings-wiki-card', - imports: [Card, Checkbox, TranslatePipe, ProjectDetailSettingAccordionComponent, FormsModule], + imports: [Card, Checkbox, Tooltip, TranslatePipe, ProjectDetailSettingAccordionComponent, FormsModule], templateUrl: './settings-wiki-card.component.html', styleUrl: './settings-wiki-card.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -25,6 +30,8 @@ export class SettingsWikiCardComponent { title = input.required(); isPublic = input(false); + isProjectReadOnly = select(UserSelectors.isProjectReadOnly); + allAccordionData: RightControl[] = []; constructor() { diff --git a/src/app/features/project/wiki/wiki.component.html b/src/app/features/project/wiki/wiki.component.html index 1170e5508..2faa27f01 100644 --- a/src/app/features/project/wiki/wiki.component.html +++ b/src/app/features/project/wiki/wiki.component.html @@ -10,6 +10,8 @@ [label]="'common.buttons.edit' | translate" [variant]="wikiModes().edit ? undefined : 'outlined'" (onClick)="toggleMode(WikiModes.Edit)" + [disabled]="disableWikiEdit()" + [pTooltip]="disabledEditTooltip() | translate" /> } } diff --git a/src/app/features/project/wiki/wiki.component.spec.ts b/src/app/features/project/wiki/wiki.component.spec.ts index 4acc2a67b..f585a053f 100644 --- a/src/app/features/project/wiki/wiki.component.spec.ts +++ b/src/app/features/project/wiki/wiki.component.spec.ts @@ -10,6 +10,7 @@ import { PLATFORM_ID } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute, Router } from '@angular/router'; +import { UserSelectors } from '@osf/core/store/user/user.selectors'; import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; import { ViewOnlyLinkMessageComponent } from '@osf/shared/components/view-only-link-message/view-only-link-message.component'; import { CompareSectionComponent } from '@osf/shared/components/wiki/compare-section/compare-section.component'; @@ -75,6 +76,7 @@ describe('WikiComponent', () => { { selector: WikiSelectors.getCompareVersionsLoading, value: false }, { selector: WikiSelectors.isWikiAnonymous, value: false }, { selector: CurrentResourceSelectors.hasWriteAccess, value: true }, + { selector: UserSelectors.isProjectReadOnly, value: false }, ]; function setup({ @@ -253,4 +255,19 @@ describe('WikiComponent', () => { expect(store.dispatch).toHaveBeenCalledWith(new ClearWiki()); }); + + it('should disable the wiki edit button and show tooltip when isProjectReadOnly is true', async () => { + setup({ + hasWriteAccess: true, + selectorOverrides: [{ selector: UserSelectors.isProjectReadOnly, value: true }], + }); + await fixture.whenStable(); + + expect(component.disabledEditTooltip()).toBe('common.errorMessages.actionUnavailable'); + + setup({ hasWriteAccess: true, selectorOverrides: [{ selector: UserSelectors.isProjectReadOnly, value: false }] }); + await fixture.whenStable(); + + expect(component.disabledEditTooltip()).toBe(''); + }); }); diff --git a/src/app/features/project/wiki/wiki.component.ts b/src/app/features/project/wiki/wiki.component.ts index 31b8635cf..40759582b 100644 --- a/src/app/features/project/wiki/wiki.component.ts +++ b/src/app/features/project/wiki/wiki.component.ts @@ -4,6 +4,7 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { ButtonGroupModule } from 'primeng/buttongroup'; +import { Tooltip } from 'primeng/tooltip'; import { filter, map, mergeMap, of, tap } from 'rxjs'; @@ -12,6 +13,7 @@ import { ChangeDetectionStrategy, Component, computed, DestroyRef, inject, PLATF import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router } from '@angular/router'; +import { UserSelectors } from '@osf/core/store/user/user.selectors'; import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; import { CompareSectionComponent } from '@osf/shared/components/wiki/compare-section/compare-section.component'; import { EditSectionComponent } from '@osf/shared/components/wiki/edit-section/edit-section.component'; @@ -39,7 +41,6 @@ import { WikiSelectors, } from '@osf/shared/stores/wiki'; import { ViewOnlyLinkMessageComponent } from '@shared/components/view-only-link-message/view-only-link-message.component'; - @Component({ selector: 'osf-wiki', imports: [ @@ -51,6 +52,7 @@ import { ViewOnlyLinkMessageComponent } from '@shared/components/view-only-link- EditSectionComponent, CompareSectionComponent, ViewOnlyLinkMessageComponent, + Tooltip, TranslatePipe, ], templateUrl: './wiki.component.html', @@ -83,6 +85,7 @@ export class WikiComponent { isCompareVersionLoading = select(WikiSelectors.getCompareVersionsLoading); isAnonymous = select(WikiSelectors.isWikiAnonymous); hasWriteAccess = select(CurrentResourceSelectors.hasWriteAccess); + disableWikiEdit = select(UserSelectors.isProjectReadOnly); actions = createDispatchMap({ getWikiModes: GetWikiModes, @@ -105,6 +108,10 @@ export class WikiComponent { readonly hasViewOnly = computed(() => this.viewOnlyService.hasViewOnlyParam(this.router)); + readonly disabledEditTooltip = computed(() => + this.disableWikiEdit() ? 'common.errorMessages.actionUnavailable' : '' + ); + constructor() { this.actions .getWikiList(ResourceType.Project, this.projectId()) diff --git a/src/app/features/registries/components/custom-step/custom-step.component.html b/src/app/features/registries/components/custom-step/custom-step.component.html index edd5941bf..f161452d6 100644 --- a/src/app/features/registries/components/custom-step/custom-step.component.html +++ b/src/app/features/registries/components/custom-step/custom-step.component.html @@ -160,7 +160,7 @@

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

    {{ 'shared.files.limitText' | translate }}

    - {{ 'shared.files.description' | translate }} + {{ fileUploadDescription() | translate }}

    @for (file of attachedFiles[q.responseKey!] || []; track file) { diff --git a/src/app/features/registries/components/custom-step/custom-step.component.spec.ts b/src/app/features/registries/components/custom-step/custom-step.component.spec.ts index b01fd3e86..409a5d1b3 100644 --- a/src/app/features/registries/components/custom-step/custom-step.component.spec.ts +++ b/src/app/features/registries/components/custom-step/custom-step.component.spec.ts @@ -8,6 +8,7 @@ import { TestBed } from '@angular/core/testing'; import { FormGroup } from '@angular/forms'; import { ActivatedRoute, Router, UrlTree } from '@angular/router'; +import { UserSelectors } from '@osf/core/store/user/user.selectors'; import { InfoIconComponent } from '@osf/shared/components/info-icon/info-icon.component'; import { FieldType } from '@osf/shared/enums/field-type.enum'; import { ToastService } from '@osf/shared/services/toast.service'; @@ -68,6 +69,7 @@ describe('CustomStepComponent', () => { const defaultSignals: SignalOverride[] = [ { selector: RegistriesSelectors.getPagesSchema, value: overrides.pages ?? [MOCK_REGISTRIES_PAGE] }, { selector: RegistriesSelectors.getStepsState, value: overrides.stepsState ?? {} }, + { selector: UserSelectors.isProjectCreationDisabled, value: false }, ]; const signals = mergeSignalOverrides(defaultSignals, overrides.selectorOverrides); @@ -165,6 +167,18 @@ describe('CustomStepComponent', () => { expect(store.dispatch).not.toHaveBeenCalled(); }); + it('should update file upload description based on isProjectCreationDisabled', () => { + const { component } = setup({ + selectorOverrides: [{ selector: UserSelectors.isProjectCreationDisabled, value: true }], + }); + expect(component.fileUploadDescription()).toBe('shared.files.descriptionNoProject'); + + const { component: component2 } = setup({ + selectorOverrides: [{ selector: UserSelectors.isProjectCreationDisabled, value: false }], + }); + expect(component2.fileUploadDescription()).toBe('shared.files.description'); + }); + it('should attach file and emit updateAction', () => { const { component } = setup(); const emitSpy = vi.spyOn(component.updateAction, 'emit'); diff --git a/src/app/features/registries/components/custom-step/custom-step.component.ts b/src/app/features/registries/components/custom-step/custom-step.component.ts index 571ba03fb..ce1d26fc7 100644 --- a/src/app/features/registries/components/custom-step/custom-step.component.ts +++ b/src/app/features/registries/components/custom-step/custom-step.component.ts @@ -29,6 +29,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; +import { UserSelectors } from '@osf/core/store/user/user.selectors'; import { InfoIconComponent } from '@osf/shared/components/info-icon/info-icon.component'; import { FILE_COUNT_ATTACHMENTS_LIMIT } from '@osf/shared/constants/files-limits.const'; import { INPUT_VALIDATION_MESSAGES } from '@osf/shared/constants/input-validation-messages.const'; @@ -87,6 +88,7 @@ export class CustomStepComponent implements OnDestroy { readonly pages = select(RegistriesSelectors.getPagesSchema); readonly stepsState = select(RegistriesSelectors.getStepsState); + readonly projectCreationDisabled = select(UserSelectors.isProjectCreationDisabled); private readonly actions = createDispatchMap({ updateStepState: UpdateStepState, @@ -99,6 +101,12 @@ export class CustomStepComponent implements OnDestroy { step = signal(this.route.snapshot.params['step']); draftId = signal(this.route.snapshot.params['id']); currentPage = computed(() => this.pages()[this.step() - 1]); + readonly fileUploadDescription = computed(() => { + if (this.projectCreationDisabled()) { + return 'shared.files.descriptionNoProject'; + } + return 'shared.files.description'; + }); stepForm: FormGroup = this.fb.group({}); attachedFiles: Record = {}; diff --git a/src/app/features/registries/components/new-registration/new-registration.component.html b/src/app/features/registries/components/new-registration/new-registration.component.html index 4a6387438..b6bbed2da 100644 --- a/src/app/features/registries/components/new-registration/new-registration.component.html +++ b/src/app/features/registries/components/new-registration/new-registration.component.html @@ -11,28 +11,30 @@

    - -

    {{ '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 dac396c35..2de035367 100644 --- a/src/app/features/registry/models/registry-overview.model.ts +++ b/src/app/features/registry/models/registry-overview.model.ts @@ -2,7 +2,7 @@ import { RegistryStatus } from '@osf/shared/enums/registry-status.enum'; import { RegistrationNodeModel } from '@shared/models/registration/registration-node.model'; export interface RegistrationOverviewModel extends RegistrationNodeModel { - associatedProjectId: string; + associatedProjectId?: string; forksCount: number; licenseId: string; providerId: string; 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 2d2f592ae..c449a879a 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 { WikiItemType } from '@osf/shared/enums/wiki-type.enum'; import { WikiModel } from '@osf/shared/models/wiki/wiki.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 fb7ac5227..905461943 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 { WikiItemType } from '@osf/shared/enums/wiki-type.enum'; import { WikiModel } from '@osf/shared/models/wiki/wiki.model'; import { WikiMenuItem } from '@osf/shared/models/wiki/wiki-menu.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 88c5a57a1..7b2257ab0 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" @@ -1956,7 +1969,8 @@ "makePublic": { "confirmButton": "Make Public", "header": "Make Project Public", - "message": "Please review your projects, components, and add-ons for sensitive or restricted information before making them public.

    Once they are made public, you should assume they will always be public. You can return them to private later, but search engines (including Google's cache) or others may access files, wiki pages, or analytics before you do." + "message": "Please review your projects, components, and add-ons for sensitive or restricted information before making them public.

    Once they are made public, you should assume they will always be public. You can return them to private later, but search engines (including Google's cache) or others may access files, wiki pages, or analytics before you do.", + "messageReadOnly": "Please review your projects, components, and add-ons for sensitive or restricted information before making them public.

    This action is irreversible." }, "privacySettingsPermissionTooltip": "You must have admin permission on this component to be able to change privacy settings", "toast": { @@ -2316,6 +2330,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 +2880,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": {