From 86e0a13820b308886f8037f7d6b86ce160e6b316 Mon Sep 17 00:00:00 2001 From: futa-ikeda Date: Fri, 15 May 2026 15:17:33 -0400 Subject: [PATCH 01/35] feat(dashboard): Prevent project creation --- .../home/pages/dashboard/dashboard.component.html | 6 +++++- .../pages/dashboard/dashboard.component.spec.ts | 11 +++++++++++ .../home/pages/dashboard/dashboard.component.ts | 14 +++++++++++++- .../sub-header/sub-header.component.html | 1 + .../sub-header/sub-header.component.spec.ts | 11 +++++++++++ .../components/sub-header/sub-header.component.ts | 1 + src/assets/i18n/en.json | 3 +++ 7 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/app/features/home/pages/dashboard/dashboard.component.html b/src/app/features/home/pages/dashboard/dashboard.component.html index 059bf4be3..7b29997a2 100644 --- a/src/app/features/home/pages/dashboard/dashboard.component.html +++ b/src/app/features/home/pages/dashboard/dashboard.component.html @@ -8,6 +8,8 @@ [title]="'home.loggedIn.dashboard.title' | translate" [icon]="'fas fa-home'" [buttonLabel]="'home.loggedIn.dashboard.createProject' | translate" + [isButtonDisabled]="projectCreationDisabled()" + [buttonTooltip]="buttonTooltip() | translate" (buttonClick)="createProject()" /> @@ -69,11 +71,13 @@

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

[title]="'home.loggedIn.dashboard.welcome' | translate" [icon]="'home'" [buttonLabel]="'home.loggedIn.dashboard.createProject' | translate" + [isButtonDisabled]="projectCreationDisabled()" + [buttonTooltip]="buttonTooltip() | translate" (buttonClick)="createProject()" />
-

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

+

{{ noProjectsMessage() | translate }}

