diff --git a/src/app/core/constants/ngxs-states.constant.ts b/src/app/core/constants/ngxs-states.constant.ts index 0f643db58..409a3d258 100644 --- a/src/app/core/constants/ngxs-states.constant.ts +++ b/src/app/core/constants/ngxs-states.constant.ts @@ -12,6 +12,7 @@ import { GlobalSearchState } from '@shared/stores/global-search'; import { InstitutionsState } from '@shared/stores/institutions'; import { InstitutionsSearchState } from '@shared/stores/institutions-search'; import { LicensesState } from '@shared/stores/licenses'; +import { LinkedProjectsState } from '@shared/stores/linked-projects'; import { MyResourcesState } from '@shared/stores/my-resources'; import { RegionsState } from '@shared/stores/regions'; @@ -34,4 +35,5 @@ export const STATES = [ CurrentResourceState, GlobalSearchState, BannersState, + LinkedProjectsState, ]; diff --git a/src/app/features/analytics/analytics.component.html b/src/app/features/analytics/analytics.component.html index 8cb527800..3ed2771cd 100644 --- a/src/app/features/analytics/analytics.component.html +++ b/src/app/features/analytics/analytics.component.html @@ -91,7 +91,7 @@ [isLoading]="isRelatedCountsLoading()" [title]="'project.analytics.kpi.forks'" [value]="relatedCounts()?.forksCount" - [showButton]="true" + [showButton]="(relatedCounts()?.forksCount ?? 0) > 0" [buttonLabel]="'project.analytics.kpi.viewForks'" (buttonClick)="navigateToDuplicates()" > @@ -100,8 +100,9 @@ [isLoading]="isRelatedCountsLoading()" [title]="'project.analytics.kpi.linksToThisProject'" [value]="relatedCounts()?.linksToCount" - [showButton]="false" + [showButton]="(relatedCounts()?.linksToCount ?? 0) > 0" [buttonLabel]="'project.analytics.kpi.viewLinks'" + (buttonClick)="navigateToLinkedProjects()" >

- + {{ duplicate.title }}

@@ -62,7 +62,7 @@

