diff --git a/src/app/features/analytics/analytics.component.ts b/src/app/features/analytics/analytics.component.ts index 33f98ab62..924e32c3f 100644 --- a/src/app/features/analytics/analytics.component.ts +++ b/src/app/features/analytics/analytics.component.ts @@ -33,9 +33,9 @@ import { Primitive } from '@osf/shared/helpers/types.helper'; import { DatasetInput } from '@osf/shared/models/charts/dataset-input.model'; import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; +import { DateRange } from './enums/date-range.enum'; import { AnalyticsKpiComponent } from './components'; import { DATE_RANGE_OPTIONS } from './constants'; -import { DateRange } from './enums'; import { AnalyticsSelectors, ClearAnalytics, GetMetrics, GetRelatedCounts } from './store'; @Component({ diff --git a/src/app/features/analytics/components/duplicate-card/duplicate-card.component.html b/src/app/features/analytics/components/duplicate-card/duplicate-card.component.html new file mode 100644 index 000000000..853062595 --- /dev/null +++ b/src/app/features/analytics/components/duplicate-card/duplicate-card.component.html @@ -0,0 +1,59 @@ +
+
+

+ + {{ duplicate().title }} +

+ + @if (canManageDuplicate()) { +
+ + + + + + + {{ item.label | translate }} + + + +
+ } +
+ +
+
+ {{ 'common.labels.forked' | translate }}: + {{ duplicate().dateCreated | date: dateFormat }} +
+ +
+ {{ 'common.labels.lastUpdated' | translate }}: + {{ duplicate().dateModified | date: dateFormat }} +
+ +
+ {{ 'common.labels.contributors' | translate }}: + + +
+ + @if (duplicate().description) { + + } +
+ + +
diff --git a/src/app/features/analytics/components/duplicate-card/duplicate-card.component.scss b/src/app/features/analytics/components/duplicate-card/duplicate-card.component.scss new file mode 100644 index 000000000..ce262f3c7 --- /dev/null +++ b/src/app/features/analytics/components/duplicate-card/duplicate-card.component.scss @@ -0,0 +1,5 @@ +.duplicate-wrapper { + border: 1px solid var(--grey-2); + border-radius: 0.75rem; + color: var(--dark-blue-1); +} diff --git a/src/app/features/analytics/components/duplicate-card/duplicate-card.component.spec.ts b/src/app/features/analytics/components/duplicate-card/duplicate-card.component.spec.ts new file mode 100644 index 000000000..cca195ff4 --- /dev/null +++ b/src/app/features/analytics/components/duplicate-card/duplicate-card.component.spec.ts @@ -0,0 +1,95 @@ +import { MockComponents } from 'ng-mocks'; + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; + +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'; +import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; +import { NodeModel } from '@osf/shared/models/nodes/base-node.model'; + +import { MOCK_NODE_WITH_ADMIN, MOCK_NODE_WITHOUT_ADMIN } from '@testing/mocks/node.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; + +import { DuplicateMenuAction } from '../../enums/duplicate-menu-action.enum'; + +import { DuplicateCardComponent } from './duplicate-card.component'; + +describe('DuplicateCardComponent', () => { + let component: DuplicateCardComponent; + let fixture: ComponentFixture; + + function setup(duplicate: NodeModel = MOCK_NODE_WITH_ADMIN) { + TestBed.configureTestingModule({ + imports: [ + DuplicateCardComponent, + ...MockComponents(IconComponent, TruncatedTextComponent, ContributorsListComponent), + ], + providers: [provideOSFCore(), provideRouter([])], + }); + + fixture = TestBed.createComponent(DuplicateCardComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('duplicate', duplicate); + fixture.detectChanges(); + } + + it('should create', () => { + setup(); + expect(component).toBeTruthy(); + }); + + it('should expose dateFormat', () => { + setup(); + expect(component.dateFormat).toBe('MMM d, y, h:mm a'); + }); + + it('should expose menu items', () => { + setup(); + expect(component.menuItems).toEqual([ + { + label: 'project.overview.actions.manageContributors', + action: DuplicateMenuAction.ManageContributors, + }, + { + label: 'project.overview.actions.settings', + action: DuplicateMenuAction.Settings, + }, + { + label: 'common.labels.delete', + action: DuplicateMenuAction.Delete, + }, + ]); + }); + + it('should allow manage when user has admin permission', () => { + setup(MOCK_NODE_WITH_ADMIN); + expect(component.canManageDuplicate()).toBe(true); + }); + + it('should allow manage when user has write permission', () => { + setup(MOCK_NODE_WITHOUT_ADMIN); + expect(component.canManageDuplicate()).toBe(true); + }); + + it('should not allow manage when user has only read permission', () => { + const readOnly: NodeModel = { + ...MOCK_NODE_WITHOUT_ADMIN, + currentUserPermissions: [UserPermissions.Read], + }; + setup(readOnly); + expect(component.canManageDuplicate()).toBe(false); + }); + + it('should emit menuAction when handleMenuAction is called', () => { + setup(); + const emitted: DuplicateMenuAction[] = []; + component.menuAction.subscribe((action) => emitted.push(action)); + + component.handleMenuAction(DuplicateMenuAction.Settings); + component.handleMenuAction(DuplicateMenuAction.Delete); + + expect(emitted).toEqual([DuplicateMenuAction.Settings, DuplicateMenuAction.Delete]); + }); +}); diff --git a/src/app/features/analytics/components/duplicate-card/duplicate-card.component.ts b/src/app/features/analytics/components/duplicate-card/duplicate-card.component.ts new file mode 100644 index 000000000..d5d854530 --- /dev/null +++ b/src/app/features/analytics/components/duplicate-card/duplicate-card.component.ts @@ -0,0 +1,64 @@ +import { TranslatePipe } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { Menu } from 'primeng/menu'; + +import { DatePipe } from '@angular/common'; +import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core'; +import { RouterLink } from '@angular/router'; + +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'; +import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; +import { NodeModel } from '@osf/shared/models/nodes/base-node.model'; + +import { DuplicateMenuAction } from '../../enums/duplicate-menu-action.enum'; + +@Component({ + selector: 'osf-duplicate-card', + imports: [ + Button, + Menu, + RouterLink, + IconComponent, + TruncatedTextComponent, + ContributorsListComponent, + DatePipe, + TranslatePipe, + ], + templateUrl: './duplicate-card.component.html', + styleUrl: './duplicate-card.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DuplicateCardComponent { + readonly duplicate = input.required(); + + readonly menuAction = output(); + + readonly dateFormat = 'MMM d, y, h:mm a'; + + readonly canManageDuplicate = computed(() => { + const permissions = this.duplicate().currentUserPermissions; + return permissions.includes(UserPermissions.Admin) || permissions.includes(UserPermissions.Write); + }); + + readonly menuItems = [ + { + label: 'project.overview.actions.manageContributors', + action: DuplicateMenuAction.ManageContributors, + }, + { + label: 'project.overview.actions.settings', + action: DuplicateMenuAction.Settings, + }, + { + label: 'common.labels.delete', + action: DuplicateMenuAction.Delete, + }, + ]; + + handleMenuAction(action: DuplicateMenuAction): void { + this.menuAction.emit(action); + } +} 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 6e4fbcfae..552636591 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 @@ -1,84 +1,21 @@
- @if (!isDuplicatesLoading() && currentResource()) { + @if (!isDuplicatesLoading()) { @if (!duplicates().length) {

{{ 'project.overview.dialog.fork.noForksMessage' | translate }}

} @else {

{{ 'project.overview.dialog.fork.forksMessage' | translate }}

@for (duplicate of duplicates(); track duplicate.id) { -
-
-

- - {{ duplicate.title }} -

- - @if (showMoreOptions(duplicate)) { - - } -
- -
-
- {{ 'common.labels.forked' | translate }}: -

{{ duplicate.dateCreated | date: 'MMM d, y, h:mm a' }}

-
- -
- {{ 'common.labels.lastUpdated' | translate }}: -

{{ duplicate.dateModified | date: 'MMM d, y, h:mm a' }}

-
- -
- {{ 'common.labels.contributors' | translate }}: - - -
- - @if (duplicate.description) { - - } -
- -
+ } @if (totalDuplicates() > pageSize) { diff --git a/src/app/features/analytics/components/view-duplicates/view-duplicates.component.scss b/src/app/features/analytics/components/view-duplicates/view-duplicates.component.scss index 32a20a323..28e822eea 100644 --- a/src/app/features/analytics/components/view-duplicates/view-duplicates.component.scss +++ b/src/app/features/analytics/components/view-duplicates/view-duplicates.component.scss @@ -2,10 +2,5 @@ display: flex; flex-direction: column; flex: 1; -} - -.duplicate-wrapper { - border: 1px solid var(--grey-2); - border-radius: 0.75rem; - color: var(--dark-blue-1); + height: 100%; } 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 83d466144..c34080430 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 @@ -1,91 +1,108 @@ +import { Store } from '@ngxs/store'; + import { MockComponents, MockProvider } from 'ng-mocks'; import { PaginatorState } from 'primeng/paginator'; -import { of } from 'rxjs'; +import { of, Subject, throwError } from 'rxjs'; + +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 { 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'; +import { DeleteComponentDialogComponent } from '@osf/features/project/overview/components/delete-component-dialog/delete-component-dialog.component'; +import { ForkProject } from '@osf/features/project/overview/store'; +import { ForkRegistry } from '@osf/features/registry/store/registry'; import { CustomPaginatorComponent } from '@osf/shared/components/custom-paginator/custom-paginator.component'; -import { IconComponent } from '@osf/shared/components/icon/icon.component'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; -import { TruncatedTextComponent } from '@osf/shared/components/truncated-text/truncated-text.component'; -import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { CurrentResourceType, ResourceType } from '@osf/shared/enums/resource-type.enum'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; -import { DuplicatesSelectors } from '@osf/shared/stores/duplicates'; +import { ForkDialogService } from '@osf/shared/services/fork-dialog.service'; +import { LoaderService } from '@osf/shared/services/loader.service'; +import { GetResourceWithChildren } from '@osf/shared/stores/current-resource'; +import { ClearDuplicates, DuplicatesSelectors, GetAllDuplicates } from '@osf/shared/stores/duplicates'; -import { MOCK_PROJECT_OVERVIEW } from '@testing/mocks/project-overview.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; -import { CustomDialogServiceMockBuilder } from '@testing/providers/custom-dialog-provider.mock'; +import { + CustomDialogServiceMock, + CustomDialogServiceMockBuilder, + CustomDialogServiceMockType, +} from '@testing/providers/custom-dialog-provider.mock'; +import { ForkDialogServiceMock, ForkDialogServiceMockType } from '@testing/providers/fork-dialog.service.mock'; +import { LoaderServiceMock } from '@testing/providers/loader-service.mock'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; import { BaseSetupOverrides, mergeSignalOverrides, provideMockStore } from '@testing/providers/store-provider.mock'; +import { DuplicateMenuAction } from '../../enums/duplicate-menu-action.enum'; +import { DuplicateCardComponent } from '../duplicate-card/duplicate-card.component'; + import { ViewDuplicatesComponent } from './view-duplicates.component'; interface SetupOverrides extends BaseSetupOverrides { - selectors?: any[]; + resourceType?: ResourceType; } -describe('Component: View Duplicates', () => { +describe('ViewDuplicatesComponent', () => { let component: ViewDuplicatesComponent; let fixture: ComponentFixture; + let store: Store; let routerMock: ReturnType; - let activatedRouteMock: ReturnType; - let mockCustomDialogService: ReturnType; + let mockCustomDialogService: CustomDialogServiceMockType; + let forkDialogService: ForkDialogServiceMockType; + let loaderService: LoaderServiceMock; function setup(overrides: SetupOverrides = {}) { mockCustomDialogService = CustomDialogServiceMockBuilder.create().build(); + forkDialogService = ForkDialogServiceMock.simple(); + loaderService = new LoaderServiceMock(); routerMock = RouterMockBuilder.create().build(); - activatedRouteMock = ActivatedRouteMockBuilder.create() - .withParams({ id: 'rid' }) - .withData({ resourceType: ResourceType.Project }) - .build(); + + const routeBuilder = ActivatedRouteMockBuilder.create() + .withParams(overrides.routeParams ?? { id: 'rid' }) + .withData({ resourceType: overrides.resourceType ?? ResourceType.Project }); + + if (overrides.hasParent === false) { + routeBuilder.withNoParent(); + } 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.isAuthenticated, value: true }, { selector: UserSelectors.isProjectCreationDisabled, value: false }, ]; - const signals = mergeSignalOverrides(defaultSelectors, overrides.selectors || []); TestBed.configureTestingModule({ imports: [ ViewDuplicatesComponent, ...MockComponents( SubHeaderComponent, - TruncatedTextComponent, LoadingSpinnerComponent, CustomPaginatorComponent, - IconComponent, - ContributorsListComponent + DuplicateCardComponent ), ], providers: [ provideOSFCore(), provideMockStore({ - signals, + signals: mergeSignalOverrides(defaultSelectors, overrides.selectorOverrides), }), MockProvider(CustomDialogService, mockCustomDialogService), + MockProvider(ForkDialogService, forkDialogService), + MockProvider(LoaderService, loaderService), MockProvider(Router, routerMock), - MockProvider(ActivatedRoute, activatedRouteMock), + MockProvider(ActivatedRoute, routeBuilder.build()), ], }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(ViewDuplicatesComponent); component = fixture.componentInstance; - fixture.detectChanges(); } @@ -94,26 +111,168 @@ describe('Component: View Duplicates', () => { expect(component).toBeTruthy(); }); - it('should disable fork button and show tooltip when isProjectCreationDisabled is true', () => { + it('should dispatch getDuplicates on init for a project', () => { + setup(); + + expect(store.dispatch).toHaveBeenCalledWith(new GetAllDuplicates('rid', CurrentResourceType.Projects, 1, 10)); + }); + + it('should dispatch getDuplicates on init for a registration', () => { + setup({ resourceType: ResourceType.Registration }); + + expect(store.dispatch).toHaveBeenCalledWith(new GetAllDuplicates('rid', CurrentResourceType.Registrations, 1, 10)); + }); + + it('should not dispatch getDuplicates when route id is missing', () => { + setup({ hasParent: false }); + + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(GetAllDuplicates)); + }); + + it('should clear duplicates on destroy', () => { + setup(); + (store.dispatch as Mock).mockClear(); + + fixture.destroy(); + + expect(store.dispatch).toHaveBeenCalledWith(new ClearDuplicates()); + }); + + it('should compute firstIndex from currentPage', () => { + setup(); + component.currentPage.set(3); + expect(component.firstIndex()).toBe(20); + }); + + it('should compute apiResourceType for project and registration', () => { + setup(); + expect(component.apiResourceType()).toBe(CurrentResourceType.Projects); + + setup({ resourceType: ResourceType.Registration }); + expect(component.apiResourceType()).toBe(CurrentResourceType.Registrations); + }); + + it('should compute duplicate button label for project and registration', () => { + setup(); + expect(component.duplicateButtonLabel()).toBe('project.overview.actions.forkProjectLabel'); + + setup({ resourceType: ResourceType.Registration }); + expect(component.duplicateButtonLabel()).toBe('project.overview.actions.duplicateRegistration'); + }); + + it('should compute duplicate button tooltip when project creation is disabled', () => { setup({ - selectors: [{ selector: UserSelectors.isProjectCreationDisabled, value: true }], + selectorOverrides: [{ selector: UserSelectors.isProjectCreationDisabled, value: true }], }); + expect(component.preventDuplicateCreation()).toBe(true); expect(component.duplicateButtonTooltip()).toBe('project.overview.actions.duplicatingProjectsNotAllowed'); }); - it('should open ForkDialog with width 450px when small and not refresh on failure', () => { + it('should return empty tooltip when project creation is allowed', () => { setup(); - (component as any).actions = { ...component.actions, getDuplicates: vi.fn() }; + expect(component.duplicateButtonTooltip()).toBe(''); + }); - const openSpy = vi - .spyOn(mockCustomDialogService, 'open') - .mockReturnValue({ onClose: of({ success: false }) } as any); + it('should navigate for manageContributors and settings menu actions', () => { + setup(); - component.handleForkResource(); + component.handleMenuAction(DuplicateMenuAction.ManageContributors, 'node-1'); + component.handleMenuAction(DuplicateMenuAction.Settings, 'node-1'); - expect(openSpy).toHaveBeenCalledWith(expect.any(Function), expect.objectContaining({ width: '450px' })); - expect((component as any).actions.getDuplicates).not.toHaveBeenCalled(); + expect(routerMock.navigate).toHaveBeenCalledWith(['node-1', 'contributors']); + expect(routerMock.navigate).toHaveBeenCalledWith(['node-1', 'settings']); + }); + + it('should open delete dialog and refresh duplicates when delete succeeds', () => { + setup(); + const onClose$ = new Subject<{ success: boolean }>(); + mockCustomDialogService.open.mockReturnValue(CustomDialogServiceMock.dialogRefWithClose(onClose$)); + (store.dispatch as Mock).mockClear(); + + component.handleMenuAction(DuplicateMenuAction.Delete, 'fork-1'); + + expect(loaderService.show).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith(new GetResourceWithChildren('fork-1', 'fork-1', ResourceType.Project)); + expect(loaderService.hide).toHaveBeenCalled(); + expect(mockCustomDialogService.open).toHaveBeenCalledWith(DeleteComponentDialogComponent, { + header: 'project.overview.dialog.deleteComponent.header', + width: '650px', + data: { isForksContext: true }, + }); + + (store.dispatch as Mock).mockClear(); + onClose$.next({ success: true }); + + expect(store.dispatch).toHaveBeenCalledWith(new GetAllDuplicates('rid', CurrentResourceType.Projects, 1, 10)); + }); + + it('should hide loader when delete components tree fails', () => { + setup(); + (store.dispatch as Mock).mockImplementation((action: unknown) => { + if (action instanceof GetResourceWithChildren) { + return throwError(() => new Error('failed')); + } + return of(true); + }); + + component.handleMenuAction(DuplicateMenuAction.Delete, 'fork-1'); + + expect(loaderService.show).toHaveBeenCalled(); + expect(loaderService.hide).toHaveBeenCalled(); + expect(mockCustomDialogService.open).not.toHaveBeenCalled(); + }); + + it('should confirm fork for the current project', () => { + setup(); + + component.handleFork(); + + expect(forkDialogService.confirmFork).toHaveBeenCalledWith( + expect.objectContaining({ resourceType: CurrentResourceType.Projects }) + ); + }); + + it('should confirm fork for the current registration', () => { + setup({ resourceType: ResourceType.Registration }); + + component.handleFork(); + + expect(forkDialogService.confirmFork).toHaveBeenCalledWith( + expect.objectContaining({ resourceType: CurrentResourceType.Registrations }) + ); + }); + + it('should not confirm fork when route id is missing', () => { + setup({ hasParent: false }); + + component.handleFork(); + + expect(forkDialogService.confirmFork).not.toHaveBeenCalled(); + }); + + it('should dispatch ForkProject and refresh duplicates from confirmFork callbacks', () => { + setup(); + component.handleFork(); + const [options] = forkDialogService.confirmFork.mock.calls[0]; + (store.dispatch as Mock).mockClear(); + + options.fork(); + options.onSuccess?.(); + + expect(store.dispatch).toHaveBeenCalledWith(new ForkProject('rid')); + expect(store.dispatch).toHaveBeenCalledWith(new GetAllDuplicates('rid', CurrentResourceType.Projects, 1, 10)); + }); + + it('should dispatch ForkRegistry from confirmFork when resource is a registration', () => { + setup({ resourceType: ResourceType.Registration }); + component.handleFork(); + const [options] = forkDialogService.confirmFork.mock.calls[0]; + (store.dispatch as Mock).mockClear(); + + options.fork(); + + expect(store.dispatch).toHaveBeenCalledWith(new ForkRegistry('rid')); }); it('should update currentPage when page is defined', () => { @@ -130,4 +289,14 @@ describe('Component: View Duplicates', () => { component.onPageChange(event); expect(component.currentPage()).toBe(5); }); + + it('should reload duplicates when page changes', () => { + setup(); + (store.dispatch as Mock).mockClear(); + + component.onPageChange({ page: 2 } as PaginatorState); + fixture.detectChanges(); + + expect(store.dispatch).toHaveBeenCalledWith(new GetAllDuplicates('rid', CurrentResourceType.Projects, 3, 10)); + }); }); 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 11ecd3444..dd6c79b48 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 @@ -2,13 +2,11 @@ import { createDispatchMap, select } from '@ngxs/store'; import { TranslatePipe } from '@ngx-translate/core'; -import { Button } from 'primeng/button'; -import { Menu } from 'primeng/menu'; import { PaginatorState } from 'primeng/paginator'; -import { map, of } from 'rxjs'; +import { filter, map, of } from 'rxjs'; -import { DatePipe, isPlatformBrowser } from '@angular/common'; +import { isPlatformBrowser } from '@angular/common'; import { ChangeDetectionStrategy, Component, @@ -21,40 +19,32 @@ import { signal, } from '@angular/core'; import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; -import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { UserSelectors } from '@core/store/user'; import { DeleteComponentDialogComponent } from '@osf/features/project/overview/components/delete-component-dialog/delete-component-dialog.component'; -import { ForkDialogComponent } from '@osf/features/project/overview/components/fork-dialog/fork-dialog.component'; -import { ClearProjectOverview, GetProjectById, ProjectOverviewSelectors } from '@osf/features/project/overview/store'; -import { ClearRegistry, GetRegistryById, RegistrySelectors } from '@osf/features/registry/store/registry'; -import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component'; +import { ForkProject } from '@osf/features/project/overview/store'; +import { ForkRegistry } from '@osf/features/registry/store/registry'; import { CustomPaginatorComponent } from '@osf/shared/components/custom-paginator/custom-paginator.component'; -import { IconComponent } from '@osf/shared/components/icon/icon.component'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; -import { TruncatedTextComponent } from '@osf/shared/components/truncated-text/truncated-text.component'; -import { ResourceType } from '@osf/shared/enums/resource-type.enum'; -import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; +import { CurrentResourceType, ResourceType } from '@osf/shared/enums/resource-type.enum'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; +import { ForkDialogService } from '@osf/shared/services/fork-dialog.service'; import { LoaderService } from '@osf/shared/services/loader.service'; import { GetResourceWithChildren } from '@osf/shared/stores/current-resource'; import { ClearDuplicates, DuplicatesSelectors, GetAllDuplicates } from '@osf/shared/stores/duplicates'; -import { BaseNodeModel } from '@shared/models/nodes/base-node.model'; + +import { DuplicateMenuAction } from '../../enums/duplicate-menu-action.enum'; +import { DuplicateCardComponent } from '../duplicate-card/duplicate-card.component'; @Component({ selector: 'osf-view-duplicates', imports: [ - Button, - Menu, - RouterLink, - IconComponent, SubHeaderComponent, - TruncatedTextComponent, LoadingSpinnerComponent, CustomPaginatorComponent, - ContributorsListComponent, - DatePipe, + DuplicateCardComponent, TranslatePipe, ], templateUrl: './view-duplicates.component.html', @@ -62,138 +52,91 @@ import { BaseNodeModel } from '@shared/models/nodes/base-node.model'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class ViewDuplicatesComponent { - private customDialogService = inject(CustomDialogService); - private loaderService = inject(LoaderService); - private route = inject(ActivatedRoute); - private router = inject(Router); - private destroyRef = inject(DestroyRef); - private platformId = inject(PLATFORM_ID); - private isBrowser = isPlatformBrowser(this.platformId); - - private project = select(ProjectOverviewSelectors.getProject); - private registration = select(RegistrySelectors.getRegistry); - - duplicates = select(DuplicatesSelectors.getDuplicates); - isDuplicatesLoading = select(DuplicatesSelectors.getDuplicatesLoading); - totalDuplicates = select(DuplicatesSelectors.getDuplicatesTotalCount); - isAuthenticated = select(UserSelectors.isAuthenticated); - preventDuplicateCreation = select(UserSelectors.isProjectCreationDisabled); + private readonly customDialogService = inject(CustomDialogService); + private readonly forkDialogService = inject(ForkDialogService); + private readonly loaderService = inject(LoaderService); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly destroyRef = inject(DestroyRef); + private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); + + readonly duplicates = select(DuplicatesSelectors.getDuplicates); + readonly isDuplicatesLoading = select(DuplicatesSelectors.getDuplicatesLoading); + readonly totalDuplicates = select(DuplicatesSelectors.getDuplicatesTotalCount); + readonly isAuthenticated = select(UserSelectors.isAuthenticated); + readonly preventDuplicateCreation = select(UserSelectors.isProjectCreationDisabled); + + readonly actions = createDispatchMap({ + getDuplicates: GetAllDuplicates, + clearDuplicates: ClearDuplicates, + getComponentsTree: GetResourceWithChildren, + forkProject: ForkProject, + forkRegistry: ForkRegistry, + }); readonly pageSize = 10; currentPage = signal(1); - firstIndex = computed(() => (this.currentPage() - 1) * this.pageSize); - duplicateButtonTooltip = computed(() => + readonly firstIndex = computed(() => (this.currentPage() - 1) * this.pageSize); + readonly duplicateButtonLabel = computed(() => + this.resourceType() === ResourceType.Registration + ? 'project.overview.actions.duplicateRegistration' + : 'project.overview.actions.forkProjectLabel' + ); + readonly duplicateButtonTooltip = computed(() => this.preventDuplicateCreation() ? 'project.overview.actions.duplicatingProjectsNotAllowed' : '' ); - readonly forkActionItems = (resourceId: string) => [ - { - label: 'project.overview.actions.manageContributors', - action: 'manageContributors', - resourceId, - }, - { - label: 'project.overview.actions.settings', - action: 'settings', - resourceId, - }, - { - label: 'common.labels.delete', - action: 'delete', - resourceId, - }, - ]; - readonly resourceId = toSignal(this.route.parent?.params.pipe(map((params) => params['id'])) ?? of(undefined)); readonly resourceType: Signal = toSignal( this.route.data.pipe(map((params) => params['resourceType'])) ?? of(undefined) ); - readonly currentResource = computed(() => { - const resourceType = this.resourceType(); - - if (resourceType) { - if (resourceType === ResourceType.Project) return this.project(); - - if (resourceType === ResourceType.Registration) return this.registration(); - } - - return null; - }); - - actions = createDispatchMap({ - getProject: GetProjectById, - getRegistration: GetRegistryById, - getDuplicates: GetAllDuplicates, - clearDuplicates: ClearDuplicates, - clearProject: ClearProjectOverview, - clearRegistration: ClearRegistry, - getComponentsTree: GetResourceWithChildren, - }); + readonly apiResourceType = computed(() => + this.resourceType() === ResourceType.Registration ? CurrentResourceType.Registrations : CurrentResourceType.Projects + ); constructor() { effect(() => { - const resourceId = this.resourceId(); - const resourceType = this.resourceType(); - - if (resourceId) { - if (resourceType === ResourceType.Project) this.actions.getProject(resourceId); - if (resourceType === ResourceType.Registration) this.actions.getRegistration(resourceId); - } - }); + const id = this.resourceId(); + const type = this.apiResourceType(); + const page = this.currentPage(); - effect(() => { - const resource = this.currentResource(); + if (!id || !type) return; - if (resource) { - this.actions.getDuplicates(resource.id, resource.type, this.currentPage(), this.pageSize); - } + this.actions.getDuplicates(id, type, page, this.pageSize); }); this.setupCleanup(); } - showMoreOptions(duplicate: BaseNodeModel) { - return ( - duplicate.currentUserPermissions.includes(UserPermissions.Admin) || - duplicate.currentUserPermissions.includes(UserPermissions.Write) - ); - } - - handleMenuAction(action: string, resourceId: string): void { + handleMenuAction(action: DuplicateMenuAction, resourceId: string): void { switch (action) { - case 'manageContributors': + case DuplicateMenuAction.ManageContributors: this.router.navigate([resourceId, 'contributors']); break; - case 'settings': + case DuplicateMenuAction.Settings: this.router.navigate([resourceId, 'settings']); break; - case 'delete': + case DuplicateMenuAction.Delete: this.handleDeleteFork(resourceId); break; } } - handleForkResource(): void { - const currentResource = this.currentResource(); - - if (currentResource) { - this.customDialogService - .open(ForkDialogComponent, { - header: 'project.overview.dialog.fork.headerProject', - width: '450px', - data: { - resourceId: currentResource.id, - resourceType: this.resourceType(), - }, - }) - .onClose.pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((result) => { - if (result?.success) { - this.actions.getDuplicates(currentResource.id, currentResource.type, this.currentPage(), this.pageSize); - } - }); - } + handleFork(): void { + const resourceId = this.resourceId(); + const apiResourceType = this.apiResourceType(); + + if (!resourceId) return; + + const isRegistration = apiResourceType === CurrentResourceType.Registrations; + + this.forkDialogService.confirmFork({ + resourceType: apiResourceType, + destroyRef: this.destroyRef, + fork: () => (isRegistration ? this.actions.forkRegistry(resourceId) : this.actions.forkProject(resourceId)), + onSuccess: () => this.actions.getDuplicates(resourceId, apiResourceType, this.currentPage(), this.pageSize), + }); } onPageChange(event: PaginatorState): void { @@ -207,40 +150,31 @@ export class ViewDuplicatesComponent { this.destroyRef.onDestroy(() => { if (this.isBrowser) { this.actions.clearDuplicates(); - this.actions.clearProject(); - this.actions.clearRegistration(); } }); } private handleDeleteFork(id: string): void { - const resourceType = this.resourceType(); - if (!resourceType) return; - this.loaderService.show(); - this.actions.getComponentsTree(id, id, resourceType).subscribe({ + this.actions.getComponentsTree(id, id, ResourceType.Project).subscribe({ next: () => { this.loaderService.hide(); this.customDialogService .open(DeleteComponentDialogComponent, { header: 'project.overview.dialog.deleteComponent.header', width: '650px', - data: { - componentId: id, - resourceType: resourceType, - isForksContext: true, - currentPage: this.currentPage(), - pageSize: this.pageSize, - }, + data: { isForksContext: true }, }) - .onClose.pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((result) => { - if (result?.success) { - const resource = this.currentResource(); - if (resource) { - this.actions.getDuplicates(resource.id, resource.type, this.currentPage(), this.pageSize); - } + .onClose.pipe( + filter((result) => !!result?.success), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(() => { + const resourceId = this.resourceId(); + const apiResourceType = this.apiResourceType(); + if (resourceId && apiResourceType) { + this.actions.getDuplicates(resourceId, apiResourceType, this.currentPage(), this.pageSize); } }); }, diff --git a/src/app/features/analytics/constants/analytics-constants.ts b/src/app/features/analytics/constants/analytics-constants.ts index e06c3039b..9993e3891 100644 --- a/src/app/features/analytics/constants/analytics-constants.ts +++ b/src/app/features/analytics/constants/analytics-constants.ts @@ -1,5 +1,5 @@ -import { DateRange } from '../enums'; -import { DateRangeOption } from '../models'; +import { DateRange } from '../enums/date-range.enum'; +import { DateRangeOption } from '../models/date-range-option.model'; export const DATE_RANGE_OPTIONS: DateRangeOption[] = [ { label: 'project.analytics.charts.pastWeek', value: DateRange.PastWeek }, diff --git a/src/app/features/analytics/enums/duplicate-menu-action.enum.ts b/src/app/features/analytics/enums/duplicate-menu-action.enum.ts new file mode 100644 index 000000000..5d78213f4 --- /dev/null +++ b/src/app/features/analytics/enums/duplicate-menu-action.enum.ts @@ -0,0 +1,5 @@ +export enum DuplicateMenuAction { + ManageContributors = 'manageContributors', + Settings = 'settings', + Delete = 'delete', +} diff --git a/src/app/features/analytics/enums/index.ts b/src/app/features/analytics/enums/index.ts deleted file mode 100644 index c984e6081..000000000 --- a/src/app/features/analytics/enums/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { DateRange } from './date-range.enum'; diff --git a/src/app/features/analytics/models/date-range-option.model.ts b/src/app/features/analytics/models/date-range-option.model.ts index 0aadb078c..e8a6d8d45 100644 --- a/src/app/features/analytics/models/date-range-option.model.ts +++ b/src/app/features/analytics/models/date-range-option.model.ts @@ -1,4 +1,4 @@ -import { DateRange } from '../enums'; +import { DateRange } from '../enums/date-range.enum'; export interface DateRangeOption { label: string; diff --git a/src/app/features/analytics/store/analytics.actions.ts b/src/app/features/analytics/store/analytics.actions.ts index 5a4e2c274..a09419a63 100644 --- a/src/app/features/analytics/store/analytics.actions.ts +++ b/src/app/features/analytics/store/analytics.actions.ts @@ -1,6 +1,6 @@ import { ResourceType } from '@osf/shared/enums/resource-type.enum'; -import { DateRange } from '../enums'; +import { DateRange } from '../enums/date-range.enum'; export class GetMetrics { static readonly type = '[Analytics] Get Metrics'; diff --git a/src/app/features/project/overview/components/fork-dialog/fork-dialog.component.scss b/src/app/features/project/overview/components/fork-dialog/fork-dialog.component.scss deleted file mode 100644 index e69de29bb..000000000 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 deleted file mode 100644 index 6389d4f8a..000000000 --- a/src/app/features/project/overview/components/fork-dialog/fork-dialog.component.spec.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { Store } from '@ngxs/store'; - -import { MockProvider } from 'ng-mocks'; - -import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; - -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'; -import { ToastService } from '@osf/shared/services/toast.service'; - -import { provideOSFCore } from '@testing/osf.testing.provider'; -import { provideDynamicDialogRefMock } from '@testing/providers/dynamic-dialog-ref.mock'; -import { - BaseSetupOverrides, - mergeSignalOverrides, - provideMockStore, - SignalOverride, -} from '@testing/providers/store-provider.mock'; -import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; - -import { ForkResource, ProjectOverviewSelectors } from '../../store'; - -import { ForkDialogComponent } from './fork-dialog.component'; - -interface SetupOverrides extends BaseSetupOverrides { - resourceId?: string; - resourceType?: ResourceType | null; -} - -describe('ForkDialogComponent', () => { - let component: ForkDialogComponent; - let fixture: ComponentFixture; - let store: Store; - let dialogRef: DynamicDialogRef; - let toastService: ToastServiceMockType; - - const defaultSignals: SignalOverride[] = [ - { selector: ProjectOverviewSelectors.getForkProjectSubmitting, value: false }, - ]; - - function setup(overrides: SetupOverrides = {}) { - const signals = mergeSignalOverrides(defaultSignals, overrides.selectorOverrides); - toastService = ToastServiceMock.simple(); - - TestBed.configureTestingModule({ - imports: [ForkDialogComponent], - providers: [ - provideOSFCore(), - provideDynamicDialogRefMock(), - MockProvider(DynamicDialogConfig, { - data: { - resourceId: overrides.resourceId ?? 'project-1', - resourceType: overrides.resourceType === undefined ? ResourceType.Project : overrides.resourceType, - }, - }), - MockProvider(ToastService, toastService), - provideMockStore({ signals }), - ], - }); - - store = TestBed.inject(Store); - dialogRef = TestBed.inject(DynamicDialogRef); - fixture = TestBed.createComponent(ForkDialogComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - } - - it('should create', () => { - setup(); - - expect(component).toBeTruthy(); - }); - - it('should not dispatch fork action when resource id is missing', () => { - setup({ resourceId: '' }); - (store.dispatch as Mock).mockClear(); - - component.handleForkConfirm(); - - expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(ForkResource)); - expect(dialogRef.close).not.toHaveBeenCalled(); - expect(toastService.showSuccess).not.toHaveBeenCalled(); - }); - - it('should not dispatch fork action when resource type is missing', () => { - setup({ resourceType: null }); - (store.dispatch as Mock).mockClear(); - - component.handleForkConfirm(); - - expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(ForkResource)); - expect(dialogRef.close).not.toHaveBeenCalled(); - expect(toastService.showSuccess).not.toHaveBeenCalled(); - }); - - it('should dispatch fork action and close dialog with success toast', () => { - setup({ resourceId: 'project-1', resourceType: ResourceType.Project }); - (store.dispatch as Mock).mockClear(); - - component.handleForkConfirm(); - - expect(store.dispatch).toHaveBeenCalledWith(new ForkResource('project-1', ResourceType.Project)); - expect(dialogRef.close).toHaveBeenCalledWith(); - expect(toastService.showSuccess).toHaveBeenCalledWith('project.overview.dialog.toast.fork.success'); - }); - - 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( - throwError(() => new HttpErrorResponse({ status: 405, error: { errors: [{ detail: errorDetail }] } })) - ); - - component.handleForkConfirm(); - expect(store.dispatch).toHaveBeenCalledWith(new ForkResource('project-1', ResourceType.Project)); - 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 deleted file mode 100644 index 495600c66..000000000 --- a/src/app/features/project/overview/components/fork-dialog/fork-dialog.component.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { createDispatchMap, select } from '@ngxs/store'; - -import { TranslatePipe } from '@ngx-translate/core'; - -import { Button } from 'primeng/button'; -import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; - -import { EMPTY } from 'rxjs'; -import { catchError } from 'rxjs/operators'; - -import { ChangeDetectionStrategy, Component, DestroyRef, inject } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; - -import { ResourceType } from '@osf/shared/enums/resource-type.enum'; -import { ToastService } from '@osf/shared/services/toast.service'; - -import { ForkResource, ProjectOverviewSelectors } from '../../store'; - -@Component({ - selector: 'osf-fork-dialog', - imports: [TranslatePipe, Button], - templateUrl: './fork-dialog.component.html', - styleUrl: './fork-dialog.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class ForkDialogComponent { - private toastService = inject(ToastService); - dialogRef = inject(DynamicDialogRef); - destroyRef = inject(DestroyRef); - readonly config = inject(DynamicDialogConfig); - - isSubmitting = select(ProjectOverviewSelectors.getForkProjectSubmitting); - - actions = createDispatchMap({ forkResource: ForkResource }); - - handleForkConfirm(): void { - const resourceId = this.config.data.resourceId as string; - const resourceType = this.config.data.resourceType as ResourceType; - - if (!resourceId || !resourceType) return; - - this.actions - .forkResource(resourceId, resourceType) - .pipe( - takeUntilDestroyed(this.destroyRef), - catchError((e) => { - this.toastService.showError(e.error.errors[0].detail); - return EMPTY; - }) - ) - .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 9eb6149d4..1412e6ed5 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 @@ -40,9 +40,11 @@
- @if (storage()) { + @let storageData = storage(); + + @if (storageData) {

- {{ +storage()!.storageUsage | fileSize }} + {{ +storageData.storageUsage | fileSize }}

} @@ -62,39 +64,13 @@ } @if (!viewOnly()) { - @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 d31f29dbf..6cdd2a3fe 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 @@ -2,65 +2,93 @@ import { Store } from '@ngxs/store'; import { MockComponents, MockProvider } from 'ng-mocks'; -import { of } from 'rxjs'; +import { Subject } from 'rxjs'; + +import { Mock } from 'vitest'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRoute, provideRouter, Router } from '@angular/router'; import { UserSelectors } from '@core/store/user'; -import { ProjectOverviewSelectors } from '@osf/features/project/overview/store'; +import { ClearDuplicatedProject, ForkProject, ProjectOverviewSelectors } from '@osf/features/project/overview/store'; +import { ForkMenuButtonComponent } from '@osf/shared/components/fork-menu-button/fork-menu-button.component'; import { SocialsShareButtonComponent } from '@osf/shared/components/socials-share-button/socials-share-button.component'; -import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { CurrentResourceType, ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { MyResourcesItem } from '@osf/shared/models/my-resources/my-resources-item.model'; import { NodeStorageModel } from '@osf/shared/models/nodes/node-storage.model'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; +import { ForkDialogService } from '@osf/shared/services/fork-dialog.service'; import { ToastService } from '@osf/shared/services/toast.service'; -import { BookmarksSelectors, GetResourceBookmark } from '@osf/shared/stores/bookmarks'; +import { + AddResourceToBookmarks, + BookmarksSelectors, + GetResourceBookmark, + RemoveResourceFromBookmarks, +} from '@osf/shared/stores/bookmarks'; import { MOCK_PROJECT_OVERVIEW } from '@testing/mocks/project-overview.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; -import { CustomDialogServiceMockBuilder } from '@testing/providers/custom-dialog-provider.mock'; +import { + CustomDialogServiceMock, + CustomDialogServiceMockBuilder, +} from '@testing/providers/custom-dialog-provider.mock'; +import { ForkDialogServiceMock, ForkDialogServiceMockType } from '@testing/providers/fork-dialog.service.mock'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; -import { RouterMockBuilder } from '@testing/providers/router-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 { ProjectOverviewModel } from '../../models'; +import { DuplicateDialogComponent } from '../duplicate-dialog/duplicate-dialog.component'; import { TogglePublicityDialogComponent } from '../toggle-publicity-dialog/toggle-publicity-dialog.component'; import { ProjectOverviewToolbarComponent } from './project-overview-toolbar.component'; interface SetupOverrides extends BaseSetupOverrides { - selectors?: any[]; + isPublic?: boolean; } describe('ProjectOverviewToolbarComponent', () => { let component: ProjectOverviewToolbarComponent; let fixture: ComponentFixture; let store: Store; - let routerMock: ReturnType; - let activatedRouteMock: ReturnType; + let routerMock: RouterMockType; let customDialogServiceMock: ReturnType; let toastService: ToastServiceMockType; - - const mockResource: ProjectOverviewModel = { - ...MOCK_PROJECT_OVERVIEW, - id: 'project-123', - title: 'Test Project', - isPublic: true, - } as ProjectOverviewModel; + let forkDialogService: ForkDialogServiceMockType; const mockStorage: NodeStorageModel = { id: 'storage-123', storageLimitStatus: 'ok', - storageUsage: '500MB', + storageUsage: '1500', + }; + + const bookmarkedItem: MyResourcesItem = { + id: 'project-123', + dateCreated: '2023-01-01', + dateModified: '2023-01-01', + isPublic: true, + contributors: [], + title: 'Test Project', + type: 'nodes', }; function setup(overrides: SetupOverrides = {}) { routerMock = RouterMockBuilder.create().build(); - activatedRouteMock = ActivatedRouteMockBuilder.create().build(); customDialogServiceMock = CustomDialogServiceMockBuilder.create().withDefaultOpen().build(); toastService = ToastServiceMock.simple(); - const defaultSelectors = [ + forkDialogService = ForkDialogServiceMock.simple(); + + const resource: ProjectOverviewModel = { + ...MOCK_PROJECT_OVERVIEW, + id: 'project-123', + title: 'Test Project', + isPublic: overrides.isPublic ?? true, + forksCount: 4, + viewOnlyLinksCount: 2, + }; + + const defaultSignals = [ { selector: BookmarksSelectors.getBookmarksCollectionId, value: 'bookmarks-123' }, { selector: BookmarksSelectors.getBookmarks, value: [] }, { selector: BookmarksSelectors.areBookmarksLoading, value: false }, @@ -70,207 +98,224 @@ describe('ProjectOverviewToolbarComponent', () => { { selector: UserSelectors.isProjectCreationDisabled, value: false }, { selector: UserSelectors.isProjectReadOnly, value: false }, ]; - const signals = mergeSignalOverrides(defaultSelectors, overrides.selectors); + const signals = mergeSignalOverrides(defaultSignals, overrides.selectorOverrides); TestBed.configureTestingModule({ - imports: [ProjectOverviewToolbarComponent, ...MockComponents(SocialsShareButtonComponent)], + imports: [ + ProjectOverviewToolbarComponent, + ...MockComponents(SocialsShareButtonComponent, ForkMenuButtonComponent), + ], providers: [ provideOSFCore(), - provideMockStore({ - signals, - }), + provideRouter([]), + provideMockStore({ signals }), MockProvider(Router, routerMock), - MockProvider(ActivatedRoute, activatedRouteMock), + MockProvider(ActivatedRoute, ActivatedRouteMockBuilder.create().build()), MockProvider(CustomDialogService, customDialogServiceMock), MockProvider(ToastService, toastService), + MockProvider(ForkDialogService, forkDialogService), ], }); store = TestBed.inject(Store); - store.dispatch = vi.fn().mockReturnValue(of(true)); fixture = TestBed.createComponent(ProjectOverviewToolbarComponent); component = fixture.componentInstance; fixture.componentRef.setInput('canEdit', true); - fixture.componentRef.setInput('currentResource', mockResource); + fixture.componentRef.setInput('currentResource', resource); fixture.componentRef.setInput('storage', mockStorage); fixture.componentRef.setInput('viewOnly', false); + fixture.detectChanges(); } - it('should create', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('should read preventDuplicateCreation from the user state', () => { + setup({ selectorOverrides: [{ selector: UserSelectors.isProjectCreationDisabled, value: true }] }); + + expect(component.preventDuplicateCreation()).toBe(true); + }); + + it('should dispatch GetResourceBookmark on init', () => { setup(); - expect(component).toBeTruthy(); + + expect(store.dispatch).toHaveBeenCalledWith( + new GetResourceBookmark('bookmarks-123', 'project-123', ResourceType.Project) + ); }); - describe('Input Bindings', () => { - it('should set canEdit input correctly', () => { - setup(); - fixture.componentRef.setInput('canEdit', false); - fixture.detectChanges(); + it('should not dispatch GetResourceBookmark without a bookmarks collection id', () => { + setup({ selectorOverrides: [{ selector: BookmarksSelectors.getBookmarksCollectionId, value: null }] }); - expect(component.canEdit()).toBe(false); - }); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(GetResourceBookmark)); + }); - it('should set currentResource input correctly', () => { - setup(); - expect(component.currentResource()).toEqual(mockResource); - }); + it('should mark the project as bookmarked when it is in the bookmarks list', () => { + setup({ selectorOverrides: [{ selector: BookmarksSelectors.getBookmarks, value: [bookmarkedItem] }] }); - it('should set storage input correctly', () => { - setup(); - expect(component.storage()).toEqual(mockStorage); - }); + expect(component.isBookmarked()).toBe(true); + }); - it('should default viewOnly to false', () => { - setup(); - expect(component.viewOnly()).toBe(false); + it('should leave the project unbookmarked when it is absent from the bookmarks list', () => { + setup({ + selectorOverrides: [{ selector: BookmarksSelectors.getBookmarks, value: [{ ...bookmarkedItem, id: 'other' }] }], }); - it('should set viewOnly input correctly', () => { - setup(); - fixture.componentRef.setInput('viewOnly', true); - fixture.detectChanges(); + expect(component.isBookmarked()).toBe(false); + }); - expect(component.viewOnly()).toBe(true); - }); + it('should add a bookmark and show a toast when the project is not bookmarked', () => { + setup(); + (store.dispatch as Mock).mockClear(); + + component.toggleBookmark(); + + expect(store.dispatch).toHaveBeenCalledWith( + new AddResourceToBookmarks('bookmarks-123', 'project-123', ResourceType.Project) + ); + expect(component.isBookmarked()).toBe(true); + expect(toastService.showSuccess).toHaveBeenCalledWith('project.overview.dialog.toast.bookmark.add'); }); - describe('Effects', () => { - it('should set isPublic from currentResource', () => { - setup(); - fixture.detectChanges(); + it('should remove a bookmark and show a toast when the project is bookmarked', () => { + setup({ selectorOverrides: [{ selector: BookmarksSelectors.getBookmarks, value: [bookmarkedItem] }] }); + (store.dispatch as Mock).mockClear(); - expect(component.isPublic()).toBe(true); - }); + component.toggleBookmark(); - it('should dispatch getResourceBookmark when bookmarksId and resource exist', () => { - setup(); - fixture.detectChanges(); + expect(store.dispatch).toHaveBeenCalledWith( + new RemoveResourceFromBookmarks('bookmarks-123', 'project-123', ResourceType.Project) + ); + expect(component.isBookmarked()).toBe(false); + expect(toastService.showSuccess).toHaveBeenCalledWith('project.overview.dialog.toast.bookmark.remove'); + }); - expect(store.dispatch).toHaveBeenCalledWith(expect.any(GetResourceBookmark)); - }); + it('should not toggle a bookmark without a bookmarks collection id', () => { + setup({ selectorOverrides: [{ selector: BookmarksSelectors.getBookmarksCollectionId, value: null }] }); + (store.dispatch as Mock).mockClear(); + + component.toggleBookmark(); + + expect(store.dispatch).not.toHaveBeenCalled(); + expect(toastService.showSuccess).not.toHaveBeenCalled(); }); - describe('handleToggleProjectPublicity', () => { - it('should open TogglePublicityDialogComponent with makePrivate header when project is public', () => { - setup(); - fixture.detectChanges(); + it('should open the make-private dialog for a public project', () => { + vi.useFakeTimers(); + setup(); - component.handleToggleProjectPublicity(); + component.handleToggleProjectPublicity(); + vi.advanceTimersByTime(100); - expect(customDialogServiceMock.open).toHaveBeenCalledWith(TogglePublicityDialogComponent, { - header: 'project.overview.dialog.makePrivate.header', - width: '600px', - data: { - projectId: 'project-123', - isCurrentlyPublic: true, - }, - }); + expect(customDialogServiceMock.open).toHaveBeenCalledWith(TogglePublicityDialogComponent, { + header: 'project.overview.dialog.makePrivate.header', + width: '600px', + data: { + projectId: 'project-123', + isCurrentlyPublic: true, + }, }); + expect(component.isPublic()).toBe(true); + }); - it('should open TogglePublicityDialogComponent with makePublic header when project is private', () => { - setup(); - fixture.componentRef.setInput('currentResource', { ...mockResource, isPublic: false }); - fixture.detectChanges(); + it('should open the make-public dialog for a private project', () => { + vi.useFakeTimers(); + setup({ isPublic: false }); - component.handleToggleProjectPublicity(); + component.handleToggleProjectPublicity(); + vi.advanceTimersByTime(100); - expect(customDialogServiceMock.open).toHaveBeenCalledWith(TogglePublicityDialogComponent, { - header: 'project.overview.dialog.makePublic.header', - width: '600px', - data: { - projectId: 'project-123', - isCurrentlyPublic: false, - }, - }); + expect(customDialogServiceMock.open).toHaveBeenCalledWith(TogglePublicityDialogComponent, { + header: 'project.overview.dialog.makePublic.header', + width: '600px', + data: { + projectId: 'project-123', + isCurrentlyPublic: false, + }, }); + expect(component.isPublic()).toBe(false); + }); - it('should not open dialog when resource is null', () => { - setup(); - fixture.componentRef.setInput('currentResource', null as any); - fixture.detectChanges(); + it('should disable the privacy toggle when a public project is read only', () => { + setup({ selectorOverrides: [{ selector: UserSelectors.isProjectReadOnly, value: true }] }); - component.handleToggleProjectPublicity(); + expect(component.disableProjectPrivacyToggle()).toBe(true); + expect(component.projectReadOnlyTooltip()).toBe('common.errorMessages.actionUnavailable'); + }); - expect(customDialogServiceMock.open).not.toHaveBeenCalled(); + it('should keep the privacy toggle enabled when a private project is read only', () => { + setup({ + isPublic: false, + selectorOverrides: [{ selector: UserSelectors.isProjectReadOnly, value: true }], }); - it('should compute disableProjectPrivacyToggle when isProjectReadOnly is false', () => { - setup(); - fixture.detectChanges(); + expect(component.disableProjectPrivacyToggle()).toBe(false); + expect(component.projectReadOnlyTooltip()).toBe(''); + }); - expect(component.isPublic()).toBe(true); - expect(component.disableProjectPrivacyToggle()).toBe(false); + it('should navigate to the duplicates view', () => { + setup(); - fixture.componentRef.setInput('currentResource', { ...mockResource, isPublic: false }); - fixture.detectChanges(); + component.forkActionItems[2].command?.({}); - expect(component.isPublic()).toBe(false); - expect(component.disableProjectPrivacyToggle()).toBe(false); + expect(routerMock.navigate).toHaveBeenCalledWith(['../analytics/duplicates'], { + relativeTo: TestBed.inject(ActivatedRoute), }); + }); - 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); + it('should open fork confirmation for a project', () => { + setup(); - fixture.componentRef.setInput('currentResource', { ...mockResource, isPublic: false }); - fixture.detectChanges(); + component.forkActionItems[0].command?.({}); - expect(component.isPublic()).toBe(false); - expect(component.disableProjectPrivacyToggle()).toBe(false); - }); + expect(forkDialogService.confirmFork).toHaveBeenCalledWith( + expect.objectContaining({ resourceType: CurrentResourceType.Projects }) + ); }); - describe('Properties', () => { - it('should have ResourceType property', () => { - setup(); - expect(component.ResourceType).toBe(ResourceType); - }); + it('should dispatch ForkProject when the fork is confirmed', () => { + setup(); + component.forkActionItems[0].command?.({}); + const [options] = forkDialogService.confirmFork.mock.calls[0]; + (store.dispatch as Mock).mockClear(); - it('should have resourceType set to Project', () => { - setup(); - expect(component.resourceType).toBe(ResourceType.Project); - }); + options.fork(); + + expect(store.dispatch).toHaveBeenCalledWith(new ForkProject('project-123')); }); - describe('preventDuplicateCreation', () => { - it('should return false when isProjectCreationDisabled is false', () => { - setup(); - expect(component.preventDuplicateCreation()).toBe(false); + it('should navigate to the duplicated project when the dialog closes', () => { + setup({ + selectorOverrides: [ + { + selector: ProjectOverviewSelectors.getDuplicatedProject, + value: { ...MOCK_PROJECT_OVERVIEW, id: 'copy-1' }, + }, + ], }); + const onClose$ = new Subject(); + customDialogServiceMock.open.mockReturnValue(CustomDialogServiceMock.dialogRefWithClose(onClose$)); + + component.forkActionItems[1].command?.({}); + onClose$.next(); - it('should return true when isProjectCreationDisabled is true', () => { - setup({ - selectors: [{ selector: UserSelectors.isProjectCreationDisabled, value: true }], - }); - fixture.detectChanges(); - expect(component.preventDuplicateCreation()).toBe(true); + expect(customDialogServiceMock.open).toHaveBeenCalledWith(DuplicateDialogComponent, { + header: 'project.overview.dialog.duplicate.header', }); + expect(routerMock.navigate).toHaveBeenCalledWith(['/', 'copy-1']); }); - describe('projectReadOnlyTooltip', () => { - it('should return empty string when isProjectReadOnly is false', () => { - setup(); - expect(component.projectReadOnlyTooltip()).toBe(''); - - fixture.componentRef.setInput('currentResource', { ...mockResource, isPublic: false }); - fixture.detectChanges(); - expect(component.projectReadOnlyTooltip()).toBe(''); - }); + it('should clear the duplicated project when the dialog completes', () => { + setup(); + const onClose$ = new Subject(); + customDialogServiceMock.open.mockReturnValue(CustomDialogServiceMock.dialogRefWithClose(onClose$)); + (store.dispatch as Mock).mockClear(); - 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'); + component.forkActionItems[1].command?.({}); + onClose$.complete(); - fixture.componentRef.setInput('currentResource', { ...mockResource, isPublic: false }); - fixture.detectChanges(); - expect(component.projectReadOnlyTooltip()).toBe(''); - }); + expect(store.dispatch).toHaveBeenCalledWith(new ClearDuplicatedProject()); }); }); 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 01a7d2710..4a5b9d77d 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 @@ -2,8 +2,8 @@ import { createDispatchMap, select } from '@ngxs/store'; import { TranslatePipe } from '@ngx-translate/core'; +import { MenuItem } from 'primeng/api'; import { Button } from 'primeng/button'; -import { Menu } from 'primeng/menu'; import { ToggleSwitch } from 'primeng/toggleswitch'; import { Tooltip } from 'primeng/tooltip'; @@ -15,12 +15,14 @@ import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { UserSelectors } from '@core/store/user'; -import { ClearDuplicatedProject, ProjectOverviewSelectors } from '@osf/features/project/overview/store'; +import { ClearDuplicatedProject, ForkProject, ProjectOverviewSelectors } from '@osf/features/project/overview/store'; +import { ForkMenuButtonComponent } from '@osf/shared/components/fork-menu-button/fork-menu-button.component'; import { SocialsShareButtonComponent } from '@osf/shared/components/socials-share-button/socials-share-button.component'; -import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { CurrentResourceType, ResourceType } from '@osf/shared/enums/resource-type.enum'; import { NodeStorageModel } from '@osf/shared/models/nodes/node-storage.model'; import { FileSizePipe } from '@osf/shared/pipes/file-size.pipe'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; +import { ForkDialogService } from '@osf/shared/services/fork-dialog.service'; import { ToastService } from '@osf/shared/services/toast.service'; import { AddResourceToBookmarks, @@ -31,7 +33,6 @@ import { import { ProjectOverviewModel } from '../../models'; import { DuplicateDialogComponent } from '../duplicate-dialog/duplicate-dialog.component'; -import { ForkDialogComponent } from '../fork-dialog/fork-dialog.component'; import { TogglePublicityDialogComponent } from '../toggle-publicity-dialog/toggle-publicity-dialog.component'; @Component({ @@ -39,63 +40,63 @@ import { TogglePublicityDialogComponent } from '../toggle-publicity-dialog/toggl imports: [ ToggleSwitch, TranslatePipe, - Menu, Button, Tooltip, FormsModule, RouterLink, FileSizePipe, SocialsShareButtonComponent, + ForkMenuButtonComponent, ], templateUrl: './project-overview-toolbar.component.html', styleUrl: './project-overview-toolbar.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class ProjectOverviewToolbarComponent { - private customDialogService = inject(CustomDialogService); - private toastService = inject(ToastService); - private destroyRef = inject(DestroyRef); - + private readonly customDialogService = inject(CustomDialogService); + private readonly forkDialogService = inject(ForkDialogService); + private readonly toastService = inject(ToastService); + private readonly destroyRef = inject(DestroyRef); private readonly router = inject(Router); private readonly route = inject(ActivatedRoute); - canEdit = input.required(); - storage = input(); - viewOnly = input(false); - currentResource = input.required(); + readonly canEdit = input.required(); + readonly storage = input(); + readonly viewOnly = input(false); + readonly currentResource = input.required(); isPublic = signal(false); isBookmarked = signal(false); - resourceType = ResourceType.Project; - bookmarksCollectionId = select(BookmarksSelectors.getBookmarksCollectionId); - bookmarks = select(BookmarksSelectors.getBookmarks); - isBookmarksLoading = select(BookmarksSelectors.areBookmarksLoading); - isBookmarksSubmitting = select(BookmarksSelectors.getBookmarksCollectionIdSubmitting); + readonly resourceType = ResourceType.Project; - duplicatedProject = select(ProjectOverviewSelectors.getDuplicatedProject); - isAuthenticated = select(UserSelectors.isAuthenticated); - preventDuplicateCreation = select(UserSelectors.isProjectCreationDisabled); - isProjectReadOnly = select(UserSelectors.isProjectReadOnly); + readonly bookmarksCollectionId = select(BookmarksSelectors.getBookmarksCollectionId); + readonly bookmarks = select(BookmarksSelectors.getBookmarks); + readonly isBookmarksLoading = select(BookmarksSelectors.areBookmarksLoading); + readonly isBookmarksSubmitting = select(BookmarksSelectors.getBookmarksCollectionIdSubmitting); - disableProjectPrivacyToggle = computed(() => this.isProjectReadOnly() && this.isPublic()); - projectReadOnlyTooltip = computed(() => - this.disableProjectPrivacyToggle() ? 'common.errorMessages.actionUnavailable' : '' - ); + readonly duplicatedProject = select(ProjectOverviewSelectors.getDuplicatedProject); + readonly isAuthenticated = select(UserSelectors.isAuthenticated); + readonly preventDuplicateCreation = select(UserSelectors.isProjectCreationDisabled); + readonly isProjectReadOnly = select(UserSelectors.isProjectReadOnly); - actions = createDispatchMap({ + readonly actions = createDispatchMap({ getResourceBookmark: GetResourceBookmark, addResourceToBookmarks: AddResourceToBookmarks, removeResourceFromBookmarks: RemoveResourceFromBookmarks, clearDuplicatedProject: ClearDuplicatedProject, + forkProject: ForkProject, }); - readonly ResourceType = ResourceType; + readonly disableProjectPrivacyToggle = computed(() => this.isProjectReadOnly() && this.isPublic()); + readonly projectReadOnlyTooltip = computed(() => + this.disableProjectPrivacyToggle() ? 'common.errorMessages.actionUnavailable' : '' + ); - readonly forkActionItems = [ + readonly forkActionItems: MenuItem[] = [ { label: 'project.overview.actions.forkProject', - command: () => this.handleForkResource(), + command: () => this.handleForkProject(), }, { label: 'project.overview.actions.duplicateProject', @@ -186,21 +187,26 @@ export class ProjectOverviewToolbarComponent { } } - private handleForkResource(): void { + navigateToDuplicatesView(): void { + this.router.navigate(['../analytics/duplicates'], { relativeTo: this.route }); + } + + private handleForkProject(): void { const resource = this.currentResource(); + if (!resource) return; - if (resource) { - this.customDialogService.open(ForkDialogComponent, { - header: 'project.overview.dialog.fork.headerProject', - data: { resourceId: resource.id, resourceType: this.resourceType }, - }); - } + this.forkDialogService.confirmFork({ + resourceType: CurrentResourceType.Projects, + destroyRef: this.destroyRef, + fork: () => this.actions.forkProject(resource.id), + }); } private handleDuplicateProject(): void { this.customDialogService .open(DuplicateDialogComponent, { header: 'project.overview.dialog.duplicate.header' }) - .onClose.subscribe({ + .onClose.pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ next: () => { const duplicatedProject = this.duplicatedProject(); if (duplicatedProject) { @@ -210,8 +216,4 @@ export class ProjectOverviewToolbarComponent { complete: () => this.actions.clearDuplicatedProject(), }); } - - navigateToDuplicatesView(): void { - this.router.navigate(['../analytics/duplicates'], { relativeTo: this.route }); - } } diff --git a/src/app/features/project/overview/services/index.ts b/src/app/features/project/overview/services/index.ts deleted file mode 100644 index 0b2facadf..000000000 --- a/src/app/features/project/overview/services/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './project-overview.service'; diff --git a/src/app/features/project/overview/services/project-overview.service.ts b/src/app/features/project/overview/services/project-overview.service.ts index 5a0caba1e..f58bd0bff 100644 --- a/src/app/features/project/overview/services/project-overview.service.ts +++ b/src/app/features/project/overview/services/project-overview.service.ts @@ -101,16 +101,6 @@ export class ProjectOverviewService { .pipe(map((res) => BaseNodeMapper.getNodesData(res))); } - forkResource(projectId: string, resourceType: string): Observable { - const payload = { - data: { - type: 'nodes', - }, - }; - - return this.jsonApiService.post(`${this.apiUrl}/${resourceType}/${projectId}/forks/`, payload); - } - duplicateProject(projectId: string, title: string): Observable { const payload = { data: { diff --git a/src/app/features/project/overview/store/project-overview.actions.ts b/src/app/features/project/overview/store/project-overview.actions.ts index 19c78cbef..58c80445c 100644 --- a/src/app/features/project/overview/store/project-overview.actions.ts +++ b/src/app/features/project/overview/store/project-overview.actions.ts @@ -1,5 +1,4 @@ import { DEFAULT_TABLE_PARAMS } from '@osf/shared/constants/default-table-params.constants'; -import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { PrivacyStatusModel } from '../models'; @@ -51,13 +50,10 @@ export class SetProjectCustomCitation { constructor(public citation: string) {} } -export class ForkResource { - static readonly type = '[Project Overview] Fork Resource'; +export class ForkProject { + static readonly type = '[Project Overview] Fork Project'; - constructor( - public resourceId: string, - public resourceType: ResourceType - ) {} + constructor(public resourceId: string) {} } export class DuplicateProject { diff --git a/src/app/features/project/overview/store/project-overview.selectors.ts b/src/app/features/project/overview/store/project-overview.selectors.ts index 01ce1bade..b45a6c7f8 100644 --- a/src/app/features/project/overview/store/project-overview.selectors.ts +++ b/src/app/features/project/overview/store/project-overview.selectors.ts @@ -31,11 +31,6 @@ export class ProjectOverviewSelectors { return state.components.isSubmitting; } - @Selector([ProjectOverviewState]) - static getForkProjectSubmitting(state: ProjectOverviewStateModel) { - return state.project.isSubmitting; - } - @Selector([ProjectOverviewState]) static getDuplicateProjectSubmitting(state: ProjectOverviewStateModel) { return state.project.isSubmitting; diff --git a/src/app/features/project/overview/store/project-overview.state.ts b/src/app/features/project/overview/store/project-overview.state.ts index e8799ccd1..d74ee787c 100644 --- a/src/app/features/project/overview/store/project-overview.state.ts +++ b/src/app/features/project/overview/store/project-overview.state.ts @@ -4,10 +4,11 @@ import { catchError, tap } from 'rxjs'; import { inject, Injectable } from '@angular/core'; +import { CurrentResourceType } from '@osf/shared/enums/resource-type.enum'; import { handleSectionError } from '@osf/shared/helpers/state-error.handler'; -import { ResourceType } from '@shared/enums/resource-type.enum'; +import { DuplicatesService } from '@osf/shared/services/duplicates.service'; -import { ProjectOverviewService } from '../services'; +import { ProjectOverviewService } from '../services/project-overview.service'; import { ClearDuplicatedProject, @@ -15,7 +16,7 @@ import { CreateComponent, DeleteComponent, DuplicateProject, - ForkResource, + ForkProject, GetComponents, GetParentProject, GetProjectById, @@ -37,7 +38,8 @@ import { PROJECT_OVERVIEW_DEFAULTS, ProjectOverviewStateModel } from './project- }) @Injectable() export class ProjectOverviewState { - projectOverviewService = inject(ProjectOverviewService); + private readonly projectOverviewService = inject(ProjectOverviewService); + private readonly duplicatesService = inject(DuplicatesService); @Action(GetProjectById) getProjectById(ctx: StateContext, action: GetProjectById) { @@ -269,36 +271,20 @@ export class ProjectOverviewState { }); } - @Action(ForkResource) - forkResource(ctx: StateContext, action: ForkResource) { - const state = ctx.getState(); - ctx.patchState({ - project: { - ...state.project, - isSubmitting: true, - }, - }); - - let resourceType = ''; - switch (action.resourceType) { - case ResourceType.Project: - resourceType = 'nodes'; - break; - case ResourceType.Registration: - resourceType = 'registrations'; - break; - } - - return this.projectOverviewService.forkResource(action.resourceId, resourceType).pipe( + @Action(ForkProject) + forkProject(ctx: StateContext, action: ForkProject) { + return this.duplicatesService.forkResource(action.resourceId, CurrentResourceType.Projects).pipe( tap(() => { + const state = ctx.getState(); + if (!state.project.data) return; + ctx.patchState({ project: { ...state.project, data: { - ...state.project.data!, - forksCount: state.project.data!.forksCount + 1, + ...state.project.data, + forksCount: state.project.data.forksCount + 1, }, - isSubmitting: false, }, }); }), diff --git a/src/app/features/project/project.routes.ts b/src/app/features/project/project.routes.ts index 91a0f33c3..2cdd5edaa 100644 --- a/src/app/features/project/project.routes.ts +++ b/src/app/features/project/project.routes.ts @@ -105,7 +105,7 @@ export const projectRoutes: Routes = [ import('../analytics/components/view-duplicates/view-duplicates.component').then( (mod) => mod.ViewDuplicatesComponent ), - providers: [provideStates([DuplicatesState])], + providers: [provideStates([DuplicatesState, SettingsState])], }, { path: 'wiki/:wikiName', diff --git a/src/app/features/registry/components/registration-overview-toolbar/registration-overview-toolbar.component.html b/src/app/features/registry/components/registration-overview-toolbar/registration-overview-toolbar.component.html index 0b5609eea..804305b96 100644 --- a/src/app/features/registry/components/registration-overview-toolbar/registration-overview-toolbar.component.html +++ b/src/app/features/registry/components/registration-overview-toolbar/registration-overview-toolbar.component.html @@ -1,5 +1,14 @@ @if (resourceId()) {
+ @if (isAuthenticated() && !viewOnly()) { + + } + @if (isAuthenticated()) { { + let component: RegistrationOverviewToolbarComponent; + let fixture: ComponentFixture; + let store: Store; + let routerMock: RouterMockType; + let toastService: ToastServiceMockType; + let forkDialogService: ForkDialogServiceMockType; + + const bookmarkedItem = { id: 'reg-1' } as MyResourcesItem; + + function setup(overrides: SetupOverrides = {}) { + routerMock = RouterMockBuilder.create().build(); + toastService = ToastServiceMock.simple(); + forkDialogService = ForkDialogServiceMock.simple(); + + const defaultSignals = [ + { selector: BookmarksSelectors.getBookmarksCollectionId, value: 'bookmarks-1' }, + { selector: BookmarksSelectors.getBookmarks, value: [] }, + { selector: BookmarksSelectors.areBookmarksLoading, value: false }, + { selector: BookmarksSelectors.getBookmarksCollectionIdSubmitting, value: false }, + { selector: UserSelectors.isAuthenticated, value: true }, + { selector: UserSelectors.isProjectCreationDisabled, value: false }, + ]; + const signals = mergeSignalOverrides(defaultSignals, overrides.selectorOverrides); + + TestBed.configureTestingModule({ + imports: [ + RegistrationOverviewToolbarComponent, + ...MockComponents(ForkMenuButtonComponent, SocialsShareButtonComponent), + ], + providers: [ + provideOSFCore(), + provideMockStore({ signals }), + MockProvider(Router, routerMock), + MockProvider(ActivatedRoute, ActivatedRouteMockBuilder.create().build()), + MockProvider(ToastService, toastService), + MockProvider(ForkDialogService, forkDialogService), + ], + }); + + store = TestBed.inject(Store); + fixture = TestBed.createComponent(RegistrationOverviewToolbarComponent); + component = fixture.componentInstance; + + fixture.componentRef.setInput('resourceId', overrides.resourceId ?? 'reg-1'); + fixture.componentRef.setInput('resourceTitle', 'Test Registration'); + fixture.componentRef.setInput('isPublic', overrides.isPublic ?? true); + fixture.componentRef.setInput('viewOnly', overrides.viewOnly ?? false); + fixture.detectChanges(); + } - return { fixture, component: fixture.componentInstance, store, mockToastService }; -} + function query(selector: string): HTMLElement | null { + return fixture.nativeElement.querySelector(selector); + } -describe('RegistrationOverviewToolbarComponent', () => { it('should dispatch GetResourceBookmark on init', () => { - const { store } = setup(); + setup(); expect(store.dispatch).toHaveBeenCalledWith( - expect.objectContaining({ - bookmarkCollectionId: MOCK_BOOKMARKS_COLLECTION_ID, - resourceId: MOCK_RESOURCE_ID, - resourceType: ResourceType.Registration, - }) + new GetResourceBookmark('bookmarks-1', 'reg-1', ResourceType.Registration) ); }); - it('should not dispatch GetResourceBookmark when bookmarksCollectionId is null', () => { - const { store } = setup({ bookmarksCollectionId: null }); + it('should not dispatch GetResourceBookmark without bookmarks collection id', () => { + setup({ selectorOverrides: [{ selector: BookmarksSelectors.getBookmarksCollectionId, value: null }] }); - expect(store.dispatch).not.toHaveBeenCalled(); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(GetResourceBookmark)); }); - it('should compute isBookmarked from bookmarks', () => { - const { component } = setup({ bookmarks: [{ id: MOCK_RESOURCE_ID }] }); + it('should render fork menu, bookmark and share buttons for authenticated user', () => { + setup(); - expect(component.isBookmarked()).toBe(true); + expect(query('osf-fork-menu-button')).toBeTruthy(); + expect(query('p-button')).toBeTruthy(); + expect(query('osf-socials-share-button')).toBeTruthy(); }); - it('should compute isBookmarked as false when not in bookmarks', () => { - const { component } = setup({ bookmarks: [{ id: 'other-id' }] }); + it('should render nothing when resourceId is empty', () => { + setup({ resourceId: '' }); - expect(component.isBookmarked()).toBe(false); + expect(query('div')).toBeNull(); }); - it('should dispatch add bookmark when not bookmarked', () => { - const { component, store, mockToastService } = setup(); - vi.spyOn(store, 'dispatch').mockReturnValue(of(undefined)); + it('should hide fork menu in view only mode', () => { + setup({ viewOnly: true }); + + expect(query('osf-fork-menu-button')).toBeNull(); + expect(query('p-button')).toBeTruthy(); + }); + + it('should hide fork menu and bookmark button for unauthenticated user', () => { + setup({ selectorOverrides: [{ selector: UserSelectors.isAuthenticated, value: false }] }); + + expect(query('osf-fork-menu-button')).toBeNull(); + expect(query('p-button')).toBeNull(); + }); + + it('should hide share button for private registration', () => { + setup({ isPublic: false }); + + expect(query('osf-socials-share-button')).toBeNull(); + }); + + it('should compute isBookmarked from bookmarks', () => { + setup({ selectorOverrides: [{ selector: BookmarksSelectors.getBookmarks, value: [bookmarkedItem] }] }); + + expect(component.isBookmarked()).toBe(true); + }); + + it('should add bookmark and show toast when not bookmarked', () => { + setup(); + (store.dispatch as Mock).mockClear(); component.toggleBookmark(); expect(store.dispatch).toHaveBeenCalledWith( - expect.objectContaining({ - bookmarkCollectionId: MOCK_BOOKMARKS_COLLECTION_ID, - resourceId: MOCK_RESOURCE_ID, - resourceType: ResourceType.Registration, - }) + new AddResourceToBookmarks('bookmarks-1', 'reg-1', ResourceType.Registration) ); - expect(mockToastService.showSuccess).toHaveBeenCalledWith('project.overview.dialog.toast.bookmark.add'); + expect(toastService.showSuccess).toHaveBeenCalledWith('project.overview.dialog.toast.bookmark.add'); }); - it('should dispatch remove bookmark when already bookmarked', () => { - const { component, store, mockToastService } = setup({ bookmarks: [{ id: MOCK_RESOURCE_ID }] }); - vi.spyOn(store, 'dispatch').mockReturnValue(of(undefined)); + it('should remove bookmark and show toast when bookmarked', () => { + setup({ selectorOverrides: [{ selector: BookmarksSelectors.getBookmarks, value: [bookmarkedItem] }] }); + (store.dispatch as Mock).mockClear(); component.toggleBookmark(); expect(store.dispatch).toHaveBeenCalledWith( - expect.objectContaining({ - bookmarkCollectionId: MOCK_BOOKMARKS_COLLECTION_ID, - resourceId: MOCK_RESOURCE_ID, - resourceType: ResourceType.Registration, - }) + new RemoveResourceFromBookmarks('bookmarks-1', 'reg-1', ResourceType.Registration) ); - expect(mockToastService.showSuccess).toHaveBeenCalledWith('project.overview.dialog.toast.bookmark.remove'); + expect(toastService.showSuccess).toHaveBeenCalledWith('project.overview.dialog.toast.bookmark.remove'); }); - it('should not dispatch toggleBookmark when resourceId is empty', () => { - const { fixture, store, mockToastService } = setup(); - fixture.componentRef.setInput('resourceId', ''); - fixture.detectChanges(); - vi.spyOn(store, 'dispatch').mockClear().mockReturnValue(of(undefined)); + it('should not toggle bookmark without bookmarks collection id', () => { + setup({ selectorOverrides: [{ selector: BookmarksSelectors.getBookmarksCollectionId, value: null }] }); + (store.dispatch as Mock).mockClear(); - fixture.componentInstance.toggleBookmark(); + component.toggleBookmark(); expect(store.dispatch).not.toHaveBeenCalled(); - expect(mockToastService.showSuccess).not.toHaveBeenCalled(); + expect(toastService.showSuccess).not.toHaveBeenCalled(); + }); + + it('should navigate to duplicates view', () => { + setup(); + + component.forkActionItems[1].command?.({}); + + expect(routerMock.navigate).toHaveBeenCalledWith(['../analytics/duplicates'], { + relativeTo: TestBed.inject(ActivatedRoute), + }); + }); + + it('should open fork confirmation for registration', () => { + setup(); + + component.forkActionItems[0].command?.({}); + + expect(forkDialogService.confirmFork).toHaveBeenCalledWith( + expect.objectContaining({ resourceType: CurrentResourceType.Registrations }) + ); + }); + + it('should dispatch ForkRegistry when fork is confirmed', () => { + setup(); + component.forkActionItems[0].command?.({}); + const [options] = forkDialogService.confirmFork.mock.calls[0]; + (store.dispatch as Mock).mockClear(); + + options.fork(); + + expect(store.dispatch).toHaveBeenCalledWith(new ForkRegistry('reg-1')); }); }); diff --git a/src/app/features/registry/components/registration-overview-toolbar/registration-overview-toolbar.component.ts b/src/app/features/registry/components/registration-overview-toolbar/registration-overview-toolbar.component.ts index 00640ee98..29fba9988 100644 --- a/src/app/features/registry/components/registration-overview-toolbar/registration-overview-toolbar.component.ts +++ b/src/app/features/registry/components/registration-overview-toolbar/registration-overview-toolbar.component.ts @@ -2,15 +2,20 @@ import { createDispatchMap, select } from '@ngxs/store'; import { TranslatePipe } from '@ngx-translate/core'; +import { MenuItem } from 'primeng/api'; import { Button } from 'primeng/button'; import { Tooltip } from 'primeng/tooltip'; import { ChangeDetectionStrategy, Component, computed, DestroyRef, effect, inject, input } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { ActivatedRoute, Router } from '@angular/router'; import { UserSelectors } from '@core/store/user'; +import { ForkRegistry } from '@osf/features/registry/store/registry'; +import { ForkMenuButtonComponent } from '@osf/shared/components/fork-menu-button/fork-menu-button.component'; import { SocialsShareButtonComponent } from '@osf/shared/components/socials-share-button/socials-share-button.component'; -import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { CurrentResourceType, ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { ForkDialogService } from '@osf/shared/services/fork-dialog.service'; import { ToastService } from '@osf/shared/services/toast.service'; import { AddResourceToBookmarks, @@ -21,18 +26,23 @@ import { @Component({ selector: 'osf-registration-overview-toolbar', - imports: [Button, Tooltip, SocialsShareButtonComponent, TranslatePipe], + imports: [Button, Tooltip, SocialsShareButtonComponent, ForkMenuButtonComponent, TranslatePipe], templateUrl: './registration-overview-toolbar.component.html', styleUrl: './registration-overview-toolbar.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class RegistrationOverviewToolbarComponent { private readonly toastService = inject(ToastService); + private readonly forkDialogService = inject(ForkDialogService); + private readonly router = inject(Router); + private readonly route = inject(ActivatedRoute); private readonly destroyRef = inject(DestroyRef); readonly resourceId = input.required(); readonly resourceTitle = input.required(); readonly isPublic = input(false); + readonly forksCount = input(0); + readonly viewOnly = input(false); readonly resourceType = ResourceType.Registration; @@ -41,6 +51,7 @@ export class RegistrationOverviewToolbarComponent { readonly isBookmarksLoading = select(BookmarksSelectors.areBookmarksLoading); readonly isBookmarksSubmitting = select(BookmarksSelectors.getBookmarksCollectionIdSubmitting); readonly isAuthenticated = select(UserSelectors.isAuthenticated); + readonly preventDuplicateCreation = select(UserSelectors.isProjectCreationDisabled); readonly isBookmarked = computed( () => this.bookmarks()?.some((bookmark) => bookmark.id === this.resourceId()) ?? false @@ -50,8 +61,20 @@ export class RegistrationOverviewToolbarComponent { getResourceBookmark: GetResourceBookmark, addResourceToBookmarks: AddResourceToBookmarks, removeResourceFromBookmarks: RemoveResourceFromBookmarks, + forkRegistry: ForkRegistry, }); + readonly forkActionItems: MenuItem[] = [ + { + label: 'project.overview.actions.duplicateRegistration', + command: () => this.handleForkResource(), + }, + { + label: 'project.overview.actions.viewDuplication', + command: () => this.navigateToDuplicatesView(), + }, + ]; + constructor() { effect(() => { const bookmarksCollectionId = this.bookmarksCollectionId(); @@ -77,4 +100,18 @@ export class RegistrationOverviewToolbarComponent { action.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => this.toastService.showSuccess(toastKey)); } + + navigateToDuplicatesView(): void { + this.router.navigate(['../analytics/duplicates'], { relativeTo: this.route }); + } + + private handleForkResource(): void { + if (!this.resourceId()) return; + + this.forkDialogService.confirmFork({ + resourceType: CurrentResourceType.Registrations, + destroyRef: this.destroyRef, + fork: () => this.actions.forkRegistry(this.resourceId()), + }); + } } diff --git a/src/app/features/registry/mappers/registry-overview.mapper.ts b/src/app/features/registry/mappers/registry-overview.mapper.ts index aa051677c..e8307162b 100644 --- a/src/app/features/registry/mappers/registry-overview.mapper.ts +++ b/src/app/features/registry/mappers/registry-overview.mapper.ts @@ -13,6 +13,6 @@ export function MapRegistrationOverview(data: RegistrationOverviewDataJsonApi): providerId: data.relationships?.provider?.data?.id, rootParentId: data.relationships.root?.data?.id, status: MapRegistryStatus(data.attributes), - forksCount: 0, + forksCount: data.relationships.forks?.links.related.meta.count ?? 0, }; } diff --git a/src/app/features/registry/models/registration-overview-json-api.model.ts b/src/app/features/registry/models/registration-overview-json-api.model.ts index 4ea2531dc..4a4919ed5 100644 --- a/src/app/features/registry/models/registration-overview-json-api.model.ts +++ b/src/app/features/registry/models/registration-overview-json-api.model.ts @@ -1,4 +1,8 @@ -import { RelationshipLinks, ToOneRelData } from '@osf/shared/models/common/json-api/relationships.model'; +import { + RelatedCountRel, + RelationshipLinks, + ToOneRelData, +} from '@osf/shared/models/common/json-api/relationships.model'; import { JsonApiResource } from '@osf/shared/models/common/json-api/resource.model'; import { ItemResponse } from '@shared/models/common/json-api/responses.model'; import { RegistrationNodeAttributesJsonApi } from '@shared/models/registration/registration-node-json-api.model'; @@ -13,6 +17,7 @@ export interface RegistrationOverviewDataJsonApi extends JsonApiResource< } interface RegistryOverviewRelationshipsJsonApi { + forks?: RelatedCountRel; license: ToOneRelData<'licenses'>; provider: ToOneRelData<'registration-providers'>; registered_from: ToOneRelData<'nodes'>; diff --git a/src/app/features/registry/pages/registry-overview/registry-overview.component.html b/src/app/features/registry/pages/registry-overview/registry-overview.component.html index 4712f0918..7e9be2e36 100644 --- a/src/app/features/registry/pages/registry-overview/registry-overview.component.html +++ b/src/app/features/registry/pages/registry-overview/registry-overview.component.html @@ -1,9 +1,11 @@ -@if (!isRegistryLoading()) { +@let currentRegistry = registry(); + +@if (!isRegistryLoading() && currentRegistry) {
}
- @if (registry()?.archiving) { + @if (currentRegistry.archiving) {
- +
- } @else if (registry()?.withdrawn) { + } @else if (currentRegistry.withdrawn) {
- +
} @else {
- @if (schemaResponse() && !schemaResponse()?.isOriginalResponse && !isInitialState()) { + @let currentSchemaResponse = schemaResponse(); + + @if (currentSchemaResponse && !currentSchemaResponse.isOriginalResponse && !isInitialState()) {

{{ 'registry.overview.updatedRevisionDescription' | translate }} - {{ schemaResponse()?.dateCreated | date }}. + {{ currentSchemaResponse.dateCreated | date }}.

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

- {{ schemaResponse()?.revisionJustification }} + {{ currentSchemaResponse.revisionJustification }}

} - @if (hasViewOnly()) { + @if (hasViewOnly) { }
@@ -70,13 +76,13 @@

@if (!isAnonymous()) {

diff --git a/src/app/features/registry/pages/registry-overview/registry-overview.component.spec.ts b/src/app/features/registry/pages/registry-overview/registry-overview.component.spec.ts index d8833dd28..408ee567b 100644 --- a/src/app/features/registry/pages/registry-overview/registry-overview.component.spec.ts +++ b/src/app/features/registry/pages/registry-overview/registry-overview.component.spec.ts @@ -15,11 +15,15 @@ import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/ 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 { RegistrationReviewStates } from '@osf/shared/enums/registration-review-states.enum'; +import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { RevisionReviewStates } from '@osf/shared/enums/revision-review-states.enum'; import { SchemaResponse } from '@osf/shared/models/registration/schema-response.model'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; +import { SignpostingService } from '@osf/shared/services/signposting.service'; import { ToastService } from '@osf/shared/services/toast.service'; import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service'; +import { GetBookmarksCollectionId } from '@osf/shared/stores/bookmarks'; +import { GetBibliographicContributors } from '@osf/shared/stores/contributors'; import { RegistrationProviderSelectors } from '@osf/shared/stores/registration-provider'; import { MOCK_REGISTRATION_OVERVIEW_MODEL } from '@testing/mocks/registration-overview-model.mock'; @@ -29,6 +33,7 @@ import { CustomDialogServiceMock } from '@testing/providers/custom-dialog-provid import { LoaderServiceMock, provideLoaderServiceMock } from '@testing/providers/loader-service.mock'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; +import { SignpostingServiceMock } from '@testing/providers/signposting-provider.mock'; import { BaseSetupOverrides, mergeSignalOverrides, provideMockStore } from '@testing/providers/store-provider.mock'; import { ToastServiceMock } from '@testing/providers/toast-provider.mock'; import { ViewOnlyLinkHelperMock } from '@testing/providers/view-only-link-helper.mock'; @@ -41,7 +46,7 @@ import { RegistryOverviewMetadataComponent } from '../../components/registry-ove import { RegistryRevisionsComponent } from '../../components/registry-revisions/registry-revisions.component'; import { RegistryStatusesComponent } from '../../components/registry-statuses/registry-statuses.component'; import { WithdrawnMessageComponent } from '../../components/withdrawn-message/withdrawn-message.component'; -import { RegistrySelectors } from '../../store/registry'; +import { GetRegistryById, GetRegistrySchemaResponses, GetSchemaBlocks, RegistrySelectors } from '../../store/registry'; import { RegistryOverviewComponent } from './registry-overview.component'; @@ -59,15 +64,21 @@ function setup(overrides: SetupOverrides = {}) { const schemaResponses = overrides.schemaResponses ?? []; const mockRouter = RouterMockBuilder.create().withUrl('/registries/registry-1').build(); - const mockActivatedRoute = ActivatedRouteMockBuilder.create() + const routeBuilder = ActivatedRouteMockBuilder.create() .withParams({ id: registryId }) - .withQueryParams(overrides.queryParams ?? {}) - .build(); + .withQueryParams(overrides.queryParams ?? {}); + + if (overrides.hasParent === false) { + routeBuilder.withNoParent(); + } + + const mockActivatedRoute = routeBuilder.build(); const mockDialogService = CustomDialogServiceMock.simple(); const mockLoaderService = new LoaderServiceMock(); const mockToastService = ToastServiceMock.simple(); const mockViewOnlyHelper = ViewOnlyLinkHelperMock.simple(overrides.hasViewOnly); + const signpostingService = SignpostingServiceMock.simple(); const signalDefaults = [ { selector: RegistrySelectors.getRegistry, value: registry }, { selector: RegistrySelectors.isRegistryLoading, value: false }, @@ -107,6 +118,7 @@ function setup(overrides: SetupOverrides = {}) { provideLoaderServiceMock(mockLoaderService), MockProvider(ToastService, mockToastService), MockProvider(ViewOnlyLinkHelperService, mockViewOnlyHelper), + MockProvider(SignpostingService, signpostingService), provideMockStore({ signals: mergeSignalOverrides(signalDefaults, overrides.selectorOverrides), }), @@ -125,6 +137,7 @@ function setup(overrides: SetupOverrides = {}) { mockDialogService, mockLoaderService, mockToastService, + signpostingService, }; } @@ -232,10 +245,16 @@ describe('RegistryOverviewComponent', () => { expect(component.isRootRegistration()).toBe(true); }); + it('should compute isRootRegistration as false for a child registration', () => { + const { component } = setup({ registry: MOCK_REGISTRATION_OVERVIEW_MODEL }); + + expect(component.isRootRegistration()).toBe(false); + }); + it('should compute hasViewOnly from ViewOnlyLinkHelperService', () => { const { component } = setup({ registry: MOCK_REGISTRATION_OVERVIEW_MODEL, hasViewOnly: true }); - expect(component.hasViewOnly()).toBe(true); + expect(component.hasViewOnly).toBe(true); }); it('should compute revisionInProgress from schemaResponses', () => { @@ -262,28 +281,56 @@ describe('RegistryOverviewComponent', () => { expect(component.schemaResponse()?.id).toBe('rev-2'); }); - it('should dispatch init actions when registryId is available', () => { + it('should return null schemaResponse when the selected index is missing', () => { + const { component } = setup({ + registry: MOCK_REGISTRATION_OVERVIEW_MODEL, + schemaResponses: [createMockSchemaResponse('rev-1', RevisionReviewStates.Approved)], + }); + + component.openRevision(3); + + expect(component.schemaResponse()).toBeNull(); + }); + + it('should load the registry, contributors, and bookmarks for the route id', () => { const { store } = setup({ registryId: 'reg-1', registry: MOCK_REGISTRATION_OVERVIEW_MODEL }); - expect(store.dispatch).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith(new GetRegistryById('reg-1')); + expect(store.dispatch).toHaveBeenCalledWith(new GetBibliographicContributors('reg-1', ResourceType.Registration)); + expect(store.dispatch).toHaveBeenCalledWith(new GetBookmarksCollectionId()); + }); + + it('should not load the registry when the parent route is missing', () => { + const { store } = setup({ hasParent: false, registry: MOCK_REGISTRATION_OVERVIEW_MODEL }); + + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(GetRegistryById)); + expect(store.dispatch).toHaveBeenCalledWith(new GetBookmarksCollectionId()); + }); + + it('should add signposting for the registry id and remove it on destroy', () => { + const { fixture, signpostingService } = setup({ registryId: 'reg-1', registry: MOCK_REGISTRATION_OVERVIEW_MODEL }); + + expect(signpostingService.addSignposting).toHaveBeenCalledWith('reg-1'); + + fixture.destroy(); + + expect(signpostingService.removeSignpostingLinkTags).toHaveBeenCalled(); }); it('should dispatch schema actions when registry exists and is not withdrawn', () => { const { store } = setup({ registry: MOCK_REGISTRATION_OVERVIEW_MODEL }); - const calls = (store.dispatch as Mock).mock.calls.map((c) => c[0]); - const hasSchemaResponses = calls.some((a) => a.constructor?.name === 'GetRegistrySchemaResponses'); - - expect(hasSchemaResponses).toBe(true); + expect(store.dispatch).toHaveBeenCalledWith( + new GetSchemaBlocks(MOCK_REGISTRATION_OVERVIEW_MODEL.registrationSchemaLink) + ); + expect(store.dispatch).toHaveBeenCalledWith(new GetRegistrySchemaResponses(MOCK_REGISTRATION_OVERVIEW_MODEL.id)); }); it('should not dispatch schema actions when registry is withdrawn', () => { const { store } = setup({ registry: { ...MOCK_REGISTRATION_OVERVIEW_MODEL, withdrawn: true } }); - const calls = (store.dispatch as Mock).mock.calls.map((c) => c[0]); - const hasSchemaResponses = calls.some((a) => a.constructor?.name === 'GetRegistrySchemaResponses'); - - expect(hasSchemaResponses).toBe(false); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(GetRegistrySchemaResponses)); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(GetSchemaBlocks)); }); it('should call loaderService.show and navigate on onUpdateRegistration', () => { @@ -297,10 +344,11 @@ describe('RegistryOverviewComponent', () => { component.onUpdateRegistration('registry-1'); expect(mockLoaderService.show).toHaveBeenCalled(); + expect(mockLoaderService.hide).toHaveBeenCalled(); expect(mockRouter.navigate).toHaveBeenCalledWith(['/registries/revisions/new-rev/justification']); }); - it('should navigate to justification on onContinueUpdateRegistration for approved revision', () => { + it('should navigate to justification on onContinueUpdateRegistration for an in-progress revision', () => { const schemaResponses = [createMockSchemaResponse('rev-1', RevisionReviewStates.RevisionInProgress)]; const { component, mockRouter } = setup({ registry: MOCK_REGISTRATION_OVERVIEW_MODEL, @@ -334,13 +382,15 @@ describe('RegistryOverviewComponent', () => { }); it('should open make decision dialog and handle success response', () => { - const onCloseSubject = new Subject(); + const onCloseSubject = new Subject<{ action: string }>(); const { component, store, mockDialogService, mockToastService, mockRouter } = setup({ registry: { ...MOCK_REGISTRATION_OVERVIEW_MODEL, id: 'reg-1' }, queryParams: { revisionId: 'rev-1' }, }); vi.spyOn(store, 'dispatch').mockReturnValue(of(undefined)); - mockDialogService.open.mockReturnValue({ onClose: onCloseSubject.asObservable() } as any); + mockDialogService.open.mockReturnValue({ + onClose: onCloseSubject.asObservable(), + } as ReturnType); component.handleOpenMakeDecisionDialog(); diff --git a/src/app/features/registry/pages/registry-overview/registry-overview.component.ts b/src/app/features/registry/pages/registry-overview/registry-overview.component.ts index fcf3bbac3..a9ea94ff3 100644 --- a/src/app/features/registry/pages/registry-overview/registry-overview.component.ts +++ b/src/app/features/registry/pages/registry-overview/registry-overview.component.ts @@ -4,7 +4,7 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Message } from 'primeng/message'; -import { map, of, switchMap } from 'rxjs'; +import { finalize, map, of, switchMap } from 'rxjs'; import { DatePipe } from '@angular/common'; import { @@ -107,7 +107,7 @@ export class RegistryOverviewComponent implements OnInit, OnDestroy { readonly revisionId = computed(() => (this.queryParams()?.['revisionId'] as string) ?? null); readonly isModeration = computed(() => this.queryParams()?.['mode'] === 'moderator'); - readonly hasViewOnly = computed(() => this.viewOnlyService.hasViewOnlyParam(this.router)); + readonly hasViewOnly = this.viewOnlyService.hasViewOnlyParam(this.router); readonly showToolbar = computed(() => !this.registry()?.archiving && !this.registry()?.withdrawn); readonly isInitialState = computed(() => this.registry()?.reviewsState === RegistrationReviewStates.Initial); readonly canMakeDecision = computed( @@ -116,7 +116,7 @@ export class RegistryOverviewComponent implements OnInit, OnDestroy { readonly canUpdate = computed(() => this.hasAdminAccess() && this.allowUpdates()); - isRootRegistration = computed(() => { + readonly isRootRegistration = computed(() => { const rootId = this.registry()?.rootParentId; return !rootId || rootId === this.registry()?.id; }); @@ -127,9 +127,8 @@ export class RegistryOverviewComponent implements OnInit, OnDestroy { }); readonly schemaResponse = computed(() => { - const index = this.selectedRevisionIndex(); const schemaResponses = this.schemaResponses() || []; - return index !== null ? schemaResponses[index] : null; + return schemaResponses[this.selectedRevisionIndex()] ?? null; }); private readonly registryId = toSignal( @@ -182,7 +181,10 @@ export class RegistryOverviewComponent implements OnInit, OnDestroy { this.loaderService.show(); this.actions .createSchemaResponse(id) - .pipe(takeUntilDestroyed(this.destroyRef)) + .pipe( + finalize(() => this.loaderService.hide()), + takeUntilDestroyed(this.destroyRef) + ) .subscribe({ next: () => { const revisionId = this.currentRevision()?.id; diff --git a/src/app/features/registry/registry.routes.ts b/src/app/features/registry/registry.routes.ts index 06109e3b9..0182a4efa 100644 --- a/src/app/features/registry/registry.routes.ts +++ b/src/app/features/registry/registry.routes.ts @@ -13,6 +13,7 @@ import { ViewOnlyLinkState } from '@osf/shared/stores/view-only-links'; import { ActivityLogsState } from '@shared/stores/activity-logs'; import { AnalyticsState } from '../analytics/store'; +import { SettingsState } from '../project/settings/store'; import { RegistryState } from './store/registry'; import { RegistryComponentsState } from './store/registry-components'; @@ -73,7 +74,7 @@ export const registryRoutes: Routes = [ import('../analytics/components/view-duplicates/view-duplicates.component').then( (mod) => mod.ViewDuplicatesComponent ), - providers: [provideStates([DuplicatesState])], + providers: [provideStates([DuplicatesState, SettingsState])], }, { path: 'files', diff --git a/src/app/features/registry/services/registry-overview.service.ts b/src/app/features/registry/services/registry-overview.service.ts index 2d69607af..ee8bfeb18 100644 --- a/src/app/features/registry/services/registry-overview.service.ts +++ b/src/app/features/registry/services/registry-overview.service.ts @@ -46,7 +46,9 @@ export class RegistryOverviewService { } getRegistrationById(id: string): Observable { - return this.jsonApiService.get(`${this.apiUrl}/registrations/${id}/`).pipe( + const params = { related_counts: 'forks' }; + + return this.jsonApiService.get(`${this.apiUrl}/registrations/${id}/`, params).pipe( map((response) => ({ registry: MapRegistrationOverview(response.data), isAnonymous: response.meta?.anonymous ?? false, diff --git a/src/app/features/registry/store/registry/registry.actions.ts b/src/app/features/registry/store/registry/registry.actions.ts index 70b702ffb..54723554a 100644 --- a/src/app/features/registry/store/registry/registry.actions.ts +++ b/src/app/features/registry/store/registry/registry.actions.ts @@ -84,6 +84,12 @@ export class SubmitDecision { ) {} } +export class ForkRegistry { + static readonly type = '[Registry] Fork Registry'; + + constructor(public resourceId: string) {} +} + export class ClearRegistry { static readonly type = '[Registry] Clear Registry'; } diff --git a/src/app/features/registry/store/registry/registry.state.ts b/src/app/features/registry/store/registry/registry.state.ts index 778fcb967..3674f4989 100644 --- a/src/app/features/registry/store/registry/registry.state.ts +++ b/src/app/features/registry/store/registry/registry.state.ts @@ -5,7 +5,9 @@ import { catchError } from 'rxjs/operators'; import { inject, Injectable } from '@angular/core'; +import { CurrentResourceType } from '@osf/shared/enums/resource-type.enum'; import { handleSectionError } from '@osf/shared/helpers/state-error.handler'; +import { DuplicatesService } from '@osf/shared/services/duplicates.service'; import { GetRegistryProvider } from '@osf/shared/stores/registration-provider'; import { RegistryOverviewService } from '../../services'; @@ -13,6 +15,7 @@ import { RegistryOverviewService } from '../../services'; import { ClearRegistry, CreateSchemaResponse, + ForkRegistry, GetRegistryById, GetRegistryIdentifiers, GetRegistryInstitutions, @@ -35,6 +38,7 @@ import { REGISTRY_DEFAULTS, RegistryStateModel } from './registry.model'; }) export class RegistryState { private readonly registryOverviewService = inject(RegistryOverviewService); + private readonly duplicatesService = inject(DuplicatesService); @Action(GetRegistryById) getRegistryById(ctx: StateContext, action: GetRegistryById) { @@ -361,6 +365,27 @@ export class RegistryState { ); } + @Action(ForkRegistry) + forkRegistry(ctx: StateContext, action: ForkRegistry) { + return this.duplicatesService.forkResource(action.resourceId, CurrentResourceType.Registrations).pipe( + tap(() => { + const state = ctx.getState(); + if (!state.registry.data) return; + + ctx.patchState({ + registry: { + ...state.registry, + data: { + ...state.registry.data, + forksCount: state.registry.data.forksCount + 1, + }, + }, + }); + }), + catchError((error) => handleSectionError(ctx, 'registry', error)) + ); + } + @Action(ClearRegistry) clearRegistry(ctx: StateContext) { ctx.patchState(REGISTRY_DEFAULTS); diff --git a/src/app/features/project/overview/components/fork-dialog/fork-dialog.component.html b/src/app/shared/components/fork-dialog/fork-dialog.component.html similarity index 67% rename from src/app/features/project/overview/components/fork-dialog/fork-dialog.component.html rename to src/app/shared/components/fork-dialog/fork-dialog.component.html index 4811c0b13..82ba337fb 100644 --- a/src/app/features/project/overview/components/fork-dialog/fork-dialog.component.html +++ b/src/app/shared/components/fork-dialog/fork-dialog.component.html @@ -1,19 +1,17 @@

- {{ 'project.overview.dialog.fork.messageProject' | translate }} + {{ messageKey | translate }}

diff --git a/src/app/shared/components/fork-dialog/fork-dialog.component.spec.ts b/src/app/shared/components/fork-dialog/fork-dialog.component.spec.ts new file mode 100644 index 000000000..e043815b2 --- /dev/null +++ b/src/app/shared/components/fork-dialog/fork-dialog.component.spec.ts @@ -0,0 +1,72 @@ +import { MockProvider } from 'ng-mocks'; + +import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; + +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { CurrentResourceType } from '@osf/shared/enums/resource-type.enum'; + +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { provideDynamicDialogRefMock } from '@testing/providers/dynamic-dialog-ref.mock'; + +import { ForkDialogComponent } from './fork-dialog.component'; + +interface SetupOverrides { + resourceType?: CurrentResourceType; +} + +describe('ForkDialogComponent', () => { + let component: ForkDialogComponent; + let fixture: ComponentFixture; + let dialogRef: DynamicDialogRef; + + function setup(overrides: SetupOverrides = {}) { + TestBed.configureTestingModule({ + imports: [ForkDialogComponent], + providers: [ + provideOSFCore(), + provideDynamicDialogRefMock(), + MockProvider(DynamicDialogConfig, { + data: { resourceType: overrides.resourceType ?? CurrentResourceType.Projects }, + }), + ], + }); + + dialogRef = TestBed.inject(DynamicDialogRef); + fixture = TestBed.createComponent(ForkDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + } + + it('should show the project message', () => { + setup(); + + expect(component.messageKey).toBe('project.overview.dialog.fork.messageProject'); + expect(fixture.nativeElement.textContent).toContain('project.overview.dialog.fork.messageProject'); + }); + + it('should show the registration message', () => { + setup({ resourceType: CurrentResourceType.Registrations }); + + expect(component.messageKey).toBe('project.overview.dialog.fork.messageRegistration'); + expect(fixture.nativeElement.textContent).toContain('project.overview.dialog.fork.messageRegistration'); + }); + + it('should close with false when cancel is clicked', () => { + setup(); + + const [cancelButton] = fixture.nativeElement.querySelectorAll('button'); + cancelButton.click(); + + expect(dialogRef.close).toHaveBeenCalledWith(false); + }); + + it('should close with true when confirm is clicked', () => { + setup(); + + const buttons = fixture.nativeElement.querySelectorAll('button'); + buttons[1].click(); + + expect(dialogRef.close).toHaveBeenCalledWith(true); + }); +}); diff --git a/src/app/shared/components/fork-dialog/fork-dialog.component.ts b/src/app/shared/components/fork-dialog/fork-dialog.component.ts new file mode 100644 index 000000000..3f5776510 --- /dev/null +++ b/src/app/shared/components/fork-dialog/fork-dialog.component.ts @@ -0,0 +1,24 @@ +import { TranslatePipe } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; + +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; + +import { CurrentResourceType } from '@osf/shared/enums/resource-type.enum'; + +@Component({ + selector: 'osf-fork-dialog', + imports: [TranslatePipe, Button], + templateUrl: './fork-dialog.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ForkDialogComponent { + readonly dialogRef = inject(DynamicDialogRef); + private readonly config = inject(DynamicDialogConfig); + + readonly messageKey = + this.config.data?.resourceType === CurrentResourceType.Registrations + ? 'project.overview.dialog.fork.messageRegistration' + : 'project.overview.dialog.fork.messageProject'; +} diff --git a/src/app/shared/components/fork-menu-button/fork-menu-button.component.html b/src/app/shared/components/fork-menu-button/fork-menu-button.component.html new file mode 100644 index 000000000..b0a88ebdd --- /dev/null +++ b/src/app/shared/components/fork-menu-button/fork-menu-button.component.html @@ -0,0 +1,30 @@ +@if (preventDuplicateCreation()) { + + {{ forksCount() }} + + +} @else { + + {{ forksCount() }} + + + + + {{ item.label | translate }} + + + + +} diff --git a/src/app/shared/components/fork-menu-button/fork-menu-button.component.spec.ts b/src/app/shared/components/fork-menu-button/fork-menu-button.component.spec.ts new file mode 100644 index 000000000..af29a270b --- /dev/null +++ b/src/app/shared/components/fork-menu-button/fork-menu-button.component.spec.ts @@ -0,0 +1,72 @@ +import { MenuItem } from 'primeng/api'; + +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { provideOSFCore } from '@testing/osf.testing.provider'; + +import { ForkMenuButtonComponent } from './fork-menu-button.component'; + +interface SetupOverrides { + forksCount?: number; + menuItems?: MenuItem[]; + preventDuplicateCreation?: boolean; +} + +describe('ForkMenuButtonComponent', () => { + let component: ForkMenuButtonComponent; + let fixture: ComponentFixture; + + const defaultMenuItems: MenuItem[] = [ + { label: 'project.overview.actions.forkProject' }, + { label: 'project.overview.actions.viewDuplication' }, + ]; + + function setup(overrides: SetupOverrides = {}) { + TestBed.configureTestingModule({ + imports: [ForkMenuButtonComponent], + providers: [provideOSFCore()], + }); + + fixture = TestBed.createComponent(ForkMenuButtonComponent); + component = fixture.componentInstance; + + fixture.componentRef.setInput('forksCount', overrides.forksCount ?? 3); + fixture.componentRef.setInput('menuItems', overrides.menuItems ?? defaultMenuItems); + fixture.componentRef.setInput('preventDuplicateCreation', overrides.preventDuplicateCreation ?? false); + fixture.detectChanges(); + } + + it('should show the forks count', () => { + setup({ forksCount: 7 }); + + expect(fixture.nativeElement.textContent).toContain('7'); + }); + + it('should show the menu when duplicate creation is allowed', () => { + setup(); + + expect(fixture.nativeElement.querySelector('p-menu')).toBeTruthy(); + expect(fixture.nativeElement.querySelector('button')?.getAttribute('aria-label')).toBe( + 'project.overview.tooltips.duplicate' + ); + }); + + it('should show the view duplicates button when duplicate creation is prevented', () => { + setup({ preventDuplicateCreation: true }); + + expect(fixture.nativeElement.querySelector('p-menu')).toBeNull(); + expect(fixture.nativeElement.querySelector('button')?.getAttribute('aria-label')).toBe( + 'project.overview.actions.viewDuplication' + ); + }); + + it('should emit viewDuplicates when the view duplicates button is clicked', () => { + setup({ preventDuplicateCreation: true }); + const viewDuplicatesSpy = vi.fn(); + component.viewDuplicates.subscribe(viewDuplicatesSpy); + + fixture.nativeElement.querySelector('button').click(); + + expect(viewDuplicatesSpy).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/app/shared/components/fork-menu-button/fork-menu-button.component.ts b/src/app/shared/components/fork-menu-button/fork-menu-button.component.ts new file mode 100644 index 000000000..2317a7bc9 --- /dev/null +++ b/src/app/shared/components/fork-menu-button/fork-menu-button.component.ts @@ -0,0 +1,22 @@ +import { TranslatePipe } from '@ngx-translate/core'; + +import { MenuItem } from 'primeng/api'; +import { Button } from 'primeng/button'; +import { Menu } from 'primeng/menu'; +import { Tooltip } from 'primeng/tooltip'; + +import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; + +@Component({ + selector: 'osf-fork-menu-button', + imports: [Button, Menu, Tooltip, TranslatePipe], + templateUrl: './fork-menu-button.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ForkMenuButtonComponent { + readonly forksCount = input.required(); + readonly menuItems = input.required(); + readonly preventDuplicateCreation = input(false); + + readonly viewDuplicates = output(); +} diff --git a/src/app/shared/models/confirm-fork-options.model.ts b/src/app/shared/models/confirm-fork-options.model.ts new file mode 100644 index 000000000..a398a503e --- /dev/null +++ b/src/app/shared/models/confirm-fork-options.model.ts @@ -0,0 +1,12 @@ +import { Observable } from 'rxjs'; + +import { DestroyRef } from '@angular/core'; + +import { CurrentResourceType } from '../enums/resource-type.enum'; + +export interface ConfirmForkOptions { + resourceType: CurrentResourceType; + destroyRef: DestroyRef; + fork: () => Observable; + onSuccess?: () => void; +} diff --git a/src/app/shared/services/duplicates.service.spec.ts b/src/app/shared/services/duplicates.service.spec.ts new file mode 100644 index 000000000..827a9e4ea --- /dev/null +++ b/src/app/shared/services/duplicates.service.spec.ts @@ -0,0 +1,108 @@ +import { MockProvider } from 'ng-mocks'; + +import { firstValueFrom, of } from 'rxjs'; + +import { TestBed } from '@angular/core/testing'; + +import { CurrentResourceType } from '@osf/shared/enums/resource-type.enum'; +import { BaseNodeMapper } from '@osf/shared/mappers/nodes'; +import { NodeModel } from '@osf/shared/models/nodes/base-node.model'; +import { NodesResponseJsonApi } from '@osf/shared/models/nodes/nodes-json-api.model'; +import { PaginatedData } from '@osf/shared/models/paginated-data.model'; + +import { MOCK_NODE_WITH_ADMIN } from '@testing/mocks/node.mock'; +import { MOCK_API_URL, provideMockEnvironment } from '@testing/providers/environment.provider.mock'; +import { JsonApiServiceMock, JsonApiServiceMockType } from '@testing/providers/json-api.service.mock'; + +import { DuplicatesService } from './duplicates.service'; +import { JsonApiService } from './json-api.service'; + +const BASE_GET_PARAMS: Record = { + embed: 'bibliographic_contributors', + 'fields[users]': 'family_name,full_name,given_name,middle_name', +}; + +function getDuplicatesApiResponse(): NodesResponseJsonApi { + return { + data: [], + links: {}, + meta: { + total: 1, + per_page: 10, + version: '2.0', + }, + }; +} + +describe('DuplicatesService', () => { + let service: DuplicatesService; + let jsonApiService: JsonApiServiceMockType; + + function setup() { + jsonApiService = JsonApiServiceMock.simple(); + + TestBed.configureTestingModule({ + providers: [DuplicatesService, MockProvider(JsonApiService, jsonApiService), provideMockEnvironment()], + }); + + service = TestBed.inject(DuplicatesService); + } + + it('should expose apiUrl from environment', () => { + setup(); + expect(service.apiUrl).toBe(MOCK_API_URL); + }); + + it('fetchAllDuplicates should request forks and map response', async () => { + setup(); + const mapped: PaginatedData = { + data: [MOCK_NODE_WITH_ADMIN], + totalCount: 1, + pageSize: 10, + }; + const mapperSpy = vi.spyOn(BaseNodeMapper, 'getNodesWithEmbedsAndTotalData').mockReturnValue(mapped); + const apiResponse = getDuplicatesApiResponse(); + jsonApiService.get.mockReturnValue(of(apiResponse)); + + const response = await firstValueFrom(service.fetchAllDuplicates('node-1', CurrentResourceType.Projects)); + + expect(jsonApiService.get).toHaveBeenCalledWith(`${MOCK_API_URL}/nodes/node-1/forks/`, BASE_GET_PARAMS); + expect(mapperSpy).toHaveBeenCalledWith(apiResponse); + expect(response).toEqual(mapped); + }); + + it('fetchAllDuplicates should include page and page size when provided', async () => { + setup(); + jsonApiService.get.mockReturnValue(of(getDuplicatesApiResponse())); + + await firstValueFrom(service.fetchAllDuplicates('reg-1', CurrentResourceType.Registrations, 2, 25)); + + expect(jsonApiService.get).toHaveBeenCalledWith(`${MOCK_API_URL}/registrations/reg-1/forks/`, { + ...BASE_GET_PARAMS, + page: 2, + 'page[size]': 25, + }); + }); + + it('forkResource should POST forks payload with projects type', async () => { + setup(); + jsonApiService.post.mockReturnValue(of(undefined)); + + await firstValueFrom(service.forkResource('node-1', CurrentResourceType.Projects)); + + expect(jsonApiService.post).toHaveBeenCalledWith(`${MOCK_API_URL}/nodes/node-1/forks/`, { + data: { type: CurrentResourceType.Projects }, + }); + }); + + it('forkResource should POST to registrations forks with projects type payload', async () => { + setup(); + jsonApiService.post.mockReturnValue(of(undefined)); + + await firstValueFrom(service.forkResource('reg-1', CurrentResourceType.Registrations)); + + expect(jsonApiService.post).toHaveBeenCalledWith(`${MOCK_API_URL}/registrations/reg-1/forks/`, { + data: { type: CurrentResourceType.Projects }, + }); + }); +}); diff --git a/src/app/shared/services/duplicates.service.ts b/src/app/shared/services/duplicates.service.ts index 5d25f3166..5ade49285 100644 --- a/src/app/shared/services/duplicates.service.ts +++ b/src/app/shared/services/duplicates.service.ts @@ -4,6 +4,7 @@ import { map } from 'rxjs/operators'; import { inject, Injectable } from '@angular/core'; import { ENVIRONMENT } from '@core/provider/environment.provider'; +import { CurrentResourceType } from '@osf/shared/enums/resource-type.enum'; import { BaseNodeMapper } from '../mappers/nodes'; import { NodeModel } from '../models/nodes/base-node.model'; @@ -46,4 +47,10 @@ export class DuplicatesService { .get(`${this.apiUrl}/${resourceType}/${resourceId}/forks/`, params) .pipe(map((res) => BaseNodeMapper.getNodesWithEmbedsAndTotalData(res))); } + + forkResource(resourceId: string, resourceType: CurrentResourceType): Observable { + const payload = { data: { type: CurrentResourceType.Projects } }; + + return this.jsonApiService.post(`${this.apiUrl}/${resourceType}/${resourceId}/forks/`, payload); + } } diff --git a/src/app/shared/services/fork-dialog.service.spec.ts b/src/app/shared/services/fork-dialog.service.spec.ts new file mode 100644 index 000000000..e8037bee5 --- /dev/null +++ b/src/app/shared/services/fork-dialog.service.spec.ts @@ -0,0 +1,162 @@ +import { MockProvider } from 'ng-mocks'; + +import { of, Subject, throwError } from 'rxjs'; + +import { DestroyRef } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; + +import { ForkDialogComponent } from '@osf/shared/components/fork-dialog/fork-dialog.component'; +import { CurrentResourceType } from '@osf/shared/enums/resource-type.enum'; +import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; +import { LoaderService } from '@osf/shared/services/loader.service'; +import { ToastService } from '@osf/shared/services/toast.service'; + +import { CustomDialogServiceMock, CustomDialogServiceMockType } from '@testing/providers/custom-dialog-provider.mock'; +import { LoaderServiceMock } from '@testing/providers/loader-service.mock'; +import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; + +import { ForkDialogService } from './fork-dialog.service'; + +describe('ForkDialogService', () => { + let service: ForkDialogService; + let customDialogService: CustomDialogServiceMockType; + let loaderService: LoaderServiceMock; + let toastService: ToastServiceMockType; + let destroyRef: DestroyRef; + + function setup() { + customDialogService = CustomDialogServiceMock.simple(); + loaderService = new LoaderServiceMock(); + toastService = ToastServiceMock.simple(); + destroyRef = { onDestroy: vi.fn() } as unknown as DestroyRef; + + TestBed.configureTestingModule({ + providers: [ + ForkDialogService, + MockProvider(CustomDialogService, customDialogService), + MockProvider(LoaderService, loaderService), + MockProvider(ToastService, toastService), + ], + }); + + service = TestBed.inject(ForkDialogService); + } + + it('confirmFork should open dialog with project header', () => { + setup(); + const onClose$ = new Subject(); + customDialogService.open.mockReturnValue(CustomDialogServiceMock.dialogRefWithClose(onClose$)); + + service.confirmFork({ + resourceType: CurrentResourceType.Projects, + destroyRef, + fork: vi.fn().mockReturnValue(of(undefined)), + }); + + expect(customDialogService.open).toHaveBeenCalledWith(ForkDialogComponent, { + header: 'project.overview.dialog.fork.headerProject', + width: '450px', + data: { resourceType: CurrentResourceType.Projects }, + }); + }); + + it('confirmFork should open dialog with registration header', () => { + setup(); + const onClose$ = new Subject(); + customDialogService.open.mockReturnValue(CustomDialogServiceMock.dialogRefWithClose(onClose$)); + + service.confirmFork({ + resourceType: CurrentResourceType.Registrations, + destroyRef, + fork: vi.fn().mockReturnValue(of(undefined)), + }); + + expect(customDialogService.open).toHaveBeenCalledWith(ForkDialogComponent, { + header: 'project.overview.dialog.fork.headerRegistration', + width: '450px', + data: { resourceType: CurrentResourceType.Registrations }, + }); + }); + + it('confirmFork should not fork when dialog is dismissed', () => { + setup(); + const onClose$ = new Subject(); + customDialogService.open.mockReturnValue(CustomDialogServiceMock.dialogRefWithClose(onClose$)); + const fork = vi.fn().mockReturnValue(of(undefined)); + const onSuccess = vi.fn(); + + service.confirmFork({ + resourceType: CurrentResourceType.Projects, + destroyRef, + fork, + onSuccess, + }); + + onClose$.next(false); + + expect(fork).not.toHaveBeenCalled(); + expect(loaderService.show).not.toHaveBeenCalled(); + expect(toastService.showSuccess).not.toHaveBeenCalled(); + expect(onSuccess).not.toHaveBeenCalled(); + }); + + it('confirmFork should fork project, show toast, and call onSuccess', () => { + setup(); + const onClose$ = new Subject(); + customDialogService.open.mockReturnValue(CustomDialogServiceMock.dialogRefWithClose(onClose$)); + const fork = vi.fn().mockReturnValue(of(undefined)); + const onSuccess = vi.fn(); + + service.confirmFork({ + resourceType: CurrentResourceType.Projects, + destroyRef, + fork, + onSuccess, + }); + + onClose$.next(true); + + expect(loaderService.show).toHaveBeenCalled(); + expect(fork).toHaveBeenCalled(); + expect(loaderService.hide).toHaveBeenCalled(); + expect(toastService.showSuccess).toHaveBeenCalledWith('project.overview.dialog.toast.fork.success'); + expect(onSuccess).toHaveBeenCalled(); + }); + + it('confirmFork should show registration success toast', () => { + setup(); + const onClose$ = new Subject(); + customDialogService.open.mockReturnValue(CustomDialogServiceMock.dialogRefWithClose(onClose$)); + + service.confirmFork({ + resourceType: CurrentResourceType.Registrations, + destroyRef, + fork: vi.fn().mockReturnValue(of(undefined)), + }); + + onClose$.next(true); + + expect(toastService.showSuccess).toHaveBeenCalledWith('project.overview.dialog.toast.fork.successRegistration'); + }); + + it('confirmFork should hide loader and skip success handlers when fork fails', () => { + setup(); + const onClose$ = new Subject(); + customDialogService.open.mockReturnValue(CustomDialogServiceMock.dialogRefWithClose(onClose$)); + const onSuccess = vi.fn(); + + service.confirmFork({ + resourceType: CurrentResourceType.Projects, + destroyRef, + fork: vi.fn().mockReturnValue(throwError(() => new Error('fork failed'))), + onSuccess, + }); + + onClose$.next(true); + + expect(loaderService.show).toHaveBeenCalled(); + expect(loaderService.hide).toHaveBeenCalled(); + expect(toastService.showSuccess).not.toHaveBeenCalled(); + expect(onSuccess).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/shared/services/fork-dialog.service.ts b/src/app/shared/services/fork-dialog.service.ts new file mode 100644 index 000000000..bbdcc9960 --- /dev/null +++ b/src/app/shared/services/fork-dialog.service.ts @@ -0,0 +1,54 @@ +import { EMPTY } from 'rxjs'; +import { catchError, filter, finalize, switchMap } from 'rxjs/operators'; + +import { inject, Injectable } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; + +import { ForkDialogComponent } from '@osf/shared/components/fork-dialog/fork-dialog.component'; +import { CurrentResourceType } from '@osf/shared/enums/resource-type.enum'; +import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; +import { LoaderService } from '@osf/shared/services/loader.service'; +import { ToastService } from '@osf/shared/services/toast.service'; + +import { ConfirmForkOptions } from '../models/confirm-fork-options.model'; + +@Injectable({ + providedIn: 'root', +}) +export class ForkDialogService { + private readonly customDialogService = inject(CustomDialogService); + private readonly loaderService = inject(LoaderService); + private readonly toastService = inject(ToastService); + + confirmFork(options: ConfirmForkOptions): void { + const isRegistration = options.resourceType === CurrentResourceType.Registrations; + + this.customDialogService + .open(ForkDialogComponent, { + header: isRegistration + ? 'project.overview.dialog.fork.headerRegistration' + : 'project.overview.dialog.fork.headerProject', + width: '450px', + data: { resourceType: options.resourceType }, + }) + .onClose.pipe( + filter((confirmed: boolean) => !!confirmed), + switchMap(() => { + this.loaderService.show(); + return options.fork().pipe( + catchError(() => EMPTY), + finalize(() => this.loaderService.hide()) + ); + }), + takeUntilDestroyed(options.destroyRef) + ) + .subscribe(() => { + this.toastService.showSuccess( + isRegistration + ? 'project.overview.dialog.toast.fork.successRegistration' + : 'project.overview.dialog.toast.fork.success' + ); + options.onSuccess?.(); + }); + } +} diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 26437b779..cabc2c33b 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -1895,6 +1895,7 @@ "overview": { "actions": { "duplicateProject": "Duplicate project (structure only)", + "duplicateRegistration": "Duplicate registration", "forkProject": "Duplicate project (with files)", "forkProjectLabel": "Duplicate project", "manageContributors": "Manage Contributors", @@ -1952,9 +1953,11 @@ }, "fork": { "confirmButton": "Duplicate", - "forksMessage": "Duplicates you have permission to view are shown here.", + "forksMessage": "Duplicates that you have permission to view are shown here.", "headerProject": "Duplicate This Project", + "headerRegistration": "Duplicate This Registration", "messageProject": "Are you sure you want to duplicate this project (with files)?", + "messageRegistration": "Are you sure you want to duplicate this registration?", "noForksMessage": "No duplicates found" }, "linkProject": { @@ -2006,7 +2009,8 @@ "success": "Project has been duplicated successfully" }, "fork": { - "success": "Project has been duplicated successfully" + "success": "Project has been duplicated successfully", + "successRegistration": "Registration has been duplicated successfully" }, "reorderComponents": { "success": "Components have been reordered successfully." diff --git a/src/testing/providers/environment.provider.mock.ts b/src/testing/providers/environment.provider.mock.ts new file mode 100644 index 000000000..61df2eb80 --- /dev/null +++ b/src/testing/providers/environment.provider.mock.ts @@ -0,0 +1,14 @@ +import { MockProvider } from 'ng-mocks'; + +import { ENVIRONMENT } from '@core/provider/environment.provider'; +import { EnvironmentModel } from '@osf/shared/models/environment.model'; + +export const MOCK_API_DOMAIN_URL = 'https://api.test'; +export const MOCK_API_URL = `${MOCK_API_DOMAIN_URL}/v2`; + +export function provideMockEnvironment(overrides: Partial = {}) { + return MockProvider(ENVIRONMENT, { + apiDomainUrl: MOCK_API_DOMAIN_URL, + ...overrides, + }); +} diff --git a/src/testing/providers/fork-dialog.service.mock.ts b/src/testing/providers/fork-dialog.service.mock.ts new file mode 100644 index 000000000..d80dc40ab --- /dev/null +++ b/src/testing/providers/fork-dialog.service.mock.ts @@ -0,0 +1,14 @@ +import { Mock } from 'vitest'; + +import { ConfirmForkOptions } from '@osf/shared/models/confirm-fork-options.model'; +import { ForkDialogService } from '@osf/shared/services/fork-dialog.service'; + +export type ForkDialogServiceMockType = Partial & { + confirmFork: Mock<(options: ConfirmForkOptions) => void>; +}; + +export const ForkDialogServiceMock = { + simple(): ForkDialogServiceMockType { + return { confirmFork: vi.fn() }; + }, +};