From af99ec8634b39da5906baa9d0f1983ae57a0bcdc Mon Sep 17 00:00:00 2001 From: Oleh Paduchak Date: Wed, 15 Oct 2025 13:33:27 +0300 Subject: [PATCH 1/4] fix(analytics): implemented linked projects --- .../core/constants/ngxs-states.constant.ts | 2 + .../analytics/analytics.component.html | 5 +- .../features/analytics/analytics.component.ts | 3 + .../view-duplicates.component.html | 6 +- .../view-duplicates.component.ts | 6 +- .../view-linked-projects.component.html | 64 ++++++ .../view-linked-projects.component.scss | 11 + .../view-linked-projects.component.spec.ts | 114 ++++++++++ .../view-linked-projects.component.ts | 204 ++++++++++++++++++ src/app/features/project/project.routes.ts | 9 + .../settings/mappers/settings.mapper.ts | 4 +- .../models/project-details-json-api.model.ts | 8 +- src/app/shared/mappers/duplicates.mapper.ts | 24 --- src/app/shared/mappers/index.ts | 1 - .../shared/mappers/nodes/base-node.mapper.ts | 27 ++- .../duplicates/duplicate-json-api.model.ts | 19 -- .../models/duplicates/duplicate.model.ts | 18 -- src/app/shared/models/duplicates/index.ts | 2 - .../nodes/base-node-data-json-api.model.ts | 3 + .../shared/models/nodes/base-node.model.ts | 11 + src/app/shared/services/duplicates.service.ts | 12 +- .../services/linked-projects.service.ts | 48 +++++ .../stores/duplicates/duplicates.model.ts | 6 +- .../shared/stores/linked-projects/index.ts | 4 + .../linked-projects.actions.ts | 14 ++ .../linked-projects/linked-projects.model.ts | 15 ++ .../linked-projects.selectors.ts | 21 ++ .../linked-projects/linked-projects.state.ts | 52 +++++ src/assets/i18n/en.json | 5 + 29 files changed, 632 insertions(+), 86 deletions(-) create mode 100644 src/app/features/analytics/components/view-linked-projects/view-linked-projects.component.html create mode 100644 src/app/features/analytics/components/view-linked-projects/view-linked-projects.component.scss create mode 100644 src/app/features/analytics/components/view-linked-projects/view-linked-projects.component.spec.ts create mode 100644 src/app/features/analytics/components/view-linked-projects/view-linked-projects.component.ts delete mode 100644 src/app/shared/mappers/duplicates.mapper.ts delete mode 100644 src/app/shared/models/duplicates/duplicate-json-api.model.ts delete mode 100644 src/app/shared/models/duplicates/duplicate.model.ts delete mode 100644 src/app/shared/models/duplicates/index.ts create mode 100644 src/app/shared/services/linked-projects.service.ts create mode 100644 src/app/shared/stores/linked-projects/index.ts create mode 100644 src/app/shared/stores/linked-projects/linked-projects.actions.ts create mode 100644 src/app/shared/stores/linked-projects/linked-projects.model.ts create mode 100644 src/app/shared/stores/linked-projects/linked-projects.selectors.ts create mode 100644 src/app/shared/stores/linked-projects/linked-projects.state.ts 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,9 @@

