Skip to content

Commit d3d71d8

Browse files
committed
Merge branch 'release/26.18.0'
2 parents 0961d94 + 51255a0 commit d3d71d8

136 files changed

Lines changed: 1770 additions & 341 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎CHANGELOG‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
We follow the CalVer (https://calver.org/) versioning scheme: YY.MINOR.MICRO.
44

5+
26.18.0 (2026-09-10)
6+
====================
7+
8+
* Project Creation and Project Read-only
9+
510
26.17.1 (2026-09-01)
611
====================
712

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "osf",
3-
"version": "26.17.1",
3+
"version": "26.18.0",
44
"scripts": {
55
"ng": "ng",
66
"analyze-bundle": "ng build --configuration=analyze-bundle && source-map-explorer dist/**/*.js --no-border-checks",

‎src/app/core/components/request-access/request-access.component.html‎

Lines changed: 31 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,44 @@
11
<section class="container text-center flex flex-column flex-1 my-7 mx-3 p-3 md:p-4">
2-
<h2>{{ 'requestAccess.title' | translate }}</h2>
2+
<h2>{{ titleTranslation() | translate }}</h2>
33

4-
<p class="mt-4">{{ 'requestAccess.message' | translate }}</p>
4+
<p class="mt-4">
5+
<span [innerHTML]="messageTranslation() | translate"></span>
6+
@if (isProjectReadOnly()) {
7+
<a class="ml-1 font-bold" [href]="'mailto:' + supportEmail">{{ supportEmail }}</a>
8+
}
9+
</p>
510

6-
<div class="flex flex-column text-left mt-4">
7-
<label class="font-bold" for="comment">
8-
{{ 'requestAccess.commentLabel' | translate }}
9-
</label>
11+
@if (!isProjectReadOnly()) {
12+
<div class="flex flex-column text-left mt-4">
13+
<label class="font-bold" for="comment">
14+
{{ 'requestAccess.commentLabel' | translate }}
15+
</label>
1016

11-
<textarea
12-
class="comment-input"
13-
pTextarea
14-
id="comment"
15-
[rows]="3"
16-
[maxLength]="commentLimit"
17-
[(ngModel)]="comment"
18-
></textarea>
19-
</div>
17+
<textarea
18+
class="comment-input"
19+
pTextarea
20+
id="comment"
21+
[rows]="3"
22+
[maxLength]="commentLimit"
23+
[(ngModel)]="comment"
24+
></textarea>
25+
</div>
26+
}
2027

2128
<div class="flex align-items-center gap-3 mt-6">
22-
<p-button
23-
class="w-full"
24-
styleClass="w-full"
25-
[label]="'requestAccess.requestAccess' | translate"
26-
(onClick)="requestAccess()"
27-
></p-button>
29+
@if (!isProjectReadOnly()) {
30+
<p-button
31+
class="w-full"
32+
styleClass="w-full"
33+
[label]="'requestAccess.requestAccess' | translate"
34+
(onClick)="requestAccess()"
35+
></p-button>
36+
}
2837

2938
<p-button
3039
class="w-full"
3140
styleClass="w-full"
32-
severity="secondary"
41+
[severity]="isProjectReadOnly() ? 'primary' : 'secondary'"
3342
[label]="'requestAccess.switchAccount' | translate"
3443
(onClick)="switchAccount()"
3544
></p-button>

‎src/app/core/components/request-access/request-access.component.spec.ts‎

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
99
import { ActivatedRoute, Router } from '@angular/router';
1010

1111
import { AuthService } from '@core/services/auth.service';
12+
import { UserSelectors } from '@core/store/user';
1213
import { InputLimits } from '@osf/shared/constants/input-limits.const';
1314
import { RequestAccessService } from '@osf/shared/services/request-access.service';
1415
import { ToastService } from '@osf/shared/services/toast.service';
@@ -18,10 +19,17 @@ import { AuthServiceMock, AuthServiceMockType } from '@testing/providers/auth-se
1819
import { LoaderServiceMock, provideLoaderServiceMock } from '@testing/providers/loader-service.mock';
1920
import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock';
2021
import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock';
22+
import { BaseSetupOverrides, mergeSignalOverrides, provideMockStore } from '@testing/providers/store-provider.mock';
2123
import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock';
2224

2325
import { RequestAccessComponent } from './request-access.component';
2426

27+
interface SetupOverrides extends BaseSetupOverrides {
28+
routeId?: string;
29+
requestAccessResult?: Observable<void>;
30+
requestAccessError?: HttpErrorResponse;
31+
}
32+
2533
describe('RequestAccessComponent', () => {
2634
let fixture: ComponentFixture<RequestAccessComponent>;
2735
let component: RequestAccessComponent;
@@ -31,12 +39,10 @@ describe('RequestAccessComponent', () => {
3139
let toastServiceMock: ToastServiceMockType;
3240
let authServiceMock: AuthServiceMockType;
3341

34-
function setup(overrides?: {
35-
routeId?: string;
36-
requestAccessResult?: Observable<void>;
37-
requestAccessError?: HttpErrorResponse;
38-
}) {
42+
function setup(overrides?: SetupOverrides) {
3943
const routeId = overrides?.routeId ?? 'project-1';
44+
const defaultSignals = [{ selector: UserSelectors.isProjectReadOnly, value: false }];
45+
const signals = mergeSignalOverrides(defaultSignals, overrides?.selectorOverrides ?? []);
4046
routerMock = RouterMockBuilder.create().withNavigate(vi.fn().mockResolvedValue(true)).build();
4147
loaderServiceMock = new LoaderServiceMock();
4248
toastServiceMock = ToastServiceMock.simple();
@@ -60,6 +66,7 @@ describe('RequestAccessComponent', () => {
6066
MockProvider(RequestAccessService, requestAccessServiceMock),
6167
MockProvider(ToastService, toastServiceMock),
6268
MockProvider(AuthService, authServiceMock),
69+
provideMockStore({ signals }),
6370
],
6471
});
6572

@@ -86,6 +93,23 @@ describe('RequestAccessComponent', () => {
8693
expect(supportLink.textContent).toContain(component.supportEmail);
8794
});
8895

96+
it('should expose title and message translations based on read-only state', () => {
97+
setup({ selectorOverrides: [{ selector: UserSelectors.isProjectReadOnly, value: true }] });
98+
expect(component.titleTranslation()).toBe('requestAccess.readOnlyTitle');
99+
expect(component.messageTranslation()).toBe('requestAccess.messageReadOnly');
100+
101+
const buttons = fixture.nativeElement.querySelectorAll('p-button');
102+
expect(buttons).toHaveLength(1);
103+
});
104+
105+
it('should expose title and message translations based on non-read-only state', () => {
106+
setup({ selectorOverrides: [{ selector: UserSelectors.isProjectReadOnly, value: false }] });
107+
expect(component.titleTranslation()).toBe('requestAccess.title');
108+
expect(component.messageTranslation()).toBe('requestAccess.message');
109+
const buttons = fixture.nativeElement.querySelectorAll('p-button');
110+
expect(buttons.length).toBe(2);
111+
});
112+
89113
it('should request access and handle success flow', () => {
90114
setup({ routeId: 'project-123' });
91115
component.comment.set('please grant access');

‎src/app/core/components/request-access/request-access.component.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { select } from '@ngxs/store';
2+
13
import { TranslatePipe } from '@ngx-translate/core';
24

35
import { Button } from 'primeng/button';
@@ -6,13 +8,14 @@ import { Textarea } from 'primeng/textarea';
68
import { map, of } from 'rxjs';
79

810
import { HttpErrorResponse } from '@angular/common/http';
9-
import { ChangeDetectionStrategy, Component, inject, model } from '@angular/core';
11+
import { ChangeDetectionStrategy, Component, computed, inject, model } from '@angular/core';
1012
import { toSignal } from '@angular/core/rxjs-interop';
1113
import { FormsModule } from '@angular/forms';
1214
import { ActivatedRoute, Router } from '@angular/router';
1315

1416
import { ENVIRONMENT } from '@core/provider/environment.provider';
1517
import { AuthService } from '@core/services/auth.service';
18+
import { UserSelectors } from '@osf/core/store/user/user.selectors';
1619
import { InputLimits } from '@osf/shared/constants/input-limits.const';
1720
import { LoaderService } from '@osf/shared/services/loader.service';
1821
import { RequestAccessService } from '@osf/shared/services/request-access.service';
@@ -41,6 +44,16 @@ export class RequestAccessComponent {
4144
private readonly toastService = inject(ToastService);
4245
private readonly authService = inject(AuthService);
4346

47+
readonly isProjectReadOnly = select(UserSelectors.isProjectReadOnly);
48+
49+
readonly titleTranslation = computed(() =>
50+
this.isProjectReadOnly() ? 'requestAccess.readOnlyTitle' : 'requestAccess.title'
51+
);
52+
53+
readonly messageTranslation = computed(() =>
54+
this.isProjectReadOnly() ? 'requestAccess.messageReadOnly' : 'requestAccess.message'
55+
);
56+
4457
requestAccess() {
4558
this.loaderService.show();
4659
this.requestAccessService.requestAccessToProject(this.id(), this.comment()).subscribe({

‎src/app/core/store/user/user.selectors.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,4 +58,14 @@ export class UserSelectors {
5858
static getActiveFlags(state: UserStateModel): string[] {
5959
return state.activeFlags || [];
6060
}
61+
62+
@Selector([UserState])
63+
static isProjectCreationDisabled(state: UserStateModel): boolean {
64+
return state.activeFlags?.includes('prevent_project_creation') || false;
65+
}
66+
67+
@Selector([UserState])
68+
static isProjectReadOnly(state: UserStateModel): boolean {
69+
return state.activeFlags?.includes('project_read_only') || false;
70+
}
6171
}

‎src/app/features/analytics/components/view-duplicates/view-duplicates.component.html‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
[title]="'project.analytics.kpi.forks' | translate"
33
[showButton]="isAuthenticated()"
44
[buttonLabel]="'project.overview.actions.forkProjectLabel' | translate"
5+
[isButtonDisabled]="preventDuplicateCreation()"
6+
[buttonTooltip]="duplicateButtonTooltip() | translate"
57
(buttonClick)="handleForkResource()"
68
/>
79

‎src/app/features/analytics/components/view-duplicates/view-duplicates.component.spec.ts‎

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { of } from 'rxjs';
77
import { ComponentFixture, TestBed } from '@angular/core/testing';
88
import { ActivatedRoute, Router } from '@angular/router';
99

10+
import { UserSelectors } from '@osf/core/store/user/user.selectors';
1011
import { ProjectOverviewSelectors } from '@osf/features/project/overview/store';
1112
import { RegistrySelectors } from '@osf/features/registry/store/registry';
1213
import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component';
@@ -24,25 +25,41 @@ import { provideOSFCore } from '@testing/osf.testing.provider';
2425
import { CustomDialogServiceMockBuilder } from '@testing/providers/custom-dialog-provider.mock';
2526
import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock';
2627
import { RouterMockBuilder } from '@testing/providers/router-provider.mock';
27-
import { provideMockStore } from '@testing/providers/store-provider.mock';
28+
import { BaseSetupOverrides, mergeSignalOverrides, provideMockStore } from '@testing/providers/store-provider.mock';
2829

2930
import { ViewDuplicatesComponent } from './view-duplicates.component';
3031

32+
interface SetupOverrides extends BaseSetupOverrides {
33+
selectors?: any[];
34+
}
35+
3136
describe('Component: View Duplicates', () => {
3237
let component: ViewDuplicatesComponent;
3338
let fixture: ComponentFixture<ViewDuplicatesComponent>;
3439
let routerMock: ReturnType<RouterMockBuilder['build']>;
3540
let activatedRouteMock: ReturnType<ActivatedRouteMockBuilder['build']>;
3641
let mockCustomDialogService: ReturnType<CustomDialogServiceMockBuilder['build']>;
3742

38-
beforeEach(() => {
43+
function setup(overrides: SetupOverrides = {}) {
3944
mockCustomDialogService = CustomDialogServiceMockBuilder.create().build();
4045
routerMock = RouterMockBuilder.create().build();
4146
activatedRouteMock = ActivatedRouteMockBuilder.create()
4247
.withParams({ id: 'rid' })
4348
.withData({ resourceType: ResourceType.Project })
4449
.build();
4550

51+
const defaultSelectors = [
52+
{ selector: DuplicatesSelectors.getDuplicates, value: [] },
53+
{ selector: DuplicatesSelectors.getDuplicatesLoading, value: false },
54+
{ selector: DuplicatesSelectors.getDuplicatesTotalCount, value: 0 },
55+
{ selector: ProjectOverviewSelectors.getProject, value: MOCK_PROJECT_OVERVIEW },
56+
{ selector: ProjectOverviewSelectors.isProjectAnonymous, value: false },
57+
{ selector: RegistrySelectors.getRegistry, value: undefined },
58+
{ selector: RegistrySelectors.isRegistryAnonymous, value: false },
59+
{ selector: UserSelectors.isProjectCreationDisabled, value: false },
60+
];
61+
const signals = mergeSignalOverrides(defaultSelectors, overrides.selectors || []);
62+
4663
TestBed.configureTestingModule({
4764
imports: [
4865
ViewDuplicatesComponent,
@@ -58,15 +75,7 @@ describe('Component: View Duplicates', () => {
5875
providers: [
5976
provideOSFCore(),
6077
provideMockStore({
61-
signals: [
62-
{ selector: DuplicatesSelectors.getDuplicates, value: [] },
63-
{ selector: DuplicatesSelectors.getDuplicatesLoading, value: false },
64-
{ selector: DuplicatesSelectors.getDuplicatesTotalCount, value: 0 },
65-
{ selector: ProjectOverviewSelectors.getProject, value: MOCK_PROJECT_OVERVIEW },
66-
{ selector: ProjectOverviewSelectors.isProjectAnonymous, value: false },
67-
{ selector: RegistrySelectors.getRegistry, value: undefined },
68-
{ selector: RegistrySelectors.isRegistryAnonymous, value: false },
69-
],
78+
signals,
7079
}),
7180
MockProvider(CustomDialogService, mockCustomDialogService),
7281
MockProvider(Router, routerMock),
@@ -78,13 +87,23 @@ describe('Component: View Duplicates', () => {
7887
component = fixture.componentInstance;
7988

8089
fixture.detectChanges();
81-
});
90+
}
8291

8392
it('should create', () => {
93+
setup();
8494
expect(component).toBeTruthy();
8595
});
8696

97+
it('should disable fork button and show tooltip when isProjectCreationDisabled is true', () => {
98+
setup({
99+
selectors: [{ selector: UserSelectors.isProjectCreationDisabled, value: true }],
100+
});
101+
expect(component.preventDuplicateCreation()).toBe(true);
102+
expect(component.duplicateButtonTooltip()).toBe('project.overview.actions.duplicatingProjectsNotAllowed');
103+
});
104+
87105
it('should open ForkDialog with width 450px when small and not refresh on failure', () => {
106+
setup();
88107
(component as any).actions = { ...component.actions, getDuplicates: vi.fn() };
89108

90109
const openSpy = vi
@@ -98,12 +117,14 @@ describe('Component: View Duplicates', () => {
98117
});
99118

100119
it('should update currentPage when page is defined', () => {
120+
setup();
101121
const event: PaginatorState = { page: 1 } as PaginatorState;
102122
component.onPageChange(event);
103123
expect(component.currentPage()).toBe(2);
104124
});
105125

106126
it('should not update currentPage when page is undefined', () => {
127+
setup();
107128
component.currentPage.set(5);
108129
const event: PaginatorState = { page: undefined } as PaginatorState;
109130
component.onPageChange(event);

‎src/app/features/analytics/components/view-duplicates/view-duplicates.component.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,11 +77,15 @@ export class ViewDuplicatesComponent {
7777
isDuplicatesLoading = select(DuplicatesSelectors.getDuplicatesLoading);
7878
totalDuplicates = select(DuplicatesSelectors.getDuplicatesTotalCount);
7979
isAuthenticated = select(UserSelectors.isAuthenticated);
80+
preventDuplicateCreation = select(UserSelectors.isProjectCreationDisabled);
8081

8182
readonly pageSize = 10;
8283

8384
currentPage = signal<number>(1);
8485
firstIndex = computed(() => (this.currentPage() - 1) * this.pageSize);
86+
duplicateButtonTooltip = computed(() =>
87+
this.preventDuplicateCreation() ? 'project.overview.actions.duplicatingProjectsNotAllowed' : ''
88+
);
8589

8690
readonly forkActionItems = (resourceId: string) => [
8791
{

‎src/app/features/collections/components/add-to-collection/add-to-collection.component.html‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ <h1 class="collections-heading flex align-items-center">{{ collectionProvider()?
1919
[stepperActiveValue]="stepperActiveValue()"
2020
[collectionId]="primaryCollectionId() ?? ''"
2121
[targetStepValue]="AddToCollectionSteps.SelectProject"
22+
[isProjectReadOnly]="isProjectReadOnly()"
2223
(projectSelected)="handleProjectSelected()"
2324
(stepChange)="handleChangeStep($event)"
2425
/>
@@ -68,7 +69,8 @@ <h1 class="collections-heading flex align-items-center">{{ collectionProvider()?
6869

6970
<p-button
7071
[label]="(isEditMode() ? 'common.buttons.update' : 'common.buttons.addToCollection') | translate"
71-
[disabled]="stepperActiveValue() !== AddToCollectionSteps.Complete"
72+
[disabled]="stepperActiveValue() !== AddToCollectionSteps.Complete || isProjectReadOnly()"
73+
[pTooltip]="disabledAddButtonTooltip() | translate"
7274
(click)="handleAddToCollection()"
7375
/>
7476
</div>

0 commit comments

Comments
 (0)