{{ 'home.loggedIn.dashboard.watchVideoBelow' | 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 ba3b7cf3c..c4ff71def 100644 --- a/src/app/features/home/pages/dashboard/dashboard.component.spec.ts +++ b/src/app/features/home/pages/dashboard/dashboard.component.spec.ts @@ -13,6 +13,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute, Router } from '@angular/router'; import { ScheduledBannerComponent } from '@core/components/osf-banners/scheduled-banner/scheduled-banner.component'; +import { UserSelectors } from '@osf/core/store/user'; import { CreateProjectDialogComponent } from '@osf/features/my-projects/components'; import { IconComponent } from '@osf/shared/components/icon/icon.component'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; @@ -49,6 +50,7 @@ describe('DashboardComponent', () => { { selector: MyResourcesSelectors.getProjects, value: [] }, { selector: MyResourcesSelectors.getTotalProjects, value: 0 }, { selector: MyResourcesSelectors.getProjectsLoading, value: false }, + { selector: UserSelectors.getActiveFlags, value: [] }, ]; interface SetupOverrides extends BaseSetupOverrides { @@ -98,6 +100,15 @@ describe('DashboardComponent', () => { expect(component).toBeTruthy(); }); + it('should disable project creation and show tooltip when prevent_project_creation flag is active', () => { + setup({ + selectorOverrides: [{ selector: UserSelectors.getActiveFlags, value: ['prevent_project_creation'] }], + }); + + expect(component.projectCreationDisabled()).toBe(true); + expect(component.buttonTooltip()).toBe('myProjects.header.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 f9fa9eb5c..7e89a8625 100644 --- a/src/app/features/home/pages/dashboard/dashboard.component.ts +++ b/src/app/features/home/pages/dashboard/dashboard.component.ts @@ -15,6 +15,7 @@ import { FormControl } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ScheduledBannerComponent } from '@core/components/osf-banners/scheduled-banner/scheduled-banner.component'; +import { UserSelectors } from '@osf/core/store/user'; import { CreateProjectDialogComponent } from '@osf/features/my-projects/components'; import { IconComponent } from '@osf/shared/components/icon/icon.component'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; @@ -29,7 +30,6 @@ import { CustomDialogService } from '@osf/shared/services/custom-dialog.service' import { ProjectRedirectDialogService } from '@osf/shared/services/project-redirect-dialog.service'; import { ClearMyResources, GetMyProjects, MyResourcesSelectors } from '@osf/shared/stores/my-resources'; import { TableParameters } from '@shared/models/table-parameters.model'; - @Component({ selector: 'osf-dashboard', imports: [ @@ -54,6 +54,7 @@ export class DashboardComponent implements OnInit { private readonly projectRedirectDialogService = inject(ProjectRedirectDialogService); private readonly platformId = inject(PLATFORM_ID); private readonly isBrowser = isPlatformBrowser(this.platformId); + private readonly activeFlags = select(UserSelectors.getActiveFlags); readonly searchControl = new FormControl(''); readonly activeProject = signal(null); @@ -72,7 +73,18 @@ export class DashboardComponent implements OnInit { return this.projects().filter((project) => project.title.toLowerCase().includes(search)); }); + readonly projectCreationDisabled = computed(() => this.activeFlags().includes('prevent_project_creation')); + readonly buttonTooltip = computed(() => + this.projectCreationDisabled() ? 'myProjects.header.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'; + }); constructor() { this.setupSearchSubscription(); diff --git a/src/app/shared/components/sub-header/sub-header.component.html b/src/app/shared/components/sub-header/sub-header.component.html index 13eee31c5..c198c9fb0 100644 --- a/src/app/shared/components/sub-header/sub-header.component.html +++ b/src/app/shared/components/sub-header/sub-header.component.html @@ -32,6 +32,7 @@

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

} diff --git a/src/app/shared/components/sub-header/sub-header.component.spec.ts b/src/app/shared/components/sub-header/sub-header.component.spec.ts index 74c875fea..a479eb79e 100644 --- a/src/app/shared/components/sub-header/sub-header.component.spec.ts +++ b/src/app/shared/components/sub-header/sub-header.component.spec.ts @@ -128,6 +128,11 @@ describe('SubHeaderComponent', () => { expect(component.isButtonDisabled()).toBe(true); }); + it('should set buttonTooltip input correctly', () => { + fixture.componentRef.setInput('buttonTooltip', 'Test button tooltip'); + expect(component.buttonTooltip()).toBe('Test button tooltip'); + }); + it('should emit buttonClick event', () => { const emitSpy = vi.spyOn(component.buttonClick, 'emit'); @@ -155,12 +160,14 @@ describe('SubHeaderComponent', () => { fixture.componentRef.setInput('description', 'Description with special chars: <>&"\''); fixture.componentRef.setInput('buttonLabel', 'Button with special chars: !@#$%'); fixture.componentRef.setInput('tooltip', 'Tooltip with special chars: [{}]|\\'); + fixture.componentRef.setInput('buttonTooltip', 'Button tooltip with special chars: @#$%()<>'); fixture.componentRef.setInput('icon', 'pi-icon-with-special-chars'); expect(component.title()).toBe('Title with special chars: @#$%^&*()'); expect(component.description()).toBe('Description with special chars: <>&"\''); expect(component.buttonLabel()).toBe('Button with special chars: !@#$%'); expect(component.tooltip()).toBe('Tooltip with special chars: [{}]|\\'); + expect(component.buttonTooltip()).toBe('Button tooltip with special chars: @#$%()<>'); expect(component.icon()).toBe('pi-icon-with-special-chars'); }); @@ -169,12 +176,14 @@ describe('SubHeaderComponent', () => { fixture.componentRef.setInput('description', ''); fixture.componentRef.setInput('buttonLabel', ''); fixture.componentRef.setInput('tooltip', ''); + fixture.componentRef.setInput('buttonTooltip', ''); fixture.componentRef.setInput('icon', ''); expect(component.title()).toBe(''); expect(component.description()).toBe(''); expect(component.buttonLabel()).toBe(''); expect(component.tooltip()).toBe(''); + expect(component.buttonTooltip()).toBe(''); expect(component.icon()).toBe(''); }); @@ -193,9 +202,11 @@ describe('SubHeaderComponent', () => { fixture.componentRef.setInput('showButton', true); fixture.componentRef.setInput('isButtonDisabled', true); fixture.componentRef.setInput('buttonLabel', 'Disabled Button'); + fixture.componentRef.setInput('buttonTooltip', 'Disabled Button Tooltip'); expect(component.showButton()).toBe(true); expect(component.isButtonDisabled()).toBe(true); expect(component.buttonLabel()).toBe('Disabled Button'); + expect(component.buttonTooltip()).toBe('Disabled Button Tooltip'); }); }); diff --git a/src/app/shared/components/sub-header/sub-header.component.ts b/src/app/shared/components/sub-header/sub-header.component.ts index e0150cc8f..559f76924 100644 --- a/src/app/shared/components/sub-header/sub-header.component.ts +++ b/src/app/shared/components/sub-header/sub-header.component.ts @@ -25,5 +25,6 @@ export class SubHeaderComponent { isLoading = input(false); isSubmitting = input(false); isButtonDisabled = input(false); + buttonTooltip = input(''); buttonClick = output(); } diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 2887a045d..2599fb079 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -796,6 +796,8 @@ "loggedIn": { "dashboard": { "createProject": "Create New Project", + "createProjectDisabledTooltip": "Projects can no longer be created.", + "getStartedHelp": "Visit Get Started Help Guides", "images": { "osfCollectionsImageAltText": "OSF Collections", @@ -804,6 +806,7 @@ "osfRegistriesImageAltTest": "OSF Registries" }, "noCreatedProject": "You haven’t created a project yet. Click the \"Create New Project\" button above to get started.", + "noCreatedProjectAndCreateProjectDisabled": "You haven’t created a project yet.", "quickSearch": { "goTo": "Go to", "myProjects": "My Projects", From 8b197a3b44fab7850b8cde50bc2d7af6449c0db4 Mon Sep 17 00:00:00 2001 From: futa-ikeda Date: Fri, 15 May 2026 15:18:41 -0400 Subject: [PATCH 02/35] feat(my-projects): Prevent project creation --- src/app/features/my-projects/my-projects.component.html | 2 ++ .../features/my-projects/my-projects.component.spec.ts | 9 +++++++++ src/app/features/my-projects/my-projects.component.ts | 6 ++++++ src/assets/i18n/en.json | 1 + 4 files changed, 18 insertions(+) diff --git a/src/app/features/my-projects/my-projects.component.html b/src/app/features/my-projects/my-projects.component.html index e3a806661..a458d1f48 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-dark'" (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 016753ab2..a148eea7e 100644 --- a/src/app/features/my-projects/my-projects.component.spec.ts +++ b/src/app/features/my-projects/my-projects.component.spec.ts @@ -11,6 +11,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'; @@ -77,6 +78,7 @@ describe('MyProjectsComponent', () => { { selector: BookmarksSelectors.getBookmarks, value: [] }, { selector: BookmarksSelectors.getBookmarksCollectionId, value: 'bookmark-collection-id' }, { selector: BookmarksSelectors.getBookmarksTotalCount, value: 0 }, + { selector: UserSelectors.getActiveFlags, value: [] }, ]; function setup(selectorOverrides?: SignalOverride[]) { @@ -132,6 +134,13 @@ describe('MyProjectsComponent', () => { expect(component).toBeTruthy(); }); + it('should disable project creation and show tooltip when prevent_project_creation flag is active', () => { + setup([{ selector: UserSelectors.getActiveFlags, value: ['prevent_project_creation'] }]); + + 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 eb801ea78..15344bcea 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'; @@ -83,6 +84,7 @@ export class MyProjectsComponent implements OnInit { readonly tableParamsService = inject(MyProjectsTableParamsService); readonly platformId = inject(PLATFORM_ID); readonly isBrowser = isPlatformBrowser(this.platformId); + readonly activeFlags = select(UserSelectors.getActiveFlags); readonly isLoading = signal(false); readonly isMedium = toSignal(inject(IS_MEDIUM)); @@ -113,6 +115,10 @@ export class MyProjectsComponent implements OnInit { readonly bookmarksCollectionId = select(BookmarksSelectors.getBookmarksCollectionId); readonly totalBookmarksCount = select(BookmarksSelectors.getBookmarksTotalCount); readonly isBookmarks = computed(() => this.selectedTab() === MyProjectsTab.Bookmarks); + readonly projectCreationDisabled = computed(() => this.activeFlags().includes('prevent_project_creation')); + readonly buttonTooltip = computed(() => + this.projectCreationDisabled() ? 'myProjects.header.createProjectDisabledTooltip' : '' + ); readonly actions = createDispatchMap({ getBookmarksCollectionId: GetBookmarksCollectionId, diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 2599fb079..2a11b6457 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -1098,6 +1098,7 @@ }, "header": { "createProject": "Create Project", + "createProjectDisabledTooltip": "Projects can no longer be created.", "title": "My Projects" }, "redirectDialog": { From d6b3fd6006be053a2cb0107e9fbaab6cc3796e20 Mon Sep 17 00:00:00 2001 From: Futa Ikeda Date: Wed, 20 May 2026 14:45:52 -0400 Subject: [PATCH 03/35] feat(dashboard): Update translation key --- .../features/home/pages/dashboard/dashboard.component.spec.ts | 2 +- src/app/features/home/pages/dashboard/dashboard.component.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 c4ff71def..062e06ebe 100644 --- a/src/app/features/home/pages/dashboard/dashboard.component.spec.ts +++ b/src/app/features/home/pages/dashboard/dashboard.component.spec.ts @@ -106,7 +106,7 @@ describe('DashboardComponent', () => { }); expect(component.projectCreationDisabled()).toBe(true); - expect(component.buttonTooltip()).toBe('myProjects.header.createProjectDisabledTooltip'); + expect(component.buttonTooltip()).toBe('home.loggedIn.dashboard.createProjectDisabledTooltip'); }); it('should read query params and fetch projects on init', () => { diff --git a/src/app/features/home/pages/dashboard/dashboard.component.ts b/src/app/features/home/pages/dashboard/dashboard.component.ts index 7e89a8625..df0022552 100644 --- a/src/app/features/home/pages/dashboard/dashboard.component.ts +++ b/src/app/features/home/pages/dashboard/dashboard.component.ts @@ -75,7 +75,7 @@ export class DashboardComponent implements OnInit { readonly projectCreationDisabled = computed(() => this.activeFlags().includes('prevent_project_creation')); readonly buttonTooltip = computed(() => - this.projectCreationDisabled() ? 'myProjects.header.createProjectDisabledTooltip' : '' + this.projectCreationDisabled() ? 'home.loggedIn.dashboard.createProjectDisabledTooltip' : '' ); readonly existsProjects = computed(() => this.projects().length || !!this.searchControl.value?.length); From 62ea70986c95aafab4654e185f43a2f42eb8139e Mon Sep 17 00:00:00 2001 From: Futa Ikeda Date: Wed, 20 May 2026 12:16:41 -0400 Subject: [PATCH 04/35] feat(duplicates): Prevent duplicate creation --- .../view-duplicates.component.html | 2 + .../view-duplicates.component.spec.ts | 45 ++++++++++---- .../view-duplicates.component.ts | 5 ++ .../project-overview-toolbar.component.html | 47 +++++++++------ ...project-overview-toolbar.component.spec.ts | 59 +++++++++++++++---- .../project-overview-toolbar.component.ts | 12 ++-- src/assets/i18n/en.json | 3 +- 7 files changed, 128 insertions(+), 45 deletions(-) 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..118649a84 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.getActiveFlags, value: [] }, + ]; + 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 preventDuplicateCreation is true', () => { + setup({ + selectors: [{ selector: UserSelectors.getActiveFlags, value: ['prevent_project_creation'] }], + }); + 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..e0d37b358 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,16 @@ export class ViewDuplicatesComponent { isDuplicatesLoading = select(DuplicatesSelectors.getDuplicatesLoading); totalDuplicates = select(DuplicatesSelectors.getDuplicatesTotalCount); isAuthenticated = select(UserSelectors.isAuthenticated); + activeFlags = select(UserSelectors.getActiveFlags); readonly pageSize = 10; currentPage = signal(1); firstIndex = computed(() => (this.currentPage() - 1) * this.pageSize); + preventDuplicateCreation = computed(() => this.activeFlags().includes('prevent_project_creation')); + duplicateButtonTooltip = computed(() => + this.preventDuplicateCreation() ? 'project.overview.actions.duplicatingProjectsNotAllowed' : '' + ); readonly forkActionItems = (resourceId: string) => [ { 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..fa1cf22f2 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 @@ -59,23 +59,36 @@ } @if (!viewOnly()) { - - {{ resource.forksCount }} - - - - - {{ item.label | translate }} - - - - + @if (preventDuplicateCreation()) { + + {{ resource.forksCount }} + + + } @else { + + {{ resource.forksCount }} + + + + + {{ item.label | translate }} + + + + + } } @if (!viewOnly()) { diff --git a/src/app/features/project/overview/components/project-overview-toolbar/project-overview-toolbar.component.spec.ts b/src/app/features/project/overview/components/project-overview-toolbar/project-overview-toolbar.component.spec.ts index f405eccae..920fb0560 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,28 @@ 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.getActiveFlags, value: [] }, + ]; + 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 +94,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 +111,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 +136,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 +152,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 +168,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 +185,7 @@ describe('ProjectOverviewToolbarComponent', () => { }); it('should not open dialog when resource is null', () => { + setup(); fixture.componentRef.setInput('currentResource', null as any); fixture.detectChanges(); @@ -177,11 +197,28 @@ describe('ProjectOverviewToolbarComponent', () => { 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 activeFlags does not include prevent_project_creation', () => { + setup(); + expect(component.preventDuplicateCreation()).toBe(false); + }); + + it('should return true when activeFlags includes prevent_project_creation', () => { + setup({ + selectors: [{ selector: UserSelectors.getActiveFlags, value: ['prevent_project_creation'] }], + }); + fixture.detectChanges(); + expect(component.preventDuplicateCreation()).toBe(true); + }); + }); }); 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..2ede79430 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,8 @@ export class ProjectOverviewToolbarComponent { duplicatedProject = select(ProjectOverviewSelectors.getDuplicatedProject); isAuthenticated = select(UserSelectors.isAuthenticated); + activeFlags = select(UserSelectors.getActiveFlags); + preventDuplicateCreation = computed(() => this.activeFlags()?.includes('prevent_project_creation') ?? false); actions = createDispatchMap({ getResourceBookmark: GetResourceBookmark, @@ -96,9 +98,7 @@ export class ProjectOverviewToolbarComponent { }, { label: 'project.overview.actions.viewDuplication', - command: () => { - this.router.navigate(['../analytics/duplicates'], { relativeTo: this.route }); - }, + command: () => this.navigateToDuplicatesView(), }, ]; @@ -205,4 +205,8 @@ export class ProjectOverviewToolbarComponent { complete: () => this.actions.clearDuplicatedProject(), }); } + + navigateToDuplicatesView(): void { + this.router.navigate(['../analytics/duplicates'], { relativeTo: this.route }); + } } diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 2a11b6457..deedbfb7a 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -1856,7 +1856,8 @@ "forkProjectLabel": "Duplicate project", "manageContributors": "Manage Contributors", "settings": "Settings", - "viewDuplication": "View duplicates" + "viewDuplication": "View duplicates", + "duplicatingProjectsNotAllowed": "Project duplicates can no longer be created." }, "citations": { "copyCitation": "Copy citation", From 59b79f454289caa562e93663d6bcd2f77b0e3325 Mon Sep 17 00:00:00 2001 From: Futa Ikeda Date: Wed, 20 May 2026 17:16:43 -0400 Subject: [PATCH 05/35] feat(project-components): Prevent component cretion --- .../overview-components.component.html | 2 + .../overview-components.component.spec.ts | 48 +++++++++++++++---- .../overview-components.component.ts | 12 ++++- src/assets/i18n/en.json | 1 + 4 files changed, 52 insertions(+), 11 deletions(-) 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..81ae36b7f 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.getActiveFlags, value: [] }, + ]; + 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 prevent_project_creation flag is active', () => { + setup({ + selectors: [{ selector: UserSelectors.getActiveFlags, value: ['prevent_project_creation'] }], + }); + + 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..63c4c86c4 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); + activeFlags = select(UserSelectors.getActiveFlags); reorderedComponents = signal([]); @@ -55,6 +58,11 @@ export class OverviewComponentsComponent { () => this.isComponentsSubmitting() || (!this.canEdit() && this.reorderedComponents().length <= 1) ); + preventComponentCreation = computed(() => this.activeFlags().includes('prevent_project_creation')); + createComponentTooltip = computed(() => + this.preventComponentCreation() ? 'project.overview.components.addComponentDisabled' : '' + ); + constructor() { effect(() => { const componentsData = this.components(); @@ -77,6 +85,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/assets/i18n/en.json b/src/assets/i18n/en.json index deedbfb7a..496876f93 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -1871,6 +1871,7 @@ }, "components": { "addComponentButton": "Add Component", + "addComponentDisabled": "Project components can no longer be created.", "linkProjectsButton": "Link Projects", "noComponentsMessage": "Add components to organize your project.", "title": "Components" From bc2cf031bca572a95f6780ff889143815e8ee64d Mon Sep 17 00:00:00 2001 From: Futa Ikeda Date: Thu, 21 May 2026 09:58:26 -0400 Subject: [PATCH 06/35] feat(preprint-component): Prevent project creation --- .../supplements-step.component.html | 2 ++ .../supplements-step.component.spec.ts | 11 ++++++++++ .../supplements-step.component.ts | 20 ++++++++++++++++++- src/assets/i18n/en.json | 1 + 4 files changed, 33 insertions(+), 1 deletion(-) 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..a205f2d9d 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.getActiveFlags, value: [] }, ]; 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 active flags', () => { + setup({ + selectorOverrides: [{ selector: UserSelectors.getActiveFlags, value: ['prevent_project_creation'] }], + detectChanges: false, + }); + expect(component.createProjectDisabled()).toBe(true); + 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..305167d78 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 activeFlags = select(UserSelectors.getActiveFlags); selectedSupplementOption = signal(SupplementOptions.None); selectedProjectId = signal(null); @@ -113,6 +126,11 @@ export class SupplementsStepComponent implements OnInit { return false; }); + createProjectDisabled = computed(() => this.activeFlags().includes('prevent_project_creation')); + createProjectTooltip = computed(() => + this.createProjectDisabled() ? 'preprints.preprintStepper.supplements.projectCreationDisabled' : '' + ); + constructor() { effect(() => { const preprint = this.createdPreprint(); diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 496876f93..2d0159dda 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -1567,6 +1567,7 @@ "connectExisting": "Connect An Existing OSF Project", "createNew": "Create A New OSF Project" }, + "projectCreationDisabled": "Projects can no longer be created.", "successMessages": { "projectConnected": "Project connected", "projectCreated": "Project created", From 3a817d3fa7ac31346d5eedd05b9f284b31f70c92 Mon Sep 17 00:00:00 2001 From: Futa Ikeda Date: Thu, 28 May 2026 09:44:31 -0400 Subject: [PATCH 07/35] feat(registration-overview): Hide associated project when hasProject is false --- .../registry-overview-metadata.component.html | 2 +- ...egistry-overview-metadata.component.spec.ts | 18 ++++++++++++++++++ .../registry-overview-metadata.component.ts | 5 ++++- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/app/features/registry/components/registry-overview-metadata/registry-overview-metadata.component.html b/src/app/features/registry/components/registry-overview-metadata/registry-overview-metadata.component.html index 6e9a32ebc..b10a562cb 100644 --- a/src/app/features/registry/components/registry-overview-metadata/registry-overview-metadata.component.html +++ b/src/app/features/registry/components/registry-overview-metadata/registry-overview-metadata.component.html @@ -50,7 +50,7 @@

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

{{ registryProvider()?.name }}

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

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

diff --git a/src/app/features/registry/components/registry-overview-metadata/registry-overview-metadata.component.spec.ts b/src/app/features/registry/components/registry-overview-metadata/registry-overview-metadata.component.spec.ts index b7f022338..72f805641 100644 --- a/src/app/features/registry/components/registry-overview-metadata/registry-overview-metadata.component.spec.ts +++ b/src/app/features/registry/components/registry-overview-metadata/registry-overview-metadata.component.spec.ts @@ -150,4 +150,22 @@ describe('RegistryOverviewMetadataComponent', () => { expect(mockRouter.navigate).toHaveBeenCalledWith(['/search'], { queryParams: { search: 'test-tag' } }); }); + + it('should show associated project section if registry has associated project and is not a project registration', () => { + const { fixture } = setup({ + registry: { ...MOCK_REGISTRY, associatedProjectId: 'project-123', hasProject: true }, + }); + const associatedProjectSection = fixture.nativeElement.querySelector( + '[data-test-registry-overview-metadata-associated-project-link]' + ); + expect(associatedProjectSection).not.toBeNull(); + }); + + it('should hide associated project section if registry has no associated project', () => { + const { fixture } = setup({ registry: { ...MOCK_REGISTRY, hasProject: false } }); + const associatedProjectSection = fixture.nativeElement.querySelector( + '[data-test-registry-overview-metadata-associated-project-link]' + ); + expect(associatedProjectSection).toBeNull(); + }); }); diff --git a/src/app/features/registry/components/registry-overview-metadata/registry-overview-metadata.component.ts b/src/app/features/registry/components/registry-overview-metadata/registry-overview-metadata.component.ts index 0fa1ff01a..f9f5aef13 100644 --- a/src/app/features/registry/components/registry-overview-metadata/registry-overview-metadata.component.ts +++ b/src/app/features/registry/components/registry-overview-metadata/registry-overview-metadata.component.ts @@ -5,7 +5,7 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { DatePipe } from '@angular/common'; -import { ChangeDetectionStrategy, Component, effect, inject } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, effect, inject } from '@angular/core'; import { Router, RouterLink } from '@angular/router'; import { ENVIRONMENT } from '@core/provider/environment.provider'; @@ -75,6 +75,9 @@ export class RegistryOverviewMetadataComponent { readonly currentResourceType = CurrentResourceType.Registrations; readonly dateFormat = 'MMM d, y, h:mm a'; readonly webUrl = this.environment.webUrl; + readonly showAssociatedProject = computed( + () => !!this.registry()?.associatedProjectId && this.registry()?.hasProject + ); private readonly actions = createDispatchMap({ getSubjects: FetchSelectedSubjects, From 7941d0c20c8e4ca32027d9c39a4609585edee1ae Mon Sep 17 00:00:00 2001 From: Futa Ikeda Date: Thu, 28 May 2026 10:40:59 -0400 Subject: [PATCH 08/35] feat(registries): Allow undefined associatedProjectId --- .../registry-overview-metadata.component.spec.ts | 10 +++++++++- .../registry/models/registry-overview.model.ts | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/app/features/registry/components/registry-overview-metadata/registry-overview-metadata.component.spec.ts b/src/app/features/registry/components/registry-overview-metadata/registry-overview-metadata.component.spec.ts index 72f805641..635669c36 100644 --- a/src/app/features/registry/components/registry-overview-metadata/registry-overview-metadata.component.spec.ts +++ b/src/app/features/registry/components/registry-overview-metadata/registry-overview-metadata.component.spec.ts @@ -162,10 +162,18 @@ describe('RegistryOverviewMetadataComponent', () => { }); it('should hide associated project section if registry has no associated project', () => { - const { fixture } = setup({ registry: { ...MOCK_REGISTRY, hasProject: false } }); + const { fixture } = setup({ registry: { ...MOCK_REGISTRY, associatedProjectId: 'abc123', hasProject: false } }); const associatedProjectSection = fixture.nativeElement.querySelector( '[data-test-registry-overview-metadata-associated-project-link]' ); expect(associatedProjectSection).toBeNull(); + + const { fixture: fixture2 } = setup({ + registry: { ...MOCK_REGISTRY, associatedProjectId: undefined, hasProject: true }, + }); + const associatedProjectSection2 = fixture2.nativeElement.querySelector( + '[data-test-registry-overview-metadata-associated-project-link]' + ); + expect(associatedProjectSection2).toBeNull(); }); }); diff --git a/src/app/features/registry/models/registry-overview.model.ts b/src/app/features/registry/models/registry-overview.model.ts index 957feafb5..d100a6384 100644 --- a/src/app/features/registry/models/registry-overview.model.ts +++ b/src/app/features/registry/models/registry-overview.model.ts @@ -5,7 +5,7 @@ import { RegistrationNodeModel } from '@shared/models/registration/registration- export interface RegistrationOverviewModel extends RegistrationNodeModel { registrationSchemaLink: string; licenseId: string; - associatedProjectId: string; + associatedProjectId?: string; providerId: string; status: RegistryStatus; forksCount: number; From 47b8b5d1dc45a74a4f24430dcd6f3c4aa66db2ec Mon Sep 17 00:00:00 2001 From: Futa Ikeda Date: Mon, 1 Jun 2026 17:46:42 -0400 Subject: [PATCH 09/35] feat(preprints): Allow Supplements step to be toggled on or off --- .../submit-preprint-stepper.component.spec.ts | 20 ++++++++++++++++ .../submit-preprint-stepper.component.ts | 11 ++++++++- .../update-preprint-stepper.component.spec.ts | 24 +++++++++++++++++++ .../update-preprint-stepper.component.ts | 7 ++++++ 4 files changed, 61 insertions(+), 1 deletion(-) 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 cbb33a9d0..c805e3d44 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.getActiveFlags, value: [] }, ]; function setup(overrides?: { selectorOverrides?: SignalOverride[] }) { @@ -170,6 +172,24 @@ describe('SubmitPreprintStepperComponent', () => { expect(stepValues).toContain(PreprintSteps.AuthorAssertions); }); + it('should filter out Supplements step when supplements are disabled via flag', () => { + setup({ + selectorOverrides: [{ selector: UserSelectors.getActiveFlags, value: ['prevent_project_creation'] }], + }); + + const stepValues = component.steps().map((s) => s.value); + expect(stepValues).not.toContain(PreprintSteps.Supplements); + }); + + it('should include Supplements step when supplements are enabled via flag', () => { + setup({ + selectorOverrides: [{ selector: UserSelectors.getActiveFlags, value: [] }], + }); + + 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 59bd3bb4a..914155f56 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'; @@ -80,6 +81,7 @@ export class SubmitPreprintStepperComponent implements OnDestroy, CanDeactivateC preprintProvider = select(PreprintProvidersSelectors.getPreprintProviderDetails(this.providerId())); isPreprintProviderLoading = select(PreprintProvidersSelectors.isPreprintProviderDetailsLoading); hasBeenSubmitted = select(PreprintStepperSelectors.hasBeenSubmitted); + activeFlags = select(UserSelectors.getActiveFlags); currentStep = signal(submitPreprintSteps[0]); @@ -87,6 +89,8 @@ export class SubmitPreprintStepperComponent implements OnDestroy, CanDeactivateC readonly PreprintSteps = PreprintSteps; + readonly supplementsEnabled = computed(() => !this.activeFlags().includes('prevent_project_creation')); + readonly steps = computed(() => { const provider = this.preprintProvider(); @@ -95,7 +99,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.supplementsEnabled()) + ); + }) .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 69345de2a..69ca42097 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'; @@ -66,6 +67,7 @@ describe('UpdatePreprintStepperComponent', () => { { selector: PreprintStepperSelectors.getPreprint, value: mockPreprint }, { selector: PreprintStepperSelectors.hasBeenSubmitted, value: false }, { selector: PreprintStepperSelectors.hasAdminAccess, value: false }, + { selector: UserSelectors.getActiveFlags, value: [] }, ]; function setup(overrides?: { selectorOverrides?: SignalOverride[] }) { @@ -168,6 +170,28 @@ describe('UpdatePreprintStepperComponent', () => { expect(stepValues).toContain(PreprintSteps.Review); }); + it('should filter out Supplements step when waffle flag is active', () => { + setup({ + selectorOverrides: [{ selector: UserSelectors.getActiveFlags, value: ['prevent_project_creation'] }], + }); + + const steps = component.updateSteps(); + const stepValues = steps.map((s) => s.value); + + expect(stepValues).not.toContain(PreprintSteps.Supplements); + }); + + it('should include Supplements step when waffle flag is inactive', () => { + setup({ + selectorOverrides: [{ selector: UserSelectors.getActiveFlags, value: [] }], + }); + + 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 16b98abb5..9f8df2681 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'; @@ -87,6 +88,7 @@ export class UpdatePreprintStepperComponent implements OnDestroy, CanDeactivateC isPreprintProviderLoading = select(PreprintProvidersSelectors.isPreprintProviderDetailsLoading); hasBeenSubmitted = select(PreprintStepperSelectors.hasBeenSubmitted); hasAdminAccess = select(PreprintStepperSelectors.hasAdminAccess); + activeFlags = select(UserSelectors.getActiveFlags); isWeb = toSignal(inject(IS_WEB)); @@ -103,6 +105,8 @@ export class UpdatePreprintStepperComponent implements OnDestroy, CanDeactivateC isPreprintRejected = computed(() => this.preprint()?.reviewsState === ReviewsState.Rejected); + readonly supplementsEnabled = computed(() => !this.activeFlags().includes('prevent_project_creation')); + readonly updateSteps = computed(() => { const provider = this.preprintProvider(); const preprint = this.preprint(); @@ -119,6 +123,9 @@ export class UpdatePreprintStepperComponent implements OnDestroy, CanDeactivateC if (step.value === PreprintSteps.AuthorAssertions) { return provider.assertionsEnabled && this.hasAdminAccess(); } + if (step.value === PreprintSteps.Supplements) { + return this.supplementsEnabled(); + } return true; }) .map((step, index) => ({ ...step, index })); From c4eda5516a73f323c991c68d8d634f655cd63aa5 Mon Sep 17 00:00:00 2001 From: Futa Ikeda Date: Tue, 2 Jun 2026 14:43:45 -0400 Subject: [PATCH 10/35] feat(registrations): Update file upload description --- .../custom-step/custom-step.component.html | 2 +- .../custom-step/custom-step.component.spec.ts | 14 ++++++++++++++ .../custom-step/custom-step.component.ts | 8 ++++++++ src/assets/i18n/en.json | 1 + 4 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/app/features/registries/components/custom-step/custom-step.component.html b/src/app/features/registries/components/custom-step/custom-step.component.html index 3424906dd..691ef71f1 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 b17f69381..2e1197425 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 } 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'; @@ -63,6 +64,7 @@ describe('CustomStepComponent', () => { const defaultSignals: SignalOverride[] = [ { selector: RegistriesSelectors.getPagesSchema, value: overrides.pages ?? [MOCK_REGISTRIES_PAGE] }, { selector: RegistriesSelectors.getStepsState, value: overrides.stepsState ?? {} }, + { selector: UserSelectors.getActiveFlags, value: [] }, ]; const signals = mergeSignalOverrides(defaultSignals, overrides.selectorOverrides); @@ -161,6 +163,18 @@ describe('CustomStepComponent', () => { expect(store.dispatch).not.toHaveBeenCalled(); }); + it('should update update file upload description based on waffle flag value', () => { + const { component } = setup({ + selectorOverrides: [{ selector: UserSelectors.getActiveFlags, value: ['prevent_project_creation'] }], + }); + expect(component.fileUploadDescription()).toBe('shared.files.descriptionNoProject'); + + const { component: component2 } = setup({ + selectorOverrides: [{ selector: UserSelectors.getActiveFlags, value: [] }], + }); + 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 357bc71b5..602c032e8 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'; @@ -88,6 +89,7 @@ export class CustomStepComponent implements OnDestroy { readonly pages = select(RegistriesSelectors.getPagesSchema); readonly stepsState = select(RegistriesSelectors.getStepsState); + readonly activeFlags = select(UserSelectors.getActiveFlags); private readonly actions = createDispatchMap({ updateStepState: UpdateStepState, @@ -99,6 +101,12 @@ export class CustomStepComponent implements OnDestroy { step = signal(this.route.snapshot.params['step']); currentPage = computed(() => this.pages()[this.step() - 1]); + readonly fileUploadDescription = computed(() => { + if (this.activeFlags().includes('prevent_project_creation')) { + return 'shared.files.descriptionNoProject'; + } + return 'shared.files.description'; + }); stepForm: FormGroup = this.fb.group({}); attachedFiles: Record = {}; diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 2d0159dda..24d19a9e3 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -2839,6 +2839,7 @@ }, "files": { "description": "Uploaded files will automatically be archived in this registration. They will also be added to a related project that will be created for this registration.", + "descriptionNoProject": "Uploaded files will automatically be archived in this registration.", "limitText": "You may attach up to 5 file(s) to this question. Files cannot total over 5GB in size." }, "license": { From 5d42bdbfb2a2647851e0a627543b6eef7b94d124 Mon Sep 17 00:00:00 2001 From: Futa Ikeda Date: Tue, 2 Jun 2026 14:44:14 -0400 Subject: [PATCH 11/35] feat(i18n): Update wording --- src/assets/i18n/en.json | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 24d19a9e3..ae2f7c0a1 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -796,8 +796,7 @@ "loggedIn": { "dashboard": { "createProject": "Create New Project", - "createProjectDisabledTooltip": "Projects can no longer be created.", - + "createProjectDisabledTooltip": "Project creation is no longer available. OSF is transitioning away from Projects as part of a platform update. Your existing projects remain accessible.", "getStartedHelp": "Visit Get Started Help Guides", "images": { "osfCollectionsImageAltText": "OSF Collections", @@ -806,7 +805,7 @@ "osfRegistriesImageAltTest": "OSF Registries" }, "noCreatedProject": "You haven’t created a project yet. Click the \"Create New Project\" button above to get started.", - "noCreatedProjectAndCreateProjectDisabled": "You haven’t created a project yet.", + "noCreatedProjectAndCreateProjectDisabled": "You don't have any projects.", "quickSearch": { "goTo": "Go to", "myProjects": "My Projects", @@ -1098,7 +1097,7 @@ }, "header": { "createProject": "Create Project", - "createProjectDisabledTooltip": "Projects can no longer be created.", + "createProjectDisabledTooltip": "Project creation is no longer available. OSF is transitioning away from Projects as part of a platform update. Your existing projects remain accessible.", "title": "My Projects" }, "redirectDialog": { @@ -1567,7 +1566,7 @@ "connectExisting": "Connect An Existing OSF Project", "createNew": "Create A New OSF Project" }, - "projectCreationDisabled": "Projects can no longer be created.", + "projectCreationDisabled": "Project creation is no longer available. OSF is transitioning away from Projects as part of a platform update. Your existing projects remain accessible.", "successMessages": { "projectConnected": "Project connected", "projectCreated": "Project created", @@ -1858,7 +1857,7 @@ "manageContributors": "Manage Contributors", "settings": "Settings", "viewDuplication": "View duplicates", - "duplicatingProjectsNotAllowed": "Project duplicates can no longer be created." + "duplicatingProjectsNotAllowed": "Duplicating projects is no longer available. OSF is transitioning away from Projects as part of a platform update." }, "citations": { "copyCitation": "Copy citation", @@ -1872,7 +1871,7 @@ }, "components": { "addComponentButton": "Add Component", - "addComponentDisabled": "Project components can no longer be created.", + "addComponentDisabled": "Component creation is no longer available. OSF is transitioning away from Projects as part of a platform update.", "linkProjectsButton": "Link Projects", "noComponentsMessage": "Add components to organize your project.", "title": "Components" From d6881e206bdeec26373335e833709c820758396e Mon Sep 17 00:00:00 2001 From: Futa Ikeda Date: Tue, 2 Jun 2026 15:51:42 -0400 Subject: [PATCH 12/35] fix(registrations): Fix test name typo --- .../components/custom-step/custom-step.component.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 2e1197425..2b12ae946 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 @@ -163,7 +163,7 @@ describe('CustomStepComponent', () => { expect(store.dispatch).not.toHaveBeenCalled(); }); - it('should update update file upload description based on waffle flag value', () => { + it('should update file upload description based on waffle flag value', () => { const { component } = setup({ selectorOverrides: [{ selector: UserSelectors.getActiveFlags, value: ['prevent_project_creation'] }], }); From 655ed2318d0b5df44fbf02536788e7857ceb3d95 Mon Sep 17 00:00:00 2001 From: Futa Ikeda Date: Wed, 17 Jun 2026 11:59:48 -0400 Subject: [PATCH 13/35] refactor(UserSelectors): add new selector to indicate if project creation is allowed --- src/app/core/store/user/user.selectors.ts | 5 +++++ .../view-duplicates.component.spec.ts | 6 +++--- .../view-duplicates/view-duplicates.component.ts | 3 +-- .../home/pages/dashboard/dashboard.component.spec.ts | 7 +++---- .../home/pages/dashboard/dashboard.component.ts | 9 ++++----- .../my-projects/my-projects.component.spec.ts | 6 +++--- .../features/my-projects/my-projects.component.ts | 3 +-- .../supplements-step.component.spec.ts | 8 ++++---- .../supplements-step/supplements-step.component.ts | 3 +-- .../submit-preprint-stepper.component.spec.ts | 12 +++++------- .../submit-preprint-stepper.component.ts | 6 ++---- .../update-preprint-stepper.component.spec.ts | 12 +++++------- .../update-preprint-stepper.component.ts | 6 ++---- .../overview-components.component.spec.ts | 6 +++--- .../overview-components.component.ts | 3 +-- .../project-overview-toolbar.component.spec.ts | 8 ++++---- .../project-overview-toolbar.component.ts | 5 ++--- .../custom-step/custom-step.component.spec.ts | 8 ++++---- .../components/custom-step/custom-step.component.ts | 4 ++-- 19 files changed, 55 insertions(+), 65 deletions(-) diff --git a/src/app/core/store/user/user.selectors.ts b/src/app/core/store/user/user.selectors.ts index 311d3eec1..d9758ec1a 100644 --- a/src/app/core/store/user/user.selectors.ts +++ b/src/app/core/store/user/user.selectors.ts @@ -58,4 +58,9 @@ 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; + } } 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 118649a84..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 @@ -56,7 +56,7 @@ describe('Component: View Duplicates', () => { { selector: ProjectOverviewSelectors.isProjectAnonymous, value: false }, { selector: RegistrySelectors.getRegistry, value: undefined }, { selector: RegistrySelectors.isRegistryAnonymous, value: false }, - { selector: UserSelectors.getActiveFlags, value: [] }, + { selector: UserSelectors.isProjectCreationDisabled, value: false }, ]; const signals = mergeSignalOverrides(defaultSelectors, overrides.selectors || []); @@ -94,9 +94,9 @@ describe('Component: View Duplicates', () => { expect(component).toBeTruthy(); }); - it('should disable fork button and show tooltip when preventDuplicateCreation is true', () => { + it('should disable fork button and show tooltip when isProjectCreationDisabled is true', () => { setup({ - selectors: [{ selector: UserSelectors.getActiveFlags, value: ['prevent_project_creation'] }], + selectors: [{ selector: UserSelectors.isProjectCreationDisabled, value: true }], }); expect(component.preventDuplicateCreation()).toBe(true); expect(component.duplicateButtonTooltip()).toBe('project.overview.actions.duplicatingProjectsNotAllowed'); 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 e0d37b358..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,13 +77,12 @@ export class ViewDuplicatesComponent { isDuplicatesLoading = select(DuplicatesSelectors.getDuplicatesLoading); totalDuplicates = select(DuplicatesSelectors.getDuplicatesTotalCount); isAuthenticated = select(UserSelectors.isAuthenticated); - activeFlags = select(UserSelectors.getActiveFlags); + preventDuplicateCreation = select(UserSelectors.isProjectCreationDisabled); readonly pageSize = 10; currentPage = signal(1); firstIndex = computed(() => (this.currentPage() - 1) * this.pageSize); - preventDuplicateCreation = computed(() => this.activeFlags().includes('prevent_project_creation')); duplicateButtonTooltip = computed(() => this.preventDuplicateCreation() ? 'project.overview.actions.duplicatingProjectsNotAllowed' : '' ); 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 062e06ebe..c6139d92a 100644 --- a/src/app/features/home/pages/dashboard/dashboard.component.spec.ts +++ b/src/app/features/home/pages/dashboard/dashboard.component.spec.ts @@ -50,7 +50,7 @@ describe('DashboardComponent', () => { { selector: MyResourcesSelectors.getProjects, value: [] }, { selector: MyResourcesSelectors.getTotalProjects, value: 0 }, { selector: MyResourcesSelectors.getProjectsLoading, value: false }, - { selector: UserSelectors.getActiveFlags, value: [] }, + { selector: UserSelectors.isProjectCreationDisabled, value: false }, ]; interface SetupOverrides extends BaseSetupOverrides { @@ -100,12 +100,11 @@ describe('DashboardComponent', () => { expect(component).toBeTruthy(); }); - it('should disable project creation and show tooltip when prevent_project_creation flag is active', () => { + it('should disable project creation and show tooltip when isProjectCreationDisabled is true', () => { setup({ - selectorOverrides: [{ selector: UserSelectors.getActiveFlags, value: ['prevent_project_creation'] }], + selectorOverrides: [{ selector: UserSelectors.isProjectCreationDisabled, value: true }], }); - expect(component.projectCreationDisabled()).toBe(true); expect(component.buttonTooltip()).toBe('home.loggedIn.dashboard.createProjectDisabledTooltip'); }); diff --git a/src/app/features/home/pages/dashboard/dashboard.component.ts b/src/app/features/home/pages/dashboard/dashboard.component.ts index df0022552..aa03a2f5b 100644 --- a/src/app/features/home/pages/dashboard/dashboard.component.ts +++ b/src/app/features/home/pages/dashboard/dashboard.component.ts @@ -54,7 +54,6 @@ export class DashboardComponent implements OnInit { private readonly projectRedirectDialogService = inject(ProjectRedirectDialogService); private readonly platformId = inject(PLATFORM_ID); private readonly isBrowser = isPlatformBrowser(this.platformId); - private readonly activeFlags = select(UserSelectors.getActiveFlags); readonly searchControl = new FormControl(''); readonly activeProject = signal(null); @@ -65,6 +64,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 actions = createDispatchMap({ getMyProjects: GetMyProjects, clearMyResources: ClearMyResources }); @@ -73,10 +73,9 @@ export class DashboardComponent implements OnInit { return this.projects().filter((project) => project.title.toLowerCase().includes(search)); }); - readonly projectCreationDisabled = computed(() => this.activeFlags().includes('prevent_project_creation')); - readonly buttonTooltip = computed(() => - this.projectCreationDisabled() ? 'home.loggedIn.dashboard.createProjectDisabledTooltip' : '' - ); + readonly buttonTooltip = computed(() => { + return this.projectCreationDisabled() ? 'home.loggedIn.dashboard.createProjectDisabledTooltip' : ''; + }); readonly existsProjects = computed(() => this.projects().length || !!this.searchControl.value?.length); readonly noProjectsMessage = computed(() => { 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 a148eea7e..34c68613e 100644 --- a/src/app/features/my-projects/my-projects.component.spec.ts +++ b/src/app/features/my-projects/my-projects.component.spec.ts @@ -78,7 +78,7 @@ describe('MyProjectsComponent', () => { { selector: BookmarksSelectors.getBookmarks, value: [] }, { selector: BookmarksSelectors.getBookmarksCollectionId, value: 'bookmark-collection-id' }, { selector: BookmarksSelectors.getBookmarksTotalCount, value: 0 }, - { selector: UserSelectors.getActiveFlags, value: [] }, + { selector: UserSelectors.isProjectCreationDisabled, value: false }, ]; function setup(selectorOverrides?: SignalOverride[]) { @@ -134,8 +134,8 @@ describe('MyProjectsComponent', () => { expect(component).toBeTruthy(); }); - it('should disable project creation and show tooltip when prevent_project_creation flag is active', () => { - setup([{ selector: UserSelectors.getActiveFlags, value: ['prevent_project_creation'] }]); + 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'); diff --git a/src/app/features/my-projects/my-projects.component.ts b/src/app/features/my-projects/my-projects.component.ts index 15344bcea..eab37b4de 100644 --- a/src/app/features/my-projects/my-projects.component.ts +++ b/src/app/features/my-projects/my-projects.component.ts @@ -84,7 +84,7 @@ export class MyProjectsComponent implements OnInit { readonly tableParamsService = inject(MyProjectsTableParamsService); readonly platformId = inject(PLATFORM_ID); readonly isBrowser = isPlatformBrowser(this.platformId); - readonly activeFlags = select(UserSelectors.getActiveFlags); + readonly projectCreationDisabled = select(UserSelectors.isProjectCreationDisabled); readonly isLoading = signal(false); readonly isMedium = toSignal(inject(IS_MEDIUM)); @@ -115,7 +115,6 @@ export class MyProjectsComponent implements OnInit { readonly bookmarksCollectionId = select(BookmarksSelectors.getBookmarksCollectionId); readonly totalBookmarksCount = select(BookmarksSelectors.getBookmarksTotalCount); readonly isBookmarks = computed(() => this.selectedTab() === MyProjectsTab.Bookmarks); - readonly projectCreationDisabled = computed(() => this.activeFlags().includes('prevent_project_creation')); readonly buttonTooltip = computed(() => this.projectCreationDisabled() ? 'myProjects.header.createProjectDisabledTooltip' : '' ); 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 a205f2d9d..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 @@ -47,7 +47,7 @@ describe('SupplementsStepComponent', () => { { selector: PreprintStepperSelectors.areAvailableProjectsLoading, value: false }, { selector: PreprintStepperSelectors.getPreprintProject, value: null }, { selector: PreprintStepperSelectors.isPreprintProjectLoading, value: false }, - { selector: UserSelectors.getActiveFlags, value: [] }, + { selector: UserSelectors.isProjectCreationDisabled, value: false }, ]; function setup(overrides?: { selectorOverrides?: SignalOverride[]; detectChanges?: boolean }) { @@ -362,12 +362,12 @@ describe('SupplementsStepComponent', () => { expect(component.isNextButtonDisabled()).toBe(false); }); - it('should compute create project disabled state based on active flags', () => { + it('should compute create project disabled state based on isProjectCreationDisabled', () => { setup({ - selectorOverrides: [{ selector: UserSelectors.getActiveFlags, value: ['prevent_project_creation'] }], + selectorOverrides: [{ selector: UserSelectors.isProjectCreationDisabled, value: true }], detectChanges: false, }); - expect(component.createProjectDisabled()).toBe(true); + 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 305167d78..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 @@ -81,7 +81,7 @@ export class SupplementsStepComponent implements OnInit { readonly areAvailableProjectsLoading = select(PreprintStepperSelectors.areAvailableProjectsLoading); readonly preprintProject = select(PreprintStepperSelectors.getPreprintProject); readonly isPreprintProjectLoading = select(PreprintStepperSelectors.isPreprintProjectLoading); - readonly activeFlags = select(UserSelectors.getActiveFlags); + readonly createProjectDisabled = select(UserSelectors.isProjectCreationDisabled); selectedSupplementOption = signal(SupplementOptions.None); selectedProjectId = signal(null); @@ -126,7 +126,6 @@ export class SupplementsStepComponent implements OnInit { return false; }); - createProjectDisabled = computed(() => this.activeFlags().includes('prevent_project_creation')); createProjectTooltip = computed(() => this.createProjectDisabled() ? 'preprints.preprintStepper.supplements.projectCreationDisabled' : '' ); 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 c805e3d44..43189f252 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 @@ -57,7 +57,7 @@ describe('SubmitPreprintStepperComponent', () => { { selector: PreprintProvidersSelectors.getPreprintProviderDetails(mockProviderId), value: mockProvider }, { selector: PreprintProvidersSelectors.isPreprintProviderDetailsLoading, value: false }, { selector: PreprintStepperSelectors.hasBeenSubmitted, value: false }, - { selector: UserSelectors.getActiveFlags, value: [] }, + { selector: UserSelectors.isProjectCreationDisabled, value: false }, ]; function setup(overrides?: { selectorOverrides?: SignalOverride[] }) { @@ -172,19 +172,17 @@ describe('SubmitPreprintStepperComponent', () => { expect(stepValues).toContain(PreprintSteps.AuthorAssertions); }); - it('should filter out Supplements step when supplements are disabled via flag', () => { + it('should filter out Supplements step when supplements are disabled via isProjectCreationDisabled', () => { setup({ - selectorOverrides: [{ selector: UserSelectors.getActiveFlags, value: ['prevent_project_creation'] }], + 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 flag', () => { - setup({ - selectorOverrides: [{ selector: UserSelectors.getActiveFlags, value: [] }], - }); + 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); 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 914155f56..a65658b59 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 @@ -81,7 +81,7 @@ export class SubmitPreprintStepperComponent implements OnDestroy, CanDeactivateC preprintProvider = select(PreprintProvidersSelectors.getPreprintProviderDetails(this.providerId())); isPreprintProviderLoading = select(PreprintProvidersSelectors.isPreprintProviderDetailsLoading); hasBeenSubmitted = select(PreprintStepperSelectors.hasBeenSubmitted); - activeFlags = select(UserSelectors.getActiveFlags); + supplementsDisabled = select(UserSelectors.isProjectCreationDisabled); currentStep = signal(submitPreprintSteps[0]); @@ -89,8 +89,6 @@ export class SubmitPreprintStepperComponent implements OnDestroy, CanDeactivateC readonly PreprintSteps = PreprintSteps; - readonly supplementsEnabled = computed(() => !this.activeFlags().includes('prevent_project_creation')); - readonly steps = computed(() => { const provider = this.preprintProvider(); @@ -102,7 +100,7 @@ export class SubmitPreprintStepperComponent implements OnDestroy, CanDeactivateC .filter((step) => { return ( (step.value !== PreprintSteps.AuthorAssertions || provider.assertionsEnabled) && - (step.value !== PreprintSteps.Supplements || this.supplementsEnabled()) + (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 69ca42097..0ee1332cd 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 @@ -67,7 +67,7 @@ describe('UpdatePreprintStepperComponent', () => { { selector: PreprintStepperSelectors.getPreprint, value: mockPreprint }, { selector: PreprintStepperSelectors.hasBeenSubmitted, value: false }, { selector: PreprintStepperSelectors.hasAdminAccess, value: false }, - { selector: UserSelectors.getActiveFlags, value: [] }, + { selector: UserSelectors.isProjectCreationDisabled, value: false }, ]; function setup(overrides?: { selectorOverrides?: SignalOverride[] }) { @@ -170,9 +170,9 @@ describe('UpdatePreprintStepperComponent', () => { expect(stepValues).toContain(PreprintSteps.Review); }); - it('should filter out Supplements step when waffle flag is active', () => { + it('should filter out Supplements step when isProjectCreationDisabled is true', () => { setup({ - selectorOverrides: [{ selector: UserSelectors.getActiveFlags, value: ['prevent_project_creation'] }], + selectorOverrides: [{ selector: UserSelectors.isProjectCreationDisabled, value: true }], }); const steps = component.updateSteps(); @@ -181,10 +181,8 @@ describe('UpdatePreprintStepperComponent', () => { expect(stepValues).not.toContain(PreprintSteps.Supplements); }); - it('should include Supplements step when waffle flag is inactive', () => { - setup({ - selectorOverrides: [{ selector: UserSelectors.getActiveFlags, value: [] }], - }); + it('should include Supplements step when isProjectCreationDisabled is false', () => { + setup(); const steps = component.updateSteps(); const stepValues = steps.map((s) => s.value); 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 9f8df2681..fb620e43e 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 @@ -88,7 +88,7 @@ export class UpdatePreprintStepperComponent implements OnDestroy, CanDeactivateC isPreprintProviderLoading = select(PreprintProvidersSelectors.isPreprintProviderDetailsLoading); hasBeenSubmitted = select(PreprintStepperSelectors.hasBeenSubmitted); hasAdminAccess = select(PreprintStepperSelectors.hasAdminAccess); - activeFlags = select(UserSelectors.getActiveFlags); + supplementsDisabled = select(UserSelectors.isProjectCreationDisabled); isWeb = toSignal(inject(IS_WEB)); @@ -105,8 +105,6 @@ export class UpdatePreprintStepperComponent implements OnDestroy, CanDeactivateC isPreprintRejected = computed(() => this.preprint()?.reviewsState === ReviewsState.Rejected); - readonly supplementsEnabled = computed(() => !this.activeFlags().includes('prevent_project_creation')); - readonly updateSteps = computed(() => { const provider = this.preprintProvider(); const preprint = this.preprint(); @@ -124,7 +122,7 @@ export class UpdatePreprintStepperComponent implements OnDestroy, CanDeactivateC return provider.assertionsEnabled && this.hasAdminAccess(); } if (step.value === PreprintSteps.Supplements) { - return this.supplementsEnabled(); + return !this.supplementsDisabled(); } return true; }) 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 81ae36b7f..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 @@ -65,7 +65,7 @@ describe('OverviewComponentsComponent', () => { { selector: ProjectOverviewSelectors.getComponentsSubmitting, value: false }, { selector: ProjectOverviewSelectors.hasMoreComponents, value: true }, { selector: ProjectOverviewSelectors.getProject, value: project }, - { selector: UserSelectors.getActiveFlags, value: [] }, + { selector: UserSelectors.isProjectCreationDisabled, value: false }, ]; const signals = mergeSignalOverrides(defaultSelectors, overrides.selectors); @@ -183,9 +183,9 @@ describe('OverviewComponentsComponent', () => { expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(ReorderComponents)); }); - it('should disable add component button and show tooltip when prevent_project_creation flag is active', () => { + it('should disable add component button and show tooltip when isProjectCreationDisabled flag is true', () => { setup({ - selectors: [{ selector: UserSelectors.getActiveFlags, value: ['prevent_project_creation'] }], + selectors: [{ selector: UserSelectors.isProjectCreationDisabled, value: true }], }); expect(component.preventComponentCreation()).toBe(true); 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 63c4c86c4..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 @@ -44,7 +44,7 @@ export class OverviewComponentsComponent { isComponentsSubmitting = select(ProjectOverviewSelectors.getComponentsSubmitting); hasMoreComponents = select(ProjectOverviewSelectors.hasMoreComponents); project = select(ProjectOverviewSelectors.getProject); - activeFlags = select(UserSelectors.getActiveFlags); + preventComponentCreation = select(UserSelectors.isProjectCreationDisabled); reorderedComponents = signal([]); @@ -58,7 +58,6 @@ export class OverviewComponentsComponent { () => this.isComponentsSubmitting() || (!this.canEdit() && this.reorderedComponents().length <= 1) ); - preventComponentCreation = computed(() => this.activeFlags().includes('prevent_project_creation')); createComponentTooltip = computed(() => this.preventComponentCreation() ? 'project.overview.components.addComponentDisabled' : '' ); 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 920fb0560..83014339e 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 @@ -67,7 +67,7 @@ describe('ProjectOverviewToolbarComponent', () => { { selector: BookmarksSelectors.getBookmarksCollectionIdSubmitting, value: false }, { selector: ProjectOverviewSelectors.getDuplicatedProject, value: null }, { selector: UserSelectors.isAuthenticated, value: true }, - { selector: UserSelectors.getActiveFlags, value: [] }, + { selector: UserSelectors.isProjectCreationDisabled, value: false }, ]; const signals = mergeSignalOverrides(defaultSelectors, overrides.selectors); @@ -208,14 +208,14 @@ describe('ProjectOverviewToolbarComponent', () => { }); describe('preventDuplicateCreation', () => { - it('should return false when activeFlags does not include prevent_project_creation', () => { + it('should return false when isProjectCreationDisabled is false', () => { setup(); expect(component.preventDuplicateCreation()).toBe(false); }); - it('should return true when activeFlags includes prevent_project_creation', () => { + it('should return true when isProjectCreationDisabled is true', () => { setup({ - selectors: [{ selector: UserSelectors.getActiveFlags, value: ['prevent_project_creation'] }], + selectors: [{ selector: UserSelectors.isProjectCreationDisabled, value: true }], }); fixture.detectChanges(); expect(component.preventDuplicateCreation()).toBe(true); 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 2ede79430..970dd9905 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, computed, DestroyRef, effect, inject, input, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, 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,8 +75,7 @@ export class ProjectOverviewToolbarComponent { duplicatedProject = select(ProjectOverviewSelectors.getDuplicatedProject); isAuthenticated = select(UserSelectors.isAuthenticated); - activeFlags = select(UserSelectors.getActiveFlags); - preventDuplicateCreation = computed(() => this.activeFlags()?.includes('prevent_project_creation') ?? false); + preventDuplicateCreation = select(UserSelectors.isProjectCreationDisabled); actions = createDispatchMap({ getResourceBookmark: GetResourceBookmark, 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 2b12ae946..3b3e0c89d 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 @@ -64,7 +64,7 @@ describe('CustomStepComponent', () => { const defaultSignals: SignalOverride[] = [ { selector: RegistriesSelectors.getPagesSchema, value: overrides.pages ?? [MOCK_REGISTRIES_PAGE] }, { selector: RegistriesSelectors.getStepsState, value: overrides.stepsState ?? {} }, - { selector: UserSelectors.getActiveFlags, value: [] }, + { selector: UserSelectors.isProjectCreationDisabled, value: false }, ]; const signals = mergeSignalOverrides(defaultSignals, overrides.selectorOverrides); @@ -163,14 +163,14 @@ describe('CustomStepComponent', () => { expect(store.dispatch).not.toHaveBeenCalled(); }); - it('should update file upload description based on waffle flag value', () => { + it('should update file upload description based on isProjectCreationDisabled', () => { const { component } = setup({ - selectorOverrides: [{ selector: UserSelectors.getActiveFlags, value: ['prevent_project_creation'] }], + selectorOverrides: [{ selector: UserSelectors.isProjectCreationDisabled, value: true }], }); expect(component.fileUploadDescription()).toBe('shared.files.descriptionNoProject'); const { component: component2 } = setup({ - selectorOverrides: [{ selector: UserSelectors.getActiveFlags, value: [] }], + selectorOverrides: [{ selector: UserSelectors.isProjectCreationDisabled, value: false }], }); expect(component2.fileUploadDescription()).toBe('shared.files.description'); }); 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 602c032e8..c11fce283 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 @@ -89,7 +89,7 @@ export class CustomStepComponent implements OnDestroy { readonly pages = select(RegistriesSelectors.getPagesSchema); readonly stepsState = select(RegistriesSelectors.getStepsState); - readonly activeFlags = select(UserSelectors.getActiveFlags); + readonly projectCreationDisabled = select(UserSelectors.isProjectCreationDisabled); private readonly actions = createDispatchMap({ updateStepState: UpdateStepState, @@ -102,7 +102,7 @@ export class CustomStepComponent implements OnDestroy { step = signal(this.route.snapshot.params['step']); currentPage = computed(() => this.pages()[this.step() - 1]); readonly fileUploadDescription = computed(() => { - if (this.activeFlags().includes('prevent_project_creation')) { + if (this.projectCreationDisabled()) { return 'shared.files.descriptionNoProject'; } return 'shared.files.description'; From 76d54e96db08691d2841ed5a6176468d16b2c7f2 Mon Sep 17 00:00:00 2001 From: Futa Ikeda Date: Wed, 17 Jun 2026 12:29:35 -0400 Subject: [PATCH 14/35] feat(wiki): Prevent wiki edits based on feature flag value --- src/app/core/store/user/user.selectors.ts | 5 ++ .../features/project/wiki/wiki.component.html | 3 ++ .../project/wiki/wiki.component.spec.ts | 17 +++++++ .../features/project/wiki/wiki.component.ts | 9 +++- .../edit-section/edit-section.component.html | 3 +- .../edit-section.component.spec.ts | 12 +++++ .../edit-section/edit-section.component.ts | 4 +- .../wiki/wiki-list/wiki-list.component.html | 8 ++++ .../wiki-list/wiki-list.component.spec.ts | 47 ++++++++++++++++++- .../wiki/wiki-list/wiki-list.component.ts | 9 +++- src/assets/i18n/en.json | 3 +- 11 files changed, 113 insertions(+), 7 deletions(-) diff --git a/src/app/core/store/user/user.selectors.ts b/src/app/core/store/user/user.selectors.ts index d9758ec1a..f8bf086fb 100644 --- a/src/app/core/store/user/user.selectors.ts +++ b/src/app/core/store/user/user.selectors.ts @@ -63,4 +63,9 @@ export class UserSelectors { 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/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/shared/components/wiki/edit-section/edit-section.component.html b/src/app/shared/components/wiki/edit-section/edit-section.component.html index 5b31ad66a..dda2c48ae 100644 --- a/src/app/shared/components/wiki/edit-section/edit-section.component.html +++ b/src/app/shared/components/wiki/edit-section/edit-section.component.html @@ -7,7 +7,8 @@

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

[label]="'common.buttons.save' | translate" severity="success" class="mr-2" - [disabled]="!currentContent()" + [disabled]="!currentContent() || disableSaveButton()" + [pTooltip]="disableSaveButton() ? ('common.errorMessages.actionUnavailable' | translate) : ''" (onClick)="save()" [loading]="isSaving()" >
diff --git a/src/app/shared/components/wiki/edit-section/edit-section.component.spec.ts b/src/app/shared/components/wiki/edit-section/edit-section.component.spec.ts index e4e005871..8b82765b6 100644 --- a/src/app/shared/components/wiki/edit-section/edit-section.component.spec.ts +++ b/src/app/shared/components/wiki/edit-section/edit-section.component.spec.ts @@ -244,4 +244,16 @@ describe('EditSectionComponent', () => { expect(component.content).toBe(''); expect(component.initialContent).toBe(''); }); + + it('should handle disableSaveButton input', () => { + fixture.componentRef.setInput('disableSaveButton', true); + fixture.detectChanges(); + + expect(component.disableSaveButton()).toBe(true); + + fixture.componentRef.setInput('disableSaveButton', false); + fixture.detectChanges(); + + expect(component.disableSaveButton()).toBe(false); + }); }); diff --git a/src/app/shared/components/wiki/edit-section/edit-section.component.ts b/src/app/shared/components/wiki/edit-section/edit-section.component.ts index a62bf469f..2a255ef50 100644 --- a/src/app/shared/components/wiki/edit-section/edit-section.component.ts +++ b/src/app/shared/components/wiki/edit-section/edit-section.component.ts @@ -4,6 +4,7 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { Checkbox } from 'primeng/checkbox'; import { Panel } from 'primeng/panel'; +import { Tooltip } from 'primeng/tooltip'; import { ChangeDetectionStrategy, Component, effect, inject, input, output } from '@angular/core'; import { FormsModule } from '@angular/forms'; @@ -14,7 +15,7 @@ import { WikiSyntaxHelpDialogComponent } from '../wiki-syntax-help-dialog/wiki-s @Component({ selector: 'osf-edit-section', - imports: [Checkbox, Panel, Button, TranslatePipe, FormsModule, LMarkdownEditorModule], + imports: [Checkbox, Panel, Button, Tooltip, TranslatePipe, FormsModule, LMarkdownEditorModule], templateUrl: './edit-section.component.html', styleUrl: './edit-section.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -25,6 +26,7 @@ export class EditSectionComponent { readonly currentContent = input.required(); readonly versionContent = input.required(); readonly isSaving = input(false); + readonly disableSaveButton = input(false); readonly contentChange = output(); readonly saveContent = output(); // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/app/shared/components/wiki/wiki-list/wiki-list.component.html b/src/app/shared/components/wiki/wiki-list/wiki-list.component.html index b6d16d796..0048a5e99 100644 --- a/src/app/shared/components/wiki/wiki-list/wiki-list.component.html +++ b/src/app/shared/components/wiki/wiki-list/wiki-list.component.html @@ -18,6 +18,8 @@ @if (canEdit()) { {{ item.label | translate }} {{ item.label | translate }} raised outlined severity="danger" + [disabled]="isWikiReadonly()" + [pTooltip]="disabledButtonTooltip() | translate: ''" (onClick)="openDeleteWikiDialog()" /> } diff --git a/src/app/shared/components/wiki/wiki-list/wiki-list.component.spec.ts b/src/app/shared/components/wiki/wiki-list/wiki-list.component.spec.ts index 0a9ac2f66..377d00ff3 100644 --- a/src/app/shared/components/wiki/wiki-list/wiki-list.component.spec.ts +++ b/src/app/shared/components/wiki/wiki-list/wiki-list.component.spec.ts @@ -5,6 +5,7 @@ import { MenuItem } from 'primeng/api'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { Router } from '@angular/router'; +import { UserSelectors } from '@osf/core/store/user/user.selectors'; import { WikiModel } from '@osf/shared/models/wiki/wiki.model'; import { WikiItemType } from '@osf/shared/models/wiki/wiki-type.model'; import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; @@ -14,6 +15,7 @@ import { ComponentWiki } from '@osf/shared/stores/wiki'; import { provideOSFCore } from '@testing/osf.testing.provider'; import { CustomConfirmationServiceMockBuilder } from '@testing/providers/custom-confirmation-provider.mock'; import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; +import { mergeSignalOverrides, provideMockStore, SignalOverride } from '@testing/providers/store-provider.mock'; import { WikiListComponent } from './wiki-list.component'; @@ -42,10 +44,13 @@ describe('WikiListComponent', () => { }, ]; - beforeEach(() => { + const defaultSignals: SignalOverride[] = [{ selector: UserSelectors.isProjectReadOnly, value: false }]; + + function setup({ selectorOverrides = defaultSignals } = {}) { mockCustomConfirmationService = CustomConfirmationServiceMockBuilder.create().build(); mockRouter = RouterMockBuilder.create().withUrl('/project/abc123/wiki').build(); + const signals = mergeSignalOverrides(defaultSignals, selectorOverrides ?? []); TestBed.configureTestingModule({ imports: [WikiListComponent], providers: [ @@ -53,14 +58,16 @@ describe('WikiListComponent', () => { MockProvider(CustomDialogService), MockProvider(CustomConfirmationService, mockCustomConfirmationService), MockProvider(Router, mockRouter), + provideMockStore({ signals }), ], }); fixture = TestBed.createComponent(WikiListComponent); component = fixture.componentInstance; - }); + } it('should create', () => { + setup(); fixture.componentRef.setInput('list', mockWikiList); fixture.componentRef.setInput('resourceId', 'resource-123'); fixture.componentRef.setInput('currentWikiId', 'wiki1'); @@ -71,6 +78,7 @@ describe('WikiListComponent', () => { }); it('should have all required inputs', () => { + setup(); fixture.componentRef.setInput('list', mockWikiList); fixture.componentRef.setInput('resourceId', 'resource-123'); fixture.componentRef.setInput('currentWikiId', 'wiki1'); @@ -84,6 +92,7 @@ describe('WikiListComponent', () => { }); it('should have default values for optional inputs', () => { + setup(); fixture.componentRef.setInput('list', mockWikiList); fixture.componentRef.setInput('resourceId', 'resource-123'); fixture.componentRef.setInput('currentWikiId', 'wiki1'); @@ -95,6 +104,7 @@ describe('WikiListComponent', () => { }); it('should have WikiItemType enum available', () => { + setup(); fixture.componentRef.setInput('list', mockWikiList); fixture.componentRef.setInput('resourceId', 'resource-123'); fixture.componentRef.setInput('currentWikiId', 'wiki1'); @@ -105,6 +115,7 @@ describe('WikiListComponent', () => { }); it('should have expanded signal initialized to true', () => { + setup(); fixture.componentRef.setInput('list', mockWikiList); fixture.componentRef.setInput('resourceId', 'resource-123'); fixture.componentRef.setInput('currentWikiId', 'wiki1'); @@ -115,6 +126,7 @@ describe('WikiListComponent', () => { }); it('should compute hasComponentsWikis correctly', () => { + setup(); fixture.componentRef.setInput('list', mockWikiList); fixture.componentRef.setInput('resourceId', 'resource-123'); fixture.componentRef.setInput('currentWikiId', 'wiki1'); @@ -125,6 +137,7 @@ describe('WikiListComponent', () => { }); it('should compute hasComponentsWikis as false when empty', () => { + setup(); fixture.componentRef.setInput('list', mockWikiList); fixture.componentRef.setInput('resourceId', 'resource-123'); fixture.componentRef.setInput('currentWikiId', 'wiki1'); @@ -135,6 +148,7 @@ describe('WikiListComponent', () => { }); it('should compute isHomeWikiSelected correctly when home wiki is selected', () => { + setup(); fixture.componentRef.setInput('list', mockWikiList); fixture.componentRef.setInput('resourceId', 'resource-123'); fixture.componentRef.setInput('currentWikiId', 'wiki1'); @@ -145,6 +159,7 @@ describe('WikiListComponent', () => { }); it('should compute isHomeWikiSelected as false when other wiki is selected', () => { + setup(); fixture.componentRef.setInput('list', mockWikiList); fixture.componentRef.setInput('resourceId', 'resource-123'); fixture.componentRef.setInput('currentWikiId', 'wiki2'); @@ -155,6 +170,7 @@ describe('WikiListComponent', () => { }); it('should compute homeWikiId correctly', () => { + setup(); fixture.componentRef.setInput('list', mockWikiList); fixture.componentRef.setInput('resourceId', 'resource-123'); fixture.componentRef.setInput('currentWikiId', 'wiki1'); @@ -165,6 +181,7 @@ describe('WikiListComponent', () => { }); it('should return true for canEditName when user can edit and item is not home', () => { + setup(); fixture.componentRef.setInput('list', mockWikiList); fixture.componentRef.setInput('resourceId', 'resource-123'); fixture.componentRef.setInput('currentWikiId', 'wiki1'); @@ -177,6 +194,7 @@ describe('WikiListComponent', () => { }); it('should return false for canEditName when item is home wiki', () => { + setup(); fixture.componentRef.setInput('list', mockWikiList); fixture.componentRef.setInput('resourceId', 'resource-123'); fixture.componentRef.setInput('currentWikiId', 'wiki1'); @@ -189,6 +207,7 @@ describe('WikiListComponent', () => { }); it('should return false for canEditName when user cannot edit', () => { + setup(); fixture.componentRef.setInput('list', mockWikiList); fixture.componentRef.setInput('resourceId', 'resource-123'); fixture.componentRef.setInput('currentWikiId', 'wiki1'); @@ -201,6 +220,7 @@ describe('WikiListComponent', () => { }); it('should compute wikiMenu with main wikis', () => { + setup(); fixture.componentRef.setInput('list', mockWikiList); fixture.componentRef.setInput('resourceId', 'resource-123'); fixture.componentRef.setInput('currentWikiId', 'wiki1'); @@ -215,6 +235,7 @@ describe('WikiListComponent', () => { }); it('should compute wikiMenu with components wikis when present', () => { + setup(); fixture.componentRef.setInput('list', mockWikiList); fixture.componentRef.setInput('resourceId', 'resource-123'); fixture.componentRef.setInput('currentWikiId', 'wiki1'); @@ -229,6 +250,7 @@ describe('WikiListComponent', () => { }); it('should open delete confirmation dialog when openDeleteWikiDialog is called', () => { + setup(); fixture.componentRef.setInput('list', mockWikiList); fixture.componentRef.setInput('resourceId', 'resource-123'); fixture.componentRef.setInput('currentWikiId', 'wiki1'); @@ -241,6 +263,7 @@ describe('WikiListComponent', () => { }); it('should emit deleteWiki when delete is confirmed', () => { + setup(); fixture.componentRef.setInput('list', mockWikiList); fixture.componentRef.setInput('resourceId', 'resource-123'); fixture.componentRef.setInput('currentWikiId', 'wiki1'); @@ -258,6 +281,7 @@ describe('WikiListComponent', () => { }); it('should toggle expanded state when collapseNavigation is called', () => { + setup(); fixture.componentRef.setInput('list', mockWikiList); fixture.componentRef.setInput('resourceId', 'resource-123'); fixture.componentRef.setInput('currentWikiId', 'wiki1'); @@ -276,6 +300,7 @@ describe('WikiListComponent', () => { }); it('should handle empty wiki list', () => { + setup(); fixture.componentRef.setInput('list', []); fixture.componentRef.setInput('resourceId', 'resource-123'); fixture.componentRef.setInput('currentWikiId', ''); @@ -289,6 +314,7 @@ describe('WikiListComponent', () => { }); it('should handle empty components list', () => { + setup(); fixture.componentRef.setInput('list', mockWikiList); fixture.componentRef.setInput('resourceId', 'resource-123'); fixture.componentRef.setInput('currentWikiId', 'wiki1'); @@ -300,4 +326,21 @@ describe('WikiListComponent', () => { const menu = component.wikiMenu(); expect(menu.length).toBe(1); }); + + it('should compute disabledButtonTooltip when wiki is read-only', () => { + const selectorOverrides: SignalOverride[] = [ + { + selector: UserSelectors.isProjectReadOnly, + value: true, + }, + ]; + setup({ selectorOverrides }); + fixture.componentRef.setInput('list', mockWikiList); + fixture.componentRef.setInput('resourceId', 'resource-123'); + fixture.componentRef.setInput('currentWikiId', 'wiki1'); + fixture.componentRef.setInput('componentsList', []); + fixture.detectChanges(); + + expect(component.disabledButtonTooltip()).toBe('common.errorMessages.actionUnavailable'); + }); }); diff --git a/src/app/shared/components/wiki/wiki-list/wiki-list.component.ts b/src/app/shared/components/wiki/wiki-list/wiki-list.component.ts index d353a9893..050e0464d 100644 --- a/src/app/shared/components/wiki/wiki-list/wiki-list.component.ts +++ b/src/app/shared/components/wiki/wiki-list/wiki-list.component.ts @@ -1,3 +1,5 @@ +import { select } from '@ngxs/store'; + import { TranslatePipe } from '@ngx-translate/core'; import { MenuItem } from 'primeng/api'; @@ -5,11 +7,13 @@ import { Button } from 'primeng/button'; import { Panel } from 'primeng/panel'; import { PanelMenu } from 'primeng/panelmenu'; import { Skeleton } from 'primeng/skeleton'; +import { Tooltip } from 'primeng/tooltip'; import { ChangeDetectionStrategy, Component, computed, DestroyRef, inject, input, output, signal } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { Router } from '@angular/router'; +import { UserSelectors } from '@osf/core/store/user/user.selectors'; import { WikiModel } from '@osf/shared/models/wiki/wiki.model'; import { WikiMenuItem } from '@osf/shared/models/wiki/wiki-menu.model'; import { WikiItemType } from '@osf/shared/models/wiki/wiki-type.model'; @@ -22,7 +26,7 @@ import { RenameWikiDialogComponent } from '../rename-wiki-dialog/rename-wiki-dia @Component({ selector: 'osf-wiki-list', - imports: [Button, Panel, PanelMenu, Skeleton, TranslatePipe], + imports: [Button, Panel, PanelMenu, Skeleton, Tooltip, TranslatePipe], templateUrl: './wiki-list.component.html', styleUrl: './wiki-list.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -45,12 +49,15 @@ export class WikiListComponent { private readonly router = inject(Router); private readonly destroyRef = inject(DestroyRef); + readonly isWikiReadonly = select(UserSelectors.isProjectReadOnly); + wikiItemType = WikiItemType; expanded = signal(true); hasComponentsWikis = computed(() => this.componentsList().length > 0); homeWikiId = computed(() => this.list()?.find((wiki) => wiki.name.toLowerCase() === 'home')?.id); isHomeWikiSelected = computed(() => this.currentWikiId() === this.homeWikiId()); + disabledButtonTooltip = computed(() => (this.isWikiReadonly() ? 'common.errorMessages.actionUnavailable' : '')); wikiMenu = computed(() => { const menu: WikiMenuItem[] = [ diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index ae2f7c0a1..5ef112a43 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -484,7 +484,8 @@ "message": "Are you sure you want to discard your unsaved changes?" }, "errorMessages": { - "serverError": "An unexpected error occurred. Please try again later." + "serverError": "An unexpected error occurred. Please try again later.", + "actionUnavailable": "This action is no longer available. Contact support if you have any questions." }, "hint": { "tagSeparators": "Use enter or comma to create a tag.", From 007079fca2543c67a773785809870b22618bc24d Mon Sep 17 00:00:00 2001 From: Futa Ikeda Date: Thu, 18 Jun 2026 10:40:10 -0400 Subject: [PATCH 15/35] feat(collections): Prevent submission to collections based on waffle flag --- .../add-to-collection.component.html | 4 +- .../add-to-collection.component.spec.ts | 39 +++++++++++---- .../add-to-collection.component.ts | 4 ++ .../select-project-step.component.html | 7 ++- .../select-project-step.component.ts | 4 +- .../collections-discover.component.html | 7 ++- .../collections-discover.component.spec.ts | 48 ++++++++++++++----- .../collections-discover.component.ts | 5 ++ .../project-selector.component.html | 1 + .../project-selector.component.ts | 2 +- 10 files changed, 95 insertions(+), 26 deletions(-) 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 41cf077d5..fb851e096 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)" /> @@ -67,7 +68,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 9f49bfde0..13c035065 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 @@ -23,7 +23,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 { mergeSignalOverrides, provideMockStore } from '@testing/providers/store-provider.mock'; import { AddToCollectionComponent } from './add-to-collection.component'; @@ -36,10 +36,19 @@ describe('AddToCollectionComponent', () => { const mockCollectionProvider = MOCK_PROVIDER; - beforeEach(() => { + function setup(selectorOverrides?: any[]) { mockRouter = RouterMockBuilder.create().build(); mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: null }).build(); mockCustomDialogService = CustomDialogServiceMockBuilder.create().build(); + const defaultSignals = [ + { selector: CollectionsSelectors.getCollectionProviderLoading, value: false }, + { selector: CollectionsSelectors.getCollectionProvider, value: mockCollectionProvider }, + { selector: ProjectsSelectors.getSelectedProject, value: MOCK_PROJECT }, + { selector: UserSelectors.getCurrentUser, value: MOCK_USER }, + { selector: UserSelectors.isProjectReadOnly, value: false }, + ]; + + const signals = mergeSignalOverrides(defaultSignals, selectorOverrides); TestBed.configureTestingModule({ imports: [ @@ -59,12 +68,7 @@ describe('AddToCollectionComponent', () => { MockProvider(CustomDialogService, mockCustomDialogService), MockProvider(ToastService), provideMockStore({ - signals: [ - { selector: CollectionsSelectors.getCollectionProviderLoading, value: false }, - { selector: CollectionsSelectors.getCollectionProvider, value: mockCollectionProvider }, - { selector: ProjectsSelectors.getSelectedProject, value: MOCK_PROJECT }, - { selector: UserSelectors.getCurrentUser, value: MOCK_USER }, - ], + signals: signals, }), ], }); @@ -72,13 +76,15 @@ describe('AddToCollectionComponent', () => { fixture = TestBed.createComponent(AddToCollectionComponent); component = fixture.componentInstance; fixture.detectChanges(); - }); + } it('should create', () => { + setup(); expect(component).toBeTruthy(); }); it('should initialize with default values', () => { + setup(); expect(component.stepperActiveValue()).toBe(AddToCollectionSteps.SelectProject); expect(component.projectMetadataSaved()).toBe(false); expect(component.projectContributorsSaved()).toBe(false); @@ -87,6 +93,7 @@ describe('AddToCollectionComponent', () => { }); it('should handle project selection', () => { + setup(); component.handleProjectSelected(); expect(component.projectContributorsSaved()).toBe(false); @@ -95,6 +102,7 @@ describe('AddToCollectionComponent', () => { }); it('should handle step change', () => { + setup(); const newStep = AddToCollectionSteps.ProjectMetadata; component.handleChangeStep(newStep); @@ -102,12 +110,14 @@ describe('AddToCollectionComponent', () => { }); it('should handle project metadata saved', () => { + setup(); component.handleProjectMetadataSaved(); expect(component.projectMetadataSaved()).toBe(true); }); it('should handle contributors saved', () => { + setup(); component.handleContributorsSaved(); expect(component.stepperActiveValue()).toBe(AddToCollectionSteps.CollectionMetadata); @@ -115,6 +125,7 @@ describe('AddToCollectionComponent', () => { }); it('should handle collection metadata saved', () => { + setup(); const mockForm = new FormGroup({}); component.handleCollectionMetadataSaved(mockForm); @@ -124,24 +135,34 @@ describe('AddToCollectionComponent', () => { }); it('should have actions defined', () => { + setup(); expect(component.actions).toBeDefined(); expect(component.actions.getCollectionProvider).toBeDefined(); expect(component.actions.clearAddToCollectionState).toBeDefined(); }); it('should handle loading state', () => { + setup(); expect(component.isProviderLoading()).toBe(false); }); it('should have collection provider data', () => { + setup(); expect(component.collectionProvider()).toEqual(mockCollectionProvider); }); it('should have selected project data', () => { + setup(); expect(component.selectedProject()).toEqual(MOCK_PROJECT); }); it('should have current user data', () => { + setup(); expect(component.currentUser()).toEqual(MOCK_USER); }); + + it('should disable the add to collection button if isProjectReadOnly', () => { + setup([{ 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 15e4fbcbb..1b185137e 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, map, Observable, of, switchMap } from 'rxjs'; @@ -59,6 +60,7 @@ import { SelectProjectStepComponent } from './select-project-step/select-project Button, Stepper, RouterLink, + Tooltip, TranslatePipe, LoadingSpinnerComponent, SelectProjectStepComponent, @@ -95,6 +97,7 @@ export class AddToCollectionComponent implements CanDeactivateComponent { selectedProject = select(ProjectsSelectors.getSelectedProject); currentUser = select(UserSelectors.getCurrentUser); currentCollectionSubmission = select(AddToCollectionSelectors.getCurrentCollectionSubmission); + isProjectReadOnly = select(UserSelectors.isProjectReadOnly); providerId = signal(''); allowNavigation = signal(false); @@ -110,6 +113,7 @@ export class AddToCollectionComponent implements CanDeactivateComponent { isCollectionMetadataDisabled = computed( () => !this.selectedProject() || !this.projectMetadataSaved() || !this.projectContributorsSaved() ); + disabledAddButtonTooltip = computed(() => (this.isProjectReadOnly() ? 'common.errorMessages.actionUnavailable' : '')); actions = createDispatchMap({ getCollectionProvider: GetCollectionProvider, 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 a17d3deab..d29b56438 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 @@ - +
@@ -27,6 +31,7 @@

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

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 1e9261f03..3f69682f3 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 09693f727..332fb45e1 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 @@ -3,6 +3,7 @@ import { MockComponents, MockProvider } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute } from '@angular/router'; +import { UserSelectors } from '@osf/core/store/user/user.selectors'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; import { SearchInputComponent } from '@osf/shared/components/search-input/search-input.component'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; @@ -13,7 +14,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, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; import { CollectionsQuerySyncService } from '../../services'; @@ -28,11 +29,24 @@ describe('CollectionsDiscoverComponent', () => { let mockCustomDialogService: ReturnType; let mockRoute: ReturnType; - beforeEach(() => { + function setup(selectorOverrides?: any[]) { toastServiceMock = ToastServiceMock.simple(); mockCustomDialogService = CustomDialogServiceMockBuilder.create().build(); mockRoute = ActivatedRouteMockBuilder.create().withParams({ providerId: 'provider-1' }).build(); + const defaultSignals = [ + { selector: CollectionsSelectors.getCollectionProvider, value: MOCK_PROVIDER }, + { selector: CollectionsSelectors.getCollectionDetails, value: null }, + { selector: CollectionsSelectors.getAllSelectedFilters, value: {} }, + { selector: CollectionsSelectors.getSortBy, value: 'date' }, + { selector: CollectionsSelectors.getSearchText, value: '' }, + { selector: CollectionsSelectors.getPageNumber, value: '1' }, + { selector: CollectionsSelectors.getCollectionProviderLoading, value: false }, + { selector: UserSelectors.isProjectReadOnly, value: false }, + ]; + + const signals = mergeSignalOverrides(defaultSignals, selectorOverrides); + TestBed.configureTestingModule({ imports: [ CollectionsDiscoverComponent, @@ -44,15 +58,7 @@ describe('CollectionsDiscoverComponent', () => { MockProvider(CustomDialogService, mockCustomDialogService), MockProvider(ActivatedRoute, mockRoute), provideMockStore({ - signals: [ - { selector: CollectionsSelectors.getCollectionProvider, value: MOCK_PROVIDER }, - { selector: CollectionsSelectors.getCollectionDetails, value: null }, - { selector: CollectionsSelectors.getAllSelectedFilters, value: {} }, - { selector: CollectionsSelectors.getSortBy, value: 'date' }, - { selector: CollectionsSelectors.getSearchText, value: '' }, - { selector: CollectionsSelectors.getPageNumber, value: '1' }, - { selector: CollectionsSelectors.getCollectionProviderLoading, value: false }, - ], + signals: signals, }), ], }).overrideComponent(CollectionsDiscoverComponent, { @@ -64,18 +70,21 @@ describe('CollectionsDiscoverComponent', () => { fixture = TestBed.createComponent(CollectionsDiscoverComponent); component = fixture.componentInstance; fixture.detectChanges(); - }); + } it('should create', () => { + setup(); expect(component).toBeTruthy(); }); it('should initialize with default values', () => { + setup(); expect(component.providerId()).toBe('provider-1'); expect(component.searchControl.value).toBe(''); }); it('should handle search triggered', () => { + setup(); const searchValue = 'test search'; component.onSearchTriggered(searchValue); @@ -84,46 +93,61 @@ describe('CollectionsDiscoverComponent', () => { }); it('should have provider id signal', () => { + setup(); expect(component.providerId()).toBe('provider-1'); }); it('should have collection provider data', () => { + setup(); expect(component.collectionProvider()).toEqual(MOCK_PROVIDER); }); it('should have collection details', () => { + setup(); expect(component.collectionDetails()).toBeNull(); }); it('should have selected filters', () => { + setup(); expect(component.selectedFilters()).toEqual({}); }); it('should have sort by value', () => { + setup(); expect(component.sortBy()).toBe('date'); }); it('should have search text', () => { + setup(); expect(component.searchText()).toBe(''); }); it('should have page number', () => { + setup(); expect(component.pageNumber()).toBe('1'); }); it('should have loading state', () => { + setup(); expect(component.isProviderLoading()).toBe(false); }); it('should compute primary collection id', () => { + setup(); expect(component.primaryCollectionId()).toBe(MOCK_PROVIDER.primaryCollection?.id); }); it('should handle search control value changes', () => { + setup(); const searchValue = 'new search value'; component.searchControl.setValue(searchValue); expect(component.searchControl.value).toBe(searchValue); }); + + it('should disable add button when user has isProjectReadOnly', () => { + setup([{ 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 0c43f26cb..cbc3168a7 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 { debounceTime } from 'rxjs'; @@ -21,6 +22,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormControl } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { UserSelectors } from '@osf/core/store/user/user.selectors'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; import { SearchInputComponent } from '@osf/shared/components/search-input/search-input.component'; import { CollectionsFilters } from '@osf/shared/models/collections/collections-filters.model'; @@ -50,6 +52,7 @@ import { CollectionsMainContentComponent } from '../collections-main-content/col SearchInputComponent, CollectionsMainContentComponent, LoadingSpinnerComponent, + Tooltip, TranslatePipe, ], templateUrl: './collections-discover.component.html', @@ -78,8 +81,10 @@ export class CollectionsDiscoverComponent { searchText = select(CollectionsSelectors.getSearchText); pageNumber = select(CollectionsSelectors.getPageNumber); 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/shared/components/project-selector/project-selector.component.html b/src/app/shared/components/project-selector/project-selector.component.html index 5d9ffdcfc..b10a31e21 100644 --- a/src/app/shared/components/project-selector/project-selector.component.html +++ b/src/app/shared/components/project-selector/project-selector.component.html @@ -3,6 +3,7 @@ [loading]="isProjectsLoading()" [options]="projectsOptions()" [filter]="true" + [disabled]="disabled()" optionLabel="label" optionValue="value" appendTo="body" diff --git a/src/app/shared/components/project-selector/project-selector.component.ts b/src/app/shared/components/project-selector/project-selector.component.ts index 5cd711e0f..fce45b443 100644 --- a/src/app/shared/components/project-selector/project-selector.component.ts +++ b/src/app/shared/components/project-selector/project-selector.component.ts @@ -25,7 +25,6 @@ import { UserSelectors } from '@core/store/user'; import { ProjectModel } from '@osf/shared/models/projects/projects.model'; import { CustomOption } from '@shared/models/select-option.model'; import { GetProjects, ProjectsSelectors } from '@shared/stores/projects'; - @Component({ selector: 'osf-project-selector', imports: [Select, TranslatePipe, FormsModule], @@ -44,6 +43,7 @@ export class ProjectSelectorComponent { placeholder = input('common.buttons.select'); showClear = input(true); excludeProjectIds = input([]); + disabled = input(false); selectedProject = model(null); projectChange = output(); From 3f34f996f13fc86ddbb7af67e24666cccf4c30d7 Mon Sep 17 00:00:00 2001 From: Futa Ikeda Date: Wed, 17 Jun 2026 17:40:20 -0400 Subject: [PATCH 16/35] feat(metadata): Prevent updates to node metadata based on waffle flag value --- .../components/base-metadata.component.ts | 9 ++++ ...ata-affiliated-institutions.component.html | 3 ++ ...-affiliated-institutions.component.spec.ts | 9 ++++ ...adata-affiliated-institutions.component.ts | 7 ++- .../metadata-contributors.component.html | 3 ++ .../metadata-contributors.component.spec.ts | 9 ++++ .../metadata-contributors.component.ts | 7 ++- .../metadata-description.component.html | 3 ++ .../metadata-description.component.spec.ts | 10 ++++ .../metadata-description.component.ts | 6 ++- .../metadata-funding.component.html | 3 ++ .../metadata-funding.component.spec.ts | 9 ++++ .../metadata-funding.component.ts | 6 ++- .../metadata-license.component.html | 3 ++ .../metadata-license.component.spec.ts | 9 ++++ .../metadata-license.component.ts | 7 ++- .../metadata-publication-doi.component.html | 3 ++ ...metadata-publication-doi.component.spec.ts | 9 ++++ .../metadata-publication-doi.component.ts | 7 ++- ...tadata-resource-information.component.html | 3 ++ ...ata-resource-information.component.spec.ts | 9 ++++ ...metadata-resource-information.component.ts | 7 ++- .../metadata-title.component.html | 3 ++ .../metadata-title.component.spec.ts | 10 ++++ .../metadata-title.component.ts | 7 ++- .../features/metadata/metadata.component.html | 16 ++++++ .../metadata/metadata.component.spec.ts | 50 +++++++++++++++---- .../features/metadata/metadata.component.ts | 4 ++ 28 files changed, 204 insertions(+), 27 deletions(-) create mode 100644 src/app/features/metadata/components/base-metadata.component.ts 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 6f00278b7..eb0684c01 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 b5a9cb0de..a32ee239b 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'; @@ -10,13 +11,15 @@ import { CustomItemMetadataRecord } from '@osf/features/metadata/models'; import { languageCodes } from '@osf/shared/constants/language.const'; import { LanguageCodeModel } from '@shared/models/language-code.model'; +import { BaseMetadataComponent } from '../base-metadata.component'; + @Component({ selector: 'osf-metadata-resource-information', - imports: [Button, Card, TranslatePipe], + imports: [Button, Card, Tooltip, TranslatePipe], 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 31a31032a..22fbe96ce 100644 --- a/src/app/features/metadata/metadata.component.html +++ b/src/app/features/metadata/metadata.component.html @@ -25,12 +25,16 @@ (openEditTitleDialog)="openEditTitleDialog()" [title]="metadata()?.title!" [readonly]="!hasWriteAccess()" + [disabled]="isProjectReadOnly()" + [disabledButtonTooltip]="disabledButtonTooltip() | translate" /> @if (isRegistrationType()) { @@ -46,6 +50,8 @@ [hasMoreContributors]="hasMoreContributors()" [readonly]="!hasWriteAccess()" (loadMoreContributors)="handleLoadMoreContributors()" + [disabled]="isProjectReadOnly()" + [disabledButtonTooltip]="disabledButtonTooltip() | translate" /> @if (isProjectType()) { @@ -80,6 +92,8 @@ (openEditLicenseDialog)="openEditLicenseDialog()" [license]="metadata()?.license!" [readonly]="!hasWriteAccess()" + [disabled]="isProjectReadOnly()" + [disabledButtonTooltip]="disabledButtonTooltip() | translate" /> @if (isRegistrationType()) { diff --git a/src/app/features/metadata/metadata.component.spec.ts b/src/app/features/metadata/metadata.component.spec.ts index 30d951f5f..60d5febe1 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,17 @@ 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.disabledButtonTooltip()).toBe('common.errorMessages.actionUnavailable'); + }); }); diff --git a/src/app/features/metadata/metadata.component.ts b/src/app/features/metadata/metadata.component.ts index ad6f68623..f4db2efab 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([ @@ -209,6 +212,7 @@ export class MetadataComponent implements OnInit, OnDestroy { isProjectType = computed(() => this.resourceType() === ResourceType.Project); isRegistrationType = computed(() => this.resourceType() === ResourceType.Registration); + disabledButtonTooltip = computed(() => (this.isProjectReadOnly() ? 'common.errorMessages.actionUnavailable' : '')); constructor() { effect(() => { From 02521e393802559c3751662d24110deaec5e6189 Mon Sep 17 00:00:00 2001 From: Futa Ikeda Date: Wed, 17 Jun 2026 17:41:14 -0400 Subject: [PATCH 17/35] feat(metadata): Prevent edit of subjects and tags based on waffle flag --- src/app/features/metadata/metadata.component.html | 4 ++-- .../features/metadata/metadata.component.spec.ts | 2 ++ src/app/features/metadata/metadata.component.ts | 14 ++++++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/app/features/metadata/metadata.component.html b/src/app/features/metadata/metadata.component.html index 22fbe96ce..bd6dc3fb2 100644 --- a/src/app/features/metadata/metadata.component.html +++ b/src/app/features/metadata/metadata.component.html @@ -113,7 +113,7 @@

diff --git a/src/app/features/metadata/metadata.component.spec.ts b/src/app/features/metadata/metadata.component.spec.ts index 60d5febe1..1f6cfb69d 100644 --- a/src/app/features/metadata/metadata.component.spec.ts +++ b/src/app/features/metadata/metadata.component.spec.ts @@ -268,6 +268,8 @@ describe('MetadataComponent', () => { 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 f4db2efab..464ec47c9 100644 --- a/src/app/features/metadata/metadata.component.ts +++ b/src/app/features/metadata/metadata.component.ts @@ -210,6 +210,20 @@ 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' : '')); From a0f08a00dfadeff0ca69f77cb44f4d52d0f4def6 Mon Sep 17 00:00:00 2001 From: Futa Ikeda Date: Thu, 18 Jun 2026 16:10:45 -0400 Subject: [PATCH 18/35] feat(project-overview): Prevent users from editing project on project overview page --- .../linked-resources.component.html | 2 + .../linked-resources.component.spec.ts | 35 +++++++---- .../linked-resources.component.ts | 11 +++- .../overview-wiki.component.html | 2 + .../overview-wiki.component.spec.ts | 39 ++++++++++--- .../overview-wiki/overview-wiki.component.ts | 6 +- .../project-overview-metadata.component.html | 2 + ...roject-overview-metadata.component.spec.ts | 58 ++++++++++++------- .../project-overview-metadata.component.ts | 10 +++- .../project-overview-toolbar.component.html | 15 +++-- ...project-overview-toolbar.component.spec.ts | 16 +++++ .../project-overview-toolbar.component.ts | 5 +- 12 files changed, 152 insertions(+), 49 deletions(-) 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-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 8c9b34ff6..94f0d6ca7 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 @@ -10,6 +10,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 f896c2108..f1649140f 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/user.selectors'; import { AffiliatedInstitutionsViewComponent } from '@osf/shared/components/affiliated-institutions-view/affiliated-institutions-view.component'; import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component'; import { ResourceCitationsComponent } from '@osf/shared/components/resource-citations/resource-citations.component'; @@ -26,7 +27,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, @@ -50,12 +51,36 @@ describe('ProjectOverviewMetadataComponent', () => { interface SetupOverrides { project?: typeof MOCK_PROJECT_OVERVIEW | null; + selectorOverrides?: { selector: any; value: any }[]; } function setup(overrides: SetupOverrides = {}) { const project = 'project' in overrides ? overrides.project : MOCK_PROJECT_OVERVIEW; mockRouter = RouterMockBuilder.create().withUrl('/project/project-1/overview').build(); + 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.isProjectReadOnly, value: false }, + ]; + const signals = mergeSignalOverrides(defaultSignals, overrides.selectorOverrides || []); + TestBed.configureTestingModule({ imports: [ ProjectOverviewMetadataComponent, @@ -75,26 +100,7 @@ describe('ProjectOverviewMetadataComponent', () => { provideOSFCore(), 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 }, - ], + signals: signals, }), ], }); @@ -173,4 +179,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 40e0507ae..1a5c694b2 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, effect, inject } from '@angular/core'; +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 { AffiliatedInstitutionsViewComponent } from '@osf/shared/components/affiliated-institutions-view/affiliated-institutions-view.component'; import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component'; import { ResourceCitationsComponent } from '@osf/shared/components/resource-citations/resource-citations.component'; @@ -43,6 +45,7 @@ import { OverviewSupplementsComponent } from '../overview-supplements/overview-s TranslatePipe, RouterLink, DatePipe, + Tooltip, TruncatedTextComponent, ResourceCitationsComponent, OverviewCollectionsComponent, @@ -79,6 +82,11 @@ export class ProjectOverviewMetadataComponent { readonly hasMoreBibliographicContributors = select(ContributorsSelectors.hasMoreBibliographicContributors); readonly projectSubmissions = select(CollectionsSelectors.getCurrentProjectSubmissions); readonly isProjectSubmissionsLoading = select(CollectionsSelectors.getCurrentProjectSubmissionsLoading); + readonly isProjectReadOnly = select(UserSelectors.isProjectReadOnly); + + 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 fa1cf22f2..1dd97d3ae 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 }}

- + + +
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 83014339e..bfe4c3905 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 @@ -68,6 +68,7 @@ describe('ProjectOverviewToolbarComponent', () => { { 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); @@ -221,4 +222,19 @@ describe('ProjectOverviewToolbarComponent', () => { expect(component.preventDuplicateCreation()).toBe(true); }); }); + + describe('projectReadOnlyTooltip', () => { + it('should return empty string when isProjectReadOnly is false', () => { + setup(); + 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'); + }); + }); }); 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 970dd9905..599f1e87a 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'; @@ -76,6 +76,9 @@ export class ProjectOverviewToolbarComponent { duplicatedProject = select(ProjectOverviewSelectors.getDuplicatedProject); isAuthenticated = select(UserSelectors.isAuthenticated); preventDuplicateCreation = select(UserSelectors.isProjectCreationDisabled); + isProjectReadOnly = select(UserSelectors.isProjectReadOnly); + + projectReadOnlyTooltip = computed(() => (this.isProjectReadOnly() ? 'common.errorMessages.actionUnavailable' : '')); actions = createDispatchMap({ getResourceBookmark: GetResourceBookmark, From f90862ed108c04c04a72515f68d1ecd5bedae467 Mon Sep 17 00:00:00 2001 From: Futa Ikeda Date: Thu, 25 Jun 2026 15:31:46 -0400 Subject: [PATCH 19/35] feat(contributors): Prevent contributor editing based on waffle flag --- .../contributors/contributors.component.html | 3 +- .../contributors.component.spec.ts | 43 ++++++++++++++ .../contributors/contributors.component.ts | 4 ++ .../project-addons.component.html | 2 +- .../registries-contributors.component.html | 1 + .../contributors-table.component.html | 27 ++++++--- .../contributors-table.component.spec.ts | 57 ++++++++++++++++++- .../contributors-table.component.ts | 11 ++++ 8 files changed, 134 insertions(+), 14 deletions(-) diff --git a/src/app/features/contributors/contributors.component.html b/src/app/features/contributors/contributors.component.html index 2f43f5b60..12ea6b088 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 1cf411bbc..5e7cd0eac 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', @@ -137,6 +139,7 @@ export class ContributorsComponent implements OnInit, OnDestroy { readonly currentUser = select(UserSelectors.getCurrentUser); pageSize = select(ContributorsSelectors.getContributorsPageSize); isLoadingMore = select(ContributorsSelectors.isContributorsLoadingMore); + isProjectReadOnly = select(UserSelectors.isProjectReadOnly); readonly tableParams = computed(() => ({ ...DEFAULT_TABLE_PARAMS, @@ -147,6 +150,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/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/shared/components/contributors/contributors-table/contributors-table.component.spec.ts b/src/app/shared/components/contributors/contributors-table/contributors-table.component.spec.ts index 26bd5465f..fe972e217 100644 --- a/src/app/shared/components/contributors/contributors-table/contributors-table.component.spec.ts +++ b/src/app/shared/components/contributors/contributors-table/contributors-table.component.spec.ts @@ -116,6 +116,77 @@ describe('ContributorsTableComponent', () => { expect(component.showLoadMore()).toBe(true); }); + it('should compute readOnlyPermissionInfo correctly', () => { + setup({ selectorOverrides: [{ selector: UserSelectors.isProjectReadOnly, value: true }] }); + fixture.detectChanges(); + expect(component.readOnlyPermissionInfo()).toEqual([ + 'project.contributors.permissionInfo.readOnlyViewProjectContent', + ]); + + setup({ selectorOverrides: [{ selector: UserSelectors.isProjectReadOnly, value: false }] }); + fixture.componentRef.setInput('resourceType', ResourceType.Project); + fixture.detectChanges(); + expect(component.readOnlyPermissionInfo()).toEqual(['project.contributors.permissionInfo.viewProjectContent']); + + setup({ selectorOverrides: [{ selector: UserSelectors.isProjectReadOnly, value: true }] }); + fixture.componentRef.setInput('resourceType', ResourceType.Registration); + fixture.detectChanges(); + expect(component.readOnlyPermissionInfo()).toEqual(['project.contributors.permissionInfo.viewRegistrationContent']); + }); + + it('should compute writePermissionInfo correctly', () => { + setup({ selectorOverrides: [{ selector: UserSelectors.isProjectReadOnly, value: true }] }); + fixture.detectChanges(); + expect(component.writePermissionInfo()).toEqual(['project.contributors.permissionInfo.readOnlyViewProjectContent']); + + setup({ selectorOverrides: [{ selector: UserSelectors.isProjectReadOnly, value: false }] }); + fixture.componentRef.setInput('resourceType', ResourceType.Project); + fixture.detectChanges(); + expect(component.writePermissionInfo()).toEqual([ + 'project.contributors.permissionInfo.read', + 'project.contributors.permissionInfo.addComponents', + 'project.contributors.permissionInfo.editContent', + ]); + + setup({ selectorOverrides: [{ selector: UserSelectors.isProjectReadOnly, value: true }] }); + fixture.componentRef.setInput('resourceType', ResourceType.Registration); + fixture.detectChanges(); + expect(component.writePermissionInfo()).toEqual([ + 'project.contributors.permissionInfo.read', + 'project.contributors.permissionInfo.editMetadata', + 'project.contributors.permissionInfo.addResourcesLinks', + ]); + }); + + it('should compute adminPermissionInfo correctly', () => { + setup({ selectorOverrides: [{ selector: UserSelectors.isProjectReadOnly, value: true }] }); + fixture.detectChanges(); + expect(component.adminPermissionInfo()).toEqual([ + 'project.contributors.permissionInfo.manageViewOnlyLinks', + 'project.contributors.permissionInfo.deleteProject', + ]); + + setup({ selectorOverrides: [{ selector: UserSelectors.isProjectReadOnly, value: false }] }); + fixture.componentRef.setInput('resourceType', ResourceType.Project); + fixture.detectChanges(); + expect(component.adminPermissionInfo()).toEqual([ + 'project.contributors.permissionInfo.readWrite', + 'project.contributors.permissionInfo.manageContributors', + 'project.contributors.permissionInfo.deleteRegister', + 'project.contributors.permissionInfo.publicPrivate', + ]); + + setup({ selectorOverrides: [{ selector: UserSelectors.isProjectReadOnly, value: true }] }); + fixture.componentRef.setInput('resourceType', ResourceType.Registration); + fixture.detectChanges(); + expect(component.adminPermissionInfo()).toEqual([ + 'project.contributors.permissionInfo.readWrite', + 'project.contributors.permissionInfo.manageContributors', + 'project.contributors.permissionInfo.withdrawRegistration', + 'project.contributors.permissionInfo.endEmbargoEarly', + ]); + }); + it('should compute properties when hasAdminAccess is true and isProjectReadonly is false', () => { setup({ selectorOverrides: [{ selector: UserSelectors.isProjectReadOnly, value: false }] }); fixture.componentRef.setInput('hasAdminAccess', true); diff --git a/src/app/shared/components/contributors/contributors-table/contributors-table.component.ts b/src/app/shared/components/contributors/contributors-table/contributors-table.component.ts index c1fa2d880..aee7652bc 100644 --- a/src/app/shared/components/contributors/contributors-table/contributors-table.component.ts +++ b/src/app/shared/components/contributors/contributors-table/contributors-table.component.ts @@ -80,6 +80,59 @@ export class ContributorsTableComponent { this.isProjectReadonly() && this.isProject() ? 'common.errorMessages.actionUnavailable' : '' ); + readOnlyPermissionInfo = computed(() => { + const translationPrefix = 'project.contributors.permissionInfo.'; + if (!this.isProject()) { + return [translationPrefix + 'viewRegistrationContent']; + } + return this.isProjectReadonly() + ? [translationPrefix + 'readOnlyViewProjectContent'] + : [translationPrefix + 'viewProjectContent']; + }); + + writePermissionInfo = computed(() => { + const translationPrefix = 'project.contributors.permissionInfo.'; + const projectPermissions = [ + translationPrefix + 'read', + translationPrefix + 'addComponents', + translationPrefix + 'editContent', + ]; + const registrationPermissions = [ + translationPrefix + 'read', + translationPrefix + 'editMetadata', + translationPrefix + 'addResourcesLinks', + ]; + + if (!this.isProject()) { + return registrationPermissions; + } + + return this.isProjectReadonly() ? [translationPrefix + 'readOnlyViewProjectContent'] : projectPermissions; + }); + + adminPermissionInfo = computed(() => { + const translationPrefix = 'project.contributors.permissionInfo.'; + const projectPermissions = [ + translationPrefix + 'readWrite', + translationPrefix + 'manageContributors', + translationPrefix + 'deleteRegister', + translationPrefix + 'publicPrivate', + ]; + const registrationPermissions = [ + translationPrefix + 'readWrite', + translationPrefix + 'manageContributors', + translationPrefix + 'withdrawRegistration', + translationPrefix + 'endEmbargoEarly', + ]; + + if (!this.isProject()) { + return registrationPermissions; + } + return this.isProjectReadonly() + ? [translationPrefix + 'manageViewOnlyLinks', translationPrefix + 'deleteProject'] + : projectPermissions; + }); + removeContributor(contributor: ContributorModel) { this.remove.emit(contributor); } diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index da96f9060..08d201945 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -1693,13 +1693,16 @@ "permissionInfo": { "addComponents": "Add and configure components", "addResourcesLinks": "Add resources links", + "deleteProject": "Delete project and components", "deleteRegister": "Delete and register project", "editContent": "Add and edit content", "editMetadata": "Edit metadata", "endEmbargoEarly": "End embargo early", "manageContributors": "Manage contributor", + "manageViewOnlyLinks": "Manage view-only links", "publicPrivate": "Public private settings", "read": "Read privileges", + "readOnlyViewProjectContent": "View project content", "readWrite": "Read and write privileges", "title": "Permission Information", "viewProjectContent": "View project content and comment", From d16eac7c4a67e3cf74dd028181820e6c54cf0326 Mon Sep 17 00:00:00 2001 From: Futa Ikeda Date: Thu, 23 Jul 2026 10:12:24 -0400 Subject: [PATCH 27/35] feat(request-access): Update request access page based on waffle flag --- .../request-access.component.html | 53 +++++++++++-------- .../request-access.component.spec.ts | 34 ++++++++++-- .../request-access.component.ts | 15 +++++- src/assets/i18n/en.json | 2 + 4 files changed, 76 insertions(+), 28 deletions(-) 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/assets/i18n/en.json b/src/assets/i18n/en.json index 08d201945..fec610e35 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -2310,6 +2310,8 @@ "commentLabel": "Comment (Optional)", "helpMessage": "If this should not have occurred, please contact", "message": "Ask for access, or switch to an account with permission.", + "messageReadOnly": "This project is in a read-only state, and new access can no longer be granted. If you are already a contributor, please switch to the account that has access to this project.

If you need assistance locating the correct account or have questions, please contact support at", + "readOnlyTitle": "You Don't Have Access", "requestAccess": "Request Access", "requestedSuccessMessage": "Your request for access has been sent.", "switchAccount": "Switch Account", From 5627640a40eea93339d7df0fad2f52a8efe52c2e Mon Sep 17 00:00:00 2001 From: Futa Ikeda Date: Fri, 24 Jul 2026 12:07:47 -0400 Subject: [PATCH 28/35] feat(project-settings): Allow projects to become public, but not private based on waffle-flag --- .../project-overview-toolbar.component.html | 2 +- ...project-overview-toolbar.component.spec.ts | 36 +++++++++++++++++++ .../project-overview-toolbar.component.ts | 5 ++- 3 files changed, 41 insertions(+), 2 deletions(-) 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 1dd97d3ae..50962fd25 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 @@ -15,7 +15,7 @@ [ariaLabel]="'common.accessibility.toggleProjectVisibility' | translate" [(ngModel)]="isPublic" (onChange)="handleToggleProjectPublicity()" - [disabled]="isProjectReadOnly()" + [disabled]="disableProjectPrivacyToggle()" /> 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 bfe4c3905..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 @@ -194,6 +194,34 @@ 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', () => { @@ -227,6 +255,10 @@ describe('ProjectOverviewToolbarComponent', () => { 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', () => { @@ -235,6 +267,10 @@ describe('ProjectOverviewToolbarComponent', () => { }); 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 599f1e87a..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 @@ -78,7 +78,10 @@ export class ProjectOverviewToolbarComponent { preventDuplicateCreation = select(UserSelectors.isProjectCreationDisabled); isProjectReadOnly = select(UserSelectors.isProjectReadOnly); - projectReadOnlyTooltip = computed(() => (this.isProjectReadOnly() ? 'common.errorMessages.actionUnavailable' : '')); + disableProjectPrivacyToggle = computed(() => this.isProjectReadOnly() && this.isPublic()); + projectReadOnlyTooltip = computed(() => + this.disableProjectPrivacyToggle() ? 'common.errorMessages.actionUnavailable' : '' + ); actions = createDispatchMap({ getResourceBookmark: GetResourceBookmark, From 43a7d30297375a7bb4c6d7050512aecd9a3f56a5 Mon Sep 17 00:00:00 2001 From: Futa Ikeda Date: Wed, 29 Jul 2026 19:19:46 -0400 Subject: [PATCH 29/35] feat(project overview): Update view forks icon; Prevent inappropriate success toast message --- .../fork-dialog/fork-dialog.component.spec.ts | 16 +++++--- .../fork-dialog/fork-dialog.component.ts | 16 +++++--- .../project-overview-toolbar.component.html | 40 +++++++++---------- 3 files changed, 41 insertions(+), 31 deletions(-) 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/project-overview-toolbar/project-overview-toolbar.component.html b/src/app/features/project/overview/components/project-overview-toolbar/project-overview-toolbar.component.html index 50962fd25..3c81b9eb8 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 @@ -62,37 +62,37 @@ } @if (!viewOnly()) { - @if (preventDuplicateCreation()) { + + + {{ resource.forksCount }} + + + + + {{ item.label | translate }} + + + + } + @if (!viewOnly()) { Date: Thu, 30 Jul 2026 14:51:53 -0400 Subject: [PATCH 30/35] feat(registrations): Prevent project-based registrations based on waffle flag --- .../new-registration.component.html | 50 ++++++++++--------- .../new-registration.component.spec.ts | 8 +++ .../new-registration.component.ts | 3 +- 3 files changed, 37 insertions(+), 24 deletions(-) diff --git a/src/app/features/registries/components/new-registration/new-registration.component.html b/src/app/features/registries/components/new-registration/new-registration.component.html index 4a6387438..b6bbed2da 100644 --- a/src/app/features/registries/components/new-registration/new-registration.component.html +++ b/src/app/features/registries/components/new-registration/new-registration.component.html @@ -11,28 +11,30 @@
- -

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

-

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

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

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

+

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

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

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

} -

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

+ @if (!isProjectReadOnly()) { +

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

+ }

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

{ { selector: RegistriesSelectors.isProvidersLoading, value: false }, { selector: RegistriesSelectors.isProjectsLoading, value: false }, { selector: UserSelectors.getCurrentUser, value: { id: 'user-1' } }, + { selector: UserSelectors.isProjectReadOnly, value: false }, { selector: RegistrationProviderSelectors.getBrandedProvider, value: { id: 'prov-1', allowSubmissions: true } }, ]; @@ -129,6 +130,13 @@ describe('NewRegistrationComponent', () => { expect(component.fromProject()).toBe(true); }); + it('should not show project panel when projectId is present, but isProjectReadOnly is true', () => { + setup({ + selectorOverrides: [{ selector: UserSelectors.isProjectReadOnly, value: true }], + }); + expect(component.fromProject()).toBe(false); + }); + it('should init form with project id from route', () => { setup(); expect(component.draftForm.get('project')?.value).toBe('proj-1'); diff --git a/src/app/features/registries/components/new-registration/new-registration.component.ts b/src/app/features/registries/components/new-registration/new-registration.component.ts index 8fc36948b..95cdc1ae6 100644 --- a/src/app/features/registries/components/new-registration/new-registration.component.ts +++ b/src/app/features/registries/components/new-registration/new-registration.component.ts @@ -42,6 +42,7 @@ export class NewRegistrationComponent { readonly isDraftSubmitting = select(RegistriesSelectors.isDraftSubmitting); readonly isProvidersLoading = select(RegistriesSelectors.isProvidersLoading); readonly isProjectsLoading = select(RegistriesSelectors.isProjectsLoading); + readonly isProjectReadOnly = select(UserSelectors.isProjectReadOnly); private readonly draftRegistration = select(RegistriesSelectors.getDraftRegistration); readonly canShowForm = computed(() => !this.isProvidersLoading() && !!this.provider()?.allowSubmissions); @@ -53,7 +54,7 @@ export class NewRegistrationComponent { createDraft: CreateDraft, }); private readonly providerId = this.route.snapshot.params['providerId']; - private readonly projectId = this.route.snapshot.queryParams['projectId']; + private readonly projectId = this.isProjectReadOnly() ? undefined : this.route.snapshot.queryParams['projectId']; private readonly filter$ = new Subject(); readonly fromProject = signal(this.projectId !== undefined); From 2ee0fe9abf5da5906133430c1c4c89bf3f4f4348 Mon Sep 17 00:00:00 2001 From: Futa Ikeda Date: Wed, 5 Aug 2026 14:05:34 -0400 Subject: [PATCH 31/35] feat(addons): Prevent new storage addons from being added when waffle flag is on --- .../addon-card/addon-card.component.html | 3 +- .../addon-card/addon-card.component.spec.ts | 50 +++++++++++++++++-- .../addons/addon-card/addon-card.component.ts | 24 +++++++-- 3 files changed, 70 insertions(+), 7 deletions(-) diff --git a/src/app/shared/components/addons/addon-card/addon-card.component.html b/src/app/shared/components/addons/addon-card/addon-card.component.html index 4d8f35d18..2840f1e75 100644 --- a/src/app/shared/components/addons/addon-card/addon-card.component.html +++ b/src/app/shared/components/addons/addon-card/addon-card.component.html @@ -21,7 +21,8 @@

{{ actualAddon()?.displayName diff --git a/src/app/shared/components/addons/addon-card/addon-card.component.spec.ts b/src/app/shared/components/addons/addon-card/addon-card.component.spec.ts index e3405245e..cd939eabb 100644 --- a/src/app/shared/components/addons/addon-card/addon-card.component.spec.ts +++ b/src/app/shared/components/addons/addon-card/addon-card.component.spec.ts @@ -3,6 +3,7 @@ import { MockProvider } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { Router } from '@angular/router'; +import { UserSelectors } from '@osf/core/store/user'; import { CredentialsFormat } from '@osf/shared/enums/addons-credentials-format.enum'; import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; import { AddonModel } from '@shared/models/addons/addon.model'; @@ -10,15 +11,25 @@ import { AddonModel } from '@shared/models/addons/addon.model'; import { provideOSFCore } from '@testing/osf.testing.provider'; import { CustomConfirmationServiceMockBuilder } from '@testing/providers/custom-confirmation-provider.mock'; import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { + BaseSetupOverrides, + mergeSignalOverrides, + provideMockStore, + SignalOverride, +} from '@testing/providers/store-provider.mock'; import { AddonCardComponent } from './addon-card.component'; +interface SetupOverrides extends BaseSetupOverrides { + selectorOverrides?: SignalOverride[]; +} + describe('AddonCardComponent', () => { let component: AddonCardComponent; let fixture: ComponentFixture; let mockRouter: ReturnType; let customConfirmationServiceMock: ReturnType; + const defaultSignals: SignalOverride[] = [{ selector: UserSelectors.isProjectReadOnly, value: false }]; const mockAddon: AddonModel = { id: 'test-addon-id', @@ -31,7 +42,7 @@ describe('AddonCardComponent', () => { externalServiceName: 'test-service', }; - beforeEach(() => { + const setup = function (overrides?: SetupOverrides) { mockRouter = RouterMockBuilder.create().withUrl('/settings/addons').build(); customConfirmationServiceMock = CustomConfirmationServiceMockBuilder.create().build(); @@ -42,6 +53,9 @@ describe('AddonCardComponent', () => { provideMockStore(), MockProvider(Router, mockRouter), MockProvider(CustomConfirmationService, customConfirmationServiceMock), + provideMockStore({ + signals: mergeSignalOverrides(defaultSignals, overrides?.selectorOverrides), + }), ], }); @@ -50,13 +64,41 @@ describe('AddonCardComponent', () => { fixture.componentRef.setInput('card', mockAddon); fixture.detectChanges(); - }); + }; it('should create', () => { + setup(); expect(component).toBeTruthy(); }); + it('should compute shouldDisableConnect when isProjectReadOnly false', () => { + expect(component.shouldDisableConnect()).toBe(false); + + fixture.componentRef.setInput('isConnected', true); + fixture.detectChanges(); + expect(component.shouldDisableConnect()).toBe(false); + + fixture.componentRef.setInput('card', { ...mockAddon, type: 'external-citation-services' }); + fixture.detectChanges(); + expect(component.shouldDisableConnect()).toBe(false); + }); + + it('should compute shouldDisableConnect when isProjectReadOnly true', () => { + setup({ selectorOverrides: [{ selector: UserSelectors.isProjectReadOnly, value: true }] }); + expect(component.shouldDisableConnect()).toBe(true); + + fixture.componentRef.setInput('isConnected', true); + fixture.detectChanges(); + expect(component.shouldDisableConnect()).toBe(false); + + fixture.componentRef.setInput('card', { ...mockAddon, type: 'external-citation-services' }); + fixture.componentRef.setInput('isConnected', false); + fixture.detectChanges(); + expect(component.shouldDisableConnect()).toBe(false); + }); + it('should navigate to connect-addon route when addon exists', () => { + setup(); component.onConnectAddon(); expect(mockRouter.navigate).toHaveBeenCalledWith(['/settings/addons/connect-addon'], { @@ -65,6 +107,7 @@ describe('AddonCardComponent', () => { }); it('should navigate to configure-addon route when addon exists', () => { + setup(); component.onConfigureAddon(); expect(mockRouter.navigate).toHaveBeenCalledWith(['/settings/addons/configure-addon'], { @@ -73,6 +116,7 @@ describe('AddonCardComponent', () => { }); it('should call confirmDelete on customConfirmationService', () => { + setup(); component.showDisableDialog(); expect(customConfirmationServiceMock.confirmDelete).toHaveBeenCalledWith({ diff --git a/src/app/shared/components/addons/addon-card/addon-card.component.ts b/src/app/shared/components/addons/addon-card/addon-card.component.ts index 16ca526b4..686bc5ffe 100644 --- a/src/app/shared/components/addons/addon-card/addon-card.component.ts +++ b/src/app/shared/components/addons/addon-card/addon-card.component.ts @@ -1,13 +1,15 @@ -import { createDispatchMap } from '@ngxs/store'; +import { createDispatchMap, select } from '@ngxs/store'; import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; +import { Tooltip } from 'primeng/tooltip'; import { Component, computed, inject, input } from '@angular/core'; import { Router } from '@angular/router'; -import { getAddonTypeString, isConfiguredAddon } from '@osf/shared/helpers/addon-type.helper'; +import { UserSelectors } from '@osf/core/store/user'; +import { getAddonTypeString, isConfiguredAddon, isStorageAddon } from '@osf/shared/helpers/addon-type.helper'; import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; import { LoaderService } from '@osf/shared/services/loader.service'; import { AddonModel } from '@shared/models/addons/addon.model'; @@ -18,7 +20,7 @@ import { DeleteAuthorizedAddon } from '@shared/stores/addons'; @Component({ selector: 'osf-addon-card', - imports: [Button, TranslatePipe], + imports: [Button, Tooltip, TranslatePipe], templateUrl: './addon-card.component.html', styleUrl: './addon-card.component.scss', }) @@ -32,6 +34,8 @@ export class AddonCardComponent { readonly isConnected = input(false); readonly hasAdminAccess = input(false); + readonly isProjectReadOnly = select(UserSelectors.isProjectReadOnly); + readonly actualAddon = computed(() => { const actualCard = this.card(); if (!actualCard) return null; @@ -74,6 +78,20 @@ export class AddonCardComponent { return hasAdmin || isOwner; }); + readonly shouldDisableConnect = computed(() => { + if (this.isConfiguredAddon() || this.isConnected()) { + return false; + } + if (this.isProjectReadOnly() && !this.isConnected() && isStorageAddon(this.actualAddon())) { + return true; + } + return false; + }); + + readonly buttonTooltip = computed(() => { + return this.shouldDisableConnect() ? 'common.errorMessages.actionUnavailable' : ''; + }); + readonly buttonLabel = computed(() => { const isConfigured = this.isConfiguredAddon(); const isConnected = this.isConnected(); From 76061865ba94a3c4c7b6c13d59162cb01f235f70 Mon Sep 17 00:00:00 2001 From: Futa Ikeda Date: Tue, 11 Aug 2026 16:22:52 -0400 Subject: [PATCH 32/35] fix(duplicates): Fix issue with duplicates button based on project creation flag --- .../project-overview-toolbar.component.html | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) 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 3c81b9eb8..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 @@ -62,7 +62,7 @@ } @if (!viewOnly()) { - - - {{ resource.forksCount }} - - - - - {{ item.label | translate }} - - - - + } @else { + + {{ resource.forksCount }} + + + + + {{ item.label | translate }} + + + + + } } - @if (!viewOnly()) { Date: Mon, 17 Aug 2026 14:13:36 -0400 Subject: [PATCH 33/35] fix(preprints): Hide supplements section if that section is not shown --- .../review-step/review-step.component.html | 22 ++++++++++--------- .../review-step/review-step.component.ts | 2 ++ 2 files changed, 14 insertions(+), 10 deletions(-) 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; From 6ba652a024ae0efe24c8a14c037ffcb5364a1d74 Mon Sep 17 00:00:00 2001 From: Futa Ikeda Date: Mon, 24 Aug 2026 13:03:53 -0400 Subject: [PATCH 34/35] fix(contributors): Update hover text position --- .../contributors-table/contributors-table.component.html | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/shared/components/contributors/contributors-table/contributors-table.component.html b/src/app/shared/components/contributors/contributors-table/contributors-table.component.html index 4a7c8e301..14e33b64b 100644 --- a/src/app/shared/components/contributors/contributors-table/contributors-table.component.html +++ b/src/app/shared/components/contributors/contributors-table/contributors-table.component.html @@ -169,6 +169,7 @@ (!canEditContributors() && contributor.userId !== currentUserId() ? controlDisabledTooltip() : '') | translate " + tooltipPosition="left" [ariaLabel]="'common.buttons.delete' | translate" (onClick)="removeContributor(contributor)" data-test-remove-contributor-button From 353466ea2e87849bfa2be817e95e7ddcf60692e0 Mon Sep 17 00:00:00 2001 From: Futa Ikeda Date: Mon, 24 Aug 2026 13:42:50 -0400 Subject: [PATCH 35/35] fix(wiki): disable collapsed wiki add button --- .../components/wiki/wiki-list/wiki-list.component.html | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/app/shared/components/wiki/wiki-list/wiki-list.component.html b/src/app/shared/components/wiki/wiki-list/wiki-list.component.html index 0048a5e99..09a2cd66b 100644 --- a/src/app/shared/components/wiki/wiki-list/wiki-list.component.html +++ b/src/app/shared/components/wiki/wiki-list/wiki-list.component.html @@ -19,7 +19,7 @@ {{ item.label | translate }}

icon="fas fa-pencil" [rounded]="true" [disabled]="isWikiReadonly()" - [pTooltip]="disabledButtonTooltip() | translate: ''" + [pTooltip]="disabledButtonTooltip() | translate" variant="text" osfStopPropagation (onClick)="openRenameWikiDialog(item.id, item.label)" @@ -110,6 +110,8 @@

{{ item.label | translate }}

{{ item.label | translate }}

outlined severity="danger" [disabled]="isWikiReadonly()" - [pTooltip]="disabledButtonTooltip() | translate: ''" + [pTooltip]="disabledButtonTooltip() | translate" (onClick)="openDeleteWikiDialog()" /> }