From 86e0a13820b308886f8037f7d6b86ce160e6b316 Mon Sep 17 00:00:00 2001 From: futa-ikeda Date: Fri, 15 May 2026 15:17:33 -0400 Subject: [PATCH 01/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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()" /> } From f6e606f47320eb3ab17349c652cb6720355614f0 Mon Sep 17 00:00:00 2001 From: Futa Ikeda Date: Mon, 24 Aug 2026 15:34:51 -0400 Subject: [PATCH 36/36] chore(node-overview): Update privacy modal language --- .../toggle-publicity-dialog.component.html | 2 +- .../toggle-publicity-dialog.component.ts | 7 +++++++ src/assets/i18n/en.json | 3 ++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/app/features/project/overview/components/toggle-publicity-dialog/toggle-publicity-dialog.component.html b/src/app/features/project/overview/components/toggle-publicity-dialog/toggle-publicity-dialog.component.html index 1cd9575c9..0c14f5081 100644 --- a/src/app/features/project/overview/components/toggle-publicity-dialog/toggle-publicity-dialog.component.html +++ b/src/app/features/project/overview/components/toggle-publicity-dialog/toggle-publicity-dialog.component.html @@ -10,7 +10,7 @@
  • {{ 'project.overview.dialog.makePrivate.messageItems.removedFromCollections' | translate }}
  • } @else { -

    +

    } } @else {
    diff --git a/src/app/features/project/overview/components/toggle-publicity-dialog/toggle-publicity-dialog.component.ts b/src/app/features/project/overview/components/toggle-publicity-dialog/toggle-publicity-dialog.component.ts index 876a0c7ae..db91999d4 100644 --- a/src/app/features/project/overview/components/toggle-publicity-dialog/toggle-publicity-dialog.component.ts +++ b/src/app/features/project/overview/components/toggle-publicity-dialog/toggle-publicity-dialog.component.ts @@ -17,6 +17,7 @@ import { } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { UserSelectors } from '@osf/core/store/user'; import { ComponentsSelectionListComponent } from '@osf/shared/components/components-selection-list/components-selection-list.component'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; @@ -44,6 +45,7 @@ export class TogglePublicityDialogComponent { destroyRef = inject(DestroyRef); isSubmitting = select(ProjectOverviewSelectors.getUpdatePublicStatusSubmitting); components = select(CurrentResourceSelectors.getResourceWithChildren); + isProjectReadOnly = select(UserSelectors.isProjectReadOnly); actions = createDispatchMap({ updateProjectPublicStatus: UpdateProjectPublicStatus }); @@ -54,6 +56,11 @@ export class TogglePublicityDialogComponent { componentsList: WritableSignal = signal([]); isInformationStep = computed(() => this.step() === TogglePublicityStep.Information); + makePublicMessage = computed(() => + this.isProjectReadOnly() + ? 'project.overview.dialog.makePublic.messageReadOnly' + : 'project.overview.dialog.makePublic.message' + ); constructor() { effect(() => { diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 31068f4c1..893e46e58 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -1969,7 +1969,8 @@ "makePublic": { "confirmButton": "Make Public", "header": "Make Project Public", - "message": "Please review your projects, components, and add-ons for sensitive or restricted information before making them public.

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

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

    This action is irreversible." }, "privacySettingsPermissionTooltip": "You must have admin permission on this component to be able to change privacy settings", "toast": {