diff --git a/src/app/core/components/request-access/request-access.component.html b/src/app/core/components/request-access/request-access.component.html
index f8b74ca4b..1791804fc 100644
--- a/src/app/core/components/request-access/request-access.component.html
+++ b/src/app/core/components/request-access/request-access.component.html
@@ -1,35 +1,44 @@
- {{ 'requestAccess.title' | translate }}
+ {{ titleTranslation() | translate }}
- {{ 'requestAccess.message' | translate }}
+
+
+ @if (isProjectReadOnly()) {
+ {{ supportEmail }}
+ }
+
-
-
+ @if (!isProjectReadOnly()) {
+
+
-
-
+
+
+ }
-
+ @if (!isProjectReadOnly()) {
+
+ }
diff --git a/src/app/core/components/request-access/request-access.component.spec.ts b/src/app/core/components/request-access/request-access.component.spec.ts
index 8051fd7f9..48a7984a7 100644
--- a/src/app/core/components/request-access/request-access.component.spec.ts
+++ b/src/app/core/components/request-access/request-access.component.spec.ts
@@ -9,6 +9,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, Router } from '@angular/router';
import { AuthService } from '@core/services/auth.service';
+import { UserSelectors } from '@core/store/user';
import { InputLimits } from '@osf/shared/constants/input-limits.const';
import { RequestAccessService } from '@osf/shared/services/request-access.service';
import { ToastService } from '@osf/shared/services/toast.service';
@@ -18,10 +19,17 @@ import { AuthServiceMock, AuthServiceMockType } from '@testing/providers/auth-se
import { LoaderServiceMock, provideLoaderServiceMock } from '@testing/providers/loader-service.mock';
import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock';
import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock';
+import { BaseSetupOverrides, mergeSignalOverrides, provideMockStore } from '@testing/providers/store-provider.mock';
import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock';
import { RequestAccessComponent } from './request-access.component';
+interface SetupOverrides extends BaseSetupOverrides {
+ routeId?: string;
+ requestAccessResult?: Observable
;
+ requestAccessError?: HttpErrorResponse;
+}
+
describe('RequestAccessComponent', () => {
let fixture: ComponentFixture;
let component: RequestAccessComponent;
@@ -31,12 +39,10 @@ describe('RequestAccessComponent', () => {
let toastServiceMock: ToastServiceMockType;
let authServiceMock: AuthServiceMockType;
- function setup(overrides?: {
- routeId?: string;
- requestAccessResult?: Observable;
- requestAccessError?: HttpErrorResponse;
- }) {
+ function setup(overrides?: SetupOverrides) {
const routeId = overrides?.routeId ?? 'project-1';
+ const defaultSignals = [{ selector: UserSelectors.isProjectReadOnly, value: false }];
+ const signals = mergeSignalOverrides(defaultSignals, overrides?.selectorOverrides ?? []);
routerMock = RouterMockBuilder.create().withNavigate(vi.fn().mockResolvedValue(true)).build();
loaderServiceMock = new LoaderServiceMock();
toastServiceMock = ToastServiceMock.simple();
@@ -60,6 +66,7 @@ describe('RequestAccessComponent', () => {
MockProvider(RequestAccessService, requestAccessServiceMock),
MockProvider(ToastService, toastServiceMock),
MockProvider(AuthService, authServiceMock),
+ provideMockStore({ signals }),
],
});
@@ -86,6 +93,23 @@ describe('RequestAccessComponent', () => {
expect(supportLink.textContent).toContain(component.supportEmail);
});
+ it('should expose title and message translations based on read-only state', () => {
+ setup({ selectorOverrides: [{ selector: UserSelectors.isProjectReadOnly, value: true }] });
+ expect(component.titleTranslation()).toBe('requestAccess.readOnlyTitle');
+ expect(component.messageTranslation()).toBe('requestAccess.messageReadOnly');
+
+ const buttons = fixture.nativeElement.querySelectorAll('p-button');
+ expect(buttons).toHaveLength(1);
+ });
+
+ it('should expose title and message translations based on non-read-only state', () => {
+ setup({ selectorOverrides: [{ selector: UserSelectors.isProjectReadOnly, value: false }] });
+ expect(component.titleTranslation()).toBe('requestAccess.title');
+ expect(component.messageTranslation()).toBe('requestAccess.message');
+ const buttons = fixture.nativeElement.querySelectorAll('p-button');
+ expect(buttons.length).toBe(2);
+ });
+
it('should request access and handle success flow', () => {
setup({ routeId: 'project-123' });
component.comment.set('please grant access');
diff --git a/src/app/core/components/request-access/request-access.component.ts b/src/app/core/components/request-access/request-access.component.ts
index eaa56b861..fe8002095 100644
--- a/src/app/core/components/request-access/request-access.component.ts
+++ b/src/app/core/components/request-access/request-access.component.ts
@@ -1,3 +1,5 @@
+import { select } from '@ngxs/store';
+
import { TranslatePipe } from '@ngx-translate/core';
import { Button } from 'primeng/button';
@@ -6,13 +8,14 @@ import { Textarea } from 'primeng/textarea';
import { map, of } from 'rxjs';
import { HttpErrorResponse } from '@angular/common/http';
-import { ChangeDetectionStrategy, Component, inject, model } from '@angular/core';
+import { ChangeDetectionStrategy, Component, computed, inject, model } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { ENVIRONMENT } from '@core/provider/environment.provider';
import { AuthService } from '@core/services/auth.service';
+import { UserSelectors } from '@osf/core/store/user/user.selectors';
import { InputLimits } from '@osf/shared/constants/input-limits.const';
import { LoaderService } from '@osf/shared/services/loader.service';
import { RequestAccessService } from '@osf/shared/services/request-access.service';
@@ -41,6 +44,16 @@ export class RequestAccessComponent {
private readonly toastService = inject(ToastService);
private readonly authService = inject(AuthService);
+ readonly isProjectReadOnly = select(UserSelectors.isProjectReadOnly);
+
+ readonly titleTranslation = computed(() =>
+ this.isProjectReadOnly() ? 'requestAccess.readOnlyTitle' : 'requestAccess.title'
+ );
+
+ readonly messageTranslation = computed(() =>
+ this.isProjectReadOnly() ? 'requestAccess.messageReadOnly' : 'requestAccess.message'
+ );
+
requestAccess() {
this.loaderService.show();
this.requestAccessService.requestAccessToProject(this.id(), this.comment()).subscribe({
diff --git a/src/app/core/store/user/user.selectors.ts b/src/app/core/store/user/user.selectors.ts
index 311d3eec1..f8bf086fb 100644
--- a/src/app/core/store/user/user.selectors.ts
+++ b/src/app/core/store/user/user.selectors.ts
@@ -58,4 +58,14 @@ export class UserSelectors {
static getActiveFlags(state: UserStateModel): string[] {
return state.activeFlags || [];
}
+
+ @Selector([UserState])
+ static isProjectCreationDisabled(state: UserStateModel): boolean {
+ return state.activeFlags?.includes('prevent_project_creation') || false;
+ }
+
+ @Selector([UserState])
+ static isProjectReadOnly(state: UserStateModel): boolean {
+ return state.activeFlags?.includes('project_read_only') || false;
+ }
}
diff --git a/src/app/features/analytics/components/view-duplicates/view-duplicates.component.html b/src/app/features/analytics/components/view-duplicates/view-duplicates.component.html
index 0b19ebb0f..6e4fbcfae 100644
--- a/src/app/features/analytics/components/view-duplicates/view-duplicates.component.html
+++ b/src/app/features/analytics/components/view-duplicates/view-duplicates.component.html
@@ -2,6 +2,8 @@
[title]="'project.analytics.kpi.forks' | translate"
[showButton]="isAuthenticated()"
[buttonLabel]="'project.overview.actions.forkProjectLabel' | translate"
+ [isButtonDisabled]="preventDuplicateCreation()"
+ [buttonTooltip]="duplicateButtonTooltip() | translate"
(buttonClick)="handleForkResource()"
/>
diff --git a/src/app/features/analytics/components/view-duplicates/view-duplicates.component.spec.ts b/src/app/features/analytics/components/view-duplicates/view-duplicates.component.spec.ts
index 5fc67205b..83d466144 100644
--- a/src/app/features/analytics/components/view-duplicates/view-duplicates.component.spec.ts
+++ b/src/app/features/analytics/components/view-duplicates/view-duplicates.component.spec.ts
@@ -7,6 +7,7 @@ import { of } from 'rxjs';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, Router } from '@angular/router';
+import { UserSelectors } from '@osf/core/store/user/user.selectors';
import { ProjectOverviewSelectors } from '@osf/features/project/overview/store';
import { RegistrySelectors } from '@osf/features/registry/store/registry';
import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component';
@@ -24,10 +25,14 @@ import { provideOSFCore } from '@testing/osf.testing.provider';
import { CustomDialogServiceMockBuilder } from '@testing/providers/custom-dialog-provider.mock';
import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock';
import { RouterMockBuilder } from '@testing/providers/router-provider.mock';
-import { provideMockStore } from '@testing/providers/store-provider.mock';
+import { BaseSetupOverrides, mergeSignalOverrides, provideMockStore } from '@testing/providers/store-provider.mock';
import { ViewDuplicatesComponent } from './view-duplicates.component';
+interface SetupOverrides extends BaseSetupOverrides {
+ selectors?: any[];
+}
+
describe('Component: View Duplicates', () => {
let component: ViewDuplicatesComponent;
let fixture: ComponentFixture;
@@ -35,7 +40,7 @@ describe('Component: View Duplicates', () => {
let activatedRouteMock: ReturnType;
let mockCustomDialogService: ReturnType;
- beforeEach(() => {
+ function setup(overrides: SetupOverrides = {}) {
mockCustomDialogService = CustomDialogServiceMockBuilder.create().build();
routerMock = RouterMockBuilder.create().build();
activatedRouteMock = ActivatedRouteMockBuilder.create()
@@ -43,6 +48,18 @@ describe('Component: View Duplicates', () => {
.withData({ resourceType: ResourceType.Project })
.build();
+ const defaultSelectors = [
+ { selector: DuplicatesSelectors.getDuplicates, value: [] },
+ { selector: DuplicatesSelectors.getDuplicatesLoading, value: false },
+ { selector: DuplicatesSelectors.getDuplicatesTotalCount, value: 0 },
+ { selector: ProjectOverviewSelectors.getProject, value: MOCK_PROJECT_OVERVIEW },
+ { selector: ProjectOverviewSelectors.isProjectAnonymous, value: false },
+ { selector: RegistrySelectors.getRegistry, value: undefined },
+ { selector: RegistrySelectors.isRegistryAnonymous, value: false },
+ { selector: UserSelectors.isProjectCreationDisabled, value: false },
+ ];
+ const signals = mergeSignalOverrides(defaultSelectors, overrides.selectors || []);
+
TestBed.configureTestingModule({
imports: [
ViewDuplicatesComponent,
@@ -58,15 +75,7 @@ describe('Component: View Duplicates', () => {
providers: [
provideOSFCore(),
provideMockStore({
- signals: [
- { selector: DuplicatesSelectors.getDuplicates, value: [] },
- { selector: DuplicatesSelectors.getDuplicatesLoading, value: false },
- { selector: DuplicatesSelectors.getDuplicatesTotalCount, value: 0 },
- { selector: ProjectOverviewSelectors.getProject, value: MOCK_PROJECT_OVERVIEW },
- { selector: ProjectOverviewSelectors.isProjectAnonymous, value: false },
- { selector: RegistrySelectors.getRegistry, value: undefined },
- { selector: RegistrySelectors.isRegistryAnonymous, value: false },
- ],
+ signals,
}),
MockProvider(CustomDialogService, mockCustomDialogService),
MockProvider(Router, routerMock),
@@ -78,13 +87,23 @@ describe('Component: View Duplicates', () => {
component = fixture.componentInstance;
fixture.detectChanges();
- });
+ }
it('should create', () => {
+ setup();
expect(component).toBeTruthy();
});
+ it('should disable fork button and show tooltip when isProjectCreationDisabled is true', () => {
+ setup({
+ selectors: [{ selector: UserSelectors.isProjectCreationDisabled, value: true }],
+ });
+ expect(component.preventDuplicateCreation()).toBe(true);
+ expect(component.duplicateButtonTooltip()).toBe('project.overview.actions.duplicatingProjectsNotAllowed');
+ });
+
it('should open ForkDialog with width 450px when small and not refresh on failure', () => {
+ setup();
(component as any).actions = { ...component.actions, getDuplicates: vi.fn() };
const openSpy = vi
@@ -98,12 +117,14 @@ describe('Component: View Duplicates', () => {
});
it('should update currentPage when page is defined', () => {
+ setup();
const event: PaginatorState = { page: 1 } as PaginatorState;
component.onPageChange(event);
expect(component.currentPage()).toBe(2);
});
it('should not update currentPage when page is undefined', () => {
+ setup();
component.currentPage.set(5);
const event: PaginatorState = { page: undefined } as PaginatorState;
component.onPageChange(event);
diff --git a/src/app/features/analytics/components/view-duplicates/view-duplicates.component.ts b/src/app/features/analytics/components/view-duplicates/view-duplicates.component.ts
index 458fdb4c6..11ecd3444 100644
--- a/src/app/features/analytics/components/view-duplicates/view-duplicates.component.ts
+++ b/src/app/features/analytics/components/view-duplicates/view-duplicates.component.ts
@@ -77,11 +77,15 @@ export class ViewDuplicatesComponent {
isDuplicatesLoading = select(DuplicatesSelectors.getDuplicatesLoading);
totalDuplicates = select(DuplicatesSelectors.getDuplicatesTotalCount);
isAuthenticated = select(UserSelectors.isAuthenticated);
+ preventDuplicateCreation = select(UserSelectors.isProjectCreationDisabled);
readonly pageSize = 10;
currentPage = signal(1);
firstIndex = computed(() => (this.currentPage() - 1) * this.pageSize);
+ duplicateButtonTooltip = computed(() =>
+ this.preventDuplicateCreation() ? 'project.overview.actions.duplicatingProjectsNotAllowed' : ''
+ );
readonly forkActionItems = (resourceId: string) => [
{
diff --git a/src/app/features/collections/components/add-to-collection/add-to-collection.component.html b/src/app/features/collections/components/add-to-collection/add-to-collection.component.html
index 15486dc58..a4cd6f026 100644
--- a/src/app/features/collections/components/add-to-collection/add-to-collection.component.html
+++ b/src/app/features/collections/components/add-to-collection/add-to-collection.component.html
@@ -19,6 +19,7 @@ {{ collectionProvider()?
[stepperActiveValue]="stepperActiveValue()"
[collectionId]="primaryCollectionId() ?? ''"
[targetStepValue]="AddToCollectionSteps.SelectProject"
+ [isProjectReadOnly]="isProjectReadOnly()"
(projectSelected)="handleProjectSelected()"
(stepChange)="handleChangeStep($event)"
/>
@@ -68,7 +69,8 @@ {{ collectionProvider()?
diff --git a/src/app/features/collections/components/add-to-collection/add-to-collection.component.spec.ts b/src/app/features/collections/components/add-to-collection/add-to-collection.component.spec.ts
index 0d7056142..8062f0dde 100644
--- a/src/app/features/collections/components/add-to-collection/add-to-collection.component.spec.ts
+++ b/src/app/features/collections/components/add-to-collection/add-to-collection.component.spec.ts
@@ -50,6 +50,8 @@ const DEFAULT_SIGNALS: SignalOverride[] = [
{ selector: CollectionsSelectors.getRequiredMetadataTemplate, value: null },
{ selector: ProjectsSelectors.getSelectedProject, value: MOCK_PROJECT },
{ selector: UserSelectors.getCurrentUser, value: MOCK_USER },
+ { selector: UserSelectors.getActiveFlags, value: [] },
+ { selector: UserSelectors.isProjectReadOnly, value: false },
{ selector: MetadataSelectors.getCedarRecords, value: [] },
{ selector: AddToCollectionSelectors.getCurrentCollectionSubmission, value: null },
];
@@ -331,4 +333,9 @@ describe('AddToCollectionComponent', () => {
expect(component.allowNavigation()).toBe(true);
expect(mockRouter.navigate).toHaveBeenCalledWith(['project-1', 'overview']);
});
+
+ it('should disable the add to collection button if isProjectReadOnly', () => {
+ const { component } = setup({ selectorOverrides: [{ selector: UserSelectors.isProjectReadOnly, value: true }] });
+ expect(component.disabledAddButtonTooltip()).toBe('common.errorMessages.actionUnavailable');
+ });
});
diff --git a/src/app/features/collections/components/add-to-collection/add-to-collection.component.ts b/src/app/features/collections/components/add-to-collection/add-to-collection.component.ts
index 7c78df5cb..e9ca8c814 100644
--- a/src/app/features/collections/components/add-to-collection/add-to-collection.component.ts
+++ b/src/app/features/collections/components/add-to-collection/add-to-collection.component.ts
@@ -4,6 +4,7 @@ import { TranslatePipe } from '@ngx-translate/core';
import { Button } from 'primeng/button';
import { Stepper } from 'primeng/stepper';
+import { Tooltip } from 'primeng/tooltip';
import { filter, finalize, map, Observable, of, switchMap } from 'rxjs';
@@ -65,6 +66,7 @@ import { SelectProjectStepComponent } from './select-project-step/select-project
Button,
Stepper,
RouterLink,
+ Tooltip,
TranslatePipe,
LoadingSpinnerComponent,
SelectProjectStepComponent,
@@ -100,6 +102,7 @@ export class AddToCollectionComponent implements CanDeactivateComponent {
selectedProject = select(ProjectsSelectors.getSelectedProject);
currentUser = select(UserSelectors.getCurrentUser);
currentCollectionSubmission = select(AddToCollectionSelectors.getCurrentCollectionSubmission);
+ isProjectReadOnly = select(UserSelectors.isProjectReadOnly);
cedarRecords = select(MetadataSelectors.getCedarRecords);
providerId = signal('');
@@ -117,6 +120,7 @@ export class AddToCollectionComponent implements CanDeactivateComponent {
isCollectionMetadataDisabled = computed(
() => !this.selectedProject() || !this.projectMetadataSaved() || !this.projectContributorsSaved()
);
+ disabledAddButtonTooltip = computed(() => (this.isProjectReadOnly() ? 'common.errorMessages.actionUnavailable' : ''));
existingCedarRecord = computed(() => {
const records = this.cedarRecords();
const templateId = this.requiredMetadataTemplate()?.id;
diff --git a/src/app/features/collections/components/add-to-collection/select-project-step/select-project-step.component.html b/src/app/features/collections/components/add-to-collection/select-project-step/select-project-step.component.html
index ceae12d81..df62da00b 100644
--- a/src/app/features/collections/components/add-to-collection/select-project-step/select-project-step.component.html
+++ b/src/app/features/collections/components/add-to-collection/select-project-step/select-project-step.component.html
@@ -1,4 +1,8 @@
-
+
@@ -28,6 +32,7 @@
{{ 'collections.addToCollection.selectProject' | translate }}
[excludeProjectIds]="excludedProjectIds()"
[publicOnly]="true"
[(selectedProject)]="currentSelectedProject"
+ [disabled]="isProjectReadOnly()"
(projectChange)="handleProjectChange($event)"
(projectsLoaded)="handleProjectsLoaded($event)"
/>
diff --git a/src/app/features/collections/components/add-to-collection/select-project-step/select-project-step.component.ts b/src/app/features/collections/components/add-to-collection/select-project-step/select-project-step.component.ts
index 7658ab614..5e380ca53 100644
--- a/src/app/features/collections/components/add-to-collection/select-project-step/select-project-step.component.ts
+++ b/src/app/features/collections/components/add-to-collection/select-project-step/select-project-step.component.ts
@@ -4,6 +4,7 @@ import { TranslatePipe } from '@ngx-translate/core';
import { Button } from 'primeng/button';
import { Step, StepItem, StepPanel } from 'primeng/stepper';
+import { Tooltip } from 'primeng/tooltip';
import { ChangeDetectionStrategy, Component, computed, input, output, signal } from '@angular/core';
@@ -16,7 +17,7 @@ import { ProjectsSelectors } from '@shared/stores/projects/projects.selectors';
@Component({
selector: 'osf-select-project-step',
- imports: [Button, TranslatePipe, ProjectSelectorComponent, Step, StepItem, StepPanel],
+ imports: [Button, Tooltip, TranslatePipe, ProjectSelectorComponent, Step, StepItem, StepPanel],
templateUrl: './select-project-step.component.html',
styleUrl: './select-project-step.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -28,6 +29,7 @@ export class SelectProjectStepComponent {
stepperActiveValue = input.required();
targetStepValue = input.required();
collectionId = input.required();
+ isProjectReadOnly = input.required();
stepChange = output();
projectSelected = output();
diff --git a/src/app/features/collections/components/collections-discover/collections-discover.component.html b/src/app/features/collections/components/collections-discover/collections-discover.component.html
index ea5c5cd98..35c0cf3d9 100644
--- a/src/app/features/collections/components/collections-discover/collections-discover.component.html
+++ b/src/app/features/collections/components/collections-discover/collections-discover.component.html
@@ -20,7 +20,12 @@ {{ collectionProvider()?
}
-
+
diff --git a/src/app/features/collections/components/collections-discover/collections-discover.component.spec.ts b/src/app/features/collections/components/collections-discover/collections-discover.component.spec.ts
index 42e4f6c3c..8e03a9d20 100644
--- a/src/app/features/collections/components/collections-discover/collections-discover.component.spec.ts
+++ b/src/app/features/collections/components/collections-discover/collections-discover.component.spec.ts
@@ -8,6 +8,7 @@ import { TestBed } from '@angular/core/testing';
import { ActivatedRoute } from '@angular/router';
import { ENVIRONMENT } from '@core/provider/environment.provider';
+import { UserSelectors } from '@core/store/user';
import { GlobalSearchComponent } from '@osf/shared/components/global-search/global-search.component';
import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component';
import { SearchInputComponent } from '@osf/shared/components/search-input/search-input.component';
@@ -20,7 +21,7 @@ import { MOCK_PROVIDER } from '@testing/mocks/provider.mock';
import { provideOSFCore } from '@testing/osf.testing.provider';
import { CustomDialogServiceMockBuilder } from '@testing/providers/custom-dialog-provider.mock';
import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock';
-import { provideMockStore } from '@testing/providers/store-provider.mock';
+import { mergeSignalOverrides, provideMockStore } from '@testing/providers/store-provider.mock';
import { ToastServiceMock } from '@testing/providers/toast-provider.mock';
import { CollectionsDiscoverComponent } from './collections-discover.component';
@@ -76,15 +77,24 @@ const MOCK_COLLECTION_PROVIDER_WITH_TEMPLATE = {
interface SetupOptions {
provider?: typeof MOCK_COLLECTION_PROVIDER | typeof MOCK_COLLECTION_PROVIDER_WITH_TEMPLATE;
+ selectorOverrides?: { selector: any; value: any }[];
}
function setup(options: SetupOptions = {}) {
- const { provider = MOCK_COLLECTION_PROVIDER } = options;
+ const { provider = MOCK_COLLECTION_PROVIDER, selectorOverrides = [] } = options;
const toastServiceMock = ToastServiceMock.simple();
const mockCustomDialogService = CustomDialogServiceMockBuilder.create().build();
const mockRoute = ActivatedRouteMockBuilder.create().withParams({ providerId: 'provider-1' }).build();
+ const defaultSignals = [
+ { selector: CollectionsSelectors.getCollectionProvider, value: provider },
+ { selector: CollectionsSelectors.getCollectionProviderLoading, value: false },
+ { selector: UserSelectors.isProjectReadOnly, value: false },
+ ];
+
+ const signals = mergeSignalOverrides(defaultSignals, selectorOverrides || []);
+
TestBed.configureTestingModule({
imports: [
CollectionsDiscoverComponent,
@@ -97,10 +107,7 @@ function setup(options: SetupOptions = {}) {
MockProvider(CustomDialogService, mockCustomDialogService),
MockProvider(ActivatedRoute, mockRoute),
provideMockStore({
- signals: [
- { selector: CollectionsSelectors.getCollectionProvider, value: provider },
- { selector: CollectionsSelectors.getCollectionProviderLoading, value: false },
- ],
+ signals,
}),
],
});
@@ -161,4 +168,9 @@ describe('CollectionsDiscoverComponent', () => {
const el = fixture.nativeElement as HTMLElement;
expect(el.querySelector('osf-global-search')).toBeTruthy();
});
+
+ it('should disable add button when user has isProjectReadOnly', () => {
+ const { component } = setup({ selectorOverrides: [{ selector: UserSelectors.isProjectReadOnly, value: true }] });
+ expect(component.disableAddButtonTooltip()).toBe('common.errorMessages.actionUnavailable');
+ });
});
diff --git a/src/app/features/collections/components/collections-discover/collections-discover.component.ts b/src/app/features/collections/components/collections-discover/collections-discover.component.ts
index 39fc8245d..dfbff4975 100644
--- a/src/app/features/collections/components/collections-discover/collections-discover.component.ts
+++ b/src/app/features/collections/components/collections-discover/collections-discover.component.ts
@@ -3,6 +3,7 @@ import { createDispatchMap, select } from '@ngxs/store';
import { TranslatePipe } from '@ngx-translate/core';
import { Button } from 'primeng/button';
+import { Tooltip } from 'primeng/tooltip';
import { isPlatformBrowser } from '@angular/common';
import {
@@ -18,6 +19,7 @@ import {
import { FormControl } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
+import { UserSelectors } from '@osf/core/store/user';
import { GlobalSearchComponent } from '@osf/shared/components/global-search/global-search.component';
import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component';
import { SearchInputComponent } from '@osf/shared/components/search-input/search-input.component';
@@ -32,7 +34,15 @@ import { CollectionsHelpDialogComponent } from '../collections-help-dialog/colle
@Component({
selector: 'osf-collections-discover',
- imports: [Button, RouterLink, SearchInputComponent, GlobalSearchComponent, LoadingSpinnerComponent, TranslatePipe],
+ imports: [
+ Button,
+ RouterLink,
+ SearchInputComponent,
+ GlobalSearchComponent,
+ LoadingSpinnerComponent,
+ Tooltip,
+ TranslatePipe,
+ ],
templateUrl: './collections-discover.component.html',
styleUrl: './collections-discover.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -53,8 +63,10 @@ export class CollectionsDiscoverComponent {
collectionProvider = select(CollectionsSelectors.getCollectionProvider);
isProviderLoading = select(CollectionsSelectors.getCollectionProviderLoading);
+ disableAddButton = select(UserSelectors.isProjectReadOnly);
primaryCollectionId = computed(() => this.collectionProvider()?.primaryCollection?.id);
+ disableAddButtonTooltip = computed(() => (this.disableAddButton() ? 'common.errorMessages.actionUnavailable' : ''));
actions = createDispatchMap({
getCollectionProvider: GetCollectionProvider,
diff --git a/src/app/features/contributors/contributors.component.html b/src/app/features/contributors/contributors.component.html
index fbcd27799..a25969495 100644
--- a/src/app/features/contributors/contributors.component.html
+++ b/src/app/features/contributors/contributors.component.html
@@ -4,8 +4,9 @@
{{ 'navigation.contributors' | translate }
@if (hasAdminAccess()) {
}
diff --git a/src/app/features/contributors/contributors.component.spec.ts b/src/app/features/contributors/contributors.component.spec.ts
index 37e11e236..0e3da7e90 100644
--- a/src/app/features/contributors/contributors.component.spec.ts
+++ b/src/app/features/contributors/contributors.component.spec.ts
@@ -78,6 +78,7 @@ describe('ContributorsComponent', () => {
{ selector: UserSelectors.getCurrentUser, value: { id: 'user-1' } },
{ selector: ContributorsSelectors.getContributorsPageSize, value: 10 },
{ selector: ContributorsSelectors.isContributorsLoadingMore, value: false },
+ { selector: UserSelectors.isProjectReadOnly, value: false },
];
function setup(overrides: BaseSetupOverrides = {}) {
@@ -243,4 +244,46 @@ describe('ContributorsComponent', () => {
expect(store.dispatch).toHaveBeenCalledWith(new ResetContributorsState());
});
+
+ it('should disable add contributor button when loading, read-only, or no admin access', () => {
+ setup({
+ routeParams: { id: 'resource-id' },
+ selectorOverrides: [
+ { selector: ContributorsSelectors.isContributorsLoading, value: true },
+ { selector: UserSelectors.isProjectReadOnly, value: false },
+ { selector: CurrentResourceSelectors.hasResourceAdminAccess, value: true },
+ ],
+ });
+ expect(component.disableAddButton()).toBe(true);
+
+ setup({
+ routeParams: { id: 'resource-id' },
+ selectorOverrides: [
+ { selector: ContributorsSelectors.isContributorsLoading, value: false },
+ { selector: UserSelectors.isProjectReadOnly, value: true },
+ { selector: CurrentResourceSelectors.hasResourceAdminAccess, value: true },
+ ],
+ });
+ expect(component.disableAddButton()).toBe(true);
+
+ setup({
+ routeParams: { id: 'resource-id' },
+ selectorOverrides: [
+ { selector: ContributorsSelectors.isContributorsLoading, value: false },
+ { selector: UserSelectors.isProjectReadOnly, value: false },
+ { selector: CurrentResourceSelectors.hasResourceAdminAccess, value: false },
+ ],
+ });
+ expect(component.disableAddButton()).toBe(true);
+
+ setup({
+ routeParams: { id: 'resource-id' },
+ selectorOverrides: [
+ { selector: ContributorsSelectors.isContributorsLoading, value: false },
+ { selector: UserSelectors.isProjectReadOnly, value: false },
+ { selector: CurrentResourceSelectors.hasResourceAdminAccess, value: true },
+ ],
+ });
+ expect(component.disableAddButton()).toBe(false);
+ });
});
diff --git a/src/app/features/contributors/contributors.component.ts b/src/app/features/contributors/contributors.component.ts
index 2fab14f2c..6618e7101 100644
--- a/src/app/features/contributors/contributors.component.ts
+++ b/src/app/features/contributors/contributors.component.ts
@@ -5,6 +5,7 @@ import { TranslatePipe } from '@ngx-translate/core';
import { Button } from 'primeng/button';
import { Select } from 'primeng/select';
import { TableModule } from 'primeng/table';
+import { Tooltip } from 'primeng/tooltip';
import { debounceTime, distinctUntilChanged, filter, map, of, switchMap } from 'rxjs';
@@ -94,6 +95,7 @@ import { ResourceInfoModel } from './models';
RequestAccessTableComponent,
ViewOnlyTableComponent,
TranslatePipe,
+ Tooltip,
],
templateUrl: './contributors.component.html',
styleUrl: './contributors.component.scss',
@@ -138,6 +140,7 @@ export class ContributorsComponent implements OnInit, OnDestroy {
readonly hasAdminAccess = select(CurrentResourceSelectors.hasResourceAdminAccess);
readonly resourceAccessRequestEnabled = select(CurrentResourceSelectors.resourceAccessRequestEnabled);
readonly currentUser = select(UserSelectors.getCurrentUser);
+ readonly isProjectReadOnly = select(UserSelectors.isProjectReadOnly);
readonly tableParams = computed(() => ({
...DEFAULT_TABLE_PARAMS,
@@ -148,6 +151,7 @@ export class ContributorsComponent implements OnInit, OnDestroy {
rows: this.pageSize(),
}));
+ disableAddButton = computed(() => this.isContributorsLoading() || this.isProjectReadOnly() || !this.hasAdminAccess());
canCreateViewLink = computed(() => !!this.resourceDetails() && !!this.resourceId());
searchPlaceholder = computed(() =>
this.resourceType() === ResourceType.Project
diff --git a/src/app/features/files/components/files-selection-actions/files-selection-actions.component.html b/src/app/features/files/components/files-selection-actions/files-selection-actions.component.html
index c29b33428..a63f8511b 100644
--- a/src/app/features/files/components/files-selection-actions/files-selection-actions.component.html
+++ b/src/app/features/files/components/files-selection-actions/files-selection-actions.component.html
@@ -18,6 +18,8 @@
@if (canUpdateFiles()) {
@if (canUpdateFiles()) {
{
expect(component.selectedFilesCount()).toBe(0);
expect(component.canUpdateFiles()).toBe(true);
expect(component.hasViewOnly()).toBe(false);
+ expect(component.isProjectReadOnly()).toBe(false);
});
it('should update selected files count input', () => {
@@ -50,7 +51,14 @@ describe('FilesSelectionActionsComponent', () => {
expect(component.hasViewOnly()).toBe(true);
});
- it('should emit copySelected output', () => {
+ it('should handle isProjectReadOnly input', () => {
+ fixture.componentRef.setInput('isProjectReadOnly', true);
+ fixture.detectChanges();
+
+ expect(component.isProjectReadOnly()).toBe(true);
+ });
+
+ it('should emit copySelected event', () => {
const copySelectedSpy = vi.spyOn(component.copySelected, 'emit');
component.copySelected.emit();
diff --git a/src/app/features/files/components/files-selection-actions/files-selection-actions.component.ts b/src/app/features/files/components/files-selection-actions/files-selection-actions.component.ts
index 54f281165..2d48328b4 100644
--- a/src/app/features/files/components/files-selection-actions/files-selection-actions.component.ts
+++ b/src/app/features/files/components/files-selection-actions/files-selection-actions.component.ts
@@ -1,12 +1,13 @@
import { TranslatePipe } from '@ngx-translate/core';
import { Button } from 'primeng/button';
+import { Tooltip } from 'primeng/tooltip';
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
@Component({
selector: 'osf-files-selection-actions',
- imports: [Button, TranslatePipe],
+ imports: [Button, Tooltip, TranslatePipe],
templateUrl: './files-selection-actions.component.html',
styleUrl: './files-selection-actions.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -15,6 +16,7 @@ export class FilesSelectionActionsComponent {
selectedFilesCount = input(0);
canUpdateFiles = input(true);
hasViewOnly = input(false);
+ isProjectReadOnly = input(false);
copySelected = output();
moveSelected = output();
deleteSelected = output();
diff --git a/src/app/features/files/pages/files/files.component.html b/src/app/features/files/pages/files/files.component.html
index 0b80d588c..b7739aa2e 100644
--- a/src/app/features/files/pages/files/files.component.html
+++ b/src/app/features/files/pages/files/files.component.html
@@ -27,6 +27,7 @@
[canUpdateFiles]="canUploadFiles()"
[selectedFilesCount]="filesSelection.length"
[hasViewOnly]="hasViewOnly()"
+ [isProjectReadOnly]="isProjectReadOnly()"
(deleteSelected)="onDeleteSelected()"
(moveSelected)="onMoveSelected()"
(copySelected)="onCopySelected()"
@@ -72,7 +73,8 @@
@if (canUploadFiles() && !hasViewOnly()) {
;
resourceId?: string;
+ fileProvider?: string;
+ hasViewOnlyParam?: boolean;
+ withResourceType?: ResourceType;
}
describe('FilesComponent', () => {
@@ -139,10 +144,11 @@ describe('FilesComponent', () => {
};
const resourceRoute = ActivatedRouteMockBuilder.create()
+ .withData({ resourceType: overrides.withResourceType ?? ResourceType.Project })
.withParams({ id: overrides.resourceId ?? 'node-1' })
.build();
const dataRoute = ActivatedRouteMockBuilder.create()
- .withData({ resourceType: ResourceType.Project })
+ .withData({ resourceType: overrides.withResourceType ?? ResourceType.Project })
.withParentRoute(resourceRoute)
.build();
const routeMock = ActivatedRouteMockBuilder.create()
@@ -166,10 +172,11 @@ describe('FilesComponent', () => {
{ selector: FilesSelectors.isConfiguredStorageAddonsLoading, value: false },
{
selector: FilesSelectors.getStorageSupportedFeatures,
- value: { [FileProvider.OsfStorage]: [SupportedFeature.AddUpdateFiles] },
+ value: { [FileProvider.OsfStorage]: [...Object.values(SupportedFeature)] },
},
{ selector: CurrentResourceSelectors.hasResourceWriteAccess, value: true },
{ selector: CurrentResourceSelectors.hasResourceAdminAccess, value: false },
+ { selector: UserSelectors.isProjectReadOnly, value: false },
];
TestBed.configureTestingModule({
@@ -232,6 +239,114 @@ describe('FilesComponent', () => {
expect(calls).toContainEqual(new GetConfiguredStorageAddons('node-1'));
});
+ it('should compute isProjectReadOnly true when readOnlyFlagActive is true and resourceType is Project', () => {
+ setup({
+ withResourceType: ResourceType.Project,
+ selectorOverrides: [{ selector: UserSelectors.isProjectReadOnly, value: true }],
+ });
+ expect(component.isProjectReadOnly()).toBe(true);
+
+ setup({
+ withResourceType: ResourceType.ProjectComponent,
+ selectorOverrides: [{ selector: UserSelectors.isProjectReadOnly, value: true }],
+ });
+ expect(component.isProjectReadOnly()).toBe(true);
+
+ setup({
+ withResourceType: ResourceType.Project,
+ selectorOverrides: [{ selector: UserSelectors.isProjectReadOnly, value: false }],
+ });
+ expect(component.isProjectReadOnly()).toBe(false);
+
+ setup({
+ withResourceType: ResourceType.ProjectComponent,
+ selectorOverrides: [{ selector: UserSelectors.isProjectReadOnly, value: false }],
+ });
+ expect(component.isProjectReadOnly()).toBe(false);
+
+ setup({
+ withResourceType: ResourceType.Registration,
+ selectorOverrides: [{ selector: UserSelectors.isProjectReadOnly, value: true }],
+ });
+ expect(component.isProjectReadOnly()).toBe(false);
+ });
+
+ it('should compute allowedMenuActions based on view only, registration, edit access and project read only flag', () => {
+ const editableMenu = {
+ [FileMenuType.Download]: true,
+ [FileMenuType.Embed]: true,
+ [FileMenuType.Share]: true,
+ [FileMenuType.Move]: true,
+ [FileMenuType.Copy]: true,
+ [FileMenuType.Rename]: true,
+ [FileMenuType.Delete]: true,
+ };
+ const readonlyMenu = {
+ [FileMenuType.Download]: true,
+ [FileMenuType.Embed]: true,
+ [FileMenuType.Share]: true,
+ [FileMenuType.Move]: false,
+ [FileMenuType.Copy]: false,
+ [FileMenuType.Rename]: false,
+ [FileMenuType.Delete]: false,
+ };
+
+ setup({
+ selectorOverrides: [
+ { selector: CurrentResourceSelectors.hasResourceWriteAccess, value: false },
+ { selector: CurrentResourceSelectors.hasResourceAdminAccess, value: false },
+ { selector: UserSelectors.isProjectReadOnly, value: false },
+ ],
+ });
+ expect(component.allowedMenuActions()).toEqual(readonlyMenu);
+
+ setup({
+ selectorOverrides: [
+ { selector: CurrentResourceSelectors.hasResourceWriteAccess, value: true },
+ { selector: CurrentResourceSelectors.hasResourceAdminAccess, value: true },
+ { selector: UserSelectors.isProjectReadOnly, value: true },
+ ],
+ });
+ expect(component.allowedMenuActions()).toEqual(readonlyMenu);
+
+ setup({
+ withResourceType: ResourceType.Registration,
+ selectorOverrides: [
+ { selector: CurrentResourceSelectors.hasResourceWriteAccess, value: true },
+ { selector: CurrentResourceSelectors.hasResourceAdminAccess, value: true },
+ { selector: UserSelectors.isProjectReadOnly, value: false },
+ ],
+ });
+ expect(component.allowedMenuActions()).toEqual(readonlyMenu);
+
+ setup({
+ selectorOverrides: [
+ { selector: CurrentResourceSelectors.hasResourceWriteAccess, value: true },
+ { selector: CurrentResourceSelectors.hasResourceAdminAccess, value: false },
+ { selector: UserSelectors.isProjectReadOnly, value: false },
+ ],
+ });
+ expect(component.allowedMenuActions()).toEqual(editableMenu);
+
+ setup({
+ selectorOverrides: [
+ { selector: CurrentResourceSelectors.hasResourceWriteAccess, value: false },
+ { selector: CurrentResourceSelectors.hasResourceAdminAccess, value: true },
+ { selector: UserSelectors.isProjectReadOnly, value: false },
+ ],
+ });
+ expect(component.allowedMenuActions()).toEqual(editableMenu);
+
+ setup({
+ selectorOverrides: [
+ { selector: CurrentResourceSelectors.hasResourceWriteAccess, value: true },
+ { selector: CurrentResourceSelectors.hasResourceAdminAccess, value: true },
+ { selector: UserSelectors.isProjectReadOnly, value: false },
+ ],
+ });
+ expect(component.allowedMenuActions()).toEqual(editableMenu);
+ });
+
it('should call uploadFiles from tree upload confirm callback', () => {
setup();
const uploadSpy = vi.spyOn(component, 'uploadFiles').mockImplementation(() => {});
diff --git a/src/app/features/files/pages/files/files.component.ts b/src/app/features/files/pages/files/files.component.ts
index 7b22779dd..81b68d112 100644
--- a/src/app/features/files/pages/files/files.component.ts
+++ b/src/app/features/files/pages/files/files.component.ts
@@ -4,6 +4,7 @@ import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { Button } from 'primeng/button';
import { Select } from 'primeng/select';
+import { Tooltip } from 'primeng/tooltip';
import { debounceTime, distinctUntilChanged, finalize, map, of, switchMap, tap } from 'rxjs';
@@ -24,6 +25,7 @@ import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
+import { UserSelectors } from '@osf/core/store/user';
import { FileUploadDialogComponent } from '@osf/shared/components/file-upload-dialog/file-upload-dialog.component';
import { FormSelectComponent } from '@osf/shared/components/form-select/form-select.component';
import { GoogleFilePickerComponent } from '@osf/shared/components/google-file-picker/google-file-picker.component';
@@ -94,6 +96,7 @@ import {
ViewOnlyLinkMessageComponent,
FilesSelectionActionsComponent,
TranslatePipe,
+ Tooltip,
],
templateUrl: './files.component.html',
styleUrl: './files.component.scss',
@@ -147,6 +150,7 @@ export class FilesComponent {
readonly supportedFeatures = select(FilesSelectors.getStorageSupportedFeatures);
readonly hasWriteAccess = select(CurrentResourceSelectors.hasResourceWriteAccess);
readonly hasAdminAccess = select(CurrentResourceSelectors.hasResourceAdminAccess);
+ readonly readOnlyFlagActive = select(UserSelectors.isProjectReadOnly);
readonly currentResourceType = computed(
() => (this.resourceMetadata()?.type as CurrentResourceType) ?? CurrentResourceType.Projects
);
@@ -181,11 +185,12 @@ export class FilesComponent {
const supportedFeatures = this.supportedFeatures()[provider] || [];
const hasViewOnly = this.hasViewOnly();
const isRegistration = this.resourceType() === ResourceType.Registration;
+ const isProjectReadOnly = this.isProjectReadOnly();
const menuMap = mapMenuActions(supportedFeatures);
const result: Record = { ...menuMap };
- if (hasViewOnly || isRegistration || !this.canEdit()) {
+ if (hasViewOnly || isRegistration || !this.canEdit() || isProjectReadOnly) {
const allowed = new Set([FileMenuType.Download, FileMenuType.Embed, FileMenuType.Share]);
(Object.keys(result) as FileMenuType[]).forEach((key) => {
@@ -207,6 +212,12 @@ export class FilesComponent {
readonly hasViewOnly = computed(() => this.viewOnlyService.hasViewOnlyParam(this.router));
readonly canEdit = computed(() => this.hasWriteAccess() || this.hasAdminAccess());
+
+ readonly isProjectReadOnly = computed(
+ () =>
+ this.readOnlyFlagActive() && [ResourceType.Project, ResourceType.ProjectComponent].includes(this.resourceType())
+ );
+
readonly isRegistration = computed(() => this.resourceType() === ResourceType.Registration);
canUploadFiles = computed(
diff --git a/src/app/features/home/pages/dashboard/dashboard.component.html b/src/app/features/home/pages/dashboard/dashboard.component.html
index 4a005698d..9a5645c48 100644
--- a/src/app/features/home/pages/dashboard/dashboard.component.html
+++ b/src/app/features/home/pages/dashboard/dashboard.component.html
@@ -7,6 +7,8 @@
[title]="subHeaderTitle() | translate"
[icon]="'fas fa-home'"
[buttonLabel]="'home.loggedIn.dashboard.createProject' | translate"
+ [isButtonDisabled]="projectCreationDisabled()"
+ [buttonTooltip]="buttonTooltip() | translate"
(buttonClick)="createProject()"
/>
@@ -64,7 +66,7 @@ {{ 'home.loggedIn.latestResearch.title' | translate }}
} @else {
-
{{ 'home.loggedIn.dashboard.noCreatedProject' | translate }}
+
{{ noProjectsMessage() | translate }}
diff --git a/src/app/features/home/pages/dashboard/dashboard.component.spec.ts b/src/app/features/home/pages/dashboard/dashboard.component.spec.ts
index c68654ccc..360640c9b 100644
--- a/src/app/features/home/pages/dashboard/dashboard.component.spec.ts
+++ b/src/app/features/home/pages/dashboard/dashboard.component.spec.ts
@@ -72,6 +72,7 @@ describe('DashboardComponent', () => {
{ selector: MyResourcesSelectors.getProjects, value: [] },
{ selector: MyResourcesSelectors.getTotalProjects, value: 0 },
{ selector: MyResourcesSelectors.getProjectsLoading, value: false },
+ { selector: UserSelectors.isProjectCreationDisabled, value: false },
{ selector: UserSelectors.getActiveFlags, value: [] },
];
@@ -131,6 +132,14 @@ describe('DashboardComponent', () => {
);
});
+ it('should disable project creation and show tooltip when isProjectCreationDisabled is true', () => {
+ setup({
+ selectorOverrides: [{ selector: UserSelectors.isProjectCreationDisabled, value: true }],
+ });
+
+ expect(component.buttonTooltip()).toBe('home.loggedIn.dashboard.createProjectDisabledTooltip');
+ });
+
it('should read query params and fetch projects on init', () => {
setup({
routeQueryParams: {
diff --git a/src/app/features/home/pages/dashboard/dashboard.component.ts b/src/app/features/home/pages/dashboard/dashboard.component.ts
index 70dbaaf5f..dff7125c6 100644
--- a/src/app/features/home/pages/dashboard/dashboard.component.ts
+++ b/src/app/features/home/pages/dashboard/dashboard.component.ts
@@ -69,6 +69,7 @@ export class DashboardComponent implements OnInit {
readonly projects = select(MyResourcesSelectors.getProjects);
readonly totalProjectsCount = select(MyResourcesSelectors.getTotalProjects);
readonly areProjectsLoading = select(MyResourcesSelectors.getProjectsLoading);
+ readonly projectCreationDisabled = select(UserSelectors.isProjectCreationDisabled);
readonly activeFlags = select(UserSelectors.getActiveFlags);
readonly actions = createDispatchMap({ getMyProjects: GetMyProjects, clearMyResources: ClearMyResources });
@@ -82,7 +83,17 @@ export class DashboardComponent implements OnInit {
return this.projects().filter((project) => project.title.toLowerCase().includes(search));
});
+ readonly buttonTooltip = computed(() => {
+ return this.projectCreationDisabled() ? 'home.loggedIn.dashboard.createProjectDisabledTooltip' : '';
+ });
+
readonly existsProjects = computed(() => this.projects().length || !!this.searchControl.value?.length);
+ readonly noProjectsMessage = computed(() => {
+ if (this.projectCreationDisabled()) {
+ return 'home.loggedIn.dashboard.noCreatedProjectAndCreateProjectDisabled';
+ }
+ return 'home.loggedIn.dashboard.noCreatedProject';
+ });
readonly subHeaderTitle = computed(() =>
this.existsProjects() ? 'home.loggedIn.dashboard.title' : 'home.loggedIn.dashboard.welcome'
);
diff --git a/src/app/features/metadata/components/base-metadata.component.ts b/src/app/features/metadata/components/base-metadata.component.ts
new file mode 100644
index 000000000..720b7109f
--- /dev/null
+++ b/src/app/features/metadata/components/base-metadata.component.ts
@@ -0,0 +1,9 @@
+import { Component, input } from '@angular/core';
+
+@Component({
+ template: '',
+})
+export abstract class BaseMetadataComponent {
+ disabled = input
(false);
+ disabledButtonTooltip = input('');
+}
diff --git a/src/app/features/metadata/components/metadata-affiliated-institutions/metadata-affiliated-institutions.component.html b/src/app/features/metadata/components/metadata-affiliated-institutions/metadata-affiliated-institutions.component.html
index b8d7488e4..e41430815 100644
--- a/src/app/features/metadata/components/metadata-affiliated-institutions/metadata-affiliated-institutions.component.html
+++ b/src/app/features/metadata/components/metadata-affiliated-institutions/metadata-affiliated-institutions.component.html
@@ -6,6 +6,9 @@ {{ 'common.labels.affiliatedInstitutions' | translate }}
diff --git a/src/app/features/metadata/components/metadata-affiliated-institutions/metadata-affiliated-institutions.component.spec.ts b/src/app/features/metadata/components/metadata-affiliated-institutions/metadata-affiliated-institutions.component.spec.ts
index 044300924..225ec12c7 100644
--- a/src/app/features/metadata/components/metadata-affiliated-institutions/metadata-affiliated-institutions.component.spec.ts
+++ b/src/app/features/metadata/components/metadata-affiliated-institutions/metadata-affiliated-institutions.component.spec.ts
@@ -42,4 +42,13 @@ describe('MetadataAffiliatedInstitutionsComponent', () => {
expect(component.readonly()).toBe(true);
});
+
+ it('should set disabled inputs', () => {
+ fixture.componentRef.setInput('disabled', true);
+ fixture.componentRef.setInput('disabledButtonTooltip', 'Editing is disabled');
+ fixture.detectChanges();
+
+ expect(component.disabled()).toBe(true);
+ expect(component.disabledButtonTooltip()).toBe('Editing is disabled');
+ });
});
diff --git a/src/app/features/metadata/components/metadata-affiliated-institutions/metadata-affiliated-institutions.component.ts b/src/app/features/metadata/components/metadata-affiliated-institutions/metadata-affiliated-institutions.component.ts
index bcf1badf8..d1dd547da 100644
--- a/src/app/features/metadata/components/metadata-affiliated-institutions/metadata-affiliated-institutions.component.ts
+++ b/src/app/features/metadata/components/metadata-affiliated-institutions/metadata-affiliated-institutions.component.ts
@@ -2,19 +2,22 @@ import { TranslatePipe } from '@ngx-translate/core';
import { Button } from 'primeng/button';
import { Card } from 'primeng/card';
+import { Tooltip } from 'primeng/tooltip';
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
import { AffiliatedInstitutionsViewComponent } from '@osf/shared/components/affiliated-institutions-view/affiliated-institutions-view.component';
import { Institution } from '@osf/shared/models/institutions/institutions.model';
+import { BaseMetadataComponent } from '../base-metadata.component';
+
@Component({
selector: 'osf-metadata-affiliated-institutions',
- imports: [Button, Card, TranslatePipe, AffiliatedInstitutionsViewComponent],
+ imports: [Button, Card, Tooltip, TranslatePipe, AffiliatedInstitutionsViewComponent],
templateUrl: './metadata-affiliated-institutions.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
-export class MetadataAffiliatedInstitutionsComponent {
+export class MetadataAffiliatedInstitutionsComponent extends BaseMetadataComponent {
openEditAffiliatedInstitutionsDialog = output();
affiliatedInstitutions = input([]);
diff --git a/src/app/features/metadata/components/metadata-contributors/metadata-contributors.component.html b/src/app/features/metadata/components/metadata-contributors/metadata-contributors.component.html
index d086e52c4..af19d1d4e 100644
--- a/src/app/features/metadata/components/metadata-contributors/metadata-contributors.component.html
+++ b/src/app/features/metadata/components/metadata-contributors/metadata-contributors.component.html
@@ -7,6 +7,9 @@ {{ 'common.labels.contributors' | translate }}
(onClick)="openEditContributorDialog.emit()"
severity="secondary"
[label]="'common.buttons.edit' | translate"
+ [disabled]="disabled()"
+ [pTooltip]="disabledButtonTooltip()"
+ tooltipPosition="left"
data-test-edit-contributors-button
>
}
diff --git a/src/app/features/metadata/components/metadata-contributors/metadata-contributors.component.spec.ts b/src/app/features/metadata/components/metadata-contributors/metadata-contributors.component.spec.ts
index f7506031b..cf4e5f6da 100644
--- a/src/app/features/metadata/components/metadata-contributors/metadata-contributors.component.spec.ts
+++ b/src/app/features/metadata/components/metadata-contributors/metadata-contributors.component.spec.ts
@@ -53,6 +53,15 @@ describe('MetadataContributorsComponent', () => {
expect(component.readonly()).toBe(true);
});
+ it('should set disabled inputs', () => {
+ fixture.componentRef.setInput('disabled', true);
+ fixture.componentRef.setInput('disabledButtonTooltip', 'Editing is disabled');
+ fixture.detectChanges();
+
+ expect(component.disabled()).toBe(true);
+ expect(component.disabledButtonTooltip()).toBe('Editing is disabled');
+ });
+
it('should emit openEditContributorDialog event', () => {
const emitSpy = vi.spyOn(component.openEditContributorDialog, 'emit');
diff --git a/src/app/features/metadata/components/metadata-contributors/metadata-contributors.component.ts b/src/app/features/metadata/components/metadata-contributors/metadata-contributors.component.ts
index abfa69571..39a2a4056 100644
--- a/src/app/features/metadata/components/metadata-contributors/metadata-contributors.component.ts
+++ b/src/app/features/metadata/components/metadata-contributors/metadata-contributors.component.ts
@@ -2,19 +2,22 @@ import { TranslatePipe } from '@ngx-translate/core';
import { Button } from 'primeng/button';
import { Card } from 'primeng/card';
+import { Tooltip } from 'primeng/tooltip';
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component';
import { ContributorModel } from '@osf/shared/models/contributors/contributor.model';
+import { BaseMetadataComponent } from '../base-metadata.component';
+
@Component({
selector: 'osf-metadata-contributors',
- imports: [Button, Card, TranslatePipe, ContributorsListComponent],
+ imports: [Button, Card, Tooltip, TranslatePipe, ContributorsListComponent],
templateUrl: './metadata-contributors.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
-export class MetadataContributorsComponent {
+export class MetadataContributorsComponent extends BaseMetadataComponent {
contributors = input([]);
isLoading = input(false);
hasMoreContributors = input(false);
diff --git a/src/app/features/metadata/components/metadata-description/metadata-description.component.html b/src/app/features/metadata/components/metadata-description/metadata-description.component.html
index 8aa659b6b..5f9f8d793 100644
--- a/src/app/features/metadata/components/metadata-description/metadata-description.component.html
+++ b/src/app/features/metadata/components/metadata-description/metadata-description.component.html
@@ -7,6 +7,9 @@ {{ 'common.labels.description' | translate }}
severity="secondary"
[label]="'common.buttons.edit' | translate"
(onClick)="openEditDescriptionDialog.emit()"
+ [disabled]="disabled()"
+ [pTooltip]="disabledButtonTooltip()"
+ tooltipPosition="left"
data-test-edit-description-button
>
}
diff --git a/src/app/features/metadata/components/metadata-description/metadata-description.component.spec.ts b/src/app/features/metadata/components/metadata-description/metadata-description.component.spec.ts
index 326a5a411..5c2e3db13 100644
--- a/src/app/features/metadata/components/metadata-description/metadata-description.component.spec.ts
+++ b/src/app/features/metadata/components/metadata-description/metadata-description.component.spec.ts
@@ -31,6 +31,16 @@ describe('MetadataDescriptionComponent', () => {
expect(component.description()).toEqual(mockDescription);
});
+ it('should set disabled inputs', () => {
+ fixture.componentRef.setInput('description', mockDescription);
+ fixture.componentRef.setInput('disabled', true);
+ fixture.componentRef.setInput('disabledButtonTooltip', 'Editing is disabled');
+ fixture.detectChanges();
+
+ expect(component.disabled()).toBe(true);
+ expect(component.disabledButtonTooltip()).toBe('Editing is disabled');
+ });
+
it('should emit openEditDescriptionDialog event', () => {
const emitSpy = vi.spyOn(component.openEditDescriptionDialog, 'emit');
diff --git a/src/app/features/metadata/components/metadata-description/metadata-description.component.ts b/src/app/features/metadata/components/metadata-description/metadata-description.component.ts
index 27a06c164..d0f5b5168 100644
--- a/src/app/features/metadata/components/metadata-description/metadata-description.component.ts
+++ b/src/app/features/metadata/components/metadata-description/metadata-description.component.ts
@@ -2,16 +2,18 @@ import { TranslatePipe } from '@ngx-translate/core';
import { Button } from 'primeng/button';
import { Card } from 'primeng/card';
+import { Tooltip } from 'primeng/tooltip';
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
+import { BaseMetadataComponent } from '../base-metadata.component';
@Component({
selector: 'osf-metadata-description',
- imports: [Card, Button, TranslatePipe],
+ imports: [Card, Button, Tooltip, TranslatePipe],
templateUrl: './metadata-description.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
-export class MetadataDescriptionComponent {
+export class MetadataDescriptionComponent extends BaseMetadataComponent {
openEditDescriptionDialog = output();
description = input.required();
readonly = input(false);
diff --git a/src/app/features/metadata/components/metadata-funding/metadata-funding.component.html b/src/app/features/metadata/components/metadata-funding/metadata-funding.component.html
index d02ec5408..5f7e0d4c0 100644
--- a/src/app/features/metadata/components/metadata-funding/metadata-funding.component.html
+++ b/src/app/features/metadata/components/metadata-funding/metadata-funding.component.html
@@ -6,6 +6,9 @@ {{ 'project.overview.metadata.fundingSupport' | translate }}
diff --git a/src/app/features/metadata/components/metadata-funding/metadata-funding.component.spec.ts b/src/app/features/metadata/components/metadata-funding/metadata-funding.component.spec.ts
index dfeaa309f..538df4b69 100644
--- a/src/app/features/metadata/components/metadata-funding/metadata-funding.component.spec.ts
+++ b/src/app/features/metadata/components/metadata-funding/metadata-funding.component.spec.ts
@@ -41,6 +41,15 @@ describe('MetadataFundingComponent', () => {
expect(component.readonly()).toBe(true);
});
+ it('should set disabled inputs', () => {
+ fixture.componentRef.setInput('disabled', true);
+ fixture.componentRef.setInput('disabledButtonTooltip', 'Editing is disabled');
+ fixture.detectChanges();
+
+ expect(component.disabled()).toBe(true);
+ expect(component.disabledButtonTooltip()).toBe('Editing is disabled');
+ });
+
it('should emit openEditFundingDialog event', () => {
const emitSpy = vi.spyOn(component.openEditFundingDialog, 'emit');
diff --git a/src/app/features/metadata/components/metadata-funding/metadata-funding.component.ts b/src/app/features/metadata/components/metadata-funding/metadata-funding.component.ts
index c0d6e7081..0fa62940f 100644
--- a/src/app/features/metadata/components/metadata-funding/metadata-funding.component.ts
+++ b/src/app/features/metadata/components/metadata-funding/metadata-funding.component.ts
@@ -2,19 +2,21 @@ import { TranslatePipe } from '@ngx-translate/core';
import { Button } from 'primeng/button';
import { Card } from 'primeng/card';
+import { Tooltip } from 'primeng/tooltip';
import { NgClass } from '@angular/common';
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
import { Funder } from '../../models';
+import { BaseMetadataComponent } from '../base-metadata.component';
@Component({
selector: 'osf-metadata-funding',
- imports: [NgClass, Button, Card, TranslatePipe],
+ imports: [NgClass, Button, Card, Tooltip, TranslatePipe],
templateUrl: './metadata-funding.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
-export class MetadataFundingComponent {
+export class MetadataFundingComponent extends BaseMetadataComponent {
openEditFundingDialog = output();
funders = input();
diff --git a/src/app/features/metadata/components/metadata-license/metadata-license.component.html b/src/app/features/metadata/components/metadata-license/metadata-license.component.html
index 77aa11ece..12728c341 100644
--- a/src/app/features/metadata/components/metadata-license/metadata-license.component.html
+++ b/src/app/features/metadata/components/metadata-license/metadata-license.component.html
@@ -7,6 +7,9 @@ {{ 'common.labels.license' | translate }}
severity="secondary"
[label]="'common.buttons.edit' | translate"
(onClick)="openEditLicenseDialog.emit()"
+ [disabled]="disabled()"
+ [pTooltip]="disabledButtonTooltip()"
+ tooltipPosition="left"
data-test-edit-license-button
/>
}
diff --git a/src/app/features/metadata/components/metadata-license/metadata-license.component.spec.ts b/src/app/features/metadata/components/metadata-license/metadata-license.component.spec.ts
index 573b993f4..d014c807f 100644
--- a/src/app/features/metadata/components/metadata-license/metadata-license.component.spec.ts
+++ b/src/app/features/metadata/components/metadata-license/metadata-license.component.spec.ts
@@ -44,6 +44,15 @@ describe('MetadataLicenseComponent', () => {
expect(component.readonly()).toBe(true);
});
+ it('should set disabled inputs', () => {
+ fixture.componentRef.setInput('disabled', true);
+ fixture.componentRef.setInput('disabledButtonTooltip', 'Editing is disabled');
+ fixture.detectChanges();
+
+ expect(component.disabled()).toBe(true);
+ expect(component.disabledButtonTooltip()).toBe('Editing is disabled');
+ });
+
it('should emit openEditLicenseDialog event', () => {
const emitSpy = vi.spyOn(component.openEditLicenseDialog, 'emit');
diff --git a/src/app/features/metadata/components/metadata-license/metadata-license.component.ts b/src/app/features/metadata/components/metadata-license/metadata-license.component.ts
index 9fc0c98d6..7639ce9be 100644
--- a/src/app/features/metadata/components/metadata-license/metadata-license.component.ts
+++ b/src/app/features/metadata/components/metadata-license/metadata-license.component.ts
@@ -2,18 +2,21 @@ import { TranslatePipe } from '@ngx-translate/core';
import { Button } from 'primeng/button';
import { Card } from 'primeng/card';
+import { Tooltip } from 'primeng/tooltip';
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
import { LicenseModel } from '@osf/shared/models/license/license.model';
+import { BaseMetadataComponent } from '../base-metadata.component';
+
@Component({
selector: 'osf-metadata-license',
- imports: [Button, Card, TranslatePipe],
+ imports: [Button, Card, Tooltip, TranslatePipe],
templateUrl: './metadata-license.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
-export class MetadataLicenseComponent {
+export class MetadataLicenseComponent extends BaseMetadataComponent {
openEditLicenseDialog = output();
readonly = input(false);
license = input(null);
diff --git a/src/app/features/metadata/components/metadata-publication-doi/metadata-publication-doi.component.html b/src/app/features/metadata/components/metadata-publication-doi/metadata-publication-doi.component.html
index 44341e63e..f29ede29d 100644
--- a/src/app/features/metadata/components/metadata-publication-doi/metadata-publication-doi.component.html
+++ b/src/app/features/metadata/components/metadata-publication-doi/metadata-publication-doi.component.html
@@ -10,6 +10,9 @@
diff --git a/src/app/features/metadata/components/metadata-publication-doi/metadata-publication-doi.component.spec.ts b/src/app/features/metadata/components/metadata-publication-doi/metadata-publication-doi.component.spec.ts
index f3ed0c6d5..fb910678b 100644
--- a/src/app/features/metadata/components/metadata-publication-doi/metadata-publication-doi.component.spec.ts
+++ b/src/app/features/metadata/components/metadata-publication-doi/metadata-publication-doi.component.spec.ts
@@ -46,6 +46,15 @@ describe('MetadataPublicationDoiComponent', () => {
expect(component.hideEditDoi()).toBe(true);
});
+ it('should set disabled inputs', () => {
+ fixture.componentRef.setInput('disabled', true);
+ fixture.componentRef.setInput('disabledButtonTooltip', 'Editing is disabled');
+ fixture.detectChanges();
+
+ expect(component.disabled()).toBe(true);
+ expect(component.disabledButtonTooltip()).toBe('Editing is disabled');
+ });
+
it('should emit openEditPublicationDoiDialog event', () => {
const emitSpy = vi.spyOn(component.openEditPublicationDoiDialog, 'emit');
diff --git a/src/app/features/metadata/components/metadata-publication-doi/metadata-publication-doi.component.ts b/src/app/features/metadata/components/metadata-publication-doi/metadata-publication-doi.component.ts
index 10231c8b2..74f579b1d 100644
--- a/src/app/features/metadata/components/metadata-publication-doi/metadata-publication-doi.component.ts
+++ b/src/app/features/metadata/components/metadata-publication-doi/metadata-publication-doi.component.ts
@@ -2,19 +2,22 @@ import { TranslatePipe } from '@ngx-translate/core';
import { Button } from 'primeng/button';
import { Card } from 'primeng/card';
+import { Tooltip } from 'primeng/tooltip';
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
import { ResourceType } from '@osf/shared/enums/resource-type.enum';
import { IdentifierModel } from '@osf/shared/models/identifiers/identifier.model';
+import { BaseMetadataComponent } from '../base-metadata.component';
+
@Component({
selector: 'osf-metadata-publication-doi',
- imports: [Button, Card, TranslatePipe],
+ imports: [Button, Card, Tooltip, TranslatePipe],
templateUrl: './metadata-publication-doi.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
-export class MetadataPublicationDoiComponent {
+export class MetadataPublicationDoiComponent extends BaseMetadataComponent {
openEditPublicationDoiDialog = output();
identifiers = input([]);
diff --git a/src/app/features/metadata/components/metadata-resource-information/metadata-resource-information.component.html b/src/app/features/metadata/components/metadata-resource-information/metadata-resource-information.component.html
index 9b2e95482..078aa4f40 100644
--- a/src/app/features/metadata/components/metadata-resource-information/metadata-resource-information.component.html
+++ b/src/app/features/metadata/components/metadata-resource-information/metadata-resource-information.component.html
@@ -15,6 +15,9 @@
diff --git a/src/app/features/metadata/components/metadata-resource-information/metadata-resource-information.component.spec.ts b/src/app/features/metadata/components/metadata-resource-information/metadata-resource-information.component.spec.ts
index 6eebd9418..8d4d8885b 100644
--- a/src/app/features/metadata/components/metadata-resource-information/metadata-resource-information.component.spec.ts
+++ b/src/app/features/metadata/components/metadata-resource-information/metadata-resource-information.component.spec.ts
@@ -51,6 +51,15 @@ describe('MetadataResourceInformationComponent', () => {
expect(component.readonly()).toBe(true);
});
+ it('should set disabled inputs', () => {
+ fixture.componentRef.setInput('disabled', true);
+ fixture.componentRef.setInput('disabledButtonTooltip', 'Editing is disabled');
+ fixture.detectChanges();
+
+ expect(component.disabled()).toBe(true);
+ expect(component.disabledButtonTooltip()).toBe('Editing is disabled');
+ });
+
it('should emit openEditResourceInformationDialog event', () => {
const emitSpy = vi.spyOn(component.openEditResourceInformationDialog, 'emit');
diff --git a/src/app/features/metadata/components/metadata-resource-information/metadata-resource-information.component.ts b/src/app/features/metadata/components/metadata-resource-information/metadata-resource-information.component.ts
index 4659ab242..3c1625878 100644
--- a/src/app/features/metadata/components/metadata-resource-information/metadata-resource-information.component.ts
+++ b/src/app/features/metadata/components/metadata-resource-information/metadata-resource-information.component.ts
@@ -2,6 +2,7 @@ import { TranslatePipe } from '@ngx-translate/core';
import { Button } from 'primeng/button';
import { Card } from 'primeng/card';
+import { Tooltip } from 'primeng/tooltip';
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
@@ -9,13 +10,15 @@ import { CustomItemMetadataRecord } from '@osf/features/metadata/models';
import { LanguageLabelPipe } from '@osf/shared/pipes/language-label.pipe';
import { ResourceTypeGeneralLabelPipe } from '@osf/shared/pipes/resource-type-general-label.pipe';
+import { BaseMetadataComponent } from '../base-metadata.component';
+
@Component({
selector: 'osf-metadata-resource-information',
- imports: [Button, Card, TranslatePipe, LanguageLabelPipe, ResourceTypeGeneralLabelPipe],
+ imports: [Button, Card, Tooltip, TranslatePipe, LanguageLabelPipe, ResourceTypeGeneralLabelPipe],
templateUrl: './metadata-resource-information.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
-export class MetadataResourceInformationComponent {
+export class MetadataResourceInformationComponent extends BaseMetadataComponent {
openEditResourceInformationDialog = output();
customItemMetadata = input.required();
diff --git a/src/app/features/metadata/components/metadata-title/metadata-title.component.html b/src/app/features/metadata/components/metadata-title/metadata-title.component.html
index 9f1e06f1a..ad4a44316 100644
--- a/src/app/features/metadata/components/metadata-title/metadata-title.component.html
+++ b/src/app/features/metadata/components/metadata-title/metadata-title.component.html
@@ -7,6 +7,9 @@ {{ 'common.labels.title' | translate }}
severity="secondary"
[label]="'common.buttons.edit' | translate"
(onClick)="openEditTitleDialog.emit()"
+ [disabled]="disabled()"
+ [pTooltip]="disabledButtonTooltip()"
+ tooltipPosition="left"
data-test-edit-title-button
>
}
diff --git a/src/app/features/metadata/components/metadata-title/metadata-title.component.spec.ts b/src/app/features/metadata/components/metadata-title/metadata-title.component.spec.ts
index 07600066a..841d9f40d 100644
--- a/src/app/features/metadata/components/metadata-title/metadata-title.component.spec.ts
+++ b/src/app/features/metadata/components/metadata-title/metadata-title.component.spec.ts
@@ -31,6 +31,16 @@ describe('MetadataTitleComponent', () => {
expect(component.title()).toEqual(mockTitle);
});
+ it('should set disabled inputs', () => {
+ fixture.componentRef.setInput('title', mockTitle);
+ fixture.componentRef.setInput('disabled', true);
+ fixture.componentRef.setInput('disabledButtonTooltip', 'Editing is disabled');
+ fixture.detectChanges();
+
+ expect(component.disabled()).toBe(true);
+ expect(component.disabledButtonTooltip()).toBe('Editing is disabled');
+ });
+
it('should emit openEditTitleDialog event', () => {
const emitSpy = vi.spyOn(component.openEditTitleDialog, 'emit');
diff --git a/src/app/features/metadata/components/metadata-title/metadata-title.component.ts b/src/app/features/metadata/components/metadata-title/metadata-title.component.ts
index b1864575c..1c02d5acb 100644
--- a/src/app/features/metadata/components/metadata-title/metadata-title.component.ts
+++ b/src/app/features/metadata/components/metadata-title/metadata-title.component.ts
@@ -2,16 +2,19 @@ import { TranslatePipe } from '@ngx-translate/core';
import { Button } from 'primeng/button';
import { Card } from 'primeng/card';
+import { Tooltip } from 'primeng/tooltip';
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
+import { BaseMetadataComponent } from '../base-metadata.component';
+
@Component({
selector: 'osf-metadata-title',
- imports: [Card, Button, TranslatePipe],
+ imports: [Card, Button, Tooltip, TranslatePipe],
templateUrl: './metadata-title.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
-export class MetadataTitleComponent {
+export class MetadataTitleComponent extends BaseMetadataComponent {
title = input.required();
readonly = input(false);
openEditTitleDialog = output();
diff --git a/src/app/features/metadata/metadata.component.html b/src/app/features/metadata/metadata.component.html
index 6c0872d5d..873a2f665 100644
--- a/src/app/features/metadata/metadata.component.html
+++ b/src/app/features/metadata/metadata.component.html
@@ -2,7 +2,8 @@
@@ -13,7 +14,7 @@
[selectedCedarTemplate]="selectedCedarTemplate()!"
[selectedCedarRecord]="selectedCedarRecord()!"
[cedarFormReadonly]="cedarFormReadonly()"
- [canEdit]="hasWriteAccess()"
+ [canEdit]="hasWriteAccess() && !isProjectReadOnly()"
(changeTab)="onTabChange($event)"
(formSubmit)="onCedarFormSubmit($event)"
(cedarFormChangeTemplate)="onCedarFormChangeTemplate()"
@@ -25,12 +26,16 @@
(openEditTitleDialog)="openEditTitleDialog()"
[title]="metadata()?.title!"
[readonly]="!hasWriteAccess()"
+ [disabled]="isProjectReadOnly()"
+ [disabledButtonTooltip]="disabledButtonTooltip() | translate"
/>
@if (isRegistrationType()) {
@@ -46,6 +51,8 @@
[hasMoreContributors]="hasMoreContributors()"
[readonly]="!hasWriteAccess()"
(loadMoreContributors)="handleLoadMoreContributors()"
+ [disabled]="isProjectReadOnly()"
+ [disabledButtonTooltip]="disabledButtonTooltip() | translate"
/>
@if (isProjectType()) {
@@ -82,6 +95,8 @@
(openEditLicenseDialog)="openEditLicenseDialog()"
[license]="metadata()?.license!"
[readonly]="!hasWriteAccess()"
+ [disabled]="isProjectReadOnly()"
+ [disabledButtonTooltip]="disabledButtonTooltip() | translate"
/>
@if (isRegistrationType()) {
@@ -99,7 +116,7 @@
diff --git a/src/app/features/metadata/metadata.component.spec.ts b/src/app/features/metadata/metadata.component.spec.ts
index 30d951f5f..1f6cfb69d 100644
--- a/src/app/features/metadata/metadata.component.spec.ts
+++ b/src/app/features/metadata/metadata.component.spec.ts
@@ -3,6 +3,7 @@ import { MockComponents, MockProvider } from 'ng-mocks';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, Router } from '@angular/router';
+import { UserSelectors } from '@osf/core/store/user/user.selectors';
import { MetadataTabsComponent } from '@osf/shared/components/metadata-tabs/metadata-tabs.component';
import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component';
import { ResourceType } from '@osf/shared/enums/resource-type.enum';
@@ -17,7 +18,7 @@ import { CustomConfirmationServiceMockBuilder } from '@testing/providers/custom-
import { CustomDialogServiceMockBuilder } from '@testing/providers/custom-dialog-provider.mock';
import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock';
import { RouterMockBuilder } from '@testing/providers/router-provider.mock';
-import { provideMockStore } from '@testing/providers/store-provider.mock';
+import { mergeSignalOverrides, provideMockStore, SignalOverride } from '@testing/providers/store-provider.mock';
import { ToastServiceMockBuilder } from '@testing/providers/toast-provider.mock';
import { MetadataAffiliatedInstitutionsComponent } from './components/metadata-affiliated-institutions/metadata-affiliated-institutions.component';
@@ -47,12 +48,23 @@ describe('MetadataComponent', () => {
const mockMetadata = MOCK_PROJECT_METADATA;
const mockResourceId = 'test-resource-id';
- beforeEach(() => {
+ function setup(selectorOverrides?: SignalOverride[]) {
activatedRouteMock = ActivatedRouteMockBuilder.create()
.withId(mockResourceId)
.withData({ resourceType: ResourceType.Project })
.build();
+ const defaultSignals: SignalOverride[] = [
+ { selector: MetadataSelectors.getResourceMetadata, value: mockMetadata },
+ { selector: MetadataSelectors.getLoading, value: false },
+ { selector: MetadataSelectors.getSubmitting, value: false },
+ { selector: MetadataSelectors.getCedarRecords, value: [] },
+ { selector: MetadataSelectors.getCedarTemplates, value: null },
+ { selector: RegistrationProviderSelectors.getBrandedProvider, value: null },
+ { selector: UserSelectors.isProjectReadOnly, value: false },
+ ];
+ const signals = mergeSignalOverrides(defaultSignals, selectorOverrides);
+
Object.defineProperty(activatedRouteMock, 'parent', {
value: {
snapshot: {
@@ -99,27 +111,22 @@ describe('MetadataComponent', () => {
MockProvider(ToastService, toastServiceMock),
MockProvider(CustomConfirmationService, customConfirmationServiceMock),
provideMockStore({
- selectors: [
- { selector: MetadataSelectors.getResourceMetadata, value: mockMetadata },
- { selector: MetadataSelectors.getLoading, value: false },
- { selector: MetadataSelectors.getSubmitting, value: false },
- { selector: MetadataSelectors.getCedarRecords, value: [] },
- { selector: MetadataSelectors.getCedarTemplates, value: null },
- { selector: RegistrationProviderSelectors.getBrandedProvider, value: null },
- ],
+ signals: signals,
}),
],
});
fixture = TestBed.createComponent(MetadataComponent);
component = fixture.componentInstance;
- });
+ }
it('should create', () => {
+ setup();
expect(component).toBeTruthy();
});
it('should handle tab change for OSF tab', () => {
+ setup();
const tabId = 'osf';
const navigateSpy = vi.spyOn(routerMock, 'navigate');
@@ -130,6 +137,7 @@ describe('MetadataComponent', () => {
});
it('should toggle edit mode', () => {
+ setup();
const initialReadonly = component.cedarFormReadonly();
component.toggleEditMode();
@@ -138,12 +146,14 @@ describe('MetadataComponent', () => {
});
it('should handle tags changed', () => {
+ setup();
const tags = ['tag1', 'tag2'];
expect(() => component.onTagsChanged(tags)).not.toThrow();
});
it('should open edit contributor dialog', () => {
+ setup();
const openSpy = vi.spyOn(customDialogServiceMock, 'open');
expect(openSpy).toHaveBeenCalledTimes(0);
@@ -152,6 +162,7 @@ describe('MetadataComponent', () => {
});
it('should open edit title dialog', () => {
+ setup();
const openSpy = vi.spyOn(customDialogServiceMock, 'open');
component.openEditTitleDialog();
@@ -160,6 +171,7 @@ describe('MetadataComponent', () => {
});
it('should open edit description dialog', () => {
+ setup();
const openSpy = vi.spyOn(customDialogServiceMock, 'open');
component.openEditDescriptionDialog();
@@ -168,6 +180,7 @@ describe('MetadataComponent', () => {
});
it('should open edit resource information dialog', () => {
+ setup();
const openSpy = vi.spyOn(customDialogServiceMock, 'open');
component.openEditResourceInformationDialog();
@@ -176,6 +189,7 @@ describe('MetadataComponent', () => {
});
it('should show resource info tooltip', () => {
+ setup();
const openSpy = vi.spyOn(customDialogServiceMock, 'open');
component.onShowResourceInfo();
@@ -184,6 +198,7 @@ describe('MetadataComponent', () => {
});
it('should open edit license dialog', () => {
+ setup();
const openSpy = vi.spyOn(customDialogServiceMock, 'open');
component.openEditLicenseDialog();
@@ -192,6 +207,7 @@ describe('MetadataComponent', () => {
});
it('should open edit funding dialog', () => {
+ setup();
const openSpy = vi.spyOn(customDialogServiceMock, 'open');
component.openEditFundingDialog();
@@ -200,6 +216,7 @@ describe('MetadataComponent', () => {
});
it('should open edit affiliated institutions dialog', () => {
+ setup();
const openSpy = vi.spyOn(customDialogServiceMock, 'open');
component.openEditAffiliatedInstitutionsDialog();
@@ -208,18 +225,21 @@ describe('MetadataComponent', () => {
});
it('should handle subject children fetch', () => {
+ setup();
const parentId = 'parent-subject-id';
expect(() => component.getSubjectChildren(parentId)).not.toThrow();
});
it('should handle subject search', () => {
+ setup();
const searchTerm = 'test search';
expect(() => component.searchSubjects(searchTerm)).not.toThrow();
});
it('should handle edit DOI for project', () => {
+ setup();
const confirmSpy = vi.spyOn(customConfirmationServiceMock, 'confirmDelete');
component.handleEditDoi();
@@ -228,6 +248,7 @@ describe('MetadataComponent', () => {
});
it('should open add record', () => {
+ setup();
const navigateSpy = vi.spyOn(routerMock, 'navigate');
component.openAddRecord();
@@ -236,10 +257,19 @@ describe('MetadataComponent', () => {
});
it('should handle cedar form change template', () => {
+ setup();
const navigateSpy = vi.spyOn(routerMock, 'navigate');
component.onCedarFormChangeTemplate();
expect(navigateSpy).toHaveBeenCalled();
});
+
+ it('should handle isProjectReadOnly', () => {
+ setup([{ selector: UserSelectors.isProjectReadOnly, value: true }]);
+
+ expect(component.isTagsReadOnly()).toBe(true);
+ expect(component.isSubjectsReadOnly()).toBe(true);
+ expect(component.disabledButtonTooltip()).toBe('common.errorMessages.actionUnavailable');
+ });
});
diff --git a/src/app/features/metadata/metadata.component.ts b/src/app/features/metadata/metadata.component.ts
index f82c3fec4..7503ba551 100644
--- a/src/app/features/metadata/metadata.component.ts
+++ b/src/app/features/metadata/metadata.component.ts
@@ -19,6 +19,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router } from '@angular/router';
import { ENVIRONMENT } from '@core/provider/environment.provider';
+import { UserSelectors } from '@osf/core/store/user/user.selectors';
import { MetadataTabsComponent } from '@osf/shared/components/metadata-tabs/metadata-tabs.component';
import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component';
import { MetadataResourceEnum } from '@osf/shared/enums/metadata-resource.enum';
@@ -160,6 +161,8 @@ export class MetadataComponent implements OnInit, OnDestroy {
hasWriteAccess = select(MetadataSelectors.hasWriteAccess);
hasAdminAccess = select(MetadataSelectors.hasAdminAccess);
+ isProjectReadOnly = select(UserSelectors.isProjectReadOnly);
+
provider = this.environment.defaultProvider;
private readonly resourceNameMap = new Map([
@@ -207,8 +210,23 @@ export class MetadataComponent implements OnInit, OnDestroy {
(!!this.metadata()?.identifiers?.length || !this.metadata()?.public)
);
+ isTagsReadOnly = computed(() => {
+ if (this.isProjectReadOnly()) {
+ return true;
+ }
+ return this.isRegistrationType() ? !this.hasAdminAccess() : !this.hasWriteAccess();
+ });
+
+ isSubjectsReadOnly = computed(() => {
+ if (this.isProjectReadOnly()) {
+ return true;
+ }
+ return !this.hasAdminAccess();
+ });
+
isProjectType = computed(() => this.resourceType() === ResourceType.Project);
isRegistrationType = computed(() => this.resourceType() === ResourceType.Registration);
+ disabledButtonTooltip = computed(() => (this.isProjectReadOnly() ? 'common.errorMessages.actionUnavailable' : ''));
constructor() {
effect(() => {
diff --git a/src/app/features/my-projects/my-projects.component.html b/src/app/features/my-projects/my-projects.component.html
index fcb4ca2c3..b35cc624a 100644
--- a/src/app/features/my-projects/my-projects.component.html
+++ b/src/app/features/my-projects/my-projects.component.html
@@ -3,6 +3,8 @@
[showButton]="true"
[buttonLabel]="'myProjects.header.createProject' | translate"
[title]="'myProjects.header.title' | translate"
+ [isButtonDisabled]="projectCreationDisabled()"
+ [buttonTooltip]="buttonTooltip() | translate"
[icon]="'custom-icon-projects'"
(buttonClick)="createProject()"
/>
diff --git a/src/app/features/my-projects/my-projects.component.spec.ts b/src/app/features/my-projects/my-projects.component.spec.ts
index db1259d33..3144bb074 100644
--- a/src/app/features/my-projects/my-projects.component.spec.ts
+++ b/src/app/features/my-projects/my-projects.component.spec.ts
@@ -9,6 +9,7 @@ import { Mock } from 'vitest';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, Router } from '@angular/router';
+import { UserSelectors } from '@osf/core/store/user/user.selectors';
import { MyProjectsTableComponent } from '@osf/shared/components/my-projects-table/my-projects-table.component';
import { SearchInputComponent } from '@osf/shared/components/search-input/search-input.component';
import { SelectComponent } from '@osf/shared/components/select/select.component';
@@ -76,6 +77,7 @@ describe('MyProjectsComponent', () => {
{ selector: BookmarksSelectors.getBookmarks, value: [] },
{ selector: BookmarksSelectors.getBookmarksCollectionId, value: 'bookmark-collection-id' },
{ selector: BookmarksSelectors.getBookmarksTotalCount, value: 0 },
+ { selector: UserSelectors.isProjectCreationDisabled, value: false },
];
function setup(
@@ -133,6 +135,13 @@ describe('MyProjectsComponent', () => {
expect(component).toBeTruthy();
});
+ it('should disable project creation and show tooltip when isProjectCreationDisabled is true', () => {
+ setup([{ selector: UserSelectors.isProjectCreationDisabled, value: true }]);
+
+ expect(component.projectCreationDisabled()).toBe(true);
+ expect(component.buttonTooltip()).toBe('myProjects.header.createProjectDisabledTooltip');
+ });
+
it('should dispatch get bookmarks collection id on init', () => {
setup();
expect(store.dispatch).toHaveBeenCalledWith(new GetBookmarksCollectionId());
diff --git a/src/app/features/my-projects/my-projects.component.ts b/src/app/features/my-projects/my-projects.component.ts
index 8fd1df271..6013c48e5 100644
--- a/src/app/features/my-projects/my-projects.component.ts
+++ b/src/app/features/my-projects/my-projects.component.ts
@@ -25,6 +25,7 @@ import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { FormControl, FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
+import { UserSelectors } from '@osf/core/store/user';
import { MyProjectsTableComponent } from '@osf/shared/components/my-projects-table/my-projects-table.component';
import { SearchInputComponent } from '@osf/shared/components/search-input/search-input.component';
import { SelectComponent } from '@osf/shared/components/select/select.component';
@@ -89,6 +90,7 @@ export class MyProjectsComponent implements OnInit {
readonly downloadOptionsService = inject(ProjectDownloadOptionsService);
readonly platformId = inject(PLATFORM_ID);
readonly isBrowser = isPlatformBrowser(this.platformId);
+ readonly projectCreationDisabled = select(UserSelectors.isProjectCreationDisabled);
readonly isLoading = signal(false);
readonly isMedium = toSignal(inject(IS_MEDIUM));
@@ -134,6 +136,9 @@ export class MyProjectsComponent implements OnInit {
readonly bookmarksCollectionId = select(BookmarksSelectors.getBookmarksCollectionId);
readonly totalBookmarksCount = select(BookmarksSelectors.getBookmarksTotalCount);
readonly isBookmarks = computed(() => this.selectedTab() === MyProjectsTab.Bookmarks);
+ readonly buttonTooltip = computed(() =>
+ this.projectCreationDisabled() ? 'myProjects.header.createProjectDisabledTooltip' : ''
+ );
readonly actions = createDispatchMap({
getBookmarksCollectionId: GetBookmarksCollectionId,
diff --git a/src/app/features/preprints/components/stepper/review-step/review-step.component.html b/src/app/features/preprints/components/stepper/review-step/review-step.component.html
index ae47fd780..325ee602d 100644
--- a/src/app/features/preprints/components/stepper/review-step/review-step.component.html
+++ b/src/app/features/preprints/components/stepper/review-step/review-step.component.html
@@ -220,16 +220,18 @@
}
-
-
-
{{ 'preprints.preprintStepper.review.sections.supplements.title' | translate }}
- @if (preprintProject()) {
-
{{ preprintProject()?.name }}
- } @else {
-
{{ 'preprints.preprintStepper.review.sections.supplements.noSupplements' | translate }}
- }
-
-
+@if (!isProjectCreationDisabled()) {
+
+
+
{{ 'preprints.preprintStepper.review.sections.supplements.title' | translate }}
+ @if (preprintProject()) {
+
{{ preprintProject()?.name }}
+ } @else {
+
{{ 'preprints.preprintStepper.review.sections.supplements.noSupplements' | translate }}
+ }
+
+
+}
(this.preprint()?.licenseOptions ?? {}) as Record);
readonly ApplicabilityStatus = ApplicabilityStatus;
diff --git a/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.html b/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.html
index be6d06524..9c3beb310 100644
--- a/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.html
+++ b/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.html
@@ -25,6 +25,8 @@ {{ 'preprints.preprintStepper.supplements.title' | translate }}
styleClass="w-full"
[label]="'preprints.preprintStepper.supplements.options.createNew' | translate"
severity="secondary"
+ [disabled]="createProjectDisabled()"
+ [pTooltip]="createProjectTooltip() | translate"
(onClick)="selectSupplementOption(SupplementOptions.CreateNewProject)"
/>
diff --git a/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.spec.ts b/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.spec.ts
index 6275e16a1..40fece834 100644
--- a/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.spec.ts
+++ b/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.spec.ts
@@ -6,6 +6,7 @@ import { Mock } from 'vitest';
import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { UserSelectors } from '@osf/core/store/user/user.selectors';
import { SupplementOptions } from '@osf/features/preprints/enums';
import {
ConnectProject,
@@ -46,6 +47,7 @@ describe('SupplementsStepComponent', () => {
{ selector: PreprintStepperSelectors.areAvailableProjectsLoading, value: false },
{ selector: PreprintStepperSelectors.getPreprintProject, value: null },
{ selector: PreprintStepperSelectors.isPreprintProjectLoading, value: false },
+ { selector: UserSelectors.isProjectCreationDisabled, value: false },
];
function setup(overrides?: { selectorOverrides?: SignalOverride[]; detectChanges?: boolean }) {
@@ -359,4 +361,13 @@ describe('SupplementsStepComponent', () => {
component.selectedSupplementOption.set(SupplementOptions.ConnectExistingProject);
expect(component.isNextButtonDisabled()).toBe(false);
});
+
+ it('should compute create project disabled state based on isProjectCreationDisabled', () => {
+ setup({
+ selectorOverrides: [{ selector: UserSelectors.isProjectCreationDisabled, value: true }],
+ detectChanges: false,
+ });
+
+ expect(component.createProjectTooltip()).toBe('preprints.preprintStepper.supplements.projectCreationDisabled');
+ });
});
diff --git a/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.ts b/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.ts
index 4eaf843ee..7d910efa6 100644
--- a/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.ts
+++ b/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.ts
@@ -6,6 +6,7 @@ import { Button } from 'primeng/button';
import { Card } from 'primeng/card';
import { Select, SelectChangeEvent } from 'primeng/select';
import { Skeleton } from 'primeng/skeleton';
+import { Tooltip } from 'primeng/tooltip';
import { debounceTime, distinctUntilChanged, map } from 'rxjs';
@@ -26,6 +27,7 @@ import {
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
+import { UserSelectors } from '@osf/core/store/user';
import { SupplementOptions } from '@osf/features/preprints/enums';
import {
ConnectProject,
@@ -45,7 +47,17 @@ import { ProjectForm } from '@shared/models/projects/create-project-form.model';
@Component({
selector: 'osf-supplements-step',
- imports: [Button, NgClass, Card, Select, AddProjectFormComponent, ReactiveFormsModule, Skeleton, TranslatePipe],
+ imports: [
+ Button,
+ NgClass,
+ Card,
+ Select,
+ AddProjectFormComponent,
+ ReactiveFormsModule,
+ Skeleton,
+ Tooltip,
+ TranslatePipe,
+ ],
templateUrl: './supplements-step.component.html',
styleUrl: './supplements-step.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -69,6 +81,7 @@ export class SupplementsStepComponent implements OnInit {
readonly areAvailableProjectsLoading = select(PreprintStepperSelectors.areAvailableProjectsLoading);
readonly preprintProject = select(PreprintStepperSelectors.getPreprintProject);
readonly isPreprintProjectLoading = select(PreprintStepperSelectors.isPreprintProjectLoading);
+ readonly createProjectDisabled = select(UserSelectors.isProjectCreationDisabled);
selectedSupplementOption = signal(SupplementOptions.None);
selectedProjectId = signal(null);
@@ -113,6 +126,10 @@ export class SupplementsStepComponent implements OnInit {
return false;
});
+ createProjectTooltip = computed(() =>
+ this.createProjectDisabled() ? 'preprints.preprintStepper.supplements.projectCreationDisabled' : ''
+ );
+
constructor() {
effect(() => {
const preprint = this.createdPreprint();
diff --git a/src/app/features/preprints/pages/submit-preprint-stepper/submit-preprint-stepper.component.spec.ts b/src/app/features/preprints/pages/submit-preprint-stepper/submit-preprint-stepper.component.spec.ts
index cb16d3bc0..43e6ed972 100644
--- a/src/app/features/preprints/pages/submit-preprint-stepper/submit-preprint-stepper.component.spec.ts
+++ b/src/app/features/preprints/pages/submit-preprint-stepper/submit-preprint-stepper.component.spec.ts
@@ -7,6 +7,7 @@ import { of } from 'rxjs';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute } from '@angular/router';
+import { UserSelectors } from '@osf/core/store/user/user.selectors';
import { StepperComponent } from '@osf/shared/components/stepper/stepper.component';
import { IS_WEB } from '@osf/shared/helpers/breakpoints.tokens';
import { BrandService } from '@osf/shared/services/brand.service';
@@ -56,6 +57,7 @@ describe('SubmitPreprintStepperComponent', () => {
{ selector: PreprintProvidersSelectors.getPreprintProviderDetails(mockProviderId), value: mockProvider },
{ selector: PreprintProvidersSelectors.isPreprintProviderDetailsLoading, value: false },
{ selector: PreprintStepperSelectors.hasBeenSubmitted, value: false },
+ { selector: UserSelectors.isProjectCreationDisabled, value: false },
];
function setup(overrides?: { selectorOverrides?: SignalOverride[] }) {
@@ -168,6 +170,22 @@ describe('SubmitPreprintStepperComponent', () => {
expect(stepValues).toContain(PreprintSteps.AuthorAssertions);
});
+ it('should filter out Supplements step when supplements are disabled via isProjectCreationDisabled', () => {
+ setup({
+ selectorOverrides: [{ selector: UserSelectors.isProjectCreationDisabled, value: true }],
+ });
+
+ const stepValues = component.steps().map((s) => s.value);
+ expect(stepValues).not.toContain(PreprintSteps.Supplements);
+ });
+
+ it('should include Supplements step when supplements are enabled via isProjectCreationDisabled', () => {
+ setup();
+
+ const stepValues = component.steps().map((s) => s.value);
+ expect(stepValues).toContain(PreprintSteps.Supplements);
+ });
+
it('should re-index steps sequentially', () => {
setup();
diff --git a/src/app/features/preprints/pages/submit-preprint-stepper/submit-preprint-stepper.component.ts b/src/app/features/preprints/pages/submit-preprint-stepper/submit-preprint-stepper.component.ts
index 65a608c74..a9f4188e9 100644
--- a/src/app/features/preprints/pages/submit-preprint-stepper/submit-preprint-stepper.component.ts
+++ b/src/app/features/preprints/pages/submit-preprint-stepper/submit-preprint-stepper.component.ts
@@ -21,6 +21,7 @@ import {
import { toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute } from '@angular/router';
+import { UserSelectors } from '@osf/core/store/user/user.selectors';
import { StepperComponent } from '@osf/shared/components/stepper/stepper.component';
import { IS_WEB } from '@osf/shared/helpers/breakpoints.tokens';
import { CanDeactivateComponent } from '@osf/shared/models/can-deactivate.interface';
@@ -79,6 +80,7 @@ export class SubmitPreprintStepperComponent implements OnDestroy, CanDeactivateC
preprintProvider = select(PreprintProvidersSelectors.getPreprintProviderDetails(this.providerId()));
isPreprintProviderLoading = select(PreprintProvidersSelectors.isPreprintProviderDetailsLoading);
hasBeenSubmitted = select(PreprintStepperSelectors.hasBeenSubmitted);
+ supplementsDisabled = select(UserSelectors.isProjectCreationDisabled);
currentStep = signal(submitPreprintSteps[0]);
@@ -94,7 +96,12 @@ export class SubmitPreprintStepperComponent implements OnDestroy, CanDeactivateC
}
return submitPreprintSteps
- .filter((step) => step.value !== PreprintSteps.AuthorAssertions || provider.assertionsEnabled)
+ .filter((step) => {
+ return (
+ (step.value !== PreprintSteps.AuthorAssertions || provider.assertionsEnabled) &&
+ (step.value !== PreprintSteps.Supplements || !this.supplementsDisabled())
+ );
+ })
.map((step, index) => ({ ...step, index }));
});
diff --git a/src/app/features/preprints/pages/update-preprint-stepper/update-preprint-stepper.component.spec.ts b/src/app/features/preprints/pages/update-preprint-stepper/update-preprint-stepper.component.spec.ts
index e35e056c7..be59800d6 100644
--- a/src/app/features/preprints/pages/update-preprint-stepper/update-preprint-stepper.component.spec.ts
+++ b/src/app/features/preprints/pages/update-preprint-stepper/update-preprint-stepper.component.spec.ts
@@ -7,6 +7,7 @@ import { of } from 'rxjs';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute } from '@angular/router';
+import { UserSelectors } from '@osf/core/store/user/user.selectors';
import { StepperComponent } from '@osf/shared/components/stepper/stepper.component';
import { IS_WEB } from '@osf/shared/helpers/breakpoints.tokens';
import { BrandService } from '@osf/shared/services/brand.service';
@@ -55,6 +56,7 @@ describe('UpdatePreprintStepperComponent', () => {
{ selector: PreprintStepperSelectors.getPreprint, value: mockPreprint },
{ selector: PreprintStepperSelectors.hasBeenSubmitted, value: false },
{ selector: PreprintStepperSelectors.hasAdminAccess, value: false },
+ { selector: UserSelectors.isProjectCreationDisabled, value: false },
];
function setup(overrides?: { selectorOverrides?: SignalOverride[] }) {
@@ -150,6 +152,26 @@ describe('UpdatePreprintStepperComponent', () => {
expect(stepValues).toContain(PreprintSteps.Review);
});
+ it('should filter out Supplements step when isProjectCreationDisabled is true', () => {
+ setup({
+ selectorOverrides: [{ selector: UserSelectors.isProjectCreationDisabled, value: true }],
+ });
+
+ const steps = component.updateSteps();
+ const stepValues = steps.map((s) => s.value);
+
+ expect(stepValues).not.toContain(PreprintSteps.Supplements);
+ });
+
+ it('should include Supplements step when isProjectCreationDisabled is false', () => {
+ setup();
+
+ const steps = component.updateSteps();
+ const stepValues = steps.map((s) => s.value);
+
+ expect(stepValues).toContain(PreprintSteps.Supplements);
+ });
+
it('should re-index steps sequentially', () => {
setup();
diff --git a/src/app/features/preprints/pages/update-preprint-stepper/update-preprint-stepper.component.ts b/src/app/features/preprints/pages/update-preprint-stepper/update-preprint-stepper.component.ts
index c288af3f4..5be1a80b7 100644
--- a/src/app/features/preprints/pages/update-preprint-stepper/update-preprint-stepper.component.ts
+++ b/src/app/features/preprints/pages/update-preprint-stepper/update-preprint-stepper.component.ts
@@ -20,6 +20,7 @@ import {
import { toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute } from '@angular/router';
+import { UserSelectors } from '@osf/core/store/user/user.selectors';
import { StepperComponent } from '@osf/shared/components/stepper/stepper.component';
import { IS_WEB } from '@osf/shared/helpers/breakpoints.tokens';
import { BrandService } from '@osf/shared/services/brand.service';
@@ -78,6 +79,7 @@ export class UpdatePreprintStepperComponent implements OnDestroy, CanDeactivateC
readonly isPreprintProviderLoading = select(PreprintProvidersSelectors.isPreprintProviderDetailsLoading);
readonly hasBeenSubmitted = select(PreprintStepperSelectors.hasBeenSubmitted);
readonly hasAdminAccess = select(PreprintStepperSelectors.hasAdminAccess);
+ readonly supplementsDisabled = select(UserSelectors.isProjectCreationDisabled);
readonly isWeb = toSignal(inject(IS_WEB));
@@ -108,6 +110,9 @@ export class UpdatePreprintStepperComponent implements OnDestroy, CanDeactivateC
if (step.value === PreprintSteps.AuthorAssertions) {
return provider.assertionsEnabled && this.hasAdminAccess();
}
+ if (step.value === PreprintSteps.Supplements) {
+ return !this.supplementsDisabled();
+ }
return true;
})
.map((step, index) => ({ ...step, index }));
diff --git a/src/app/features/project/overview/components/fork-dialog/fork-dialog.component.spec.ts b/src/app/features/project/overview/components/fork-dialog/fork-dialog.component.spec.ts
index 78ef81dc7..6389d4f8a 100644
--- a/src/app/features/project/overview/components/fork-dialog/fork-dialog.component.spec.ts
+++ b/src/app/features/project/overview/components/fork-dialog/fork-dialog.component.spec.ts
@@ -4,10 +4,11 @@ import { MockProvider } from 'ng-mocks';
import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog';
-import { EMPTY } from 'rxjs';
+import { throwError } from 'rxjs';
import { Mock } from 'vitest';
+import { HttpErrorResponse } from '@angular/common/http';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ResourceType } from '@osf/shared/enums/resource-type.enum';
@@ -105,18 +106,21 @@ describe('ForkDialogComponent', () => {
component.handleForkConfirm();
expect(store.dispatch).toHaveBeenCalledWith(new ForkResource('project-1', ResourceType.Project));
- expect(dialogRef.close).toHaveBeenCalledWith({ success: true });
+ expect(dialogRef.close).toHaveBeenCalledWith();
expect(toastService.showSuccess).toHaveBeenCalledWith('project.overview.dialog.toast.fork.success');
});
- it('should still close dialog and show toast when fork action errors', () => {
+ it('should keep dialog open and show toast when fork action errors', () => {
+ const errorDetail = 'Fork creation failed';
setup({ resourceId: 'project-1', resourceType: ResourceType.Project });
(store.dispatch as Mock).mockClear();
- (store.dispatch as Mock).mockReturnValueOnce(EMPTY);
+ (store.dispatch as Mock).mockReturnValueOnce(
+ throwError(() => new HttpErrorResponse({ status: 405, error: { errors: [{ detail: errorDetail }] } }))
+ );
component.handleForkConfirm();
expect(store.dispatch).toHaveBeenCalledWith(new ForkResource('project-1', ResourceType.Project));
- expect(dialogRef.close).toHaveBeenCalledWith({ success: true });
- expect(toastService.showSuccess).toHaveBeenCalledWith('project.overview.dialog.toast.fork.success');
+ expect(dialogRef.close).callCount(0);
+ expect(toastService.showError).toHaveBeenCalledWith(errorDetail);
});
});
diff --git a/src/app/features/project/overview/components/fork-dialog/fork-dialog.component.ts b/src/app/features/project/overview/components/fork-dialog/fork-dialog.component.ts
index da9e17296..495600c66 100644
--- a/src/app/features/project/overview/components/fork-dialog/fork-dialog.component.ts
+++ b/src/app/features/project/overview/components/fork-dialog/fork-dialog.component.ts
@@ -5,7 +5,8 @@ import { TranslatePipe } from '@ngx-translate/core';
import { Button } from 'primeng/button';
import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog';
-import { finalize } from 'rxjs';
+import { EMPTY } from 'rxjs';
+import { catchError } from 'rxjs/operators';
import { ChangeDetectionStrategy, Component, DestroyRef, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@@ -42,11 +43,16 @@ export class ForkDialogComponent {
.forkResource(resourceId, resourceType)
.pipe(
takeUntilDestroyed(this.destroyRef),
- finalize(() => {
- this.dialogRef.close({ success: true });
- this.toastService.showSuccess('project.overview.dialog.toast.fork.success');
+ catchError((e) => {
+ this.toastService.showError(e.error.errors[0].detail);
+ return EMPTY;
})
)
- .subscribe();
+ .subscribe({
+ next: () => {
+ this.dialogRef.close();
+ this.toastService.showSuccess('project.overview.dialog.toast.fork.success');
+ },
+ });
}
}
diff --git a/src/app/features/project/overview/components/linked-resources/linked-resources.component.html b/src/app/features/project/overview/components/linked-resources/linked-resources.component.html
index dd7f7b2c1..552509480 100644
--- a/src/app/features/project/overview/components/linked-resources/linked-resources.component.html
+++ b/src/app/features/project/overview/components/linked-resources/linked-resources.component.html
@@ -6,6 +6,8 @@ {{ 'project.overview.linkedProjects.title' | translate }}
severity="secondary"
[label]="'project.overview.components.linkProjectsButton' | translate"
(onClick)="openLinkProjectModal()"
+ [disabled]="isProjectReadOnly()"
+ [pTooltip]="disabledButtonTooltip() | translate"
/>
}
diff --git a/src/app/features/project/overview/components/linked-resources/linked-resources.component.spec.ts b/src/app/features/project/overview/components/linked-resources/linked-resources.component.spec.ts
index a316b29da..00a0a030f 100644
--- a/src/app/features/project/overview/components/linked-resources/linked-resources.component.spec.ts
+++ b/src/app/features/project/overview/components/linked-resources/linked-resources.component.spec.ts
@@ -2,6 +2,7 @@ import { MockComponents, MockProvider } from 'ng-mocks';
import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { UserSelectors } from '@osf/core/store/user/user.selectors';
import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component';
import { IconComponent } from '@osf/shared/components/icon/icon.component';
import { CustomDialogService } from '@osf/shared/services/custom-dialog.service';
@@ -10,7 +11,7 @@ import { NodeLinksSelectors } from '@osf/shared/stores/node-links';
import { MOCK_NODE_WITH_ADMIN } from '@testing/mocks/node.mock';
import { provideOSFCore } from '@testing/osf.testing.provider';
import { CustomDialogServiceMockBuilder } from '@testing/providers/custom-dialog-provider.mock';
-import { provideMockStore } from '@testing/providers/store-provider.mock';
+import { mergeSignalOverrides, provideMockStore, SignalOverride } from '@testing/providers/store-provider.mock';
import { ProjectOverviewSelectors } from '../../store';
import { DeleteNodeLinkDialogComponent } from '../delete-node-link-dialog/delete-node-link-dialog.component';
@@ -29,21 +30,24 @@ describe('LinkedProjectsComponent', () => {
{ ...MOCK_NODE_WITH_ADMIN, id: 'resource-3', title: 'Linked Resource 3' },
];
- beforeEach(() => {
+ function setup(selectorOverrides?: SignalOverride[]) {
customDialogServiceMock = CustomDialogServiceMockBuilder.create().withDefaultOpen().build();
+ const defaultSignals: SignalOverride[] = [
+ { selector: NodeLinksSelectors.getLinkedResources, value: mockLinkedResources },
+ { selector: NodeLinksSelectors.getLinkedResourcesLoading, value: false },
+ { selector: NodeLinksSelectors.hasMoreLinkedResources, value: false },
+ { selector: NodeLinksSelectors.isLoadingMoreLinkedResources, value: false },
+ { selector: ProjectOverviewSelectors.getProject, value: MOCK_NODE_WITH_ADMIN },
+ { selector: UserSelectors.isProjectReadOnly, value: false },
+ ];
+ const signals = mergeSignalOverrides(defaultSignals, selectorOverrides);
TestBed.configureTestingModule({
imports: [LinkedResourcesComponent, ...MockComponents(IconComponent, ContributorsListComponent)],
providers: [
provideOSFCore(),
provideMockStore({
- signals: [
- { selector: NodeLinksSelectors.getLinkedResources, value: mockLinkedResources },
- { selector: NodeLinksSelectors.getLinkedResourcesLoading, value: false },
- { selector: NodeLinksSelectors.hasMoreLinkedResources, value: false },
- { selector: NodeLinksSelectors.isLoadingMoreLinkedResources, value: false },
- { selector: ProjectOverviewSelectors.getProject, value: MOCK_NODE_WITH_ADMIN },
- ],
+ signals: signals,
}),
MockProvider(CustomDialogService, customDialogServiceMock),
],
@@ -53,9 +57,10 @@ describe('LinkedProjectsComponent', () => {
component = fixture.componentInstance;
fixture.componentRef.setInput('canEdit', true);
fixture.detectChanges();
- });
+ }
it('should open LinkResourceDialogComponent with correct config', () => {
+ setup();
component.openLinkProjectModal();
expect(customDialogServiceMock.open).toHaveBeenCalledWith(LinkResourceDialogComponent, {
@@ -66,6 +71,7 @@ describe('LinkedProjectsComponent', () => {
});
it('should find resource by id and open DeleteNodeLinkDialogComponent with correct config when resource exists', () => {
+ setup();
component.openDeleteResourceModal('resource-2');
expect(customDialogServiceMock.open).toHaveBeenCalledWith(DeleteNodeLinkDialogComponent, {
@@ -76,10 +82,19 @@ describe('LinkedProjectsComponent', () => {
});
it('should return early and not open dialog when resource is not found', () => {
+ setup();
customDialogServiceMock.open.mockClear();
component.openDeleteResourceModal('non-existent-id');
expect(customDialogServiceMock.open).not.toHaveBeenCalled();
});
+
+ it('should return disabledButtonTooltip based on isProjectReadOnly', () => {
+ setup();
+ expect(component.disabledButtonTooltip()).toBe('');
+
+ setup([{ selector: UserSelectors.isProjectReadOnly, value: true }]);
+ expect(component.disabledButtonTooltip()).toBe('common.errorMessages.actionUnavailable');
+ });
});
diff --git a/src/app/features/project/overview/components/linked-resources/linked-resources.component.ts b/src/app/features/project/overview/components/linked-resources/linked-resources.component.ts
index cd1d88944..f78fe4801 100644
--- a/src/app/features/project/overview/components/linked-resources/linked-resources.component.ts
+++ b/src/app/features/project/overview/components/linked-resources/linked-resources.component.ts
@@ -4,12 +4,14 @@ import { TranslatePipe } from '@ngx-translate/core';
import { Button } from 'primeng/button';
import { Skeleton } from 'primeng/skeleton';
+import { Tooltip } from 'primeng/tooltip';
import { filter } from 'rxjs';
-import { ChangeDetectionStrategy, Component, DestroyRef, inject, input } from '@angular/core';
+import { ChangeDetectionStrategy, Component, computed, DestroyRef, inject, input } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+import { UserSelectors } from '@osf/core/store/user/user.selectors';
import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component';
import { IconComponent } from '@osf/shared/components/icon/icon.component';
import { TruncatedTextComponent } from '@osf/shared/components/truncated-text/truncated-text.component';
@@ -22,7 +24,7 @@ import { LinkResourceDialogComponent } from '../link-resource-dialog/link-resour
@Component({
selector: 'osf-linked-resources',
- imports: [Button, Skeleton, TranslatePipe, TruncatedTextComponent, IconComponent, ContributorsListComponent],
+ imports: [Button, Skeleton, Tooltip, TranslatePipe, TruncatedTextComponent, IconComponent, ContributorsListComponent],
templateUrl: './linked-resources.component.html',
styleUrl: './linked-resources.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -38,6 +40,11 @@ export class LinkedResourcesComponent {
hasMoreLinkedResources = select(NodeLinksSelectors.hasMoreLinkedResources);
isLoadingMoreLinkedResources = select(NodeLinksSelectors.isLoadingMoreLinkedResources);
currentProject = select(ProjectOverviewSelectors.getProject);
+ isProjectReadOnly = select(UserSelectors.isProjectReadOnly);
+
+ readonly disabledButtonTooltip = computed(() =>
+ this.isProjectReadOnly() ? 'common.errorMessages.actionUnavailable' : ''
+ );
private readonly actions = createDispatchMap({
getLinkedResources: GetLinkedResources,
diff --git a/src/app/features/project/overview/components/overview-components/overview-components.component.html b/src/app/features/project/overview/components/overview-components/overview-components.component.html
index ff16cb4f4..3a0712b54 100644
--- a/src/app/features/project/overview/components/overview-components/overview-components.component.html
+++ b/src/app/features/project/overview/components/overview-components/overview-components.component.html
@@ -7,6 +7,8 @@ {{ 'project.overview.components.title' | translate }}
(onClick)="handleAddComponent()"
severity="secondary"
[label]="'project.overview.components.addComponentButton' | translate"
+ [disabled]="preventComponentCreation()"
+ [pTooltip]="createComponentTooltip() | translate"
/>
}
diff --git a/src/app/features/project/overview/components/overview-components/overview-components.component.spec.ts b/src/app/features/project/overview/components/overview-components/overview-components.component.spec.ts
index 40dfea920..0b84dfb28 100644
--- a/src/app/features/project/overview/components/overview-components/overview-components.component.spec.ts
+++ b/src/app/features/project/overview/components/overview-components/overview-components.component.spec.ts
@@ -8,6 +8,7 @@ import { CdkDragDrop } from '@angular/cdk/drag-drop';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
+import { UserSelectors } from '@osf/core/store/user/user.selectors';
import { ResourceType } from '@osf/shared/enums/resource-type.enum';
import { NodeModel } from '@osf/shared/models/nodes/base-node.model';
import { CustomDialogService } from '@osf/shared/services/custom-dialog.service';
@@ -20,7 +21,7 @@ import { provideOSFCore } from '@testing/osf.testing.provider';
import { CustomDialogServiceMockBuilder } from '@testing/providers/custom-dialog-provider.mock';
import { LoaderServiceMock } from '@testing/providers/loader-service.mock';
import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock';
-import { provideMockStore } from '@testing/providers/store-provider.mock';
+import { BaseSetupOverrides, mergeSignalOverrides, provideMockStore } from '@testing/providers/store-provider.mock';
import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock';
import { LoadMoreComponents, ProjectOverviewSelectors, ReorderComponents } from '../../store';
@@ -30,6 +31,10 @@ import { DeleteComponentDialogComponent } from '../delete-component-dialog/delet
import { OverviewComponentsComponent } from './overview-components.component';
+interface SetupOverrides extends BaseSetupOverrides {
+ selectors?: any[];
+}
+
describe('OverviewComponentsComponent', () => {
let component: OverviewComponentsComponent;
let fixture: ComponentFixture;
@@ -48,12 +53,22 @@ describe('OverviewComponentsComponent', () => {
rootParentId: 'root-1',
};
- beforeEach(() => {
+ function setup(overrides: SetupOverrides = {}) {
routerMock = RouterMockBuilder.create().build();
customDialogService = CustomDialogServiceMockBuilder.create().build();
loaderService = new LoaderServiceMock();
toastService = ToastServiceMock.simple();
+ const defaultSelectors = [
+ { selector: ProjectOverviewSelectors.getComponents, value: components },
+ { selector: ProjectOverviewSelectors.getComponentsLoading, value: false },
+ { selector: ProjectOverviewSelectors.getComponentsSubmitting, value: false },
+ { selector: ProjectOverviewSelectors.hasMoreComponents, value: true },
+ { selector: ProjectOverviewSelectors.getProject, value: project },
+ { selector: UserSelectors.isProjectCreationDisabled, value: false },
+ ];
+ const signals = mergeSignalOverrides(defaultSelectors, overrides.selectors);
+
TestBed.configureTestingModule({
imports: [OverviewComponentsComponent, MockComponent(ComponentCardComponent)],
providers: [
@@ -63,13 +78,7 @@ describe('OverviewComponentsComponent', () => {
MockProvider(LoaderService, loaderService),
MockProvider(ToastService, toastService),
provideMockStore({
- signals: [
- { selector: ProjectOverviewSelectors.getComponents, value: components },
- { selector: ProjectOverviewSelectors.getComponentsLoading, value: false },
- { selector: ProjectOverviewSelectors.getComponentsSubmitting, value: false },
- { selector: ProjectOverviewSelectors.hasMoreComponents, value: true },
- { selector: ProjectOverviewSelectors.getProject, value: project },
- ],
+ signals,
}),
],
});
@@ -79,17 +88,20 @@ describe('OverviewComponentsComponent', () => {
component = fixture.componentInstance;
fixture.componentRef.setInput('canEdit', true);
fixture.detectChanges();
- });
+ }
it('should create', () => {
+ setup();
expect(component).toBeTruthy();
});
it('should initialize reorderedComponents from components selector', () => {
+ setup();
expect(component.reorderedComponents()).toEqual(components);
});
it('should open add component dialog', () => {
+ setup();
component.handleAddComponent();
expect(customDialogService.open).toHaveBeenCalledWith(AddComponentDialogComponent, {
@@ -99,18 +111,21 @@ describe('OverviewComponentsComponent', () => {
});
it('should navigate for manageContributors action', () => {
+ setup();
component.handleMenuAction('manageContributors', 'comp-a');
expect(routerMock.navigate).toHaveBeenCalledWith(['comp-a', 'contributors']);
});
it('should navigate for settings action', () => {
+ setup();
component.handleMenuAction('settings', 'comp-a');
expect(routerMock.navigate).toHaveBeenCalledWith(['comp-a', 'settings']);
});
it('should open delete component dialog through delete menu action', () => {
+ setup();
component.handleMenuAction('delete', 'comp-a');
expect(loaderService.show).toHaveBeenCalled();
@@ -124,6 +139,7 @@ describe('OverviewComponentsComponent', () => {
});
it('should open component url in same tab on navigate', () => {
+ setup();
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
vi.spyOn(routerMock, 'createUrlTree').mockReturnValue({} as any);
vi.spyOn(routerMock, 'serializeUrl').mockReturnValue('/comp-a');
@@ -135,6 +151,7 @@ describe('OverviewComponentsComponent', () => {
});
it('should dispatch load more components when project exists', () => {
+ setup();
(store.dispatch as Mock).mockClear();
component.loadMoreComponents();
@@ -143,6 +160,7 @@ describe('OverviewComponentsComponent', () => {
});
it('should reorder components and dispatch reorder action', () => {
+ setup();
(store.dispatch as Mock).mockClear();
const event = { previousIndex: 0, currentIndex: 1 } as CdkDragDrop;
@@ -154,6 +172,7 @@ describe('OverviewComponentsComponent', () => {
});
it('should not reorder when canEdit is false', () => {
+ setup();
fixture.componentRef.setInput('canEdit', false);
fixture.detectChanges();
(store.dispatch as Mock).mockClear();
@@ -163,4 +182,13 @@ describe('OverviewComponentsComponent', () => {
expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(ReorderComponents));
});
+
+ it('should disable add component button and show tooltip when isProjectCreationDisabled flag is true', () => {
+ setup({
+ selectors: [{ selector: UserSelectors.isProjectCreationDisabled, value: true }],
+ });
+
+ expect(component.preventComponentCreation()).toBe(true);
+ expect(component.createComponentTooltip()).toBe('project.overview.components.addComponentDisabled');
+ });
});
diff --git a/src/app/features/project/overview/components/overview-components/overview-components.component.ts b/src/app/features/project/overview/components/overview-components/overview-components.component.ts
index 0ab0bdfd4..e7b7bafd4 100644
--- a/src/app/features/project/overview/components/overview-components/overview-components.component.ts
+++ b/src/app/features/project/overview/components/overview-components/overview-components.component.ts
@@ -4,11 +4,13 @@ import { TranslatePipe } from '@ngx-translate/core';
import { Button } from 'primeng/button';
import { Skeleton } from 'primeng/skeleton';
+import { Tooltip } from 'primeng/tooltip';
import { CdkDrag, CdkDragDrop, CdkDropList, moveItemInArray } from '@angular/cdk/drag-drop';
import { ChangeDetectionStrategy, Component, computed, effect, inject, input, signal } from '@angular/core';
import { Router } from '@angular/router';
+import { UserSelectors } from '@osf/core/store/user';
import { ResourceType } from '@osf/shared/enums/resource-type.enum';
import { NodeModel } from '@osf/shared/models/nodes/base-node.model';
import { CustomDialogService } from '@osf/shared/services/custom-dialog.service';
@@ -23,7 +25,7 @@ import { DeleteComponentDialogComponent } from '../delete-component-dialog/delet
@Component({
selector: 'osf-project-components',
- imports: [Button, CdkDrag, CdkDropList, Skeleton, TranslatePipe, ComponentCardComponent],
+ imports: [Button, CdkDrag, CdkDropList, Skeleton, Tooltip, TranslatePipe, ComponentCardComponent],
templateUrl: './overview-components.component.html',
styleUrl: './overview-components.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -42,6 +44,7 @@ export class OverviewComponentsComponent {
isComponentsSubmitting = select(ProjectOverviewSelectors.getComponentsSubmitting);
hasMoreComponents = select(ProjectOverviewSelectors.hasMoreComponents);
project = select(ProjectOverviewSelectors.getProject);
+ preventComponentCreation = select(UserSelectors.isProjectCreationDisabled);
reorderedComponents = signal([]);
@@ -55,6 +58,10 @@ export class OverviewComponentsComponent {
() => this.isComponentsSubmitting() || (!this.canEdit() && this.reorderedComponents().length <= 1)
);
+ createComponentTooltip = computed(() =>
+ this.preventComponentCreation() ? 'project.overview.components.addComponentDisabled' : ''
+ );
+
constructor() {
effect(() => {
const componentsData = this.components();
@@ -77,6 +84,8 @@ export class OverviewComponentsComponent {
}
handleAddComponent(): void {
+ if (this.preventComponentCreation()) return;
+
this.customDialogService.open(AddComponentDialogComponent, {
header: 'project.overview.dialog.addComponent.header',
width: '850px',
diff --git a/src/app/features/project/overview/components/overview-wiki/overview-wiki.component.html b/src/app/features/project/overview/components/overview-wiki/overview-wiki.component.html
index c4b0bad38..d48afc456 100644
--- a/src/app/features/project/overview/components/overview-wiki/overview-wiki.component.html
+++ b/src/app/features/project/overview/components/overview-wiki/overview-wiki.component.html
@@ -7,6 +7,8 @@ {{ 'project.overview.wiki.title' | translate }}
severity="secondary"
[label]="'common.buttons.edit' | translate"
(onClick)="navigateToWiki()"
+ [disabled]="isProjectReadOnly()"
+ [pTooltip]="disabledButtonTooltip() | translate"
data-test-edit-wiki-button
>
}
diff --git a/src/app/features/project/overview/components/overview-wiki/overview-wiki.component.spec.ts b/src/app/features/project/overview/components/overview-wiki/overview-wiki.component.spec.ts
index 06fff3e1e..72e3232fa 100644
--- a/src/app/features/project/overview/components/overview-wiki/overview-wiki.component.spec.ts
+++ b/src/app/features/project/overview/components/overview-wiki/overview-wiki.component.spec.ts
@@ -3,13 +3,14 @@ import { MockComponents, MockProvider } from 'ng-mocks';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
+import { UserSelectors } from '@osf/core/store/user/user.selectors';
import { MarkdownComponent } from '@osf/shared/components/markdown/markdown.component';
import { TruncatedTextComponent } from '@osf/shared/components/truncated-text/truncated-text.component';
import { WikiSelectors } from '@osf/shared/stores/wiki';
import { provideOSFCore } from '@testing/osf.testing.provider';
import { RouterMockBuilder } from '@testing/providers/router-provider.mock';
-import { provideMockStore } from '@testing/providers/store-provider.mock';
+import { mergeSignalOverrides, provideMockStore, SignalOverride } from '@testing/providers/store-provider.mock';
import { OverviewWikiComponent } from './overview-wiki.component';
@@ -20,18 +21,21 @@ describe('OverviewWikiComponent', () => {
const mockResourceId = 'project-123';
- beforeEach(() => {
+ function setup(signalOverrides?: SignalOverride[]) {
routerMock = RouterMockBuilder.create().build();
+ const defaultSignals = [
+ { selector: WikiSelectors.getHomeWikiLoading, value: false },
+ { selector: WikiSelectors.getHomeWikiContent, value: null },
+ { selector: UserSelectors.isProjectReadOnly, value: false },
+ ];
+ const signals = mergeSignalOverrides(defaultSignals, signalOverrides);
TestBed.configureTestingModule({
imports: [OverviewWikiComponent, ...MockComponents(TruncatedTextComponent, MarkdownComponent)],
providers: [
provideOSFCore(),
provideMockStore({
- signals: [
- { selector: WikiSelectors.getHomeWikiLoading, value: false },
- { selector: WikiSelectors.getHomeWikiContent, value: null },
- ],
+ signals: signals,
}),
MockProvider(Router, routerMock),
],
@@ -39,18 +43,21 @@ describe('OverviewWikiComponent', () => {
fixture = TestBed.createComponent(OverviewWikiComponent);
component = fixture.componentInstance;
- });
+ }
it('should create', () => {
+ setup();
expect(component).toBeTruthy();
});
it('should default resourceId to empty string', () => {
+ setup();
fixture.detectChanges();
expect(component.resourceId()).toBe('');
});
it('should set resourceId input correctly', () => {
+ setup();
fixture.componentRef.setInput('resourceId', mockResourceId);
fixture.detectChanges();
@@ -58,11 +65,13 @@ describe('OverviewWikiComponent', () => {
});
it('should default canEdit to false', () => {
+ setup();
fixture.detectChanges();
expect(component.canEdit()).toBe(false);
});
it('should set canEdit input correctly', () => {
+ setup();
fixture.componentRef.setInput('canEdit', true);
fixture.detectChanges();
@@ -70,16 +79,19 @@ describe('OverviewWikiComponent', () => {
});
it('should get isWikiLoading from store', () => {
+ setup();
fixture.detectChanges();
expect(component.isWikiLoading).toBeDefined();
});
it('should get wikiContent from store', () => {
+ setup();
fixture.detectChanges();
expect(component.wikiContent).toBeDefined();
});
it('should compute wiki link with resourceId', () => {
+ setup();
fixture.componentRef.setInput('resourceId', mockResourceId);
fixture.detectChanges();
@@ -87,12 +99,14 @@ describe('OverviewWikiComponent', () => {
});
it('should compute wiki link with empty resourceId', () => {
+ setup();
fixture.detectChanges();
expect(component.wikiLink()).toEqual(['/', '', 'wiki']);
});
it('should navigate to wiki link', () => {
+ setup();
fixture.componentRef.setInput('resourceId', mockResourceId);
fixture.detectChanges();
@@ -102,10 +116,21 @@ describe('OverviewWikiComponent', () => {
});
it('should navigate with empty resourceId', () => {
+ setup();
fixture.detectChanges();
component.navigateToWiki();
expect(routerMock.navigate).toHaveBeenCalledWith(['/', '', 'wiki']);
});
+
+ it('should compute disabledButtonTooltip based on isProjectReadOnly', () => {
+ setup([{ selector: UserSelectors.isProjectReadOnly, value: true }]);
+ fixture.detectChanges();
+ expect(component.disabledButtonTooltip()).toBe('common.errorMessages.actionUnavailable');
+
+ setup([{ selector: UserSelectors.isProjectReadOnly, value: false }]);
+ fixture.detectChanges();
+ expect(component.disabledButtonTooltip()).toBe('');
+ });
});
diff --git a/src/app/features/project/overview/components/overview-wiki/overview-wiki.component.ts b/src/app/features/project/overview/components/overview-wiki/overview-wiki.component.ts
index f7bcd30c4..fcb1ae647 100644
--- a/src/app/features/project/overview/components/overview-wiki/overview-wiki.component.ts
+++ b/src/app/features/project/overview/components/overview-wiki/overview-wiki.component.ts
@@ -4,17 +4,19 @@ import { TranslatePipe } from '@ngx-translate/core';
import { Button } from 'primeng/button';
import { Skeleton } from 'primeng/skeleton';
+import { Tooltip } from 'primeng/tooltip';
import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core';
import { Router } from '@angular/router';
+import { UserSelectors } from '@osf/core/store/user';
import { MarkdownComponent } from '@osf/shared/components/markdown/markdown.component';
import { TruncatedTextComponent } from '@osf/shared/components/truncated-text/truncated-text.component';
import { WikiSelectors } from '@osf/shared/stores/wiki';
@Component({
selector: 'osf-overview-wiki',
- imports: [Skeleton, TranslatePipe, TruncatedTextComponent, MarkdownComponent, Button],
+ imports: [Skeleton, Tooltip, TranslatePipe, TruncatedTextComponent, MarkdownComponent, Button],
templateUrl: './overview-wiki.component.html',
styleUrl: './overview-wiki.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -24,11 +26,13 @@ export class OverviewWikiComponent {
isWikiLoading = select(WikiSelectors.getHomeWikiLoading);
wikiContent = select(WikiSelectors.getHomeWikiContent);
+ isProjectReadOnly = select(UserSelectors.isProjectReadOnly);
resourceId = input('');
canEdit = input(false);
wikiLink = computed(() => ['/', this.resourceId(), 'wiki']);
+ disabledButtonTooltip = computed(() => (this.isProjectReadOnly() ? 'common.errorMessages.actionUnavailable' : ''));
navigateToWiki() {
this.router.navigate(this.wikiLink());
diff --git a/src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.html b/src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.html
index 463704e67..4ebbd829b 100644
--- a/src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.html
+++ b/src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.html
@@ -21,6 +21,8 @@ {{ 'common.labels.metadata' | translate }}
[routerLink]="'../metadata'"
severity="secondary"
[label]="'common.buttons.edit' | translate"
+ [disabled]="isProjectReadOnly()"
+ [pTooltip]="disabledButtonTooltip() | translate"
>
}
diff --git a/src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.spec.ts b/src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.spec.ts
index 7bf51cdd0..846a7b0a8 100644
--- a/src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.spec.ts
+++ b/src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.spec.ts
@@ -7,6 +7,7 @@ import { Mock } from 'vitest';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
+import { UserSelectors } from '@osf/core/store/user';
import {
GetCedarMetadataRecords,
GetCedarMetadataTemplates,
@@ -34,7 +35,7 @@ import { FetchSelectedSubjects, SubjectsSelectors } from '@osf/shared/stores/sub
import { MOCK_PROJECT_OVERVIEW } from '@testing/mocks/project-overview.mock';
import { provideOSFCore } from '@testing/osf.testing.provider';
import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock';
-import { provideMockStore } from '@testing/providers/store-provider.mock';
+import { mergeSignalOverrides, provideMockStore } from '@testing/providers/store-provider.mock';
import {
GetProjectIdentifiers,
@@ -59,6 +60,7 @@ describe('ProjectOverviewMetadataComponent', () => {
interface SetupOverrides {
project?: typeof MOCK_PROJECT_OVERVIEW | null;
+ selectorOverrides?: { selector: any; value: any }[];
}
function setup(overrides: SetupOverrides = {}) {
@@ -66,6 +68,34 @@ describe('ProjectOverviewMetadataComponent', () => {
mockRouter = RouterMockBuilder.create().withUrl('/project/project-1/overview').build();
metadataRecordsService = { downloadMetadata: vi.fn() };
+ const defaultSignals = [
+ { selector: ProjectOverviewSelectors.getProject, value: project },
+ { selector: ProjectOverviewSelectors.isProjectAnonymous, value: false },
+ { selector: ProjectOverviewSelectors.hasWriteAccess, value: true },
+ { selector: ProjectOverviewSelectors.getInstitutions, value: [] },
+ { selector: ProjectOverviewSelectors.isInstitutionsLoading, value: false },
+ { selector: ProjectOverviewSelectors.getIdentifiers, value: [] },
+ { selector: ProjectOverviewSelectors.isIdentifiersLoading, value: false },
+ { selector: ProjectOverviewSelectors.getLicense, value: null },
+ { selector: ProjectOverviewSelectors.isLicenseLoading, value: false },
+ { selector: ProjectOverviewSelectors.getPreprints, value: [] },
+ { selector: ProjectOverviewSelectors.isPreprintsLoading, value: false },
+ { selector: SubjectsSelectors.getSelectedSubjects, value: [] },
+ { selector: SubjectsSelectors.areSelectedSubjectsLoading, value: false },
+ { selector: ContributorsSelectors.getBibliographicContributors, value: [] },
+ { selector: ContributorsSelectors.isBibliographicContributorsLoading, value: false },
+ { selector: ContributorsSelectors.hasMoreBibliographicContributors, value: false },
+ { selector: CollectionsSelectors.getCurrentProjectSubmissions, value: [] },
+ { selector: CollectionsSelectors.getCurrentProjectSubmissionsLoading, value: false },
+ { selector: UserSelectors.getActiveFlags, value: [] },
+ { selector: UserSelectors.isProjectReadOnly, value: false },
+ { selector: MetadataSelectors.getCedarRecords, value: [] },
+ { selector: MetadataSelectors.getCedarTemplates, value: null },
+ { selector: MetadataSelectors.getCustomItemMetadata, value: null },
+ { selector: MetadataSelectors.isCustomItemMetadataLoading, value: false },
+ ];
+ const signals = mergeSignalOverrides(defaultSignals, overrides.selectorOverrides || []);
+
TestBed.configureTestingModule({
imports: [
ProjectOverviewMetadataComponent,
@@ -87,30 +117,7 @@ describe('ProjectOverviewMetadataComponent', () => {
MockProvider(MetadataRecordsService, metadataRecordsService),
MockProvider(Router, mockRouter),
provideMockStore({
- signals: [
- { selector: ProjectOverviewSelectors.getProject, value: project },
- { selector: ProjectOverviewSelectors.isProjectAnonymous, value: false },
- { selector: ProjectOverviewSelectors.hasWriteAccess, value: true },
- { selector: ProjectOverviewSelectors.getInstitutions, value: [] },
- { selector: ProjectOverviewSelectors.isInstitutionsLoading, value: false },
- { selector: ProjectOverviewSelectors.getIdentifiers, value: [] },
- { selector: ProjectOverviewSelectors.isIdentifiersLoading, value: false },
- { selector: ProjectOverviewSelectors.getLicense, value: null },
- { selector: ProjectOverviewSelectors.isLicenseLoading, value: false },
- { selector: ProjectOverviewSelectors.getPreprints, value: [] },
- { selector: ProjectOverviewSelectors.isPreprintsLoading, value: false },
- { selector: SubjectsSelectors.getSelectedSubjects, value: [] },
- { selector: SubjectsSelectors.areSelectedSubjectsLoading, value: false },
- { selector: ContributorsSelectors.getBibliographicContributors, value: [] },
- { selector: ContributorsSelectors.isBibliographicContributorsLoading, value: false },
- { selector: ContributorsSelectors.hasMoreBibliographicContributors, value: false },
- { selector: CollectionsSelectors.getCurrentProjectSubmissions, value: [] },
- { selector: CollectionsSelectors.getCurrentProjectSubmissionsLoading, value: false },
- { selector: MetadataSelectors.getCedarRecords, value: [] },
- { selector: MetadataSelectors.getCedarTemplates, value: null },
- { selector: MetadataSelectors.getCustomItemMetadata, value: null },
- { selector: MetadataSelectors.isCustomItemMetadataLoading, value: false },
- ],
+ signals: signals,
}),
],
});
@@ -208,4 +215,14 @@ describe('ProjectOverviewMetadataComponent', () => {
expect(component.resourceType).toBe(CurrentResourceType.Projects);
expect(component.dateFormat).toBe('MMM d, y, h:mm a');
});
+
+ it('should compute disabledButtonTooltip based on isProjectReadOnly', () => {
+ setup({ selectorOverrides: [{ selector: UserSelectors.isProjectReadOnly, value: true }] });
+ fixture.detectChanges();
+ expect(component.disabledButtonTooltip()).toBe('common.errorMessages.actionUnavailable');
+
+ setup({ selectorOverrides: [{ selector: UserSelectors.isProjectReadOnly, value: false }] });
+ fixture.detectChanges();
+ expect(component.disabledButtonTooltip()).toBe('');
+ });
});
diff --git a/src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.ts b/src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.ts
index 6cd5128d6..4ab4f60a5 100644
--- a/src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.ts
+++ b/src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.ts
@@ -3,11 +3,13 @@ import { createDispatchMap, select } from '@ngxs/store';
import { TranslatePipe } from '@ngx-translate/core';
import { Button } from 'primeng/button';
+import { Tooltip } from 'primeng/tooltip';
import { DatePipe } from '@angular/common';
import { ChangeDetectionStrategy, Component, computed, effect, inject } from '@angular/core';
import { Router, RouterLink } from '@angular/router';
+import { UserSelectors } from '@osf/core/store/user/user.selectors';
import {
GetCedarMetadataRecords,
GetCedarMetadataTemplates,
@@ -53,6 +55,7 @@ import { OverviewSupplementsComponent } from '../overview-supplements/overview-s
TranslatePipe,
RouterLink,
DatePipe,
+ Tooltip,
TruncatedTextComponent,
ResourceCitationsComponent,
OverviewCollectionsComponent,
@@ -95,9 +98,13 @@ export class ProjectOverviewMetadataComponent {
readonly hasMoreBibliographicContributors = select(ContributorsSelectors.hasMoreBibliographicContributors);
readonly projectSubmissions = select(CollectionsSelectors.getCurrentProjectSubmissions);
readonly isProjectSubmissionsLoading = select(CollectionsSelectors.getCurrentProjectSubmissionsLoading);
+ readonly isProjectReadOnly = select(UserSelectors.isProjectReadOnly);
readonly cedarRecords = select(MetadataSelectors.getCedarRecords);
- private readonly cedarTemplatesResponse = select(MetadataSelectors.getCedarTemplates);
readonly cedarTemplates = computed(() => this.cedarTemplatesResponse()?.data ?? null);
+ private readonly cedarTemplatesResponse = select(MetadataSelectors.getCedarTemplates);
+ readonly disabledButtonTooltip = computed(() =>
+ this.isProjectReadOnly() ? 'common.errorMessages.actionUnavailable' : ''
+ );
readonly resourceType = CurrentResourceType.Projects;
readonly dateFormat = 'MMM d, y, h:mm a';
diff --git a/src/app/features/project/overview/components/project-overview-toolbar/project-overview-toolbar.component.html b/src/app/features/project/overview/components/project-overview-toolbar/project-overview-toolbar.component.html
index cf3d39e99..9eb6149d4 100644
--- a/src/app/features/project/overview/components/project-overview-toolbar/project-overview-toolbar.component.html
+++ b/src/app/features/project/overview/components/project-overview-toolbar/project-overview-toolbar.component.html
@@ -9,12 +9,15 @@
{{ 'project.overview.header.privateProject' | translate }}
-
+
+
+
@@ -59,23 +62,36 @@
}
@if (!viewOnly()) {
-
- {{ resource.forksCount }}
-
-
-
-
-
-
-
+ @if (preventDuplicateCreation()) {
+
+ {{ resource.forksCount }}
+
+
+ } @else {
+
+ {{ resource.forksCount }}
+
+
+
+
+
+
+
+ }
}
@if (!viewOnly()) {
diff --git a/src/app/features/project/overview/components/project-overview-toolbar/project-overview-toolbar.component.spec.ts b/src/app/features/project/overview/components/project-overview-toolbar/project-overview-toolbar.component.spec.ts
index f405eccae..d31f29dbf 100644
--- a/src/app/features/project/overview/components/project-overview-toolbar/project-overview-toolbar.component.spec.ts
+++ b/src/app/features/project/overview/components/project-overview-toolbar/project-overview-toolbar.component.spec.ts
@@ -21,7 +21,7 @@ import { provideOSFCore } from '@testing/osf.testing.provider';
import { CustomDialogServiceMockBuilder } from '@testing/providers/custom-dialog-provider.mock';
import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock';
import { RouterMockBuilder } from '@testing/providers/router-provider.mock';
-import { provideMockStore } from '@testing/providers/store-provider.mock';
+import { BaseSetupOverrides, mergeSignalOverrides, provideMockStore } from '@testing/providers/store-provider.mock';
import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock';
import { ProjectOverviewModel } from '../../models';
@@ -29,6 +29,10 @@ import { TogglePublicityDialogComponent } from '../toggle-publicity-dialog/toggl
import { ProjectOverviewToolbarComponent } from './project-overview-toolbar.component';
+interface SetupOverrides extends BaseSetupOverrides {
+ selectors?: any[];
+}
+
describe('ProjectOverviewToolbarComponent', () => {
let component: ProjectOverviewToolbarComponent;
let fixture: ComponentFixture
;
@@ -51,25 +55,29 @@ describe('ProjectOverviewToolbarComponent', () => {
storageUsage: '500MB',
};
- beforeEach(() => {
+ function setup(overrides: SetupOverrides = {}) {
routerMock = RouterMockBuilder.create().build();
activatedRouteMock = ActivatedRouteMockBuilder.create().build();
customDialogServiceMock = CustomDialogServiceMockBuilder.create().withDefaultOpen().build();
toastService = ToastServiceMock.simple();
+ const defaultSelectors = [
+ { selector: BookmarksSelectors.getBookmarksCollectionId, value: 'bookmarks-123' },
+ { selector: BookmarksSelectors.getBookmarks, value: [] },
+ { selector: BookmarksSelectors.areBookmarksLoading, value: false },
+ { selector: BookmarksSelectors.getBookmarksCollectionIdSubmitting, value: false },
+ { selector: ProjectOverviewSelectors.getDuplicatedProject, value: null },
+ { selector: UserSelectors.isAuthenticated, value: true },
+ { selector: UserSelectors.isProjectCreationDisabled, value: false },
+ { selector: UserSelectors.isProjectReadOnly, value: false },
+ ];
+ const signals = mergeSignalOverrides(defaultSelectors, overrides.selectors);
TestBed.configureTestingModule({
imports: [ProjectOverviewToolbarComponent, ...MockComponents(SocialsShareButtonComponent)],
providers: [
provideOSFCore(),
provideMockStore({
- signals: [
- { selector: BookmarksSelectors.getBookmarksCollectionId, value: 'bookmarks-123' },
- { selector: BookmarksSelectors.getBookmarks, value: [] },
- { selector: BookmarksSelectors.areBookmarksLoading, value: false },
- { selector: BookmarksSelectors.getBookmarksCollectionIdSubmitting, value: false },
- { selector: ProjectOverviewSelectors.getDuplicatedProject, value: null },
- { selector: UserSelectors.isAuthenticated, value: true },
- ],
+ signals,
}),
MockProvider(Router, routerMock),
MockProvider(ActivatedRoute, activatedRouteMock),
@@ -87,14 +95,16 @@ describe('ProjectOverviewToolbarComponent', () => {
fixture.componentRef.setInput('currentResource', mockResource);
fixture.componentRef.setInput('storage', mockStorage);
fixture.componentRef.setInput('viewOnly', false);
- });
+ }
it('should create', () => {
+ setup();
expect(component).toBeTruthy();
});
describe('Input Bindings', () => {
it('should set canEdit input correctly', () => {
+ setup();
fixture.componentRef.setInput('canEdit', false);
fixture.detectChanges();
@@ -102,18 +112,22 @@ describe('ProjectOverviewToolbarComponent', () => {
});
it('should set currentResource input correctly', () => {
+ setup();
expect(component.currentResource()).toEqual(mockResource);
});
it('should set storage input correctly', () => {
+ setup();
expect(component.storage()).toEqual(mockStorage);
});
it('should default viewOnly to false', () => {
+ setup();
expect(component.viewOnly()).toBe(false);
});
it('should set viewOnly input correctly', () => {
+ setup();
fixture.componentRef.setInput('viewOnly', true);
fixture.detectChanges();
@@ -123,12 +137,14 @@ describe('ProjectOverviewToolbarComponent', () => {
describe('Effects', () => {
it('should set isPublic from currentResource', () => {
+ setup();
fixture.detectChanges();
expect(component.isPublic()).toBe(true);
});
it('should dispatch getResourceBookmark when bookmarksId and resource exist', () => {
+ setup();
fixture.detectChanges();
expect(store.dispatch).toHaveBeenCalledWith(expect.any(GetResourceBookmark));
@@ -137,6 +153,9 @@ describe('ProjectOverviewToolbarComponent', () => {
describe('handleToggleProjectPublicity', () => {
it('should open TogglePublicityDialogComponent with makePrivate header when project is public', () => {
+ setup();
+ fixture.detectChanges();
+
component.handleToggleProjectPublicity();
expect(customDialogServiceMock.open).toHaveBeenCalledWith(TogglePublicityDialogComponent, {
@@ -150,6 +169,7 @@ describe('ProjectOverviewToolbarComponent', () => {
});
it('should open TogglePublicityDialogComponent with makePublic header when project is private', () => {
+ setup();
fixture.componentRef.setInput('currentResource', { ...mockResource, isPublic: false });
fixture.detectChanges();
@@ -166,6 +186,7 @@ describe('ProjectOverviewToolbarComponent', () => {
});
it('should not open dialog when resource is null', () => {
+ setup();
fixture.componentRef.setInput('currentResource', null as any);
fixture.detectChanges();
@@ -173,15 +194,83 @@ describe('ProjectOverviewToolbarComponent', () => {
expect(customDialogServiceMock.open).not.toHaveBeenCalled();
});
+
+ it('should compute disableProjectPrivacyToggle when isProjectReadOnly is false', () => {
+ setup();
+ fixture.detectChanges();
+
+ expect(component.isPublic()).toBe(true);
+ expect(component.disableProjectPrivacyToggle()).toBe(false);
+
+ fixture.componentRef.setInput('currentResource', { ...mockResource, isPublic: false });
+ fixture.detectChanges();
+
+ expect(component.isPublic()).toBe(false);
+ expect(component.disableProjectPrivacyToggle()).toBe(false);
+ });
+
+ it('should compute disableProjectPrivacyToggle when isProjectReadOnly is true', () => {
+ setup({ selectors: [{ selector: UserSelectors.isProjectReadOnly, value: true }] });
+ fixture.detectChanges();
+
+ expect(component.isPublic()).toBe(true);
+ expect(component.disableProjectPrivacyToggle()).toBe(true);
+
+ fixture.componentRef.setInput('currentResource', { ...mockResource, isPublic: false });
+ fixture.detectChanges();
+
+ expect(component.isPublic()).toBe(false);
+ expect(component.disableProjectPrivacyToggle()).toBe(false);
+ });
});
describe('Properties', () => {
it('should have ResourceType property', () => {
+ setup();
expect(component.ResourceType).toBe(ResourceType);
});
it('should have resourceType set to Project', () => {
+ setup();
expect(component.resourceType).toBe(ResourceType.Project);
});
});
+
+ describe('preventDuplicateCreation', () => {
+ it('should return false when isProjectCreationDisabled is false', () => {
+ setup();
+ expect(component.preventDuplicateCreation()).toBe(false);
+ });
+
+ it('should return true when isProjectCreationDisabled is true', () => {
+ setup({
+ selectors: [{ selector: UserSelectors.isProjectCreationDisabled, value: true }],
+ });
+ fixture.detectChanges();
+ expect(component.preventDuplicateCreation()).toBe(true);
+ });
+ });
+
+ describe('projectReadOnlyTooltip', () => {
+ it('should return empty string when isProjectReadOnly is false', () => {
+ setup();
+ expect(component.projectReadOnlyTooltip()).toBe('');
+
+ fixture.componentRef.setInput('currentResource', { ...mockResource, isPublic: false });
+ fixture.detectChanges();
+ expect(component.projectReadOnlyTooltip()).toBe('');
+ });
+
+ it('should return tooltip message when isProjectReadOnly is true', () => {
+ setup({
+ selectors: [{ selector: UserSelectors.isProjectReadOnly, value: true }],
+ });
+ fixture.detectChanges();
+ expect(component.projectReadOnlyTooltip()).toBe('common.errorMessages.actionUnavailable');
+
+ fixture.componentRef.setInput('currentResource', { ...mockResource, isPublic: false });
+ fixture.detectChanges();
+ expect(component.projectReadOnlyTooltip()).toBe('');
+ });
+ });
});
diff --git a/src/app/features/project/overview/components/project-overview-toolbar/project-overview-toolbar.component.ts b/src/app/features/project/overview/components/project-overview-toolbar/project-overview-toolbar.component.ts
index 812e20ef2..01a7d2710 100644
--- a/src/app/features/project/overview/components/project-overview-toolbar/project-overview-toolbar.component.ts
+++ b/src/app/features/project/overview/components/project-overview-toolbar/project-overview-toolbar.component.ts
@@ -9,7 +9,7 @@ import { Tooltip } from 'primeng/tooltip';
import { timer } from 'rxjs';
-import { ChangeDetectionStrategy, Component, DestroyRef, effect, inject, input, signal } from '@angular/core';
+import { ChangeDetectionStrategy, Component, computed, DestroyRef, effect, inject, input, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
@@ -75,6 +75,13 @@ export class ProjectOverviewToolbarComponent {
duplicatedProject = select(ProjectOverviewSelectors.getDuplicatedProject);
isAuthenticated = select(UserSelectors.isAuthenticated);
+ preventDuplicateCreation = select(UserSelectors.isProjectCreationDisabled);
+ isProjectReadOnly = select(UserSelectors.isProjectReadOnly);
+
+ disableProjectPrivacyToggle = computed(() => this.isProjectReadOnly() && this.isPublic());
+ projectReadOnlyTooltip = computed(() =>
+ this.disableProjectPrivacyToggle() ? 'common.errorMessages.actionUnavailable' : ''
+ );
actions = createDispatchMap({
getResourceBookmark: GetResourceBookmark,
@@ -96,9 +103,7 @@ export class ProjectOverviewToolbarComponent {
},
{
label: 'project.overview.actions.viewDuplication',
- command: () => {
- this.router.navigate(['../analytics/duplicates'], { relativeTo: this.route });
- },
+ command: () => this.navigateToDuplicatesView(),
},
];
@@ -205,4 +210,8 @@ export class ProjectOverviewToolbarComponent {
complete: () => this.actions.clearDuplicatedProject(),
});
}
+
+ navigateToDuplicatesView(): void {
+ this.router.navigate(['../analytics/duplicates'], { relativeTo: this.route });
+ }
}
diff --git a/src/app/features/project/project-addons/project-addons.component.html b/src/app/features/project/project-addons/project-addons.component.html
index 3fcf71ec0..4d4691b15 100644
--- a/src/app/features/project/project-addons/project-addons.component.html
+++ b/src/app/features/project/project-addons/project-addons.component.html
@@ -1,4 +1,4 @@
-
+
diff --git a/src/app/features/project/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.wikiText' | translate: { projectName: title() } }}
- {{ '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 c75b1b6dd..6623c4e1c 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';
@@ -74,6 +75,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({
@@ -252,4 +254,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 ea96d56c0..2c82b1674 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 }}
+
+
+ }