+ @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) {
- @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() }}
+
+
+
+
+
+
+
+}
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