-
- {{ duplicate.title }}
+
+ {{ relatedItem.title }}
- @if (showMoreOptions(duplicate)) {
+ @if (showMoreOptions(relatedItem)) {
}
diff --git a/src/app/features/analytics/components/view-duplicates/view-duplicates.component.scss b/src/app/features/analytics/components/view-related-projects/view-related-projects.component.scss
similarity index 100%
rename from src/app/features/analytics/components/view-duplicates/view-duplicates.component.scss
rename to src/app/features/analytics/components/view-related-projects/view-related-projects.component.scss
diff --git a/src/app/features/analytics/components/view-duplicates/view-duplicates.component.spec.ts b/src/app/features/analytics/components/view-related-projects/view-related-projects.component.spec.ts
similarity index 86%
rename from src/app/features/analytics/components/view-duplicates/view-duplicates.component.spec.ts
rename to src/app/features/analytics/components/view-related-projects/view-related-projects.component.spec.ts
index 336aaacbb..80fe9a26a 100644
--- a/src/app/features/analytics/components/view-duplicates/view-duplicates.component.spec.ts
+++ b/src/app/features/analytics/components/view-related-projects/view-related-projects.component.spec.ts
@@ -10,7 +10,8 @@ import { ActivatedRoute, Router } from '@angular/router';
import { ProjectOverviewSelectors } from '@osf/features/project/overview/store';
import { RegistryOverviewSelectors } from '@osf/features/registry/store/registry-overview';
import { ResourceType } from '@osf/shared/enums';
-import { DuplicatesSelectors } from '@osf/shared/stores';
+import { IS_SMALL } from '@osf/shared/helpers';
+import { RelatedSelectors } from '@osf/shared/stores';
import {
ContributorsListComponent,
CustomPaginatorComponent,
@@ -22,7 +23,7 @@ import {
import { MOCK_PROJECT_OVERVIEW } from '@shared/mocks';
import { CustomDialogService } from '@shared/services';
-import { ViewDuplicatesComponent } from './view-duplicates.component';
+import { ViewRelatedProjectsComponent } from './view-related-projects.component';
import { OSFTestingModule } from '@testing/osf.testing.module';
import { CustomDialogServiceMockBuilder } from '@testing/providers/custom-dialog-provider.mock';
@@ -31,8 +32,8 @@ import { RouterMockBuilder } from '@testing/providers/router-provider.mock';
import { provideMockStore } from '@testing/providers/store-provider.mock';
describe('Component: View Duplicates', () => {
- let component: ViewDuplicatesComponent;
- let fixture: ComponentFixture
;
+ let component: ViewRelatedProjectsComponent;
+ let fixture: ComponentFixture;
let routerMock: ReturnType;
let activatedRouteMock: ReturnType;
let mockCustomDialogService: ReturnType;
@@ -47,7 +48,7 @@ describe('Component: View Duplicates', () => {
await TestBed.configureTestingModule({
imports: [
- ViewDuplicatesComponent,
+ ViewRelatedProjectsComponent,
OSFTestingModule,
...MockComponents(
SubHeaderComponent,
@@ -61,9 +62,9 @@ describe('Component: View Duplicates', () => {
providers: [
provideMockStore({
signals: [
- { selector: DuplicatesSelectors.getDuplicates, value: [] },
- { selector: DuplicatesSelectors.getDuplicatesLoading, value: false },
- { selector: DuplicatesSelectors.getDuplicatesTotalCount, value: 0 },
+ { selector: RelatedSelectors.getRelated, value: [] },
+ { selector: RelatedSelectors.getRelatedLoading, value: false },
+ { selector: RelatedSelectors.getRelatedTotalCount, value: 0 },
{ selector: ProjectOverviewSelectors.getProject, value: MOCK_PROJECT_OVERVIEW },
{ selector: ProjectOverviewSelectors.isProjectAnonymous, value: false },
{ selector: RegistryOverviewSelectors.getRegistry, value: undefined },
@@ -76,7 +77,7 @@ describe('Component: View Duplicates', () => {
],
}).compileComponents();
- fixture = TestBed.createComponent(ViewDuplicatesComponent);
+ fixture = TestBed.createComponent(ViewRelatedProjectsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
diff --git a/src/app/features/analytics/components/view-duplicates/view-duplicates.component.ts b/src/app/features/analytics/components/view-related-projects/view-related-projects.component.ts
similarity index 65%
rename from src/app/features/analytics/components/view-duplicates/view-duplicates.component.ts
rename to src/app/features/analytics/components/view-related-projects/view-related-projects.component.ts
index 595a71d56..d040442be 100644
--- a/src/app/features/analytics/components/view-duplicates/view-duplicates.component.ts
+++ b/src/app/features/analytics/components/view-related-projects/view-related-projects.component.ts
@@ -1,6 +1,6 @@
import { createDispatchMap, select } from '@ngxs/store';
-import { TranslatePipe } from '@ngx-translate/core';
+import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { Button } from 'primeng/button';
import { Menu } from 'primeng/menu';
@@ -16,6 +16,7 @@ import {
DestroyRef,
effect,
inject,
+ OnInit,
Signal,
signal,
} from '@angular/core';
@@ -23,7 +24,12 @@ import { toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { UserSelectors } from '@core/store/user';
-import { DeleteComponentDialogComponent, ForkDialogComponent } from '@osf/features/project/overview/components';
+import { RelatedItemsType } from '@osf/features/analytics/enums/related-items-type';
+import {
+ DeleteComponentDialogComponent,
+ ForkDialogComponent,
+ LinkResourceDialogComponent,
+} from '@osf/features/project/overview/components';
import { ClearProjectOverview, GetProjectById, ProjectOverviewSelectors } from '@osf/features/project/overview/store';
import {
ClearRegistryOverview,
@@ -39,13 +45,14 @@ import {
TruncatedTextComponent,
} from '@osf/shared/components';
import { ResourceType, UserPermissions } from '@osf/shared/enums';
+import { IS_MEDIUM } from '@osf/shared/helpers';
import { ToolbarResource } from '@osf/shared/models';
import { Duplicate } from '@osf/shared/models/duplicates';
-import { CustomDialogService, LoaderService } from '@osf/shared/services';
-import { ClearDuplicates, DuplicatesSelectors, GetAllDuplicates, GetResourceWithChildren } from '@osf/shared/stores';
+import { CustomDialogService } from '@osf/shared/services';
+import { ClearDuplicates, GetAllDuplicates, GetAllLinkedProjects, RelatedSelectors } from '@osf/shared/stores';
@Component({
- selector: 'osf-view-duplicates',
+ selector: 'osf-view-related-projects',
imports: [
SubHeaderComponent,
TranslatePipe,
@@ -59,25 +66,64 @@ import { ClearDuplicates, DuplicatesSelectors, GetAllDuplicates, GetResourceWith
IconComponent,
ContributorsListComponent,
],
- templateUrl: './view-duplicates.component.html',
- styleUrl: './view-duplicates.component.scss',
+ templateUrl: './view-related-projects.component.html',
+ styleUrl: './view-related-projects.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
-export class ViewDuplicatesComponent {
+export class ViewRelatedProjectsComponent implements OnInit {
private customDialogService = inject(CustomDialogService);
private loaderService = inject(LoaderService);
private route = inject(ActivatedRoute);
private router = inject(Router);
private destroyRef = inject(DestroyRef);
+ private translateService = inject(TranslateService);
private project = select(ProjectOverviewSelectors.getProject);
private registration = select(RegistryOverviewSelectors.getRegistry);
private isProjectAnonymous = select(ProjectOverviewSelectors.isProjectAnonymous);
private isRegistryAnonymous = select(RegistryOverviewSelectors.isRegistryAnonymous);
+ private itemType = signal(null);
- duplicates = select(DuplicatesSelectors.getDuplicates);
- isDuplicatesLoading = select(DuplicatesSelectors.getDuplicatesLoading);
- totalDuplicates = select(DuplicatesSelectors.getDuplicatesTotalCount);
+ items = select(RelatedSelectors.getRelated);
+ isLoading = select(RelatedSelectors.getRelatedLoading);
+ totalDuplicates = select(RelatedSelectors.getRelatedTotalCount);
isAuthenticated = select(UserSelectors.isAuthenticated);
+ isMedium = toSignal(inject(IS_MEDIUM));
+
+ title = computed(() => {
+ switch (this.itemType()) {
+ case RelatedItemsType.Duplicates:
+ return this.translateService.instant('project.analytics.kpi.forks');
+ case RelatedItemsType.Linked:
+ return this.translateService.instant('project.analytics.viewRelated.linkedProjectsTitle');
+ }
+ });
+
+ emptyScreenMessage = computed(() => {
+ switch (this.itemType()) {
+ case RelatedItemsType.Duplicates:
+ return this.translateService.instant('project.overview.dialog.fork.noForksMessage');
+ case RelatedItemsType.Linked:
+ return this.translateService.instant('project.analytics.viewRelated.noLinkedProjectsMessage');
+ }
+ });
+
+ message = computed(() => {
+ switch (this.itemType()) {
+ case RelatedItemsType.Duplicates:
+ return this.translateService.instant('project.overview.dialog.fork.forksMessage');
+ case RelatedItemsType.Linked:
+ return this.translateService.instant('project.analytics.viewRelated.linkedProjectsMessage');
+ }
+ });
+
+ mainActionMessage = computed(() => {
+ switch (this.itemType()) {
+ case RelatedItemsType.Duplicates:
+ return this.translateService.instant('project.overview.actions.forkProjectLabel');
+ case RelatedItemsType.Linked:
+ return this.translateService.instant('project.overview.components.linkProjectsButton');
+ }
+ });
readonly pageSize = 10;
readonly UserPermissions = UserPermissions;
@@ -123,12 +169,19 @@ export class ViewDuplicatesComponent {
getProject: GetProjectById,
getRegistration: GetRegistryById,
getDuplicates: GetAllDuplicates,
+ getLinkedProjects: GetAllLinkedProjects,
clearDuplicates: ClearDuplicates,
clearProject: ClearProjectOverview,
clearRegistration: ClearRegistryOverview,
getComponentsTree: GetResourceWithChildren,
});
+ ngOnInit() {
+ this.route.paramMap.subscribe((params) => {
+ this.itemType.set((params.get('type') as RelatedItemsType) ?? RelatedItemsType.Duplicates);
+ });
+ }
+
constructor() {
effect(() => {
const resourceId = this.resourceId();
@@ -142,9 +195,16 @@ export class ViewDuplicatesComponent {
effect(() => {
const resource = this.currentResource();
-
- if (resource) {
- this.actions.getDuplicates(resource.id, resource.type, parseInt(this.currentPage()), this.pageSize);
+ if (!resource) {
+ return;
+ }
+ switch (this.itemType()) {
+ case RelatedItemsType.Duplicates:
+ this.actions.getDuplicates(resource.id, resource.type, parseInt(this.currentPage()), this.pageSize);
+ break;
+ case RelatedItemsType.Linked:
+ this.actions.getLinkedProjects(resource.id, resource.type, parseInt(this.currentPage()), this.pageSize);
+ break;
}
});
@@ -173,11 +233,16 @@ export class ViewDuplicatesComponent {
showMoreOptions(duplicate: Duplicate) {
return (
- duplicate.currentUserPermissions.includes(UserPermissions.Admin) ||
- duplicate.currentUserPermissions.includes(UserPermissions.Write)
+ this.isShowingDuplicates() &&
+ (duplicate.currentUserPermissions.includes(UserPermissions.Admin) ||
+ duplicate.currentUserPermissions.includes(UserPermissions.Write))
);
}
+ isShowingDuplicates() {
+ return this.itemType() == RelatedItemsType.Duplicates;
+ }
+
handleMenuAction(action: string, resourceId: string): void {
switch (action) {
case 'manageContributors':
@@ -191,6 +256,26 @@ export class ViewDuplicatesComponent {
break;
}
}
+ handleMainAction() {
+ switch (this.itemType()) {
+ case RelatedItemsType.Duplicates:
+ this.handleForkResource();
+ return;
+
+ case RelatedItemsType.Linked:
+ this.openLinkProjectModal();
+ return;
+ }
+ }
+
+ openLinkProjectModal() {
+ const dialogWidth = this.isMedium() ? '850px' : '95vw';
+
+ this.customDialogService.open(LinkResourceDialogComponent, {
+ header: 'project.overview.dialog.linkProject.header',
+ width: dialogWidth,
+ });
+ }
handleForkResource(): void {
const toolbarResource = this.toolbarResource();
diff --git a/src/app/features/analytics/enums/index.ts b/src/app/features/analytics/enums/index.ts
index c984e6081..342d2ca33 100644
--- a/src/app/features/analytics/enums/index.ts
+++ b/src/app/features/analytics/enums/index.ts
@@ -1 +1,2 @@
export { DateRange } from './date-range.enum';
+export { RelatedItemsType } from './related-items-type';
diff --git a/src/app/features/analytics/enums/related-items-type.ts b/src/app/features/analytics/enums/related-items-type.ts
new file mode 100644
index 000000000..c4afb9e4c
--- /dev/null
+++ b/src/app/features/analytics/enums/related-items-type.ts
@@ -0,0 +1,4 @@
+export const enum RelatedItemsType {
+ Duplicates = 'forks',
+ Linked = 'linked_by_nodes',
+}
diff --git a/src/app/features/project/project.routes.ts b/src/app/features/project/project.routes.ts
index 8c34fdce1..59d47c324 100644
--- a/src/app/features/project/project.routes.ts
+++ b/src/app/features/project/project.routes.ts
@@ -9,8 +9,8 @@ import {
CitationsState,
CollectionsState,
ContributorsState,
- DuplicatesState,
NodeLinksState,
+ RelatedState,
SubjectsState,
ViewOnlyLinkState,
} from '@osf/shared/stores';
@@ -96,13 +96,13 @@ export const projectRoutes: Routes = [
providers: [provideStates([AnalyticsState])],
},
{
- path: 'analytics/duplicates',
+ path: 'analytics/related-projects/:type',
data: { resourceType: ResourceType.Project },
loadComponent: () =>
- import('../analytics/components/view-duplicates/view-duplicates.component').then(
- (mod) => mod.ViewDuplicatesComponent
+ import('@osf/features/analytics/components/view-related-projects/view-related-projects.component').then(
+ (mod) => mod.ViewRelatedProjectsComponent
),
- providers: [provideStates([DuplicatesState])],
+ providers: [provideStates([RelatedState])],
},
{
path: 'wiki',
diff --git a/src/app/features/registry/registry.routes.ts b/src/app/features/registry/registry.routes.ts
index f26006bbe..6ac716565 100644
--- a/src/app/features/registry/registry.routes.ts
+++ b/src/app/features/registry/registry.routes.ts
@@ -5,13 +5,7 @@ import { Routes } from '@angular/router';
import { viewOnlyGuard } from '@osf/core/guards';
import { ResourceType } from '@osf/shared/enums';
import { LicensesService } from '@osf/shared/services';
-import {
- CitationsState,
- ContributorsState,
- DuplicatesState,
- SubjectsState,
- ViewOnlyLinkState,
-} from '@osf/shared/stores';
+import { CitationsState, ContributorsState, RelatedState, SubjectsState, ViewOnlyLinkState } from '@osf/shared/stores';
import { ActivityLogsState } from '@shared/stores/activity-logs';
import { AnalyticsState } from '../analytics/store';
@@ -77,13 +71,13 @@ export const registryRoutes: Routes = [
providers: [provideStates([AnalyticsState])],
},
{
- path: 'analytics/duplicates',
+ path: 'analytics/related-projects/:type',
data: { resourceType: ResourceType.Registration },
loadComponent: () =>
- import('../analytics/components/view-duplicates/view-duplicates.component').then(
- (mod) => mod.ViewDuplicatesComponent
+ import('../analytics/components/view-related-projects/view-related-projects.component').then(
+ (mod) => mod.ViewRelatedProjectsComponent
),
- providers: [provideStates([DuplicatesState])],
+ providers: [provideStates([RelatedState])],
},
{
path: 'files',
diff --git a/src/app/shared/services/index.ts b/src/app/shared/services/index.ts
index 0f52b0dd9..dee9a9b4c 100644
--- a/src/app/shared/services/index.ts
+++ b/src/app/shared/services/index.ts
@@ -7,7 +7,6 @@ export { CollectionsService } from './collections.service';
export { ContributorsService } from './contributors.service';
export { CustomConfirmationService } from './custom-confirmation.service';
export { CustomDialogService } from './custom-dialog.service';
-export { DuplicatesService } from './duplicates.service';
export { FilesService } from './files.service';
export { GlobalSearchService } from './global-search.service';
export { GoogleFilePickerDownloadService } from './google-file-picker.download.service';
@@ -23,6 +22,7 @@ export { NodeLinksService } from './node-links.service';
export { ProjectRedirectDialogService } from './project-redirect-dialog.service';
export { RegionsService } from './regions.service';
export { RegistrationProviderService } from './registration-provider.service';
+export { RelatedService } from './related.service';
export { RequestAccessService } from './request-access.service';
export { ResourceGuidService } from './resource.service';
export { ResourceCardService } from './resource-card.service';
diff --git a/src/app/shared/services/duplicates.service.ts b/src/app/shared/services/related.service.ts
similarity index 65%
rename from src/app/shared/services/duplicates.service.ts
rename to src/app/shared/services/related.service.ts
index ff89ed899..c9d5bed8c 100644
--- a/src/app/shared/services/duplicates.service.ts
+++ b/src/app/shared/services/related.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 { RelatedItemsType } from '@osf/features/analytics/enums';
import { DuplicatesMapper } from '../mappers';
import { ResponseJsonApi } from '../models';
@@ -14,7 +15,7 @@ import { JsonApiService } from './json-api.service';
@Injectable({
providedIn: 'root',
})
-export class DuplicatesService {
+export class RelatedService {
private jsonApiService = inject(JsonApiService);
private readonly environment = inject(ENVIRONMENT);
@@ -22,11 +23,25 @@ export class DuplicatesService {
return `${this.environment.apiDomainUrl}/v2`;
}
+ fetchAllLinkedNodes(resourceId: string, resourceType: string, pageNumber?: number, pageSize?: number) {
+ return this.fetchAllRelated(resourceId, resourceType, RelatedItemsType.Linked, pageNumber, pageSize);
+ }
+
fetchAllDuplicates(
resourceId: string,
resourceType: string,
pageNumber?: number,
pageSize?: number
+ ): Observable {
+ return this.fetchAllRelated(resourceId, resourceType, RelatedItemsType.Duplicates, pageNumber, pageSize);
+ }
+
+ private fetchAllRelated(
+ resourceId: string,
+ resourceType: string,
+ relatedType: RelatedItemsType,
+ pageNumber?: number,
+ pageSize?: number
): Observable {
const params: Record = {
embed: 'bibliographic_contributors',
@@ -42,7 +57,7 @@ export class DuplicatesService {
}
return this.jsonApiService
- .get>(`${this.apiUrl}/${resourceType}/${resourceId}/forks/`, params)
+ .get>(`${this.apiUrl}/${resourceType}/${resourceId}/${relatedType}/`, params)
.pipe(map((res) => DuplicatesMapper.fromDuplicatesJsonApiResponse(res)));
}
}
diff --git a/src/app/shared/stores/duplicates/duplicates.selectors.ts b/src/app/shared/stores/duplicates/duplicates.selectors.ts
deleted file mode 100644
index 67446a676..000000000
--- a/src/app/shared/stores/duplicates/duplicates.selectors.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { Selector } from '@ngxs/store';
-
-import { DuplicatesStateModel } from './duplicates.model';
-import { DuplicatesState } from './duplicates.state';
-
-export class DuplicatesSelectors {
- @Selector([DuplicatesState])
- static getDuplicates(state: DuplicatesStateModel) {
- return state.duplicates.data;
- }
-
- @Selector([DuplicatesState])
- static getDuplicatesLoading(state: DuplicatesStateModel) {
- return state.duplicates.isLoading;
- }
-
- @Selector([DuplicatesState])
- static getDuplicatesTotalCount(state: DuplicatesStateModel) {
- return state.duplicates.totalCount;
- }
-}
diff --git a/src/app/shared/stores/duplicates/duplicates.state.ts b/src/app/shared/stores/duplicates/duplicates.state.ts
deleted file mode 100644
index e34f64f09..000000000
--- a/src/app/shared/stores/duplicates/duplicates.state.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-import { Action, State, StateContext } from '@ngxs/store';
-
-import { catchError, tap } from 'rxjs';
-
-import { inject, Injectable } from '@angular/core';
-
-import { handleSectionError } from '@shared/helpers';
-import { DuplicatesService } from '@shared/services/duplicates.service';
-
-import { ClearDuplicates, GetAllDuplicates } from './duplicates.actions';
-import { DUPLICATES_DEFAULTS, DuplicatesStateModel } from './duplicates.model';
-
-@State({
- name: 'duplicates',
- defaults: DUPLICATES_DEFAULTS,
-})
-@Injectable()
-export class DuplicatesState {
- duplicatesService = inject(DuplicatesService);
-
- @Action(GetAllDuplicates)
- getDuplicates(ctx: StateContext, action: GetAllDuplicates) {
- const state = ctx.getState();
- ctx.patchState({
- duplicates: {
- ...state.duplicates,
- isLoading: true,
- },
- });
-
- return this.duplicatesService
- .fetchAllDuplicates(action.resourceId, action.resourceType, action.page, action.pageSize)
- .pipe(
- tap((response) => {
- ctx.patchState({
- duplicates: {
- data: response.data,
- isLoading: false,
- error: null,
- totalCount: response.totalCount,
- },
- });
- }),
- catchError((error) => handleSectionError(ctx, 'duplicates', error))
- );
- }
-
- @Action(ClearDuplicates)
- clearDuplicates(ctx: StateContext) {
- ctx.patchState(DUPLICATES_DEFAULTS);
- }
-}
diff --git a/src/app/shared/stores/duplicates/index.ts b/src/app/shared/stores/duplicates/index.ts
index 7d2822569..c8c529bd6 100644
--- a/src/app/shared/stores/duplicates/index.ts
+++ b/src/app/shared/stores/duplicates/index.ts
@@ -1,4 +1,4 @@
-export * from './duplicates.actions';
-export * from './duplicates.model';
-export * from './duplicates.selectors';
-export * from './duplicates.state';
+export * from './related.actions';
+export * from './related.model';
+export * from './related.selectors';
+export * from './related.state.service';
diff --git a/src/app/shared/stores/duplicates/duplicates.actions.ts b/src/app/shared/stores/duplicates/related.actions.ts
similarity index 56%
rename from src/app/shared/stores/duplicates/duplicates.actions.ts
rename to src/app/shared/stores/duplicates/related.actions.ts
index 4bcf560da..a042cc3f4 100644
--- a/src/app/shared/stores/duplicates/duplicates.actions.ts
+++ b/src/app/shared/stores/duplicates/related.actions.ts
@@ -9,6 +9,17 @@ export class GetAllDuplicates {
) {}
}
+export class GetAllLinkedProjects {
+ static readonly type = '[Forks] Get All Linked Projects';
+
+ constructor(
+ public resourceId: string,
+ public resourceType: string,
+ public page: number,
+ public pageSize: number
+ ) {}
+}
+
export class ClearDuplicates {
static readonly type = '[Forks] Clear Duplicates';
}
diff --git a/src/app/shared/stores/duplicates/duplicates.model.ts b/src/app/shared/stores/duplicates/related.model.ts
similarity index 82%
rename from src/app/shared/stores/duplicates/duplicates.model.ts
rename to src/app/shared/stores/duplicates/related.model.ts
index ae0951a5b..f21ce3292 100644
--- a/src/app/shared/stores/duplicates/duplicates.model.ts
+++ b/src/app/shared/stores/duplicates/related.model.ts
@@ -3,11 +3,11 @@ import { AsyncStateWithTotalCount } from '@osf/shared/models';
import { Duplicate } from 'src/app/shared/models/duplicates';
export interface DuplicatesStateModel {
- duplicates: AsyncStateWithTotalCount;
+ related: AsyncStateWithTotalCount;
}
export const DUPLICATES_DEFAULTS: DuplicatesStateModel = {
- duplicates: {
+ related: {
data: [],
isLoading: false,
isSubmitting: false,
diff --git a/src/app/shared/stores/duplicates/related.selectors.ts b/src/app/shared/stores/duplicates/related.selectors.ts
new file mode 100644
index 000000000..e0f4e7c8c
--- /dev/null
+++ b/src/app/shared/stores/duplicates/related.selectors.ts
@@ -0,0 +1,21 @@
+import { Selector } from '@ngxs/store';
+
+import { DuplicatesStateModel } from './related.model';
+import { RelatedState } from './related.state.service';
+
+export class RelatedSelectors {
+ @Selector([RelatedState])
+ static getRelated(state: DuplicatesStateModel) {
+ return state.related.data;
+ }
+
+ @Selector([RelatedState])
+ static getRelatedLoading(state: DuplicatesStateModel) {
+ return state.related.isLoading;
+ }
+
+ @Selector([RelatedState])
+ static getRelatedTotalCount(state: DuplicatesStateModel) {
+ return state.related.totalCount;
+ }
+}
diff --git a/src/app/shared/stores/duplicates/related.state.service.ts b/src/app/shared/stores/duplicates/related.state.service.ts
new file mode 100644
index 000000000..ecb9213ff
--- /dev/null
+++ b/src/app/shared/stores/duplicates/related.state.service.ts
@@ -0,0 +1,79 @@
+import { Action, State, StateContext } from '@ngxs/store';
+
+import { catchError, tap } from 'rxjs';
+
+import { inject, Injectable } from '@angular/core';
+
+import { handleSectionError } from '@shared/helpers';
+import { RelatedService } from '@shared/services/related.service';
+
+import { ClearDuplicates, GetAllDuplicates, GetAllLinkedProjects } from './related.actions';
+import { DUPLICATES_DEFAULTS, DuplicatesStateModel } from './related.model';
+
+@State({
+ name: 'duplicates',
+ defaults: DUPLICATES_DEFAULTS,
+})
+@Injectable()
+export class RelatedState {
+ relatedService = inject(RelatedService);
+
+ @Action(GetAllDuplicates)
+ getDuplicates(ctx: StateContext, action: GetAllDuplicates) {
+ const state = ctx.getState();
+ ctx.patchState({
+ related: {
+ ...state.related,
+ isLoading: true,
+ },
+ });
+
+ return this.relatedService
+ .fetchAllDuplicates(action.resourceId, action.resourceType, action.page, action.pageSize)
+ .pipe(
+ tap((response) => {
+ ctx.patchState({
+ related: {
+ data: response.data,
+ isLoading: false,
+ error: null,
+ totalCount: response.totalCount,
+ },
+ });
+ }),
+ catchError((error) => handleSectionError(ctx, 'related', error))
+ );
+ }
+
+ @Action(GetAllLinkedProjects)
+ getLinkedProjects(ctx: StateContext, action: GetAllDuplicates) {
+ const state = ctx.getState();
+ ctx.patchState({
+ related: {
+ ...state.related,
+ isLoading: true,
+ },
+ });
+
+ return this.relatedService
+ .fetchAllLinkedNodes(action.resourceId, action.resourceType, action.page, action.pageSize)
+ .pipe(
+ tap((response) => {
+ ctx.patchState({
+ related: {
+ data: response.data,
+ isLoading: false,
+ error: null,
+ totalCount: response.totalCount,
+ },
+ });
+ }),
+ catchError((error) => handleSectionError(ctx, 'related', error))
+ );
+ }
+
+ @Action(ClearDuplicates)
+ clearDuplicates(ctx: StateContext) {
+ ctx.patchState(DUPLICATES_DEFAULTS);
+ }
+}
diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json
index 89943c1b4..de5fecbf4 100644
--- a/src/assets/i18n/en.json
+++ b/src/assets/i18n/en.json
@@ -539,6 +539,11 @@
"viewForks": "View duplicates",
"viewLinks": "View links"
},
+ "viewRelated": {
+ "linkedProjectsTitle": "Linked Projects",
+ "linkedProjectsMessage": "Linked Projects you have permission to view are shown here.",
+ "noLinkedProjectsMessage": "No Linked Projects found."
+ },
"charts": {
"showAnalytics": "Show analytics for date range:",
"pastWeek": "Past week",