{{ 'common.labels.contributors' | translate }}: - +
@if (duplicate.description) { 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 595a71d56..176e26edd 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 @@ -39,8 +39,7 @@ import { TruncatedTextComponent, } from '@osf/shared/components'; import { ResourceType, UserPermissions } from '@osf/shared/enums'; -import { ToolbarResource } from '@osf/shared/models'; -import { Duplicate } from '@osf/shared/models/duplicates'; +import { BaseNodeModel, ToolbarResource } from '@osf/shared/models'; import { CustomDialogService, LoaderService } from '@osf/shared/services'; import { ClearDuplicates, DuplicatesSelectors, GetAllDuplicates, GetResourceWithChildren } from '@osf/shared/stores'; @@ -58,6 +57,7 @@ import { ClearDuplicates, DuplicatesSelectors, GetAllDuplicates, GetResourceWith CustomPaginatorComponent, IconComponent, ContributorsListComponent, + DatePipe, ], templateUrl: './view-duplicates.component.html', styleUrl: './view-duplicates.component.scss', @@ -171,7 +171,7 @@ export class ViewDuplicatesComponent { return null; }); - showMoreOptions(duplicate: Duplicate) { + showMoreOptions(duplicate: BaseNodeModel) { return ( duplicate.currentUserPermissions.includes(UserPermissions.Admin) || duplicate.currentUserPermissions.includes(UserPermissions.Write) diff --git a/src/app/features/analytics/components/view-linked-projects/view-linked-projects.component.html b/src/app/features/analytics/components/view-linked-projects/view-linked-projects.component.html new file mode 100644 index 000000000..973b611b3 --- /dev/null +++ b/src/app/features/analytics/components/view-linked-projects/view-linked-projects.component.html @@ -0,0 +1,62 @@ + + +
+ @if (!isLoading() && currentResource()) { + @if (!linkedProjects().length) { +

{{ 'project.analytics.viewRelated.noLinkedProjectsMessage' | translate }}

+ } @else { +

{{ 'project.analytics.viewRelated.linkedProjectsMessage' | translate }}

+ + @for (duplicate of linkedProjects(); track duplicate.id) { +
+
+

+ + {{ duplicate.title }} +

+
+ +
+
+ {{ '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 (totalLinkedProjects() > pageSize) { + + } + } + } @else { + + } +
diff --git a/src/app/features/analytics/components/view-linked-projects/view-linked-projects.component.scss b/src/app/features/analytics/components/view-linked-projects/view-linked-projects.component.scss new file mode 100644 index 000000000..32a20a323 --- /dev/null +++ b/src/app/features/analytics/components/view-linked-projects/view-linked-projects.component.scss @@ -0,0 +1,11 @@ +:host { + display: flex; + flex-direction: column; + flex: 1; +} + +.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/view-linked-projects/view-linked-projects.component.spec.ts b/src/app/features/analytics/components/view-linked-projects/view-linked-projects.component.spec.ts new file mode 100644 index 000000000..4260e386d --- /dev/null +++ b/src/app/features/analytics/components/view-linked-projects/view-linked-projects.component.spec.ts @@ -0,0 +1,99 @@ +import { MockComponents, MockProvider } from 'ng-mocks'; + +import { PaginatorState } from 'primeng/paginator'; + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +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 { + ContributorsListComponent, + CustomPaginatorComponent, + IconComponent, + LoadingSpinnerComponent, + SubHeaderComponent, + TruncatedTextComponent, +} from '@shared/components'; +import { MOCK_PROJECT_OVERVIEW } from '@shared/mocks'; +import { CustomDialogService } from '@shared/services'; + +import { ViewLinkedProjectsComponent } from './view-linked-projects.component'; + +import { OSFTestingModule } from '@testing/osf.testing.module'; +import { CustomDialogServiceMockBuilder } from '@testing/providers/custom-dialog-provider.mock'; +import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; +import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; + +describe('Component: View Duplicates', () => { + let component: ViewLinkedProjectsComponent; + let fixture: ComponentFixture; + let routerMock: ReturnType; + let activatedRouteMock: ReturnType; + let mockCustomDialogService: ReturnType; + + beforeEach(async () => { + mockCustomDialogService = CustomDialogServiceMockBuilder.create().build(); + routerMock = RouterMockBuilder.create().build(); + activatedRouteMock = ActivatedRouteMockBuilder.create() + .withParams({ id: 'rid' }) + .withData({ resourceType: ResourceType.Project }) + .build(); + + await TestBed.configureTestingModule({ + imports: [ + ViewLinkedProjectsComponent, + OSFTestingModule, + ...MockComponents( + SubHeaderComponent, + TruncatedTextComponent, + LoadingSpinnerComponent, + CustomPaginatorComponent, + IconComponent, + ContributorsListComponent + ), + ], + providers: [ + provideMockStore({ + signals: [ + { selector: DuplicatesSelectors.getDuplicates, value: [] }, + { selector: DuplicatesSelectors.getDuplicatesLoading, value: false }, + { selector: DuplicatesSelectors.getDuplicatesTotalCount, value: 0 }, + { selector: ProjectOverviewSelectors.getProject, value: MOCK_PROJECT_OVERVIEW }, + { selector: ProjectOverviewSelectors.isProjectAnonymous, value: false }, + { selector: RegistryOverviewSelectors.getRegistry, value: undefined }, + { selector: RegistryOverviewSelectors.isRegistryAnonymous, value: false }, + ], + }), + MockProvider(CustomDialogService, mockCustomDialogService), + MockProvider(Router, routerMock), + MockProvider(ActivatedRoute, activatedRouteMock), + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ViewLinkedProjectsComponent); + component = fixture.componentInstance; + + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should update currentPage when page is defined', () => { + const event: PaginatorState = { page: 1 } as PaginatorState; + component.onPageChange(event); + expect(component.currentPage()).toBe('2'); + }); + + it('should not update currentPage when page is undefined', () => { + component.currentPage.set('5'); + const event: PaginatorState = { page: undefined } as PaginatorState; + component.onPageChange(event); + expect(component.currentPage()).toBe('5'); + }); +}); diff --git a/src/app/features/analytics/components/view-linked-projects/view-linked-projects.component.ts b/src/app/features/analytics/components/view-linked-projects/view-linked-projects.component.ts new file mode 100644 index 000000000..62c40b840 --- /dev/null +++ b/src/app/features/analytics/components/view-linked-projects/view-linked-projects.component.ts @@ -0,0 +1,137 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { TranslatePipe } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { PaginatorState } from 'primeng/paginator'; + +import { map, of } from 'rxjs'; + +import { DatePipe } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + effect, + inject, + Signal, + signal, +} from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { ActivatedRoute, RouterLink } from '@angular/router'; + +import { ClearProjectOverview, GetProjectById, ProjectOverviewSelectors } from '@osf/features/project/overview/store'; +import { + ClearRegistryOverview, + GetRegistryById, + RegistryOverviewSelectors, +} from '@osf/features/registry/store/registry-overview'; +import { + ContributorsListComponent, + CustomPaginatorComponent, + IconComponent, + LoadingSpinnerComponent, + SubHeaderComponent, + TruncatedTextComponent, +} from '@osf/shared/components'; +import { ResourceType } from '@osf/shared/enums'; +import { ClearLinkedProjects, GetAllLinkedProjects, LinkedProjectsSelectors } from '@shared/stores/linked-projects'; + +@Component({ + selector: 'osf-view-linked-nodes', + imports: [ + SubHeaderComponent, + TranslatePipe, + Button, + TruncatedTextComponent, + DatePipe, + LoadingSpinnerComponent, + RouterLink, + CustomPaginatorComponent, + IconComponent, + ContributorsListComponent, + DatePipe, + ], + templateUrl: './view-linked-projects.component.html', + styleUrl: './view-linked-projects.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ViewLinkedProjectsComponent { + private route = inject(ActivatedRoute); + private destroyRef = inject(DestroyRef); + private project = select(ProjectOverviewSelectors.getProject); + private registration = select(RegistryOverviewSelectors.getRegistry); + + linkedProjects = select(LinkedProjectsSelectors.getLinkedProjects); + isLoading = select(LinkedProjectsSelectors.getLinkedProjectsLoading); + totalLinkedProjects = select(LinkedProjectsSelectors.getLinkedProjectsTotalCount); + + readonly pageSize = 10; + + currentPage = signal('1'); + firstIndex = computed(() => (parseInt(this.currentPage()) - 1) * this.pageSize); + + 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, + getLinkedProjects: GetAllLinkedProjects, + clearLinkedProjects: ClearLinkedProjects, + clearProject: ClearProjectOverview, + clearRegistration: ClearRegistryOverview, + }); + + 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); + } + }); + + effect(() => { + const resource = this.currentResource(); + + if (resource) { + this.actions.getLinkedProjects(resource.id, resource.type, parseInt(this.currentPage()), this.pageSize); + } + }); + + this.setupCleanup(); + } + + onPageChange(event: PaginatorState): void { + if (event.page !== undefined) { + const pageNumber = (event.page + 1).toString(); + this.currentPage.set(pageNumber); + } + } + + setupCleanup(): void { + this.destroyRef.onDestroy(() => { + this.actions.clearLinkedProjects(); + this.actions.clearProject(); + this.actions.clearRegistration(); + }); + } +} diff --git a/src/app/features/project/project.routes.ts b/src/app/features/project/project.routes.ts index 8c34fdce1..3496fe3f2 100644 --- a/src/app/features/project/project.routes.ts +++ b/src/app/features/project/project.routes.ts @@ -95,6 +95,15 @@ export const projectRoutes: Routes = [ data: { resourceType: ResourceType.Project }, providers: [provideStates([AnalyticsState])], }, + { + path: 'analytics/linked-projects', + data: { resourceType: ResourceType.Project }, + loadComponent: () => + import('../analytics/components/view-linked-projects/view-linked-projects.component').then( + (mod) => mod.ViewLinkedProjectsComponent + ), + providers: [provideStates([DuplicatesState])], + }, { path: 'analytics/duplicates', data: { resourceType: ResourceType.Project }, diff --git a/src/app/features/project/settings/mappers/settings.mapper.ts b/src/app/features/project/settings/mappers/settings.mapper.ts index 34f3af5a1..ea05448a0 100644 --- a/src/app/features/project/settings/mappers/settings.mapper.ts +++ b/src/app/features/project/settings/mappers/settings.mapper.ts @@ -1,9 +1,9 @@ import { UserPermissions } from '@osf/shared/enums'; import { InstitutionsMapper } from '@osf/shared/mappers'; import { RegionsMapper } from '@osf/shared/mappers/regions'; +import { BaseNodeDataJsonApi } from '@shared/models'; import { - NodeDataJsonApi, NodeDetailsModel, ProjectSettingsDataJsonApi, ProjectSettingsModel, @@ -27,14 +27,14 @@ export class SettingsMapper { } as ProjectSettingsModel; } - static fromNodeResponse(data: NodeDataJsonApi): NodeDetailsModel { + static fromNodeResponse(data: BaseNodeDataJsonApi): NodeDetailsModel { return { id: data.id, title: data.attributes.title, description: data.attributes.description, isPublic: data.attributes.public, - region: data.embeds ? RegionsMapper.getRegion(data?.embeds?.region?.data) : null, - affiliatedInstitutions: data.embeds + region: data.embeds?.region ? RegionsMapper.getRegion(data?.embeds?.region?.data) : null, + affiliatedInstitutions: data.embeds?.affiliated_institutions ? InstitutionsMapper.fromInstitutionsResponse(data.embeds.affiliated_institutions) : [], currentUserPermissions: data.attributes.current_user_permissions as UserPermissions[], diff --git a/src/app/features/project/settings/models/index.ts b/src/app/features/project/settings/models/index.ts index 5d0d33700..987e0418b 100644 --- a/src/app/features/project/settings/models/index.ts +++ b/src/app/features/project/settings/models/index.ts @@ -1,6 +1,5 @@ export * from './node-details.model'; export * from './project-details.model'; -export * from './project-details-json-api.model'; export * from './project-settings.model'; export * from './project-settings-response.model'; export * from './right-control.model'; diff --git a/src/app/features/project/settings/models/project-details-json-api.model.ts b/src/app/features/project/settings/models/project-details-json-api.model.ts deleted file mode 100644 index bae19f9f8..000000000 --- a/src/app/features/project/settings/models/project-details-json-api.model.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { - BaseNodeDataJsonApi, - InstitutionsJsonApiResponse, - RegionDataJsonApi, - ResponseDataJsonApi, -} from '@osf/shared/models'; - -export type NodeResponseJsonApi = ResponseDataJsonApi; - -export interface NodeDataJsonApi extends BaseNodeDataJsonApi { - embeds: NodeEmbedsJsonApi; -} - -interface NodeEmbedsJsonApi { - region: { - data: RegionDataJsonApi; - }; - affiliated_institutions: InstitutionsJsonApiResponse; -} diff --git a/src/app/features/project/settings/services/settings.service.ts b/src/app/features/project/settings/services/settings.service.ts index f4a808179..08d2188c3 100644 --- a/src/app/features/project/settings/services/settings.service.ts +++ b/src/app/features/project/settings/services/settings.service.ts @@ -6,6 +6,8 @@ import { ENVIRONMENT } from '@core/provider/environment.provider'; import { SubscriptionFrequency } from '@osf/shared/enums'; import { NotificationSubscriptionMapper } from '@osf/shared/mappers'; import { + BaseNodeDataJsonApi, + NodeResponseJsonApi, NodeShortInfoModel, NotificationSubscription, NotificationSubscriptionGetResponseJsonApi, @@ -16,9 +18,7 @@ import { JsonApiService } from '@shared/services'; import { SettingsMapper } from '../mappers'; import { - NodeDataJsonApi, NodeDetailsModel, - NodeResponseJsonApi, ProjectSettingsDataJsonApi, ProjectSettingsModel, ProjectSettingsResponseJsonApi, @@ -79,7 +79,7 @@ export class SettingsService { updateProjectById(model: UpdateNodeRequestModel): Observable { return this.jsonApiService - .patch(`${this.apiUrl}/nodes/${model?.data?.id}/`, model) + .patch(`${this.apiUrl}/nodes/${model?.data?.id}/`, model) .pipe(map((response) => SettingsMapper.fromNodeResponse(response))); } diff --git a/src/app/shared/mappers/duplicates.mapper.ts b/src/app/shared/mappers/duplicates.mapper.ts deleted file mode 100644 index b003f6909..000000000 --- a/src/app/shared/mappers/duplicates.mapper.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { ResponseJsonApi } from '@shared/models'; - -import { DuplicateJsonApi, DuplicatesWithTotal } from '../models/duplicates'; - -import { ContributorsMapper } from './contributors'; - -export class DuplicatesMapper { - static fromDuplicatesJsonApiResponse(response: ResponseJsonApi): DuplicatesWithTotal { - return { - data: response.data.map((duplicate) => ({ - id: duplicate.id, - type: duplicate.type, - title: duplicate.attributes.title, - description: duplicate.attributes.description, - dateCreated: duplicate.attributes.forked_date, - dateModified: duplicate.attributes.date_modified, - public: duplicate.attributes.public, - currentUserPermissions: duplicate.attributes.current_user_permissions, - contributors: ContributorsMapper.getContributors(duplicate.embeds.bibliographic_contributors.data), - })), - totalCount: response.meta.total, - }; - } -} diff --git a/src/app/shared/mappers/index.ts b/src/app/shared/mappers/index.ts index cb549be1b..53015bb65 100644 --- a/src/app/shared/mappers/index.ts +++ b/src/app/shared/mappers/index.ts @@ -4,7 +4,6 @@ export * from './citations.mapper'; export * from './collections'; export * from './components'; export * from './contributors'; -export * from './duplicates.mapper'; export * from './emails.mapper'; export * from './files/files.mapper'; export * from './filters/filter-option.mapper'; diff --git a/src/app/shared/mappers/nodes/base-node.mapper.ts b/src/app/shared/mappers/nodes/base-node.mapper.ts index a18d238ec..75f0a79b7 100644 --- a/src/app/shared/mappers/nodes/base-node.mapper.ts +++ b/src/app/shared/mappers/nodes/base-node.mapper.ts @@ -1,10 +1,30 @@ -import { BaseNodeDataJsonApi, BaseNodeModel, NodeShortInfoModel } from '@osf/shared/models'; +import { + BaseNodeDataJsonApi, + BaseNodeModel, + NodeModel, + NodeShortInfoModel, + PaginatedData, + ResponseJsonApi, +} from '@osf/shared/models'; +import { ContributorsMapper } from '@shared/mappers'; export class BaseNodeMapper { static getNodesData(data: BaseNodeDataJsonApi[]): BaseNodeModel[] { return data.map((item) => this.getNodeData(item)); } + static getNodesWithEmbedsData(data: BaseNodeDataJsonApi[]): NodeModel[] { + return data.map((item) => this.getNodeWithEmbedsData(item)); + } + + static getNodesWithEmbedsAndTotalData(response: ResponseJsonApi): PaginatedData { + return { + data: BaseNodeMapper.getNodesWithEmbedsData(response.data), + totalCount: response.meta.total, + pageSize: response.meta.per_page, + }; + } + static getNodesWithChildren(data: BaseNodeDataJsonApi[], parentId: string): NodeShortInfoModel[] { return this.getAllDescendants(data, parentId).map((item) => ({ id: item.id, @@ -43,6 +63,14 @@ export class BaseNodeMapper { }; } + static getNodeWithEmbedsData(data: BaseNodeDataJsonApi): NodeModel { + const baseNode = BaseNodeMapper.getNodeData(data); + return { + ...baseNode, + bibliographicContributors: ContributorsMapper.getContributors(data.embeds?.bibliographic_contributors?.data), + }; + } + static getAllDescendants(allNodes: BaseNodeDataJsonApi[], parentId: string): BaseNodeDataJsonApi[] { const parent = allNodes.find((n) => n.id === parentId); if (!parent) return []; diff --git a/src/app/shared/models/duplicates/duplicate-json-api.model.ts b/src/app/shared/models/duplicates/duplicate-json-api.model.ts deleted file mode 100644 index b81280c9c..000000000 --- a/src/app/shared/models/duplicates/duplicate-json-api.model.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { ContributorDataJsonApi } from '../contributors'; - -export interface DuplicateJsonApi { - id: string; - type: string; - attributes: { - title: string; - forked_date: string; - date_modified: string; - description: string; - public: boolean; - current_user_permissions: string[]; - }; - embeds: { - bibliographic_contributors: { - data: ContributorDataJsonApi[]; - }; - }; -} diff --git a/src/app/shared/models/duplicates/duplicate.model.ts b/src/app/shared/models/duplicates/duplicate.model.ts deleted file mode 100644 index 7edeb6d13..000000000 --- a/src/app/shared/models/duplicates/duplicate.model.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { ContributorModel } from '../contributors'; - -export interface Duplicate { - id: string; - type: string; - title: string; - description: string; - dateCreated: string; - dateModified: string; - public: boolean; - currentUserPermissions: string[]; - contributors: ContributorModel[]; -} - -export interface DuplicatesWithTotal { - data: Duplicate[]; - totalCount: number; -} diff --git a/src/app/shared/models/duplicates/index.ts b/src/app/shared/models/duplicates/index.ts deleted file mode 100644 index 7702deeb4..000000000 --- a/src/app/shared/models/duplicates/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './duplicate.model'; -export * from './duplicate-json-api.model'; diff --git a/src/app/shared/models/nodes/base-node-data-json-api.model.ts b/src/app/shared/models/nodes/base-node-data-json-api.model.ts index d02117230..89dfe5693 100644 --- a/src/app/shared/models/nodes/base-node-data-json-api.model.ts +++ b/src/app/shared/models/nodes/base-node-data-json-api.model.ts @@ -1,3 +1,5 @@ +import { BaseNodeEmbedsJsonApi } from '@shared/models'; + import { BaseNodeAttributesJsonApi } from './base-node-attributes-json-api.model'; import { BaseNodeLinksJsonApi } from './base-node-links-json-api.model'; import { BaseNodeRelationships } from './base-node-relationships-json-api.model'; @@ -8,4 +10,5 @@ export interface BaseNodeDataJsonApi { attributes: BaseNodeAttributesJsonApi; links: BaseNodeLinksJsonApi; relationships: BaseNodeRelationships; + embeds?: BaseNodeEmbedsJsonApi; } diff --git a/src/app/shared/models/nodes/base-node-embeds-json-api.model.ts b/src/app/shared/models/nodes/base-node-embeds-json-api.model.ts index 8c128c33c..5f7ace796 100644 --- a/src/app/shared/models/nodes/base-node-embeds-json-api.model.ts +++ b/src/app/shared/models/nodes/base-node-embeds-json-api.model.ts @@ -1,17 +1,27 @@ -import { IdentifierAttributes } from '@shared/models'; +import { + ContributorDataJsonApi, + IdentifierAttributes, + IdentifiersJsonApiData, + InstitutionDataJsonApi, + LicenseDataJsonApi, + RegionDataJsonApi, +} from '@shared/models'; export interface BaseNodeEmbedsJsonApi { - bibliographic_contributors?: { - data: ContributorResource[]; + affiliated_institutions?: { + data: InstitutionDataJsonApi[]; }; - license?: { - data: LicenseResource; + bibliographic_contributors?: { + data: ContributorDataJsonApi[]; }; identifiers?: { - data: IdentifierResource[]; + data: IdentifiersJsonApiData[]; }; - affiliated_institutions?: { - data: InstitutionResource[]; + license?: { + data: LicenseDataJsonApi; + }; + region?: { + data: RegionDataJsonApi; }; } diff --git a/src/app/shared/models/nodes/base-node.model.ts b/src/app/shared/models/nodes/base-node.model.ts index 4d80408ae..4187db43e 100644 --- a/src/app/shared/models/nodes/base-node.model.ts +++ b/src/app/shared/models/nodes/base-node.model.ts @@ -1,3 +1,5 @@ +import { ContributorModel } from '@shared/models'; + import { LicensesOption } from '../license.model'; export interface BaseNodeModel { @@ -22,3 +24,7 @@ export interface BaseNodeModel { rootParentId?: string; type: string; } + +export interface NodeModel extends BaseNodeModel { + bibliographicContributors?: ContributorModel[]; +} diff --git a/src/app/shared/services/duplicates.service.ts b/src/app/shared/services/duplicates.service.ts index ff89ed899..df29dc98e 100644 --- a/src/app/shared/services/duplicates.service.ts +++ b/src/app/shared/services/duplicates.service.ts @@ -5,9 +5,8 @@ import { inject, Injectable } from '@angular/core'; import { ENVIRONMENT } from '@core/provider/environment.provider'; -import { DuplicatesMapper } from '../mappers'; -import { ResponseJsonApi } from '../models'; -import { DuplicateJsonApi, DuplicatesWithTotal } from '../models/duplicates'; +import { BaseNodeMapper } from '../mappers'; +import { BaseNodeDataJsonApi, NodeModel, PaginatedData, ResponseJsonApi } from '../models'; import { JsonApiService } from './json-api.service'; @@ -27,7 +26,7 @@ export class DuplicatesService { resourceType: string, pageNumber?: number, pageSize?: number - ): Observable { + ): Observable> { const params: Record = { embed: 'bibliographic_contributors', 'fields[users]': 'family_name,full_name,given_name,middle_name', @@ -42,7 +41,7 @@ export class DuplicatesService { } return this.jsonApiService - .get>(`${this.apiUrl}/${resourceType}/${resourceId}/forks/`, params) - .pipe(map((res) => DuplicatesMapper.fromDuplicatesJsonApiResponse(res))); + .get>(`${this.apiUrl}/${resourceType}/${resourceId}/forks/`, params) + .pipe(map((res) => BaseNodeMapper.getNodesWithEmbedsAndTotalData(res))); } } diff --git a/src/app/shared/services/linked-projects.service.ts b/src/app/shared/services/linked-projects.service.ts new file mode 100644 index 000000000..d7ecf265e --- /dev/null +++ b/src/app/shared/services/linked-projects.service.ts @@ -0,0 +1,49 @@ +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { inject, Injectable } from '@angular/core'; + +import { ENVIRONMENT } from '@core/provider/environment.provider'; + +import { BaseNodeMapper } from '../mappers'; +import { BaseNodeDataJsonApi, NodeModel, PaginatedData, ResponseJsonApi } from '../models'; + +import { JsonApiService } from './json-api.service'; + +@Injectable({ + providedIn: 'root', +}) +export class LinkedProjectsService { + private jsonApiService = inject(JsonApiService); + private readonly environment = inject(ENVIRONMENT); + + get apiUrl() { + return `${this.environment.apiDomainUrl}/v2`; + } + + fetchAllLinkedProjects( + resourceId: string, + resourceType: string, + pageNumber?: number, + pageSize?: number + ): Observable> { + const params: Record = { + embed: 'bibliographic_contributors', + 'fields[users]': 'family_name,full_name,given_name,middle_name', + }; + + if (pageNumber) { + params['page'] = pageNumber; + } + + if (pageSize) { + params['page[size]'] = pageSize; + } + + return this.jsonApiService + .get< + ResponseJsonApi + >(`${this.apiUrl}/${resourceType}/${resourceId}/linked_by_nodes/`, params) + .pipe(map((res) => BaseNodeMapper.getNodesWithEmbedsAndTotalData(res))); + } +} diff --git a/src/app/shared/stores/duplicates/duplicates.model.ts b/src/app/shared/stores/duplicates/duplicates.model.ts index ae0951a5b..bb8867bb2 100644 --- a/src/app/shared/stores/duplicates/duplicates.model.ts +++ b/src/app/shared/stores/duplicates/duplicates.model.ts @@ -1,9 +1,7 @@ -import { AsyncStateWithTotalCount } from '@osf/shared/models'; - -import { Duplicate } from 'src/app/shared/models/duplicates'; +import { AsyncStateWithTotalCount, NodeModel } from '@osf/shared/models'; export interface DuplicatesStateModel { - duplicates: AsyncStateWithTotalCount; + duplicates: AsyncStateWithTotalCount; } export const DUPLICATES_DEFAULTS: DuplicatesStateModel = { diff --git a/src/app/shared/stores/linked-projects/index.ts b/src/app/shared/stores/linked-projects/index.ts new file mode 100644 index 000000000..b2d083666 --- /dev/null +++ b/src/app/shared/stores/linked-projects/index.ts @@ -0,0 +1,4 @@ +export * from './linked-projects.actions'; +export * from './linked-projects.model'; +export * from './linked-projects.selectors'; +export * from './linked-projects.state'; diff --git a/src/app/shared/stores/linked-projects/linked-projects.actions.ts b/src/app/shared/stores/linked-projects/linked-projects.actions.ts new file mode 100644 index 000000000..aa5cb70ce --- /dev/null +++ b/src/app/shared/stores/linked-projects/linked-projects.actions.ts @@ -0,0 +1,14 @@ +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 ClearLinkedProjects { + static readonly type = '[Forks] Clear Linked Projects'; +} diff --git a/src/app/shared/stores/linked-projects/linked-projects.model.ts b/src/app/shared/stores/linked-projects/linked-projects.model.ts new file mode 100644 index 000000000..a4f1e28d0 --- /dev/null +++ b/src/app/shared/stores/linked-projects/linked-projects.model.ts @@ -0,0 +1,15 @@ +import { AsyncStateWithTotalCount, NodeModel } from '@osf/shared/models'; + +export interface LinkedProjectsStateModel { + linkedProjects: AsyncStateWithTotalCount; +} + +export const LINKED_PROJECTS_DEFAULTS: LinkedProjectsStateModel = { + linkedProjects: { + data: [], + isLoading: false, + isSubmitting: false, + error: null, + totalCount: 0, + }, +}; diff --git a/src/app/shared/stores/linked-projects/linked-projects.selectors.ts b/src/app/shared/stores/linked-projects/linked-projects.selectors.ts new file mode 100644 index 000000000..d4e98128d --- /dev/null +++ b/src/app/shared/stores/linked-projects/linked-projects.selectors.ts @@ -0,0 +1,21 @@ +import { Selector } from '@ngxs/store'; + +import { LinkedProjectsStateModel } from './linked-projects.model'; +import { LinkedProjectsState } from './linked-projects.state'; + +export class LinkedProjectsSelectors { + @Selector([LinkedProjectsState]) + static getLinkedProjects(state: LinkedProjectsStateModel) { + return state.linkedProjects.data; + } + + @Selector([LinkedProjectsState]) + static getLinkedProjectsLoading(state: LinkedProjectsStateModel) { + return state.linkedProjects.isLoading; + } + + @Selector([LinkedProjectsState]) + static getLinkedProjectsTotalCount(state: LinkedProjectsStateModel) { + return state.linkedProjects.totalCount; + } +} diff --git a/src/app/shared/stores/linked-projects/linked-projects.state.ts b/src/app/shared/stores/linked-projects/linked-projects.state.ts new file mode 100644 index 000000000..42dcd4c88 --- /dev/null +++ b/src/app/shared/stores/linked-projects/linked-projects.state.ts @@ -0,0 +1,52 @@ +import { Action, State, StateContext } from '@ngxs/store'; + +import { catchError, tap } from 'rxjs'; + +import { inject, Injectable } from '@angular/core'; + +import { handleSectionError } from '@shared/helpers'; +import { LinkedProjectsService } from '@shared/services/linked-projects.service'; + +import { ClearLinkedProjects, GetAllLinkedProjects } from './linked-projects.actions'; +import { LINKED_PROJECTS_DEFAULTS, LinkedProjectsStateModel } from './linked-projects.model'; + +@State({ + name: 'linkedProjects', + defaults: LINKED_PROJECTS_DEFAULTS, +}) +@Injectable() +export class LinkedProjectsState { + linkedProjectsService = inject(LinkedProjectsService); + + @Action(GetAllLinkedProjects) + getLinkedProjects(ctx: StateContext, action: GetAllLinkedProjects) { + const state = ctx.getState(); + ctx.patchState({ + linkedProjects: { + ...state.linkedProjects, + isLoading: true, + }, + }); + + return this.linkedProjectsService + .fetchAllLinkedProjects(action.resourceId, action.resourceType, action.page, action.pageSize) + .pipe( + tap((response) => { + ctx.patchState({ + linkedProjects: { + data: response.data, + isLoading: false, + error: null, + totalCount: response.totalCount, + }, + }); + }), + catchError((error) => handleSectionError(ctx, 'linkedProjects', error)) + ); + } + + @Action(ClearLinkedProjects) + clearLinkedProjects(ctx: StateContext) { + ctx.patchState(LINKED_PROJECTS_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",