{{ '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..42534f135 --- /dev/null +++ b/src/app/features/analytics/components/view-linked-projects/view-linked-projects.component.html @@ -0,0 +1,64 @@ + + +
+ @if (!isDuplicatesLoading() && currentResource()) { + @if (!duplicates().length) { +

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

+ } @else { +

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

+ + @for (duplicate of duplicates(); 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 (totalDuplicates() > 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..565a17ea9 --- /dev/null +++ b/src/app/features/analytics/components/view-linked-projects/view-linked-projects.component.spec.ts @@ -0,0 +1,114 @@ +import { MockComponents, MockProvider } from 'ng-mocks'; + +import { PaginatorState } from 'primeng/paginator'; + +import { of } from 'rxjs'; + +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 open ForkDialog with width 450px when small and not refresh on failure', () => { + (component as any).actions = { ...component.actions, getDuplicates: jest.fn() }; + + const openSpy = jest + .spyOn(mockCustomDialogService, 'open') + .mockReturnValue({ onClose: of({ success: false }) } as any); + + component.handleForkResource(); + + expect(openSpy).toHaveBeenCalledWith(expect.any(Function), expect.objectContaining({ width: '450px' })); + expect((component as any).actions.getDuplicates).not.toHaveBeenCalled(); + }); + + 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..437b50ac6 --- /dev/null +++ b/src/app/features/analytics/components/view-linked-projects/view-linked-projects.component.ts @@ -0,0 +1,204 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { TranslatePipe } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { Menu } from 'primeng/menu'; +import { PaginatorState } from 'primeng/paginator'; + +import { map, of } from 'rxjs'; + +import { 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, Router, RouterLink } from '@angular/router'; + +import { UserSelectors } from '@core/store/user'; +import { DeleteComponentDialogComponent } from '@osf/features/project/overview/components'; +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, UserPermissions } from '@osf/shared/enums'; +import { CustomDialogService, LoaderService } from '@osf/shared/services'; +import { GetResourceWithChildren } from '@osf/shared/stores'; +import { ClearLinkedProjects, GetAllLinkedProjects, LinkedProjectsSelectors } from '@shared/stores/linked-projects'; + +@Component({ + selector: 'osf-view-linked-nodes', + imports: [ + SubHeaderComponent, + TranslatePipe, + Button, + Menu, + 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 customDialogService = inject(CustomDialogService); + private loaderService = inject(LoaderService); + private route = inject(ActivatedRoute); + private router = inject(Router); + private destroyRef = inject(DestroyRef); + private project = select(ProjectOverviewSelectors.getProject); + private registration = select(RegistryOverviewSelectors.getRegistry); + private isProjectAnonymous = select(ProjectOverviewSelectors.isProjectAnonymous); + private isRegistryAnonymous = select(RegistryOverviewSelectors.isRegistryAnonymous); + + duplicates = select(LinkedProjectsSelectors.getLinkedProjects); + isDuplicatesLoading = select(LinkedProjectsSelectors.getLinkedProjectsLoading); + totalDuplicates = select(LinkedProjectsSelectors.getLinkedProjectsTotalCount); + isAuthenticated = select(UserSelectors.isAuthenticated); + + readonly pageSize = 10; + readonly UserPermissions = UserPermissions; + + currentPage = signal('1'); + firstIndex = computed(() => (parseInt(this.currentPage()) - 1) * this.pageSize); + + readonly forkActionItems = (resourceId: string) => [ + { + label: 'project.overview.actions.manageContributors', + action: 'manageContributors', + resourceId, + }, + { + label: 'project.overview.actions.settings', + action: 'settings', + resourceId, + }, + { + label: 'project.overview.actions.delete', + action: 'delete', + resourceId, + }, + ]; + + readonly resourceId = toSignal(this.route.parent?.params.pipe(map((params) => params['id'])) ?? of(undefined)); + readonly resourceType: Signal = toSignal( + this.route.data.pipe(map((params) => params['resourceType'])) ?? of(undefined) + ); + readonly currentResource = computed(() => { + const resourceType = this.resourceType(); + + if (resourceType) { + if (resourceType === ResourceType.Project) return this.project(); + + if (resourceType === ResourceType.Registration) return this.registration(); + } + + return null; + }); + + actions = createDispatchMap({ + getProject: GetProjectById, + getRegistration: GetRegistryById, + getLinkedProjects: GetAllLinkedProjects, + clearLinkedProjects: ClearLinkedProjects, + clearProject: ClearProjectOverview, + clearRegistration: ClearRegistryOverview, + getComponentsTree: GetResourceWithChildren, + }); + + 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(); + }); + } + + private handleDeleteFork(id: string): void { + const resourceType = this.resourceType(); + if (!resourceType) return; + + this.loaderService.show(); + + this.actions.getComponentsTree(id, id, resourceType).subscribe({ + next: () => { + this.loaderService.hide(); + this.customDialogService + .open(DeleteComponentDialogComponent, { + header: 'project.overview.dialog.deleteComponent.header', + width: '650px', + data: { + componentId: id, + resourceType: resourceType, + isForksContext: true, + currentPage: parseInt(this.currentPage()), + pageSize: this.pageSize, + }, + }) + .onClose.subscribe((result) => { + if (result?.success) { + const resource = this.currentResource(); + if (resource) { + this.actions.getLinkedProjects(resource.id, resource.type, parseInt(this.currentPage()), this.pageSize); + } + } + }); + }, + error: () => { + this.loaderService.hide(); + }, + }); + } +} 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..2981dd895 100644 --- a/src/app/features/project/settings/mappers/settings.mapper.ts +++ b/src/app/features/project/settings/mappers/settings.mapper.ts @@ -33,8 +33,8 @@ export class SettingsMapper { 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/project-details-json-api.model.ts b/src/app/features/project/settings/models/project-details-json-api.model.ts index bae19f9f8..d6d196202 100644 --- 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 @@ -1,5 +1,6 @@ import { BaseNodeDataJsonApi, + ContributorDataJsonApi, InstitutionsJsonApiResponse, RegionDataJsonApi, ResponseDataJsonApi, @@ -12,8 +13,11 @@ export interface NodeDataJsonApi extends BaseNodeDataJsonApi { } interface NodeEmbedsJsonApi { - region: { + region?: { data: RegionDataJsonApi; }; - affiliated_institutions: InstitutionsJsonApiResponse; + affiliated_institutions?: InstitutionsJsonApiResponse; + bibliographic_contributors?: { + data: ContributorDataJsonApi[]; + }; } 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..0a7b2a4f3 100644 --- a/src/app/shared/mappers/nodes/base-node.mapper.ts +++ b/src/app/shared/mappers/nodes/base-node.mapper.ts @@ -1,10 +1,27 @@ -import { BaseNodeDataJsonApi, BaseNodeModel, NodeShortInfoModel } from '@osf/shared/models'; +import { NodeDataJsonApi } from '@osf/features/project/settings/models'; +import { + BaseNodeDataJsonApi, + BaseNodeModel, + NodeModel, + NodeShortInfoModel, + NodesWithTotal, + 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: NodeDataJsonApi[]): NodeModel[] { + return data.map((item) => this.getNodeWithEmbedsData(item)); + } + + static getNodesWithEmbedsAndTotalData(response: ResponseJsonApi): NodesWithTotal { + return { data: BaseNodeMapper.getNodesWithEmbedsData(response.data), totalCount: response.meta.total }; + } + static getNodesWithChildren(data: BaseNodeDataJsonApi[], parentId: string): NodeShortInfoModel[] { return this.getAllDescendants(data, parentId).map((item) => ({ id: item.id, @@ -43,6 +60,14 @@ export class BaseNodeMapper { }; } + static getNodeWithEmbedsData(data: NodeDataJsonApi): NodeModel { + const baseNode = BaseNodeMapper.getNodeData(data); + return { + ...baseNode, + bibliographic_contributors: 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..5f8820581 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 { MetaJsonApi } 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; + meta: MetaJsonApi; } diff --git a/src/app/shared/models/nodes/base-node.model.ts b/src/app/shared/models/nodes/base-node.model.ts index 4d80408ae..ccb226d1c 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,12 @@ export interface BaseNodeModel { rootParentId?: string; type: string; } + +export interface NodeModel extends BaseNodeModel { + bibliographic_contributors?: ContributorModel[]; +} + +export interface NodesWithTotal { + data: NodeModel[]; + totalCount: number; +} diff --git a/src/app/shared/services/duplicates.service.ts b/src/app/shared/services/duplicates.service.ts index ff89ed899..6178a67f8 100644 --- a/src/app/shared/services/duplicates.service.ts +++ b/src/app/shared/services/duplicates.service.ts @@ -4,10 +4,10 @@ import { map } from 'rxjs/operators'; import { inject, Injectable } from '@angular/core'; import { ENVIRONMENT } from '@core/provider/environment.provider'; +import { NodeDataJsonApi } from '@osf/features/project/settings/models'; -import { DuplicatesMapper } from '../mappers'; -import { ResponseJsonApi } from '../models'; -import { DuplicateJsonApi, DuplicatesWithTotal } from '../models/duplicates'; +import { BaseNodeMapper } from '../mappers'; +import { NodesWithTotal, ResponseJsonApi } from '../models'; import { JsonApiService } from './json-api.service'; @@ -27,7 +27,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 +42,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..3983f30f9 --- /dev/null +++ b/src/app/shared/services/linked-projects.service.ts @@ -0,0 +1,48 @@ +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { inject, Injectable } from '@angular/core'; + +import { ENVIRONMENT } from '@core/provider/environment.provider'; +import { NodeDataJsonApi } from '@osf/features/project/settings/models'; + +import { BaseNodeMapper } from '../mappers'; +import { NodesWithTotal, 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>(`${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", From 26bfc0f679a2c8c7da05fb52867aac49876be348 Mon Sep 17 00:00:00 2001 From: Oleh Paduchak Date: Wed, 15 Oct 2025 17:36:49 +0300 Subject: [PATCH 2/4] chore(analytics): clean up models and components --- .../view-duplicates.component.html | 4 +- .../view-linked-projects.component.html | 4 +- .../view-linked-projects.component.ts | 66 +------------------ .../shared/mappers/nodes/base-node.mapper.ts | 12 ++-- .../nodes/base-node-data-json-api.model.ts | 3 - .../shared/models/nodes/base-node.model.ts | 7 +- src/app/shared/services/duplicates.service.ts | 4 +- .../services/linked-projects.service.ts | 4 +- 8 files changed, 17 insertions(+), 87 deletions(-) diff --git a/src/app/features/analytics/components/view-duplicates/view-duplicates.component.html b/src/app/features/analytics/components/view-duplicates/view-duplicates.component.html index 31397a7ed..f301739c9 100644 --- a/src/app/features/analytics/components/view-duplicates/view-duplicates.component.html +++ b/src/app/features/analytics/components/view-duplicates/view-duplicates.component.html @@ -62,9 +62,7 @@

{{ 'common.labels.contributors' | translate }}: - +
@if (duplicate.description) { 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 index 42534f135..57855c504 100644 --- 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 @@ -30,9 +30,7 @@

{{ 'common.labels.contributors' | translate }}: - +
@if (duplicate.description) { 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 index 437b50ac6..e88890c68 100644 --- 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 @@ -3,7 +3,6 @@ import { createDispatchMap, select } from '@ngxs/store'; import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; -import { Menu } from 'primeng/menu'; import { PaginatorState } from 'primeng/paginator'; import { map, of } from 'rxjs'; @@ -20,10 +19,9 @@ import { signal, } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; -import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { ActivatedRoute, RouterLink } from '@angular/router'; import { UserSelectors } from '@core/store/user'; -import { DeleteComponentDialogComponent } from '@osf/features/project/overview/components'; import { ClearProjectOverview, GetProjectById, ProjectOverviewSelectors } from '@osf/features/project/overview/store'; import { ClearRegistryOverview, @@ -39,7 +37,6 @@ import { TruncatedTextComponent, } from '@osf/shared/components'; import { ResourceType, UserPermissions } from '@osf/shared/enums'; -import { CustomDialogService, LoaderService } from '@osf/shared/services'; import { GetResourceWithChildren } from '@osf/shared/stores'; import { ClearLinkedProjects, GetAllLinkedProjects, LinkedProjectsSelectors } from '@shared/stores/linked-projects'; @@ -49,7 +46,6 @@ import { ClearLinkedProjects, GetAllLinkedProjects, LinkedProjectsSelectors } fr SubHeaderComponent, TranslatePipe, Button, - Menu, TruncatedTextComponent, DatePipe, LoadingSpinnerComponent, @@ -64,15 +60,10 @@ import { ClearLinkedProjects, GetAllLinkedProjects, LinkedProjectsSelectors } fr changeDetection: ChangeDetectionStrategy.OnPush, }) export class ViewLinkedProjectsComponent { - private customDialogService = inject(CustomDialogService); - private loaderService = inject(LoaderService); private route = inject(ActivatedRoute); - private router = inject(Router); private destroyRef = inject(DestroyRef); private project = select(ProjectOverviewSelectors.getProject); private registration = select(RegistryOverviewSelectors.getRegistry); - private isProjectAnonymous = select(ProjectOverviewSelectors.isProjectAnonymous); - private isRegistryAnonymous = select(RegistryOverviewSelectors.isRegistryAnonymous); duplicates = select(LinkedProjectsSelectors.getLinkedProjects); isDuplicatesLoading = select(LinkedProjectsSelectors.getLinkedProjectsLoading); @@ -85,28 +76,11 @@ export class ViewLinkedProjectsComponent { currentPage = signal('1'); firstIndex = computed(() => (parseInt(this.currentPage()) - 1) * this.pageSize); - readonly forkActionItems = (resourceId: string) => [ - { - label: 'project.overview.actions.manageContributors', - action: 'manageContributors', - resourceId, - }, - { - label: 'project.overview.actions.settings', - action: 'settings', - resourceId, - }, - { - label: 'project.overview.actions.delete', - action: 'delete', - resourceId, - }, - ]; - readonly resourceId = toSignal(this.route.parent?.params.pipe(map((params) => params['id'])) ?? of(undefined)); readonly resourceType: Signal = toSignal( this.route.data.pipe(map((params) => params['resourceType'])) ?? of(undefined) ); + readonly currentResource = computed(() => { const resourceType = this.resourceType(); @@ -165,40 +139,4 @@ export class ViewLinkedProjectsComponent { this.actions.clearRegistration(); }); } - - private handleDeleteFork(id: string): void { - const resourceType = this.resourceType(); - if (!resourceType) return; - - this.loaderService.show(); - - this.actions.getComponentsTree(id, id, resourceType).subscribe({ - next: () => { - this.loaderService.hide(); - this.customDialogService - .open(DeleteComponentDialogComponent, { - header: 'project.overview.dialog.deleteComponent.header', - width: '650px', - data: { - componentId: id, - resourceType: resourceType, - isForksContext: true, - currentPage: parseInt(this.currentPage()), - pageSize: this.pageSize, - }, - }) - .onClose.subscribe((result) => { - if (result?.success) { - const resource = this.currentResource(); - if (resource) { - this.actions.getLinkedProjects(resource.id, resource.type, parseInt(this.currentPage()), this.pageSize); - } - } - }); - }, - error: () => { - this.loaderService.hide(); - }, - }); - } } diff --git a/src/app/shared/mappers/nodes/base-node.mapper.ts b/src/app/shared/mappers/nodes/base-node.mapper.ts index 0a7b2a4f3..2cf73c75d 100644 --- a/src/app/shared/mappers/nodes/base-node.mapper.ts +++ b/src/app/shared/mappers/nodes/base-node.mapper.ts @@ -4,7 +4,7 @@ import { BaseNodeModel, NodeModel, NodeShortInfoModel, - NodesWithTotal, + PaginatedData, ResponseJsonApi, } from '@osf/shared/models'; import { ContributorsMapper } from '@shared/mappers'; @@ -18,8 +18,12 @@ export class BaseNodeMapper { return data.map((item) => this.getNodeWithEmbedsData(item)); } - static getNodesWithEmbedsAndTotalData(response: ResponseJsonApi): NodesWithTotal { - return { data: BaseNodeMapper.getNodesWithEmbedsData(response.data), totalCount: response.meta.total }; + 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[] { @@ -64,7 +68,7 @@ export class BaseNodeMapper { const baseNode = BaseNodeMapper.getNodeData(data); return { ...baseNode, - bibliographic_contributors: ContributorsMapper.getContributors(data.embeds.bibliographic_contributors?.data), + bibliographicContributors: ContributorsMapper.getContributors(data.embeds.bibliographic_contributors?.data), }; } 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 5f8820581..d02117230 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,5 +1,3 @@ -import { MetaJsonApi } 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'; @@ -10,5 +8,4 @@ export interface BaseNodeDataJsonApi { attributes: BaseNodeAttributesJsonApi; links: BaseNodeLinksJsonApi; relationships: BaseNodeRelationships; - meta: MetaJsonApi; } diff --git a/src/app/shared/models/nodes/base-node.model.ts b/src/app/shared/models/nodes/base-node.model.ts index ccb226d1c..4187db43e 100644 --- a/src/app/shared/models/nodes/base-node.model.ts +++ b/src/app/shared/models/nodes/base-node.model.ts @@ -26,10 +26,5 @@ export interface BaseNodeModel { } export interface NodeModel extends BaseNodeModel { - bibliographic_contributors?: ContributorModel[]; -} - -export interface NodesWithTotal { - data: NodeModel[]; - totalCount: number; + bibliographicContributors?: ContributorModel[]; } diff --git a/src/app/shared/services/duplicates.service.ts b/src/app/shared/services/duplicates.service.ts index 6178a67f8..323e99378 100644 --- a/src/app/shared/services/duplicates.service.ts +++ b/src/app/shared/services/duplicates.service.ts @@ -7,7 +7,7 @@ import { ENVIRONMENT } from '@core/provider/environment.provider'; import { NodeDataJsonApi } from '@osf/features/project/settings/models'; import { BaseNodeMapper } from '../mappers'; -import { NodesWithTotal, ResponseJsonApi } from '../models'; +import { NodeModel, PaginatedData, ResponseJsonApi } from '../models'; import { JsonApiService } from './json-api.service'; @@ -27,7 +27,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', diff --git a/src/app/shared/services/linked-projects.service.ts b/src/app/shared/services/linked-projects.service.ts index 3983f30f9..25c522091 100644 --- a/src/app/shared/services/linked-projects.service.ts +++ b/src/app/shared/services/linked-projects.service.ts @@ -7,7 +7,7 @@ import { ENVIRONMENT } from '@core/provider/environment.provider'; import { NodeDataJsonApi } from '@osf/features/project/settings/models'; import { BaseNodeMapper } from '../mappers'; -import { NodesWithTotal, ResponseJsonApi } from '../models'; +import { NodeModel, PaginatedData, ResponseJsonApi } from '../models'; import { JsonApiService } from './json-api.service'; @@ -27,7 +27,7 @@ export class LinkedProjectsService { resourceType: string, pageNumber?: number, pageSize?: number - ): Observable { + ): Observable> { const params: Record = { embed: 'bibliographic_contributors', 'fields[users]': 'family_name,full_name,given_name,middle_name', From 1cc3274d7e2bc436e08fa183b9c8fdfa25491b7b Mon Sep 17 00:00:00 2001 From: Oleh Paduchak Date: Wed, 15 Oct 2025 17:55:23 +0300 Subject: [PATCH 3/4] chore(analytics): consolidated node models --- .../settings/mappers/settings.mapper.ts | 4 +-- .../features/project/settings/models/index.ts | 1 - .../models/project-details-json-api.model.ts | 23 ---------------- .../settings/services/settings.service.ts | 6 ++--- .../shared/mappers/nodes/base-node.mapper.ts | 9 +++---- .../nodes/base-node-data-json-api.model.ts | 3 +++ .../nodes/base-node-embeds-json-api.model.ts | 26 +++++++++++++------ src/app/shared/services/duplicates.service.ts | 5 ++-- .../services/linked-projects.service.ts | 7 ++--- 9 files changed, 36 insertions(+), 48 deletions(-) delete mode 100644 src/app/features/project/settings/models/project-details-json-api.model.ts diff --git a/src/app/features/project/settings/mappers/settings.mapper.ts b/src/app/features/project/settings/mappers/settings.mapper.ts index 2981dd895..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,7 +27,7 @@ export class SettingsMapper { } as ProjectSettingsModel; } - static fromNodeResponse(data: NodeDataJsonApi): NodeDetailsModel { + static fromNodeResponse(data: BaseNodeDataJsonApi): NodeDetailsModel { return { id: data.id, title: data.attributes.title, 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 d6d196202..000000000 --- a/src/app/features/project/settings/models/project-details-json-api.model.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { - BaseNodeDataJsonApi, - ContributorDataJsonApi, - 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; - bibliographic_contributors?: { - data: ContributorDataJsonApi[]; - }; -} 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/nodes/base-node.mapper.ts b/src/app/shared/mappers/nodes/base-node.mapper.ts index 2cf73c75d..75f0a79b7 100644 --- a/src/app/shared/mappers/nodes/base-node.mapper.ts +++ b/src/app/shared/mappers/nodes/base-node.mapper.ts @@ -1,4 +1,3 @@ -import { NodeDataJsonApi } from '@osf/features/project/settings/models'; import { BaseNodeDataJsonApi, BaseNodeModel, @@ -14,11 +13,11 @@ export class BaseNodeMapper { return data.map((item) => this.getNodeData(item)); } - static getNodesWithEmbedsData(data: NodeDataJsonApi[]): NodeModel[] { + static getNodesWithEmbedsData(data: BaseNodeDataJsonApi[]): NodeModel[] { return data.map((item) => this.getNodeWithEmbedsData(item)); } - static getNodesWithEmbedsAndTotalData(response: ResponseJsonApi): PaginatedData { + static getNodesWithEmbedsAndTotalData(response: ResponseJsonApi): PaginatedData { return { data: BaseNodeMapper.getNodesWithEmbedsData(response.data), totalCount: response.meta.total, @@ -64,11 +63,11 @@ export class BaseNodeMapper { }; } - static getNodeWithEmbedsData(data: NodeDataJsonApi): NodeModel { + static getNodeWithEmbedsData(data: BaseNodeDataJsonApi): NodeModel { const baseNode = BaseNodeMapper.getNodeData(data); return { ...baseNode, - bibliographicContributors: ContributorsMapper.getContributors(data.embeds.bibliographic_contributors?.data), + bibliographicContributors: ContributorsMapper.getContributors(data.embeds?.bibliographic_contributors?.data), }; } 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/services/duplicates.service.ts b/src/app/shared/services/duplicates.service.ts index 323e99378..df29dc98e 100644 --- a/src/app/shared/services/duplicates.service.ts +++ b/src/app/shared/services/duplicates.service.ts @@ -4,10 +4,9 @@ import { map } from 'rxjs/operators'; import { inject, Injectable } from '@angular/core'; import { ENVIRONMENT } from '@core/provider/environment.provider'; -import { NodeDataJsonApi } from '@osf/features/project/settings/models'; import { BaseNodeMapper } from '../mappers'; -import { NodeModel, PaginatedData, ResponseJsonApi } from '../models'; +import { BaseNodeDataJsonApi, NodeModel, PaginatedData, ResponseJsonApi } from '../models'; import { JsonApiService } from './json-api.service'; @@ -42,7 +41,7 @@ export class DuplicatesService { } return this.jsonApiService - .get>(`${this.apiUrl}/${resourceType}/${resourceId}/forks/`, params) + .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 index 25c522091..d7ecf265e 100644 --- a/src/app/shared/services/linked-projects.service.ts +++ b/src/app/shared/services/linked-projects.service.ts @@ -4,10 +4,9 @@ import { map } from 'rxjs/operators'; import { inject, Injectable } from '@angular/core'; import { ENVIRONMENT } from '@core/provider/environment.provider'; -import { NodeDataJsonApi } from '@osf/features/project/settings/models'; import { BaseNodeMapper } from '../mappers'; -import { NodeModel, PaginatedData, ResponseJsonApi } from '../models'; +import { BaseNodeDataJsonApi, NodeModel, PaginatedData, ResponseJsonApi } from '../models'; import { JsonApiService } from './json-api.service'; @@ -42,7 +41,9 @@ export class LinkedProjectsService { } return this.jsonApiService - .get>(`${this.apiUrl}/${resourceType}/${resourceId}/linked_by_nodes/`, params) + .get< + ResponseJsonApi + >(`${this.apiUrl}/${resourceType}/${resourceId}/linked_by_nodes/`, params) .pipe(map((res) => BaseNodeMapper.getNodesWithEmbedsAndTotalData(res))); } } From 72d0359c79ddaf3aec45aff69b7166f380a28335 Mon Sep 17 00:00:00 2001 From: Oleh Paduchak Date: Wed, 15 Oct 2025 18:19:23 +0300 Subject: [PATCH 4/4] fix(linked-projects): removed unused stuff --- .../view-linked-projects.component.html | 10 +++++----- .../view-linked-projects.component.spec.ts | 15 --------------- .../view-linked-projects.component.ts | 13 ++++--------- 3 files changed, 9 insertions(+), 29 deletions(-) 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 index 57855c504..973b611b3 100644 --- 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 @@ -1,13 +1,13 @@
- @if (!isDuplicatesLoading() && currentResource()) { - @if (!duplicates().length) { + @if (!isLoading() && currentResource()) { + @if (!linkedProjects().length) {

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

} @else {

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

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

@@ -47,9 +47,9 @@

} - @if (totalDuplicates() > pageSize) { + @if (totalLinkedProjects() > pageSize) { { expect(component).toBeTruthy(); }); - it('should open ForkDialog with width 450px when small and not refresh on failure', () => { - (component as any).actions = { ...component.actions, getDuplicates: jest.fn() }; - - const openSpy = jest - .spyOn(mockCustomDialogService, 'open') - .mockReturnValue({ onClose: of({ success: false }) } as any); - - component.handleForkResource(); - - expect(openSpy).toHaveBeenCalledWith(expect.any(Function), expect.objectContaining({ width: '450px' })); - expect((component as any).actions.getDuplicates).not.toHaveBeenCalled(); - }); - it('should update currentPage when page is defined', () => { const event: PaginatorState = { page: 1 } as PaginatorState; component.onPageChange(event); 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 index e88890c68..62c40b840 100644 --- 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 @@ -21,7 +21,6 @@ import { import { toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute, RouterLink } from '@angular/router'; -import { UserSelectors } from '@core/store/user'; import { ClearProjectOverview, GetProjectById, ProjectOverviewSelectors } from '@osf/features/project/overview/store'; import { ClearRegistryOverview, @@ -36,8 +35,7 @@ import { SubHeaderComponent, TruncatedTextComponent, } from '@osf/shared/components'; -import { ResourceType, UserPermissions } from '@osf/shared/enums'; -import { GetResourceWithChildren } from '@osf/shared/stores'; +import { ResourceType } from '@osf/shared/enums'; import { ClearLinkedProjects, GetAllLinkedProjects, LinkedProjectsSelectors } from '@shared/stores/linked-projects'; @Component({ @@ -65,13 +63,11 @@ export class ViewLinkedProjectsComponent { private project = select(ProjectOverviewSelectors.getProject); private registration = select(RegistryOverviewSelectors.getRegistry); - duplicates = select(LinkedProjectsSelectors.getLinkedProjects); - isDuplicatesLoading = select(LinkedProjectsSelectors.getLinkedProjectsLoading); - totalDuplicates = select(LinkedProjectsSelectors.getLinkedProjectsTotalCount); - isAuthenticated = select(UserSelectors.isAuthenticated); + linkedProjects = select(LinkedProjectsSelectors.getLinkedProjects); + isLoading = select(LinkedProjectsSelectors.getLinkedProjectsLoading); + totalLinkedProjects = select(LinkedProjectsSelectors.getLinkedProjectsTotalCount); readonly pageSize = 10; - readonly UserPermissions = UserPermissions; currentPage = signal('1'); firstIndex = computed(() => (parseInt(this.currentPage()) - 1) * this.pageSize); @@ -100,7 +96,6 @@ export class ViewLinkedProjectsComponent { clearLinkedProjects: ClearLinkedProjects, clearProject: ClearProjectOverview, clearRegistration: ClearRegistryOverview, - getComponentsTree: GetResourceWithChildren, }); constructor() {