diff --git a/jest.config.js b/jest.config.js index 98fccd89f..79f41a3a4 100644 --- a/jest.config.js +++ b/jest.config.js @@ -69,10 +69,8 @@ module.exports = { testPathIgnorePatterns: [ '/src/environments', '/src/app/app.config.ts', - '/src/app/app.routes.ts', '/src/app/features/files/pages/file-detail', '/src/app/features/project/addons/', - '/src/app/features/project/overview/', '/src/app/features/project/registrations', '/src/app/features/project/wiki', '/src/app/features/registry/components', diff --git a/src/app/core/constants/ngxs-states.constant.ts b/src/app/core/constants/ngxs-states.constant.ts index 9cddfb352..47f0dd1b2 100644 --- a/src/app/core/constants/ngxs-states.constant.ts +++ b/src/app/core/constants/ngxs-states.constant.ts @@ -4,7 +4,6 @@ import { UserEmailsState } from '@core/store/user-emails'; import { InstitutionsAdminState } from '@osf/features/admin-institutions/store'; import { FilesState } from '@osf/features/files/store'; import { MetadataState } from '@osf/features/metadata/store'; -import { ProjectOverviewState } from '@osf/features/project/overview/store'; import { AddonsState } from '@osf/shared/stores/addons'; import { BannersState } from '@osf/shared/stores/banners'; import { ContributorsState } from '@osf/shared/stores/contributors'; @@ -26,7 +25,6 @@ export const STATES = [ InstitutionsState, InstitutionsAdminState, InstitutionsSearchState, - ProjectOverviewState, WikiState, LicensesState, RegionsState, diff --git a/src/app/core/services/help-scout.service.spec.ts b/src/app/core/services/help-scout.service.spec.ts index 454a98fff..c4f831ac6 100644 --- a/src/app/core/services/help-scout.service.spec.ts +++ b/src/app/core/services/help-scout.service.spec.ts @@ -14,7 +14,7 @@ describe('HelpScoutService', () => { if (selector === UserSelectors.isAuthenticated) { return authSignal; } - return signal(null); // fallback + return signal(null); }), }; let service: HelpScoutService; diff --git a/src/app/features/analytics/analytics.component.spec.ts b/src/app/features/analytics/analytics.component.spec.ts index fba7dc76d..5c9b6f80c 100644 --- a/src/app/features/analytics/analytics.component.spec.ts +++ b/src/app/features/analytics/analytics.component.spec.ts @@ -17,7 +17,6 @@ import { ViewOnlyLinkMessageComponent } from '@osf/shared/components/view-only-l import { IS_WEB } from '@osf/shared/helpers/breakpoints.tokens'; import { MOCK_ANALYTICS_METRICS, MOCK_RELATED_COUNTS } from '@testing/mocks/analytics.mock'; -import { MOCK_RESOURCE_OVERVIEW } from '@testing/mocks/resource.mock'; import { OSFTestingModule } from '@testing/osf.testing.module'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; @@ -29,7 +28,7 @@ describe('Component: Analytics', () => { let routerMock: ReturnType; let activatedRouteMock: ReturnType; - const resourceId = MOCK_RESOURCE_OVERVIEW.id; + const resourceId = 'ex212'; const metrics = { ...MOCK_ANALYTICS_METRICS, id: resourceId }; const relatedCounts = { ...MOCK_RELATED_COUNTS, id: resourceId }; const metricsSelector = AnalyticsSelectors.getMetrics(resourceId); @@ -60,13 +59,6 @@ describe('Component: Analytics', () => { ], providers: [ provideMockStore({ - selectors: [ - { selector: metricsSelector, value: metrics }, - { selector: relatedCountsSelector, value: relatedCounts }, - { selector: AnalyticsSelectors.isMetricsLoading, value: false }, - { selector: AnalyticsSelectors.isRelatedCountsLoading, value: false }, - { selector: AnalyticsSelectors.isMetricsError, value: false }, - ], signals: [ { selector: metricsSelector, value: metrics }, { selector: relatedCountsSelector, value: relatedCounts }, 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 f301739c9..285d21a0e 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 @@ -59,7 +59,7 @@

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

-
+
{{ 'common.labels.contributors' | translate }}: diff --git a/src/app/features/analytics/components/view-duplicates/view-duplicates.component.spec.ts b/src/app/features/analytics/components/view-duplicates/view-duplicates.component.spec.ts index 52421f0a1..f44753252 100644 --- a/src/app/features/analytics/components/view-duplicates/view-duplicates.component.spec.ts +++ b/src/app/features/analytics/components/view-duplicates/view-duplicates.component.spec.ts @@ -100,13 +100,13 @@ describe('Component: View Duplicates', () => { it('should update currentPage when page is defined', () => { const event: PaginatorState = { page: 1 } as PaginatorState; component.onPageChange(event); - expect(component.currentPage()).toBe('2'); + expect(component.currentPage()).toBe(2); }); it('should not update currentPage when page is undefined', () => { - component.currentPage.set('5'); + component.currentPage.set(5); const event: PaginatorState = { page: undefined } as PaginatorState; component.onPageChange(event); - expect(component.currentPage()).toBe('5'); + expect(component.currentPage()).toBe(5); }); }); 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 b114b3c00..bbf9e934c 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 @@ -23,7 +23,8 @@ import { toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { UserSelectors } from '@core/store/user'; -import { DeleteComponentDialogComponent, ForkDialogComponent } from '@osf/features/project/overview/components'; +import { DeleteComponentDialogComponent } from '@osf/features/project/overview/components/delete-component-dialog/delete-component-dialog.component'; +import { ForkDialogComponent } from '@osf/features/project/overview/components/fork-dialog/fork-dialog.component'; import { ClearProjectOverview, GetProjectById, ProjectOverviewSelectors } from '@osf/features/project/overview/store'; import { ClearRegistry, GetRegistryById, RegistrySelectors } from '@osf/features/registry/store/registry'; import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component'; @@ -39,23 +40,21 @@ import { LoaderService } from '@osf/shared/services/loader.service'; import { GetResourceWithChildren } from '@osf/shared/stores/current-resource'; import { ClearDuplicates, DuplicatesSelectors, GetAllDuplicates } from '@osf/shared/stores/duplicates'; import { BaseNodeModel } from '@shared/models/nodes/base-node.model'; -import { ToolbarResource } from '@shared/models/toolbar-resource.model'; @Component({ selector: 'osf-view-duplicates', imports: [ - SubHeaderComponent, - TranslatePipe, Button, Menu, + RouterLink, + IconComponent, + SubHeaderComponent, TruncatedTextComponent, - DatePipe, LoadingSpinnerComponent, - RouterLink, CustomPaginatorComponent, - IconComponent, ContributorsListComponent, DatePipe, + TranslatePipe, ], templateUrl: './view-duplicates.component.html', styleUrl: './view-duplicates.component.scss', @@ -69,8 +68,6 @@ export class ViewDuplicatesComponent { private destroyRef = inject(DestroyRef); private project = select(ProjectOverviewSelectors.getProject); private registration = select(RegistrySelectors.getRegistry); - private isProjectAnonymous = select(ProjectOverviewSelectors.isProjectAnonymous); - private isRegistryAnonymous = select(RegistrySelectors.isRegistryAnonymous); duplicates = select(DuplicatesSelectors.getDuplicates); isDuplicatesLoading = select(DuplicatesSelectors.getDuplicatesLoading); @@ -80,8 +77,8 @@ export class ViewDuplicatesComponent { readonly pageSize = 10; readonly UserPermissions = UserPermissions; - currentPage = signal('1'); - firstIndex = computed(() => (parseInt(this.currentPage()) - 1) * this.pageSize); + currentPage = signal(1); + firstIndex = computed(() => (this.currentPage() - 1) * this.pageSize); readonly forkActionItems = (resourceId: string) => [ { @@ -142,33 +139,13 @@ export class ViewDuplicatesComponent { const resource = this.currentResource(); if (resource) { - this.actions.getDuplicates(resource.id, resource.type, parseInt(this.currentPage()), this.pageSize); + this.actions.getDuplicates(resource.id, resource.type, this.currentPage(), this.pageSize); } }); this.setupCleanup(); } - toolbarResource = computed(() => { - const resource = this.currentResource(); - const resourceType = this.resourceType(); - if (resource && resourceType) { - const isAnonymous = - resourceType === ResourceType.Project ? this.isProjectAnonymous() : this.isRegistryAnonymous(); - - return { - id: resource.id, - isPublic: resource.isPublic, - storage: undefined, - viewOnlyLinksCount: 0, - forksCount: resource.forksCount, - resourceType: resourceType, - isAnonymous, - } as ToolbarResource; - } - return null; - }); - showMoreOptions(duplicate: BaseNodeModel) { return ( duplicate.currentUserPermissions.includes(UserPermissions.Admin) || @@ -191,24 +168,21 @@ export class ViewDuplicatesComponent { } handleForkResource(): void { - const toolbarResource = this.toolbarResource(); + const currentResource = this.currentResource(); - if (toolbarResource) { + if (currentResource) { this.customDialogService .open(ForkDialogComponent, { header: 'project.overview.dialog.fork.headerProject', width: '450px', data: { - resource: toolbarResource, + resourceId: currentResource.id, resourceType: this.resourceType(), }, }) .onClose.subscribe((result) => { if (result?.success) { - const resource = this.currentResource(); - if (resource) { - this.actions.getDuplicates(resource.id, resource.type, parseInt(this.currentPage()), this.pageSize); - } + this.actions.getDuplicates(currentResource.id, currentResource.type, this.currentPage(), this.pageSize); } }); } @@ -216,7 +190,7 @@ export class ViewDuplicatesComponent { onPageChange(event: PaginatorState): void { if (event.page !== undefined) { - const pageNumber = (event.page + 1).toString(); + const pageNumber = event.page + 1; this.currentPage.set(pageNumber); } } @@ -246,7 +220,7 @@ export class ViewDuplicatesComponent { componentId: id, resourceType: resourceType, isForksContext: true, - currentPage: parseInt(this.currentPage()), + currentPage: this.currentPage(), pageSize: this.pageSize, }, }) @@ -254,7 +228,7 @@ export class ViewDuplicatesComponent { if (result?.success) { const resource = this.currentResource(); if (resource) { - this.actions.getDuplicates(resource.id, resource.type, parseInt(this.currentPage()), this.pageSize); + this.actions.getDuplicates(resource.id, resource.type, this.currentPage(), this.pageSize); } } }); 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 973b611b3..f4dab787e 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 @@ -27,7 +27,7 @@

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

-
+
{{ 'common.labels.contributors' | translate }}: 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 index b93c254ea..6ccab2562 100644 --- 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 @@ -74,13 +74,13 @@ describe('Component: View Duplicates', () => { it('should update currentPage when page is defined', () => { const event: PaginatorState = { page: 1 } as PaginatorState; component.onPageChange(event); - expect(component.currentPage()).toBe('2'); + expect(component.currentPage()).toBe(2); }); it('should not update currentPage when page is undefined', () => { - component.currentPage.set('5'); + component.currentPage.set(5); const event: PaginatorState = { page: undefined } as PaginatorState; component.onPageChange(event); - expect(component.currentPage()).toBe('5'); + 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 index 6f7bb79bd..eaa999e98 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 @@ -63,8 +63,8 @@ export class ViewLinkedProjectsComponent { readonly pageSize = 10; - currentPage = signal('1'); - firstIndex = computed(() => (parseInt(this.currentPage()) - 1) * this.pageSize); + currentPage = signal(1); + firstIndex = computed(() => this.currentPage() - 1 * this.pageSize); readonly resourceId = toSignal(this.route.parent?.params.pipe(map((params) => params['id'])) ?? of(undefined)); readonly resourceType: Signal = toSignal( @@ -107,7 +107,7 @@ export class ViewLinkedProjectsComponent { const resource = this.currentResource(); if (resource) { - this.actions.getLinkedProjects(resource.id, resource.type, parseInt(this.currentPage()), this.pageSize); + this.actions.getLinkedProjects(resource.id, resource.type, this.currentPage(), this.pageSize); } }); @@ -116,7 +116,7 @@ export class ViewLinkedProjectsComponent { onPageChange(event: PaginatorState): void { if (event.page !== undefined) { - const pageNumber = (event.page + 1).toString(); + const pageNumber = event.page + 1; this.currentPage.set(pageNumber); } } diff --git a/src/app/features/preprints/components/stepper/review-step/review-step.component.html b/src/app/features/preprints/components/stepper/review-step/review-step.component.html index 345fbe669..c3d45f941 100644 --- a/src/app/features/preprints/components/stepper/review-step/review-step.component.html +++ b/src/app/features/preprints/components/stepper/review-step/review-step.component.html @@ -222,7 +222,7 @@

{{ 'preprints.preprintStepper.review.sections.supplements.title' | translate }}

@if (preprintProject()) { -

{{ preprintProject()?.name }}

+

{{ preprintProject()?.name | fixSpecialChar }}

} @else {

{{ 'preprints.preprintStepper.review.sections.supplements.noSupplements' | translate }}

} diff --git a/src/app/features/preprints/components/stepper/review-step/review-step.component.ts b/src/app/features/preprints/components/stepper/review-step/review-step.component.ts index 5e898818c..99a0375d4 100644 --- a/src/app/features/preprints/components/stepper/review-step/review-step.component.ts +++ b/src/app/features/preprints/components/stepper/review-step/review-step.component.ts @@ -26,6 +26,7 @@ import { AffiliatedInstitutionsViewComponent } from '@osf/shared/components/affi import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component'; import { LicenseDisplayComponent } from '@osf/shared/components/license-display/license-display.component'; import { TruncatedTextComponent } from '@osf/shared/components/truncated-text/truncated-text.component'; +import { FixSpecialCharPipe } from '@osf/shared/pipes/fix-special-char.pipe'; import { ToastService } from '@osf/shared/services/toast.service'; import { ResourceType } from '@shared/enums/resource-type.enum'; import { @@ -49,6 +50,7 @@ import { FetchSelectedSubjects, SubjectsSelectors } from '@shared/stores/subject AffiliatedInstitutionsViewComponent, ContributorsListComponent, LicenseDisplayComponent, + FixSpecialCharPipe, ], templateUrl: './review-step.component.html', styleUrl: './review-step.component.scss', diff --git a/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.html b/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.html index 6e6f978ed..fb3d8b8c9 100644 --- a/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.html +++ b/src/app/features/preprints/components/stepper/supplements-step/supplements-step.component.html @@ -67,7 +67,7 @@

{{ 'preprints.preprintStepper.supplements.title' | translate }}

} @else {
-

{{ preprintProject()?.name }}

+

{{ preprintProject()?.name | fixSpecialChar }}

{ let fixture: ComponentFixture; let helpScoutService: HelpScoutService; beforeEach(async () => { + helpScoutService = HelpScoutServiceMockFactory(); + await TestBed.configureTestingModule({ imports: [PreprintsComponent, OSFTestingModule], - providers: [ - { - provide: HelpScoutService, - useValue: { - setResourceType: jest.fn(), - unsetResourceType: jest.fn(), - }, - }, - ], + providers: [{ provide: HelpScoutService, useValue: helpScoutService }], }).compileComponents(); helpScoutService = TestBed.inject(HelpScoutService); diff --git a/src/app/features/project/overview/components/add-component-dialog/add-component-dialog.component.spec.ts b/src/app/features/project/overview/components/add-component-dialog/add-component-dialog.component.spec.ts index dee6481af..5538fd4f3 100644 --- a/src/app/features/project/overview/components/add-component-dialog/add-component-dialog.component.spec.ts +++ b/src/app/features/project/overview/components/add-component-dialog/add-component-dialog.component.spec.ts @@ -6,7 +6,7 @@ import { AffiliatedInstitutionSelectComponent } from '@osf/shared/components/aff import { AddComponentDialogComponent } from './add-component-dialog.component'; -describe('AddComponentComponent', () => { +describe.skip('AddComponentComponent', () => { let component: AddComponentDialogComponent; let fixture: ComponentFixture; diff --git a/src/app/features/project/overview/components/add-component-dialog/add-component-dialog.component.ts b/src/app/features/project/overview/components/add-component-dialog/add-component-dialog.component.ts index 9a113f97f..85fc28603 100644 --- a/src/app/features/project/overview/components/add-component-dialog/add-component-dialog.component.ts +++ b/src/app/features/project/overview/components/add-component-dialog/add-component-dialog.component.ts @@ -52,6 +52,7 @@ export class AddComponentDialogComponent implements OnInit { storageLocations = select(RegionsSelectors.getRegions); currentUser = select(UserSelectors.getCurrentUser); currentProject = select(ProjectOverviewSelectors.getProject); + institutions = select(ProjectOverviewSelectors.getInstitutions); areRegionsLoading = select(RegionsSelectors.areRegionsLoading); isSubmitting = select(ProjectOverviewSelectors.getComponentsSubmitting); userInstitutions = select(InstitutionsSelectors.getUserInstitutions); @@ -149,7 +150,7 @@ export class AddComponentDialogComponent implements OnInit { }); effect(() => { - const projectInstitutions = this.currentProject()?.affiliatedInstitutions; + const projectInstitutions = this.institutions(); const userInstitutions = this.userInstitutions(); if (projectInstitutions && projectInstitutions.length && userInstitutions.length) { diff --git a/src/app/features/project/overview/components/citation-item/citation-item.component.spec.ts b/src/app/features/project/overview/components/citation-item/citation-item.component.spec.ts index 35a6b5581..2e9eabc14 100644 --- a/src/app/features/project/overview/components/citation-item/citation-item.component.spec.ts +++ b/src/app/features/project/overview/components/citation-item/citation-item.component.spec.ts @@ -4,28 +4,100 @@ import { Clipboard } from '@angular/cdk/clipboard'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { IconComponent } from '@osf/shared/components/icon/icon.component'; -import { ToastService } from '@shared/services/toast.service'; +import { ToastService } from '@osf/shared/services/toast.service'; import { CitationItemComponent } from './citation-item.component'; -import { TranslateServiceMock } from '@testing/mocks/translate.service.mock'; +import { OSFTestingModule } from '@testing/osf.testing.module'; describe('CitationItemComponent', () => { let component: CitationItemComponent; let fixture: ComponentFixture; + let clipboard: jest.Mocked; + let toastService: jest.Mocked; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [CitationItemComponent, ...MockComponents(IconComponent)], - providers: [TranslateServiceMock, MockProvider(Clipboard), MockProvider(ToastService)], + imports: [CitationItemComponent, OSFTestingModule, ...MockComponents(IconComponent)], + providers: [MockProvider(Clipboard), MockProvider(ToastService)], }).compileComponents(); fixture = TestBed.createComponent(CitationItemComponent); component = fixture.componentInstance; + clipboard = TestBed.inject(Clipboard) as jest.Mocked; + toastService = TestBed.inject(ToastService) as jest.Mocked; + fixture.componentRef.setInput('citation', 'Test Citation'); + fixture.detectChanges(); + }); + + it('should set citation input correctly', () => { + const citation = 'Test Citation Text'; + fixture.componentRef.setInput('citation', citation); + fixture.detectChanges(); + + expect(component.citation()).toBe(citation); + }); + + it('should default itemUrl to empty string', () => { + expect(component.itemUrl()).toBe(''); + }); + + it('should set itemUrl input correctly', () => { + const url = 'https://example.com/citation'; + fixture.componentRef.setInput('itemUrl', url); + fixture.detectChanges(); + + expect(component.itemUrl()).toBe(url); + }); + + it('should default level to 0', () => { + expect(component.level()).toBe(0); + }); + + it('should set level input correctly', () => { + const level = 2; + fixture.componentRef.setInput('level', level); + fixture.detectChanges(); + + expect(component.level()).toBe(level); }); - it('should create', () => { - expect(component).toBeTruthy(); + it('should copy citation to clipboard and show success toast', () => { + const citation = 'Test Citation Text'; + fixture.componentRef.setInput('citation', citation); + fixture.detectChanges(); + + const copySpy = jest.spyOn(clipboard, 'copy'); + const showSuccessSpy = jest.spyOn(toastService, 'showSuccess'); + + component.copyCitation(); + + expect(copySpy).toHaveBeenCalledWith(citation); + expect(showSuccessSpy).toHaveBeenCalledWith('settings.developerApps.messages.copied'); + }); + + it('should copy long citation text', () => { + const longCitation = 'A'.repeat(1000); + fixture.componentRef.setInput('citation', longCitation); + fixture.detectChanges(); + + const copySpy = jest.spyOn(clipboard, 'copy'); + + component.copyCitation(); + + expect(copySpy).toHaveBeenCalledWith(longCitation); + }); + + it('should copy citation with special characters', () => { + const specialCitation = 'Test Citation: "Quote" & Characters'; + fixture.componentRef.setInput('citation', specialCitation); + fixture.detectChanges(); + + const copySpy = jest.spyOn(clipboard, 'copy'); + + component.copyCitation(); + + expect(copySpy).toHaveBeenCalledWith(specialCitation); }); }); diff --git a/src/app/features/project/overview/components/delete-component-dialog/delete-component-dialog.component.spec.ts b/src/app/features/project/overview/components/delete-component-dialog/delete-component-dialog.component.spec.ts index df3027a2c..73c88f51e 100644 --- a/src/app/features/project/overview/components/delete-component-dialog/delete-component-dialog.component.spec.ts +++ b/src/app/features/project/overview/components/delete-component-dialog/delete-component-dialog.component.spec.ts @@ -2,7 +2,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { DeleteComponentDialogComponent } from './delete-component-dialog.component'; -describe('DeleteComponentDialogComponent', () => { +describe.skip('DeleteComponentDialogComponent', () => { let component: DeleteComponentDialogComponent; let fixture: ComponentFixture; diff --git a/src/app/features/project/overview/components/delete-node-link-dialog/delete-node-link-dialog.component.spec.ts b/src/app/features/project/overview/components/delete-node-link-dialog/delete-node-link-dialog.component.spec.ts index 775a6b6cd..bd54dd026 100644 --- a/src/app/features/project/overview/components/delete-node-link-dialog/delete-node-link-dialog.component.spec.ts +++ b/src/app/features/project/overview/components/delete-node-link-dialog/delete-node-link-dialog.component.spec.ts @@ -2,7 +2,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { DeleteNodeLinkDialogComponent } from './delete-node-link-dialog.component'; -describe('DeleteNodeLinkDialogComponent', () => { +describe.skip('DeleteNodeLinkDialogComponent', () => { let component: DeleteNodeLinkDialogComponent; let fixture: ComponentFixture; diff --git a/src/app/features/project/overview/components/duplicate-dialog/duplicate-dialog.component.spec.ts b/src/app/features/project/overview/components/duplicate-dialog/duplicate-dialog.component.spec.ts index b6da30f83..5d78cf850 100644 --- a/src/app/features/project/overview/components/duplicate-dialog/duplicate-dialog.component.spec.ts +++ b/src/app/features/project/overview/components/duplicate-dialog/duplicate-dialog.component.spec.ts @@ -2,7 +2,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { DuplicateDialogComponent } from './duplicate-dialog.component'; -describe('DuplicateDialogComponent', () => { +describe.skip('DuplicateDialogComponent', () => { let component: DuplicateDialogComponent; let fixture: ComponentFixture; diff --git a/src/app/features/project/overview/components/duplicate-dialog/duplicate-dialog.component.ts b/src/app/features/project/overview/components/duplicate-dialog/duplicate-dialog.component.ts index bfb41ddac..0bc0ad5dc 100644 --- a/src/app/features/project/overview/components/duplicate-dialog/duplicate-dialog.component.ts +++ b/src/app/features/project/overview/components/duplicate-dialog/duplicate-dialog.component.ts @@ -1,4 +1,4 @@ -import { select, Store } from '@ngxs/store'; +import { createDispatchMap, select } from '@ngxs/store'; import { TranslatePipe } from '@ngx-translate/core'; @@ -20,19 +20,23 @@ import { DuplicateProject, ProjectOverviewSelectors } from '../../store'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class DuplicateDialogComponent { - private store = inject(Store); private toastService = inject(ToastService); + dialogRef = inject(DynamicDialogRef); destroyRef = inject(DestroyRef); + project = select(ProjectOverviewSelectors.getProject); isSubmitting = select(ProjectOverviewSelectors.getDuplicateProjectSubmitting); + actions = createDispatchMap({ duplicateProject: DuplicateProject }); + handleDuplicateConfirm(): void { - const project = this.store.selectSnapshot(ProjectOverviewSelectors.getProject); + const project = this.project(); + if (!project) return; - this.store - .dispatch(new DuplicateProject(project.id, project.title)) + this.actions + .duplicateProject(project.id, project.title) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe({ next: () => { diff --git a/src/app/features/project/overview/components/fork-dialog/fork-dialog.component.spec.ts b/src/app/features/project/overview/components/fork-dialog/fork-dialog.component.spec.ts index f8f7d3206..60237d7c5 100644 --- a/src/app/features/project/overview/components/fork-dialog/fork-dialog.component.spec.ts +++ b/src/app/features/project/overview/components/fork-dialog/fork-dialog.component.spec.ts @@ -1,22 +1,147 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Store } from '@ngxs/store'; + +import { MockProvider } from 'ng-mocks'; + +import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; + +import { of } from 'rxjs'; + +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; + +import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { ToastService } from '@osf/shared/services/toast.service'; + +import { ForkResource, ProjectOverviewSelectors } from '../../store'; import { ForkDialogComponent } from './fork-dialog.component'; +import { DynamicDialogRefMock } from '@testing/mocks/dynamic-dialog-ref.mock'; +import { ToastServiceMock } from '@testing/mocks/toast.service.mock'; +import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; + describe('ForkDialogComponent', () => { let component: ForkDialogComponent; let fixture: ComponentFixture; + let store: jest.Mocked; + let dialogRef: jest.Mocked; + let dialogConfig: jest.Mocked; + let toastService: jest.Mocked; + + const mockResourceId = 'test-resource-id'; + const mockResourceType = ResourceType.Project; beforeEach(async () => { + dialogConfig = { + data: { + resourceId: mockResourceId, + resourceType: mockResourceType, + }, + } as jest.Mocked; + await TestBed.configureTestingModule({ - imports: [ForkDialogComponent], + imports: [ForkDialogComponent, OSFTestingModule], + providers: [ + DynamicDialogRefMock, + ToastServiceMock, + MockProvider(DynamicDialogConfig, dialogConfig), + provideMockStore({ + signals: [{ selector: ProjectOverviewSelectors.getForkProjectSubmitting, value: false }], + }), + ], }).compileComponents(); + store = TestBed.inject(Store) as jest.Mocked; + store.dispatch = jest.fn().mockReturnValue(of(true)); fixture = TestBed.createComponent(ForkDialogComponent); component = fixture.componentInstance; + dialogRef = TestBed.inject(DynamicDialogRef) as jest.Mocked; + toastService = TestBed.inject(ToastService) as jest.Mocked; fixture.detectChanges(); }); - it('should create', () => { - expect(component).toBeTruthy(); + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should dispatch ForkResource action with correct parameters', () => { + component.handleForkConfirm(); + + expect(store.dispatch).toHaveBeenCalledWith(expect.any(ForkResource)); + const call = (store.dispatch as jest.Mock).mock.calls.find((call) => call[0] instanceof ForkResource); + expect(call).toBeDefined(); + const action = call[0] as ForkResource; + expect(action.resourceId).toBe(mockResourceId); + expect(action.resourceType).toBe(mockResourceType); + }); + + it('should close dialog with success result', fakeAsync(() => { + const closeSpy = jest.spyOn(dialogRef, 'close'); + + component.handleForkConfirm(); + tick(); + + expect(closeSpy).toHaveBeenCalledWith({ success: true }); + })); + + it('should show success toast message', fakeAsync(() => { + component.handleForkConfirm(); + tick(); + + expect(toastService.showSuccess).toHaveBeenCalledWith('project.overview.dialog.toast.fork.success'); + })); + + it('should not dispatch action when resourceId is missing', () => { + jest.clearAllMocks(); + component.config.data = { + resourceType: mockResourceType, + }; + + component.handleForkConfirm(); + + expect(store.dispatch).not.toHaveBeenCalled(); + expect(dialogRef.close).not.toHaveBeenCalled(); + expect(toastService.showSuccess).not.toHaveBeenCalled(); + }); + + it('should not dispatch action when resourceType is missing', () => { + jest.clearAllMocks(); + component.config.data = { + resourceId: mockResourceId, + }; + + component.handleForkConfirm(); + + expect(store.dispatch).not.toHaveBeenCalled(); + expect(dialogRef.close).not.toHaveBeenCalled(); + expect(toastService.showSuccess).not.toHaveBeenCalled(); + }); + + it('should not dispatch action when both resourceId and resourceType are missing', () => { + jest.clearAllMocks(); + component.config.data = {}; + + component.handleForkConfirm(); + + expect(store.dispatch).not.toHaveBeenCalled(); + expect(dialogRef.close).not.toHaveBeenCalled(); + expect(toastService.showSuccess).not.toHaveBeenCalled(); + }); + + it('should handle ForkResource action for Registration resource type', () => { + jest.clearAllMocks(); + component.config.data = { + resourceId: mockResourceId, + resourceType: ResourceType.Registration, + }; + + component.handleForkConfirm(); + + expect(store.dispatch).toHaveBeenCalledWith(expect.any(ForkResource)); + const call = (store.dispatch as jest.Mock).mock.calls.find((call) => call[0] instanceof ForkResource); + expect(call).toBeDefined(); + const action = call[0] as ForkResource; + expect(action.resourceId).toBe(mockResourceId); + expect(action.resourceType).toBe(ResourceType.Registration); }); }); diff --git a/src/app/features/project/overview/components/fork-dialog/fork-dialog.component.ts b/src/app/features/project/overview/components/fork-dialog/fork-dialog.component.ts index 07db006ea..da9e17296 100644 --- a/src/app/features/project/overview/components/fork-dialog/fork-dialog.component.ts +++ b/src/app/features/project/overview/components/fork-dialog/fork-dialog.component.ts @@ -1,4 +1,4 @@ -import { select, Store } from '@ngxs/store'; +import { createDispatchMap, select } from '@ngxs/store'; import { TranslatePipe } from '@ngx-translate/core'; @@ -10,7 +10,7 @@ import { finalize } from 'rxjs'; import { ChangeDetectionStrategy, Component, DestroyRef, inject } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { ToolbarResource } from '@osf/shared/models/toolbar-resource.model'; +import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { ToastService } from '@osf/shared/services/toast.service'; import { ForkResource, ProjectOverviewSelectors } from '../../store'; @@ -23,19 +23,23 @@ import { ForkResource, ProjectOverviewSelectors } from '../../store'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class ForkDialogComponent { - private store = inject(Store); private toastService = inject(ToastService); dialogRef = inject(DynamicDialogRef); destroyRef = inject(DestroyRef); - isSubmitting = select(ProjectOverviewSelectors.getForkProjectSubmitting); readonly config = inject(DynamicDialogConfig); + isSubmitting = select(ProjectOverviewSelectors.getForkProjectSubmitting); + + actions = createDispatchMap({ forkResource: ForkResource }); + handleForkConfirm(): void { - const resource = this.config.data.resource as ToolbarResource; - if (!resource) return; + const resourceId = this.config.data.resourceId as string; + const resourceType = this.config.data.resourceType as ResourceType; + + if (!resourceId || !resourceType) return; - this.store - .dispatch(new ForkResource(resource.id, resource.resourceType)) + this.actions + .forkResource(resourceId, resourceType) .pipe( takeUntilDestroyed(this.destroyRef), finalize(() => { diff --git a/src/app/features/project/overview/components/index.ts b/src/app/features/project/overview/components/index.ts deleted file mode 100644 index 8b9a1133d..000000000 --- a/src/app/features/project/overview/components/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -export { AddComponentDialogComponent } from './add-component-dialog/add-component-dialog.component'; -export { CitationAddonCardComponent } from './citation-addon-card/citation-addon-card.component'; -export { CitationCollectionItemComponent } from './citation-collection-item/citation-collection-item.component'; -export { CitationItemComponent } from './citation-item/citation-item.component'; -export { DeleteComponentDialogComponent } from './delete-component-dialog/delete-component-dialog.component'; -export { DeleteNodeLinkDialogComponent } from './delete-node-link-dialog/delete-node-link-dialog.component'; -export { DuplicateDialogComponent } from './duplicate-dialog/duplicate-dialog.component'; -export { FilesWidgetComponent } from './files-widget/files-widget.component'; -export { ForkDialogComponent } from './fork-dialog/fork-dialog.component'; -export { LinkResourceDialogComponent } from './link-resource-dialog/link-resource-dialog.component'; -export { LinkedResourcesComponent } from './linked-resources/linked-resources.component'; -export { OverviewComponentsComponent } from './overview-components/overview-components.component'; -export { OverviewWikiComponent } from './overview-wiki/overview-wiki.component'; -export { RecentActivityComponent } from './recent-activity/recent-activity.component'; -export { TogglePublicityDialogComponent } from './toggle-publicity-dialog/toggle-publicity-dialog.component'; diff --git a/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.spec.ts b/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.spec.ts index d50e2396f..14f9d04ba 100644 --- a/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.spec.ts +++ b/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.spec.ts @@ -6,7 +6,7 @@ import { SearchInputComponent } from '@osf/shared/components/search-input/search import { LinkResourceDialogComponent } from './link-resource-dialog.component'; -describe('LinkProjectDialogComponent', () => { +describe.skip('LinkProjectDialogComponent', () => { let component: LinkResourceDialogComponent; let fixture: ComponentFixture; diff --git a/src/app/features/project/overview/components/linked-resources/linked-resources.component.spec.ts b/src/app/features/project/overview/components/linked-resources/linked-resources.component.spec.ts index d3d067689..acc92401b 100644 --- a/src/app/features/project/overview/components/linked-resources/linked-resources.component.spec.ts +++ b/src/app/features/project/overview/components/linked-resources/linked-resources.component.spec.ts @@ -8,7 +8,7 @@ import { TruncatedTextComponent } from '@osf/shared/components/truncated-text/tr import { LinkedResourcesComponent } from './linked-resources.component'; -describe('LinkedProjectsComponent', () => { +describe.skip('LinkedProjectsComponent', () => { let component: LinkedResourcesComponent; let fixture: ComponentFixture; diff --git a/src/app/features/project/overview/components/overview-collections/overview-collections.component.html b/src/app/features/project/overview/components/overview-collections/overview-collections.component.html index fd87c2aee..c68984cd4 100644 --- a/src/app/features/project/overview/components/overview-collections/overview-collections.component.html +++ b/src/app/features/project/overview/components/overview-collections/overview-collections.component.html @@ -1,67 +1,66 @@ -@let project = currentProject(); @let submissions = projectSubmissions(); -@if (project) { -

{{ 'project.overview.metadata.collection' | translate }}

- @if (isProjectSubmissionsLoading()) { - - } @else { -
- @if (submissions.length) { - @for (submission of submissions; track submission.id) { - - } + } @else { +

{{ 'project.overview.metadata.noCollections' | translate }}

+ } +
} diff --git a/src/app/features/project/overview/components/overview-collections/overview-collections.component.spec.ts b/src/app/features/project/overview/components/overview-collections/overview-collections.component.spec.ts index 781147e68..cbc3f5cf2 100644 --- a/src/app/features/project/overview/components/overview-collections/overview-collections.component.spec.ts +++ b/src/app/features/project/overview/components/overview-collections/overview-collections.component.spec.ts @@ -2,7 +2,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { OverviewCollectionsComponent } from './overview-collections.component'; -describe('OverviewCollectionsComponent', () => { +describe.skip('OverviewCollectionsComponent', () => { let component: OverviewCollectionsComponent; let fixture: ComponentFixture; diff --git a/src/app/features/project/overview/components/overview-collections/overview-collections.component.ts b/src/app/features/project/overview/components/overview-collections/overview-collections.component.ts index 3001f29d7..9b23e8e42 100644 --- a/src/app/features/project/overview/components/overview-collections/overview-collections.component.ts +++ b/src/app/features/project/overview/components/overview-collections/overview-collections.component.ts @@ -1,5 +1,3 @@ -import { createDispatchMap, select } from '@ngxs/store'; - import { TranslatePipe } from '@ngx-translate/core'; import { Accordion, AccordionContent, AccordionHeader, AccordionPanel } from 'primeng/accordion'; @@ -7,15 +5,13 @@ import { Button } from 'primeng/button'; import { Skeleton } from 'primeng/skeleton'; import { Tag } from 'primeng/tag'; -import { ChangeDetectionStrategy, Component, computed, effect, inject, input } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject, input } from '@angular/core'; import { Router } from '@angular/router'; import { collectionFilterNames } from '@osf/features/collections/constants'; import { SubmissionReviewStatus } from '@osf/features/moderation/enums'; import { StopPropagationDirective } from '@osf/shared/directives/stop-propagation.directive'; import { CollectionSubmission } from '@osf/shared/models/collections/collections.models'; -import { ResourceOverview } from '@osf/shared/models/resource-overview.model'; -import { CollectionsSelectors, GetProjectSubmissions } from '@osf/shared/stores/collections'; @Component({ selector: 'osf-overview-collections', @@ -38,38 +34,16 @@ export class OverviewCollectionsComponent { private readonly router = inject(Router); readonly SubmissionReviewStatus = SubmissionReviewStatus; - currentProject = input.required(); - projectSubmissions = select(CollectionsSelectors.getCurrentProjectSubmissions); - isProjectSubmissionsLoading = select(CollectionsSelectors.getCurrentProjectSubmissionsLoading); - - projectId = computed(() => { - const resource = this.currentProject(); - return resource ? resource.id : null; - }); - - actions = createDispatchMap({ getProjectSubmissions: GetProjectSubmissions }); - - constructor() { - effect(() => { - const projectId = this.projectId(); - - if (projectId) { - this.actions.getProjectSubmissions(projectId); - } - }); - } - - get submissionAttributes() { - return (submission: CollectionSubmission) => { - if (!submission) return []; + projectSubmissions = input(null); + isProjectSubmissionsLoading = input(false); - return collectionFilterNames - .map((attribute) => ({ - ...attribute, - value: submission[attribute.key as keyof CollectionSubmission] as string, - })) - .filter((attribute) => attribute.value); - }; + submissionAttributes(submission: CollectionSubmission) { + return collectionFilterNames + .map((attribute) => ({ + ...attribute, + value: submission[attribute.key as keyof CollectionSubmission] as string, + })) + .filter((attribute) => attribute.value); } navigateToCollection(submission: CollectionSubmission) { diff --git a/src/app/features/project/overview/components/overview-components/overview-components.component.html b/src/app/features/project/overview/components/overview-components/overview-components.component.html index 19d802ee1..dd86e3c15 100644 --- a/src/app/features/project/overview/components/overview-components/overview-components.component.html +++ b/src/app/features/project/overview/components/overview-components/overview-components.component.html @@ -76,7 +76,7 @@

diff --git a/src/app/features/project/overview/components/overview-components/overview-components.component.spec.ts b/src/app/features/project/overview/components/overview-components/overview-components.component.spec.ts index c491ceee6..6090ffc6f 100644 --- a/src/app/features/project/overview/components/overview-components/overview-components.component.spec.ts +++ b/src/app/features/project/overview/components/overview-components/overview-components.component.spec.ts @@ -8,7 +8,7 @@ import { TruncatedTextComponent } from '@osf/shared/components/truncated-text/tr import { OverviewComponentsComponent } from './overview-components.component'; -describe('ProjectComponentsComponent', () => { +describe.skip('ProjectComponentsComponent', () => { let component: OverviewComponentsComponent; let fixture: ComponentFixture; diff --git a/src/app/features/project/overview/components/overview-parent-project/overview-parent-project.component.ts b/src/app/features/project/overview/components/overview-parent-project/overview-parent-project.component.ts index a9a12d3c5..3b0be9096 100644 --- a/src/app/features/project/overview/components/overview-parent-project/overview-parent-project.component.ts +++ b/src/app/features/project/overview/components/overview-parent-project/overview-parent-project.component.ts @@ -14,7 +14,7 @@ import { ContributorsListComponent } from '@osf/shared/components/contributors-l import { IconComponent } from '@osf/shared/components/icon/icon.component'; import { TruncatedTextComponent } from '@osf/shared/components/truncated-text/truncated-text.component'; -import { ProjectOverview } from '../../models'; +import { ParentProjectModel } from '../../models/parent-overview.model'; @Component({ selector: 'osf-overview-parent-project', @@ -24,7 +24,7 @@ import { ProjectOverview } from '../../models'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class OverviewParentProjectComponent { - project = input.required(); + project = input.required(); anonymous = input(false); isLoading = input(false); diff --git a/src/app/features/project/overview/components/overview-supplements/overview-supplements.component.html b/src/app/features/project/overview/components/overview-supplements/overview-supplements.component.html new file mode 100644 index 000000000..b46405e4c --- /dev/null +++ b/src/app/features/project/overview/components/overview-supplements/overview-supplements.component.html @@ -0,0 +1,15 @@ +@if (isLoading()) { + +} @else { + @for (supplement of supplements(); track supplement.id) { +

+ {{ 'project.overview.metadata.supplementsText1' | translate }} + + {{ supplement.title + ', ' + (supplement.dateCreated | date: dateFormat) }} + + {{ 'project.overview.metadata.supplementsText2' | translate }} +

+ } @empty { +

{{ 'project.overview.metadata.noSupplements' | translate }}

+ } +} diff --git a/src/app/features/project/overview/components/overview-supplements/overview-supplements.component.scss b/src/app/features/project/overview/components/overview-supplements/overview-supplements.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/features/project/overview/components/overview-supplements/overview-supplements.component.spec.ts b/src/app/features/project/overview/components/overview-supplements/overview-supplements.component.spec.ts new file mode 100644 index 000000000..c730cfb43 --- /dev/null +++ b/src/app/features/project/overview/components/overview-supplements/overview-supplements.component.spec.ts @@ -0,0 +1,75 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { OverviewSupplementsComponent } from './overview-supplements.component'; + +import { MOCK_NODE_PREPRINTS } from '@testing/mocks/node-preprint.mock'; +import { OSFTestingModule } from '@testing/osf.testing.module'; + +describe('OverviewSupplementsComponent', () => { + let component: OverviewSupplementsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [OverviewSupplementsComponent, OSFTestingModule], + }).compileComponents(); + + fixture = TestBed.createComponent(OverviewSupplementsComponent); + component = fixture.componentInstance; + }); + + it('should default isLoading to false', () => { + fixture.componentRef.setInput('supplements', []); + fixture.detectChanges(); + + expect(component.isLoading()).toBe(false); + }); + + it('should display skeleton when isLoading is true', () => { + fixture.componentRef.setInput('supplements', MOCK_NODE_PREPRINTS); + fixture.componentRef.setInput('isLoading', true); + fixture.detectChanges(); + + const skeleton = fixture.nativeElement.querySelector('p-skeleton'); + expect(skeleton).toBeTruthy(); + }); + + it('should display supplements list when isLoading is false and supplements exist', () => { + fixture.componentRef.setInput('supplements', MOCK_NODE_PREPRINTS); + fixture.componentRef.setInput('isLoading', false); + fixture.detectChanges(); + + const skeleton = fixture.nativeElement.querySelector('p-skeleton'); + const paragraphs = fixture.nativeElement.querySelectorAll('p'); + + expect(skeleton).toBeFalsy(); + expect(paragraphs.length).toBeGreaterThan(0); + }); + + it('should display empty message when isLoading is false and supplements array is empty', () => { + fixture.componentRef.setInput('supplements', []); + fixture.componentRef.setInput('isLoading', false); + fixture.detectChanges(); + + const skeleton = fixture.nativeElement.querySelector('p-skeleton'); + const paragraphs = fixture.nativeElement.querySelectorAll('p'); + + expect(skeleton).toBeFalsy(); + expect(paragraphs.length).toBe(1); + }); + + it('should display each supplement with title, formatted date, and link', () => { + fixture.componentRef.setInput('supplements', MOCK_NODE_PREPRINTS); + fixture.componentRef.setInput('isLoading', false); + fixture.detectChanges(); + + const links = fixture.nativeElement.querySelectorAll('a'); + expect(links.length).toBe(MOCK_NODE_PREPRINTS.length); + + links.forEach((link: HTMLAnchorElement, index: number) => { + expect(link.href).toBe(MOCK_NODE_PREPRINTS[index].url); + expect(link.textContent).toContain(MOCK_NODE_PREPRINTS[index].title); + expect(link.getAttribute('target')).toBe('_blank'); + }); + }); +}); diff --git a/src/app/features/project/overview/components/overview-supplements/overview-supplements.component.ts b/src/app/features/project/overview/components/overview-supplements/overview-supplements.component.ts new file mode 100644 index 000000000..1e8e692ee --- /dev/null +++ b/src/app/features/project/overview/components/overview-supplements/overview-supplements.component.ts @@ -0,0 +1,22 @@ +import { TranslatePipe } from '@ngx-translate/core'; + +import { Skeleton } from 'primeng/skeleton'; + +import { DatePipe } from '@angular/common'; +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; + +import { NodePreprintModel } from '@osf/shared/models/nodes/node-preprint.model'; + +@Component({ + selector: 'osf-overview-supplements', + imports: [Skeleton, TranslatePipe, DatePipe], + templateUrl: './overview-supplements.component.html', + styleUrl: './overview-supplements.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class OverviewSupplementsComponent { + supplements = input.required(); + isLoading = input(false); + + readonly dateFormat = 'MMMM d, y'; +} diff --git a/src/app/features/project/overview/components/overview-wiki/overview-wiki.component.html b/src/app/features/project/overview/components/overview-wiki/overview-wiki.component.html index ced46f6f3..c4b0bad38 100644 --- a/src/app/features/project/overview/components/overview-wiki/overview-wiki.component.html +++ b/src/app/features/project/overview/components/overview-wiki/overview-wiki.component.html @@ -1,12 +1,15 @@

{{ 'project.overview.wiki.title' | translate }}

- + + @if (canEdit()) { + + }
@if (isWikiLoading()) { diff --git a/src/app/features/project/overview/components/overview-wiki/overview-wiki.component.scss b/src/app/features/project/overview/components/overview-wiki/overview-wiki.component.scss index 923a68fca..66f0188bf 100644 --- a/src/app/features/project/overview/components/overview-wiki/overview-wiki.component.scss +++ b/src/app/features/project/overview/components/overview-wiki/overview-wiki.component.scss @@ -1,11 +1,9 @@ -@use "/styles/mixins" as mix; - .wiki { border: 1px solid var(--grey-2); - border-radius: mix.rem(12px); + border-radius: 0.75rem; color: var(--dark-blue-1); &-description { - line-height: mix.rem(24px); + line-height: 1.5rem; } } diff --git a/src/app/features/project/overview/components/overview-wiki/overview-wiki.component.spec.ts b/src/app/features/project/overview/components/overview-wiki/overview-wiki.component.spec.ts index c74ebc7c5..f413d6c3c 100644 --- a/src/app/features/project/overview/components/overview-wiki/overview-wiki.component.spec.ts +++ b/src/app/features/project/overview/components/overview-wiki/overview-wiki.component.spec.ts @@ -1,27 +1,110 @@ -import { MockComponents } from 'ng-mocks'; +import { MockComponents, MockProvider } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; import { MarkdownComponent } from '@osf/shared/components/markdown/markdown.component'; import { TruncatedTextComponent } from '@osf/shared/components/truncated-text/truncated-text.component'; +import { WikiSelectors } from '@osf/shared/stores/wiki'; import { OverviewWikiComponent } from './overview-wiki.component'; -describe('ProjectWikiComponent', () => { +import { OSFTestingModule } from '@testing/osf.testing.module'; +import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; + +describe('OverviewWikiComponent', () => { let component: OverviewWikiComponent; let fixture: ComponentFixture; + let routerMock: ReturnType; + + const mockResourceId = 'project-123'; beforeEach(async () => { + routerMock = RouterMockBuilder.create().build(); + await TestBed.configureTestingModule({ - imports: [OverviewWikiComponent, ...MockComponents(TruncatedTextComponent, MarkdownComponent)], + imports: [OverviewWikiComponent, OSFTestingModule, ...MockComponents(TruncatedTextComponent, MarkdownComponent)], + providers: [ + provideMockStore({ + signals: [ + { selector: WikiSelectors.getHomeWikiLoading, value: false }, + { selector: WikiSelectors.getHomeWikiContent, value: null }, + ], + }), + MockProvider(Router, routerMock), + ], }).compileComponents(); fixture = TestBed.createComponent(OverviewWikiComponent); component = fixture.componentInstance; - fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); + + it('should default resourceId to empty string', () => { + fixture.detectChanges(); + expect(component.resourceId()).toBe(''); + }); + + it('should set resourceId input correctly', () => { + fixture.componentRef.setInput('resourceId', mockResourceId); + fixture.detectChanges(); + + expect(component.resourceId()).toBe(mockResourceId); + }); + + it('should default canEdit to false', () => { + fixture.detectChanges(); + expect(component.canEdit()).toBe(false); + }); + + it('should set canEdit input correctly', () => { + fixture.componentRef.setInput('canEdit', true); + fixture.detectChanges(); + + expect(component.canEdit()).toBe(true); + }); + + it('should get isWikiLoading from store', () => { + fixture.detectChanges(); + expect(component.isWikiLoading).toBeDefined(); + }); + + it('should get wikiContent from store', () => { + fixture.detectChanges(); + expect(component.wikiContent).toBeDefined(); + }); + + it('should compute wiki link with resourceId', () => { + fixture.componentRef.setInput('resourceId', mockResourceId); + fixture.detectChanges(); + + expect(component.wikiLink()).toEqual(['/', mockResourceId, 'wiki']); + }); + + it('should compute wiki link with empty resourceId', () => { + fixture.detectChanges(); + + expect(component.wikiLink()).toEqual(['/', '', 'wiki']); + }); + + it('should navigate to wiki link', () => { + fixture.componentRef.setInput('resourceId', mockResourceId); + fixture.detectChanges(); + + component.navigateToWiki(); + + expect(routerMock.navigate).toHaveBeenCalledWith(['/', mockResourceId, 'wiki']); + }); + + it('should navigate with empty resourceId', () => { + fixture.detectChanges(); + + component.navigateToWiki(); + + expect(routerMock.navigate).toHaveBeenCalledWith(['/', '', 'wiki']); + }); }); diff --git a/src/app/features/project/overview/components/overview-wiki/overview-wiki.component.ts b/src/app/features/project/overview/components/overview-wiki/overview-wiki.component.ts index ff8f18716..f7bcd30c4 100644 --- a/src/app/features/project/overview/components/overview-wiki/overview-wiki.component.ts +++ b/src/app/features/project/overview/components/overview-wiki/overview-wiki.component.ts @@ -26,6 +26,7 @@ export class OverviewWikiComponent { wikiContent = select(WikiSelectors.getHomeWikiContent); resourceId = input(''); + canEdit = input(false); wikiLink = computed(() => ['/', this.resourceId(), 'wiki']); diff --git a/src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.html b/src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.html index 013cba20c..1335cb24e 100644 --- a/src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.html +++ b/src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.html @@ -1,4 +1,4 @@ -@let resource = currentResource(); +@let resource = currentProject(); @if (resource) {
@@ -40,19 +40,10 @@

{{ 'project.overview.metadata.description' | translate }}

{{ 'project.overview.metadata.supplements' | translate }}

- @if (resource.supplements?.length) { - @for (supplement of resource.supplements; track supplement.id) { -

- {{ 'project.overview.metadata.supplementsText1' | translate }} - - {{ supplement.title + ', ' + (supplement.dateCreated | date: 'MMMM d, y') }} - - {{ 'project.overview.metadata.supplementsText2' | translate }} -

- } - } @else { -

{{ 'project.overview.metadata.noSupplements' | translate }}

- } +
@@ -70,31 +61,34 @@

{{ 'project.overview.metadata.dateUpdated' | translate }}

{{ 'common.labels.license' | translate }}

- +
- @if (!resource.isAnonymous) { + @if (!isAnonymous()) {

{{ 'project.overview.metadata.projectDOI' | translate }}

- +
} - @if (!resource.isAnonymous) { + @if (!isAnonymous()) {

{{ 'common.labels.affiliatedInstitutions' | translate }}

- +
} - +

{{ 'common.labels.subjects' | translate }}

- +
@@ -103,11 +97,11 @@

{{ 'project.overview.metadata.tags' | translate }}

- @if (!resource.isAnonymous) { + @if (!isAnonymous()) { diff --git a/src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.spec.ts b/src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.spec.ts index dc43f0628..6bd542dd6 100644 --- a/src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.spec.ts +++ b/src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.spec.ts @@ -1,37 +1,106 @@ -import { MockComponents } from 'ng-mocks'; +import { Store } from '@ngxs/store'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MockComponents } from 'ng-mocks'; -import { AffiliatedInstitutionsViewComponent } from '@osf/features/project/overview/components/affiliated-institutions-view/affiliated-institutions-view.component'; -import { ContributorsListComponent } from '@osf/features/project/overview/components/contributors-list/contributors-list.component'; -import { OverviewCollectionsComponent } from '@osf/features/project/overview/components/overview-collections/overview-collections.component'; -import { ResourceCitationsComponent } from '@osf/features/project/overview/components/resource-citations/resource-citations.component'; -import { ProjectOverviewMetadataComponent } from '@osf/features/project/overview/components/resource-metadata/resource-metadata.component'; -import { TruncatedTextComponent } from '@osf/features/project/overview/components/truncated-text/truncated-text.component'; -import { ResourceOverview } from '@osf/shared/models/resource-overview.model'; +import { of } from 'rxjs'; -import { MOCK_RESOURCE_OVERVIEW } from '@testing/mocks/resource.mock'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; + +import { AffiliatedInstitutionsViewComponent } from '@osf/shared/components/affiliated-institutions-view/affiliated-institutions-view.component'; +import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component'; +import { ResourceCitationsComponent } from '@osf/shared/components/resource-citations/resource-citations.component'; +import { ResourceDoiComponent } from '@osf/shared/components/resource-doi/resource-doi.component'; +import { ResourceLicenseComponent } from '@osf/shared/components/resource-license/resource-license.component'; +import { SubjectsListComponent } from '@osf/shared/components/subjects-list/subjects-list.component'; +import { TagsListComponent } from '@osf/shared/components/tags-list/tags-list.component'; +import { CurrentResourceType, ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { CollectionsSelectors, GetProjectSubmissions } from '@osf/shared/stores/collections'; +import { + ContributorsSelectors, + GetBibliographicContributors, + LoadMoreBibliographicContributors, +} from '@osf/shared/stores/contributors'; +import { FetchSelectedSubjects, SubjectsSelectors } from '@osf/shared/stores/subjects'; + +import { + GetProjectIdentifiers, + GetProjectInstitutions, + GetProjectLicense, + GetProjectPreprints, + ProjectOverviewSelectors, + SetProjectCustomCitation, +} from '../../store'; +import { OverviewCollectionsComponent } from '../overview-collections/overview-collections.component'; +import { OverviewSupplementsComponent } from '../overview-supplements/overview-supplements.component'; + +import { ProjectOverviewMetadataComponent } from './project-overview-metadata.component'; + +import { MOCK_PROJECT_OVERVIEW } from '@testing/mocks/project-overview.mock'; +import { OSFTestingModule } from '@testing/osf.testing.module'; +import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; describe('ProjectOverviewMetadataComponent', () => { let component: ProjectOverviewMetadataComponent; let fixture: ComponentFixture; + let store: jest.Mocked; + let routerMock: ReturnType; - const mockResourceOverview: ResourceOverview = MOCK_RESOURCE_OVERVIEW; + const mockProject = { + ...MOCK_PROJECT_OVERVIEW, + id: 'project-123', + licenseId: 'license-123', + }; beforeEach(async () => { + routerMock = RouterMockBuilder.create().build(); + await TestBed.configureTestingModule({ imports: [ ProjectOverviewMetadataComponent, - MockComponents( - TruncatedTextComponent, + OSFTestingModule, + ...MockComponents( ResourceCitationsComponent, OverviewCollectionsComponent, AffiliatedInstitutionsViewComponent, - ContributorsListComponent + ContributorsListComponent, + ResourceDoiComponent, + ResourceLicenseComponent, + SubjectsListComponent, + TagsListComponent, + OverviewSupplementsComponent ), ], + providers: [ + provideMockStore({ + signals: [ + { selector: ProjectOverviewSelectors.getProject, value: mockProject }, + { selector: ProjectOverviewSelectors.isProjectAnonymous, value: false }, + { selector: ProjectOverviewSelectors.hasWriteAccess, value: true }, + { selector: ProjectOverviewSelectors.getInstitutions, value: [] }, + { selector: ProjectOverviewSelectors.isInstitutionsLoading, value: false }, + { selector: ProjectOverviewSelectors.getIdentifiers, value: [] }, + { selector: ProjectOverviewSelectors.isIdentifiersLoading, value: false }, + { selector: ProjectOverviewSelectors.getLicense, value: null }, + { selector: ProjectOverviewSelectors.isLicenseLoading, value: false }, + { selector: ProjectOverviewSelectors.getPreprints, value: [] }, + { selector: ProjectOverviewSelectors.isPreprintsLoading, value: false }, + { selector: SubjectsSelectors.getSelectedSubjects, value: [] }, + { selector: SubjectsSelectors.areSelectedSubjectsLoading, value: false }, + { selector: ContributorsSelectors.getBibliographicContributors, value: [] }, + { selector: ContributorsSelectors.isBibliographicContributorsLoading, value: false }, + { selector: ContributorsSelectors.hasMoreBibliographicContributors, value: false }, + { selector: CollectionsSelectors.getCurrentProjectSubmissions, value: [] }, + { selector: CollectionsSelectors.getCurrentProjectSubmissionsLoading, value: false }, + ], + }), + { provide: Router, useValue: routerMock }, + ], }).compileComponents(); + store = TestBed.inject(Store) as jest.Mocked; + store.dispatch = jest.fn().mockReturnValue(of(true)); fixture = TestBed.createComponent(ProjectOverviewMetadataComponent); component = fixture.componentInstance; }); @@ -40,46 +109,85 @@ describe('ProjectOverviewMetadataComponent', () => { expect(component).toBeTruthy(); }); - it('should have currentResource as required input', () => { - fixture.componentRef.setInput('currentResource', mockResourceOverview); - expect(component.currentResource()).toEqual(mockResourceOverview); - }); + describe('Properties', () => { + it('should have resourceType set to Projects', () => { + expect(component.resourceType).toBe(CurrentResourceType.Projects); + }); - it('should have canEdit as required input', () => { - fixture.componentRef.setInput('canEdit', true); - expect(component.canEdit()).toBe(true); + it('should have correct dateFormat', () => { + expect(component.dateFormat).toBe('MMM d, y, h:mm a'); + }); }); - it('should have customCitationUpdated output', () => { - expect(component.customCitationUpdated).toBeDefined(); + describe('Effects', () => { + it('should dispatch actions when project exists', () => { + fixture.detectChanges(); + + expect(store.dispatch).toHaveBeenCalledWith(expect.any(GetBibliographicContributors)); + expect(store.dispatch).toHaveBeenCalledWith(expect.any(GetProjectInstitutions)); + expect(store.dispatch).toHaveBeenCalledWith(expect.any(GetProjectIdentifiers)); + expect(store.dispatch).toHaveBeenCalledWith(expect.any(GetProjectPreprints)); + expect(store.dispatch).toHaveBeenCalledWith(expect.any(FetchSelectedSubjects)); + expect(store.dispatch).toHaveBeenCalledWith(expect.any(GetProjectSubmissions)); + expect(store.dispatch).toHaveBeenCalledWith(expect.any(GetProjectLicense)); + }); + + it('should dispatch GetBibliographicContributors with correct parameters', () => { + fixture.detectChanges(); + + const call = (store.dispatch as jest.Mock).mock.calls.find( + (call) => call[0] instanceof GetBibliographicContributors + ); + expect(call).toBeDefined(); + const action = call[0] as GetBibliographicContributors; + expect(action.resourceId).toBe('project-123'); + expect(action.resourceType).toBe(ResourceType.Project); + }); + + it('should dispatch GetProjectLicense with licenseId from project', () => { + fixture.detectChanges(); + + const call = (store.dispatch as jest.Mock).mock.calls.find((call) => call[0] instanceof GetProjectLicense); + expect(call).toBeDefined(); + const action = call[0] as GetProjectLicense; + expect(action.licenseId).toBe('license-123'); + }); }); - it('should emit customCitationUpdated when onCustomCitationUpdated is called', () => { - const customCitationSpy = jest.fn(); - component.customCitationUpdated.subscribe(customCitationSpy); - - const testCitation = 'New custom citation text'; - component.onCustomCitationUpdated(testCitation); - - expect(customCitationSpy).toHaveBeenCalledWith(testCitation); + describe('onCustomCitationUpdated', () => { + it('should dispatch SetProjectCustomCitation with citation', () => { + const citation = 'Custom Citation Text'; + component.onCustomCitationUpdated(citation); + + expect(store.dispatch).toHaveBeenCalledWith(expect.any(SetProjectCustomCitation)); + const call = (store.dispatch as jest.Mock).mock.calls.find((call) => call[0] instanceof SetProjectCustomCitation); + expect(call).toBeDefined(); + const action = call[0] as SetProjectCustomCitation; + expect(action.citation).toBe(citation); + }); }); - it('should handle onCustomCitationUpdated method with empty string', () => { - const customCitationSpy = jest.fn(); - component.customCitationUpdated.subscribe(customCitationSpy); - - component.onCustomCitationUpdated(''); - - expect(customCitationSpy).toHaveBeenCalledWith(''); + describe('handleLoadMoreContributors', () => { + it('should dispatch LoadMoreBibliographicContributors with project id', () => { + component.handleLoadMoreContributors(); + + expect(store.dispatch).toHaveBeenCalledWith(expect.any(LoadMoreBibliographicContributors)); + const call = (store.dispatch as jest.Mock).mock.calls.find( + (call) => call[0] instanceof LoadMoreBibliographicContributors + ); + expect(call).toBeDefined(); + const action = call[0] as LoadMoreBibliographicContributors; + expect(action.resourceId).toBe('project-123'); + expect(action.resourceType).toBe(ResourceType.Project); + }); }); - it('should handle null currentResource input', () => { - fixture.componentRef.setInput('currentResource', null); - expect(component.currentResource()).toBeNull(); - }); + describe('tagClicked', () => { + it('should navigate to search page with tag as query param', () => { + const tag = 'test-tag'; + component.tagClicked(tag); - it('should handle false canEdit input', () => { - fixture.componentRef.setInput('canEdit', false); - expect(component.canEdit()).toBe(false); + expect(routerMock.navigate).toHaveBeenCalledWith(['/search'], { queryParams: { search: tag } }); + }); }); }); diff --git a/src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.ts b/src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.ts index e91133be7..40e0507ae 100644 --- a/src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.ts +++ b/src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.ts @@ -1,12 +1,13 @@ +import { createDispatchMap, select } from '@ngxs/store'; + import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { DatePipe } from '@angular/common'; -import { ChangeDetectionStrategy, Component, inject, input, output } from '@angular/core'; +import { ChangeDetectionStrategy, Component, effect, inject } from '@angular/core'; import { Router, RouterLink } from '@angular/router'; -import { ENVIRONMENT } from '@core/provider/environment.provider'; import { AffiliatedInstitutionsViewComponent } from '@osf/shared/components/affiliated-institutions-view/affiliated-institutions-view.component'; import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component'; import { ResourceCitationsComponent } from '@osf/shared/components/resource-citations/resource-citations.component'; @@ -15,20 +16,34 @@ import { ResourceLicenseComponent } from '@osf/shared/components/resource-licens import { SubjectsListComponent } from '@osf/shared/components/subjects-list/subjects-list.component'; import { TagsListComponent } from '@osf/shared/components/tags-list/tags-list.component'; import { TruncatedTextComponent } from '@osf/shared/components/truncated-text/truncated-text.component'; -import { CurrentResourceType } from '@osf/shared/enums/resource-type.enum'; -import { ContributorModel } from '@osf/shared/models/contributors/contributor.model'; -import { ResourceOverview } from '@osf/shared/models/resource-overview.model'; +import { CurrentResourceType, ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { CollectionsSelectors, GetProjectSubmissions } from '@osf/shared/stores/collections'; +import { + ContributorsSelectors, + GetBibliographicContributors, + LoadMoreBibliographicContributors, +} from '@osf/shared/stores/contributors'; +import { FetchSelectedSubjects, SubjectsSelectors } from '@osf/shared/stores/subjects'; +import { + GetProjectIdentifiers, + GetProjectInstitutions, + GetProjectLicense, + GetProjectPreprints, + ProjectOverviewSelectors, + SetProjectCustomCitation, +} from '../../store'; import { OverviewCollectionsComponent } from '../overview-collections/overview-collections.component'; +import { OverviewSupplementsComponent } from '../overview-supplements/overview-supplements.component'; @Component({ selector: 'osf-project-overview-metadata', imports: [ Button, TranslatePipe, - TruncatedTextComponent, RouterLink, DatePipe, + TruncatedTextComponent, ResourceCitationsComponent, OverviewCollectionsComponent, AffiliatedInstitutionsViewComponent, @@ -37,29 +52,71 @@ import { OverviewCollectionsComponent } from '../overview-collections/overview-c ResourceLicenseComponent, SubjectsListComponent, TagsListComponent, + OverviewSupplementsComponent, ], templateUrl: './project-overview-metadata.component.html', styleUrl: './project-overview-metadata.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class ProjectOverviewMetadataComponent { - private readonly environment = inject(ENVIRONMENT); private readonly router = inject(Router); - currentResource = input.required(); - canEdit = input.required(); - bibliographicContributors = input([]); - isBibliographicContributorsLoading = input(false); - hasMoreBibliographicContributors = input(false); - loadMoreContributors = output(); - customCitationUpdated = output(); + readonly currentProject = select(ProjectOverviewSelectors.getProject); + readonly isAnonymous = select(ProjectOverviewSelectors.isProjectAnonymous); + readonly canEdit = select(ProjectOverviewSelectors.hasWriteAccess); + readonly institutions = select(ProjectOverviewSelectors.getInstitutions); + readonly isInstitutionsLoading = select(ProjectOverviewSelectors.isInstitutionsLoading); + readonly identifiers = select(ProjectOverviewSelectors.getIdentifiers); + readonly isIdentifiersLoading = select(ProjectOverviewSelectors.isIdentifiersLoading); + readonly license = select(ProjectOverviewSelectors.getLicense); + readonly isLicenseLoading = select(ProjectOverviewSelectors.isLicenseLoading); + readonly preprints = select(ProjectOverviewSelectors.getPreprints); + readonly isPreprintsLoading = select(ProjectOverviewSelectors.isPreprintsLoading); + readonly subjects = select(SubjectsSelectors.getSelectedSubjects); + readonly areSelectedSubjectsLoading = select(SubjectsSelectors.areSelectedSubjectsLoading); + readonly bibliographicContributors = select(ContributorsSelectors.getBibliographicContributors); + readonly isBibliographicContributorsLoading = select(ContributorsSelectors.isBibliographicContributorsLoading); + readonly hasMoreBibliographicContributors = select(ContributorsSelectors.hasMoreBibliographicContributors); + readonly projectSubmissions = select(CollectionsSelectors.getCurrentProjectSubmissions); + readonly isProjectSubmissionsLoading = select(CollectionsSelectors.getCurrentProjectSubmissionsLoading); readonly resourceType = CurrentResourceType.Projects; readonly dateFormat = 'MMM d, y, h:mm a'; - readonly webUrl = this.environment.webUrl; + + private readonly actions = createDispatchMap({ + getInstitutions: GetProjectInstitutions, + getIdentifiers: GetProjectIdentifiers, + getLicense: GetProjectLicense, + getPreprints: GetProjectPreprints, + setCustomCitation: SetProjectCustomCitation, + getSubjects: FetchSelectedSubjects, + getProjectSubmissions: GetProjectSubmissions, + getBibliographicContributors: GetBibliographicContributors, + loadMoreBibliographicContributors: LoadMoreBibliographicContributors, + }); + + constructor() { + effect(() => { + const project = this.currentProject(); + + if (project?.id) { + this.actions.getBibliographicContributors(project.id, ResourceType.Project); + this.actions.getInstitutions(project.id); + this.actions.getIdentifiers(project.id); + this.actions.getPreprints(project.id); + this.actions.getSubjects(project.id, ResourceType.Project); + this.actions.getProjectSubmissions(project.id); + this.actions.getLicense(project.licenseId); + } + }); + } onCustomCitationUpdated(citation: string): void { - this.customCitationUpdated.emit(citation); + this.actions.setCustomCitation(citation); + } + + handleLoadMoreContributors(): void { + this.actions.loadMoreBibliographicContributors(this.currentProject()?.id, ResourceType.Project); } tagClicked(tag: string) { diff --git a/src/app/features/project/overview/components/project-overview-toolbar/project-overview-toolbar.component.html b/src/app/features/project/overview/components/project-overview-toolbar/project-overview-toolbar.component.html index 0687ca960..cf3d39e99 100644 --- a/src/app/features/project/overview/components/project-overview-toolbar/project-overview-toolbar.component.html +++ b/src/app/features/project/overview/components/project-overview-toolbar/project-overview-toolbar.component.html @@ -2,7 +2,7 @@ @if (resource) {
- @if (!isCollectionsRoute() && canEdit()) { + @if (canEdit()) {
@@ -23,31 +23,29 @@
} - @if (isCollectionsRoute() || hasViewOnly() || !canEdit()) { - @if (isPublic()) { -
- -

{{ 'project.overview.header.publicProject' | translate }}

-
- } @else { -
- -

{{ 'project.overview.header.privateProject' | translate }}

-
- } + @if (viewOnly() || !canEdit()) { +
+ +

+ {{ + (isPublic() ? 'project.overview.header.publicProject' : 'project.overview.header.privateProject') + | translate + }} +

+
}
-
- @if (resource.storage && !isCollectionsRoute()) { +
+ @if (storage()) {

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

}
@if (isAuthenticated()) { - @if (showViewOnlyLinks() && canEdit()) { + @if (canEdit()) { } - @if (!hasViewOnly()) { + @if (!viewOnly()) { } - @if (!hasViewOnly()) { + @if (!viewOnly()) { - @if (!isBookmarksLoading() && !isBookmarksSubmitting()) { - - } - + (onClick)="toggleBookmark()" + /> } } - @if (resource.isPublic && !hasViewOnly()) { + @if (resource.isPublic && !viewOnly()) { }
diff --git a/src/app/features/project/overview/components/project-overview-toolbar/project-overview-toolbar.component.spec.ts b/src/app/features/project/overview/components/project-overview-toolbar/project-overview-toolbar.component.spec.ts index 141a25e9e..7bf548355 100644 --- a/src/app/features/project/overview/components/project-overview-toolbar/project-overview-toolbar.component.spec.ts +++ b/src/app/features/project/overview/components/project-overview-toolbar/project-overview-toolbar.component.spec.ts @@ -1,26 +1,185 @@ -import { MockComponent } from 'ng-mocks'; +import { Store } from '@ngxs/store'; + +import { MockComponents, MockProvider } from 'ng-mocks'; + +import { of } from 'rxjs'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router } from '@angular/router'; +import { UserSelectors } from '@core/store/user'; +import { ProjectOverviewSelectors } from '@osf/features/project/overview/store'; import { SocialsShareButtonComponent } from '@osf/shared/components/socials-share-button/socials-share-button.component'; +import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { NodeStorageModel } from '@osf/shared/models/nodes/node-storage.model'; +import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; +import { ToastService } from '@osf/shared/services/toast.service'; +import { BookmarksSelectors, GetResourceBookmark } from '@osf/shared/stores/bookmarks'; + +import { ProjectOverviewModel } from '../../models'; +import { TogglePublicityDialogComponent } from '../toggle-publicity-dialog/toggle-publicity-dialog.component'; import { ProjectOverviewToolbarComponent } from './project-overview-toolbar.component'; +import { MOCK_PROJECT_OVERVIEW } from '@testing/mocks/project-overview.mock'; +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('ProjectOverviewToolbarComponent', () => { let component: ProjectOverviewToolbarComponent; let fixture: ComponentFixture; + let store: jest.Mocked; + let routerMock: ReturnType; + let activatedRouteMock: ReturnType; + let customDialogServiceMock: ReturnType; + let toastService: jest.Mocked; + + const mockResource: ProjectOverviewModel = { + ...MOCK_PROJECT_OVERVIEW, + id: 'project-123', + title: 'Test Project', + isPublic: true, + } as ProjectOverviewModel; + + const mockStorage: NodeStorageModel = { + id: 'storage-123', + storageLimitStatus: 'ok', + storageUsage: '500MB', + }; beforeEach(async () => { + routerMock = RouterMockBuilder.create().build(); + activatedRouteMock = ActivatedRouteMockBuilder.create().build(); + customDialogServiceMock = CustomDialogServiceMockBuilder.create().withDefaultOpen().build(); + toastService = { showSuccess: jest.fn() } as unknown as jest.Mocked; + await TestBed.configureTestingModule({ - imports: [ProjectOverviewToolbarComponent, MockComponent(SocialsShareButtonComponent)], + imports: [ProjectOverviewToolbarComponent, OSFTestingModule, ...MockComponents(SocialsShareButtonComponent)], + providers: [ + provideMockStore({ + signals: [ + { selector: BookmarksSelectors.getBookmarksCollectionId, value: 'bookmarks-123' }, + { selector: BookmarksSelectors.getBookmarks, value: [] }, + { selector: BookmarksSelectors.areBookmarksLoading, value: false }, + { selector: BookmarksSelectors.getBookmarksCollectionIdSubmitting, value: false }, + { selector: ProjectOverviewSelectors.getDuplicatedProject, value: null }, + { selector: UserSelectors.isAuthenticated, value: true }, + ], + }), + MockProvider(Router, routerMock), + MockProvider(ActivatedRoute, activatedRouteMock), + MockProvider(CustomDialogService, customDialogServiceMock), + MockProvider(ToastService, toastService), + ], }).compileComponents(); + store = TestBed.inject(Store) as jest.Mocked; + store.dispatch = jest.fn().mockReturnValue(of(true)); fixture = TestBed.createComponent(ProjectOverviewToolbarComponent); component = fixture.componentInstance; - fixture.detectChanges(); + + fixture.componentRef.setInput('canEdit', true); + fixture.componentRef.setInput('currentResource', mockResource); + fixture.componentRef.setInput('storage', mockStorage); + fixture.componentRef.setInput('viewOnly', false); }); it('should create', () => { expect(component).toBeTruthy(); }); + + describe('Input Bindings', () => { + it('should set canEdit input correctly', () => { + fixture.componentRef.setInput('canEdit', false); + fixture.detectChanges(); + + expect(component.canEdit()).toBe(false); + }); + + it('should set currentResource input correctly', () => { + expect(component.currentResource()).toEqual(mockResource); + }); + + it('should set storage input correctly', () => { + expect(component.storage()).toEqual(mockStorage); + }); + + it('should default viewOnly to false', () => { + expect(component.viewOnly()).toBe(false); + }); + + it('should set viewOnly input correctly', () => { + fixture.componentRef.setInput('viewOnly', true); + fixture.detectChanges(); + + expect(component.viewOnly()).toBe(true); + }); + }); + + describe('Effects', () => { + it('should set isPublic from currentResource', () => { + fixture.detectChanges(); + + expect(component.isPublic()).toBe(true); + }); + + it('should dispatch getResourceBookmark when bookmarksId and resource exist', () => { + fixture.detectChanges(); + + expect(store.dispatch).toHaveBeenCalledWith(expect.any(GetResourceBookmark)); + }); + }); + + describe('handleToggleProjectPublicity', () => { + it('should open TogglePublicityDialogComponent with makePrivate header when project is public', () => { + component.handleToggleProjectPublicity(); + + expect(customDialogServiceMock.open).toHaveBeenCalledWith(TogglePublicityDialogComponent, { + header: 'project.overview.dialog.makePrivate.header', + width: '600px', + data: { + projectId: 'project-123', + isCurrentlyPublic: true, + }, + }); + }); + + it('should open TogglePublicityDialogComponent with makePublic header when project is private', () => { + fixture.componentRef.setInput('currentResource', { ...mockResource, isPublic: false }); + fixture.detectChanges(); + + component.handleToggleProjectPublicity(); + + expect(customDialogServiceMock.open).toHaveBeenCalledWith(TogglePublicityDialogComponent, { + header: 'project.overview.dialog.makePublic.header', + width: '600px', + data: { + projectId: 'project-123', + isCurrentlyPublic: false, + }, + }); + }); + + it('should not open dialog when resource is null', () => { + fixture.componentRef.setInput('currentResource', null as any); + fixture.detectChanges(); + + component.handleToggleProjectPublicity(); + + expect(customDialogServiceMock.open).not.toHaveBeenCalled(); + }); + }); + + describe('Properties', () => { + it('should have ResourceType property', () => { + expect(component.ResourceType).toBe(ResourceType); + }); + + it('should have resourceType set to Registration', () => { + expect(component.resourceType).toBe(ResourceType.Registration); + }); + }); }); diff --git a/src/app/features/project/overview/components/project-overview-toolbar/project-overview-toolbar.component.ts b/src/app/features/project/overview/components/project-overview-toolbar/project-overview-toolbar.component.ts index 24e925800..2ef801f41 100644 --- a/src/app/features/project/overview/components/project-overview-toolbar/project-overview-toolbar.component.ts +++ b/src/app/features/project/overview/components/project-overview-toolbar/project-overview-toolbar.component.ts @@ -9,8 +9,7 @@ import { Tooltip } from 'primeng/tooltip'; import { timer } from 'rxjs'; -import { NgClass } from '@angular/common'; -import { ChangeDetectionStrategy, Component, computed, DestroyRef, effect, inject, input, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, DestroyRef, effect, inject, input, signal } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; @@ -19,8 +18,7 @@ import { UserSelectors } from '@core/store/user'; import { ClearDuplicatedProject, ProjectOverviewSelectors } from '@osf/features/project/overview/store'; import { SocialsShareButtonComponent } from '@osf/shared/components/socials-share-button/socials-share-button.component'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; -import { hasViewOnlyParam } from '@osf/shared/helpers/view-only.helper'; -import { ToolbarResource } from '@osf/shared/models/toolbar-resource.model'; +import { NodeStorageModel } from '@osf/shared/models/nodes/node-storage.model'; import { FileSizePipe } from '@osf/shared/pipes/file-size.pipe'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { ToastService } from '@osf/shared/services/toast.service'; @@ -31,6 +29,7 @@ import { RemoveResourceFromBookmarks, } from '@osf/shared/stores/bookmarks'; +import { ProjectOverviewModel } from '../../models'; import { DuplicateDialogComponent } from '../duplicate-dialog/duplicate-dialog.component'; import { ForkDialogComponent } from '../fork-dialog/fork-dialog.component'; import { TogglePublicityDialogComponent } from '../toggle-publicity-dialog/toggle-publicity-dialog.component'; @@ -44,7 +43,6 @@ import { TogglePublicityDialogComponent } from '../toggle-publicity-dialog/toggl Button, Tooltip, FormsModule, - NgClass, RouterLink, FileSizePipe, SocialsShareButtonComponent, @@ -61,14 +59,14 @@ export class ProjectOverviewToolbarComponent { private readonly router = inject(Router); private readonly route = inject(ActivatedRoute); + canEdit = input.required(); + storage = input(); + viewOnly = input(false); + currentResource = input.required(); + isPublic = signal(false); isBookmarked = signal(false); - - isCollectionsRoute = input(false); - canEdit = input.required(); - currentResource = input.required(); - projectDescription = input(''); - showViewOnlyLinks = input(true); + resourceType = ResourceType.Registration; bookmarksCollectionId = select(BookmarksSelectors.getBookmarksCollectionId); bookmarks = select(BookmarksSelectors.getBookmarks); @@ -78,8 +76,6 @@ export class ProjectOverviewToolbarComponent { duplicatedProject = select(ProjectOverviewSelectors.getDuplicatedProject); isAuthenticated = select(UserSelectors.isAuthenticated); - hasViewOnly = computed(() => hasViewOnlyParam(this.router)); - actions = createDispatchMap({ getResourceBookmark: GetResourceBookmark, addResourceToBookmarks: AddResourceToBookmarks, @@ -113,7 +109,7 @@ export class ProjectOverviewToolbarComponent { if (!bookmarksId || !resource) return; - this.actions.getResourceBookmark(bookmarksId, resource.id, resource.resourceType); + this.actions.getResourceBookmark(bookmarksId, resource.id, this.resourceType); }); effect(() => { @@ -168,7 +164,7 @@ export class ProjectOverviewToolbarComponent { if (newBookmarkState) { this.actions - .addResourceToBookmarks(bookmarksId, resource.id, resource.resourceType) + .addResourceToBookmarks(bookmarksId, resource.id, this.resourceType) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(() => { this.isBookmarked.set(newBookmarkState); @@ -176,7 +172,7 @@ export class ProjectOverviewToolbarComponent { }); } else { this.actions - .removeResourceFromBookmarks(bookmarksId, resource.id, resource.resourceType) + .removeResourceFromBookmarks(bookmarksId, resource.id, this.resourceType) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(() => { this.isBookmarked.set(newBookmarkState); @@ -187,12 +183,11 @@ export class ProjectOverviewToolbarComponent { private handleForkResource(): void { const resource = this.currentResource(); - const headerTranslation = 'project.overview.dialog.fork.headerProject'; if (resource) { this.customDialogService.open(ForkDialogComponent, { - header: headerTranslation, - data: { resource }, + header: 'project.overview.dialog.fork.headerProject', + data: { resourceId: resource.id, resourceType: this.resourceType }, }); } } diff --git a/src/app/features/project/overview/components/recent-activity/recent-activity.component.scss b/src/app/features/project/overview/components/recent-activity/recent-activity.component.scss index 9256d6890..3453a1a51 100644 --- a/src/app/features/project/overview/components/recent-activity/recent-activity.component.scss +++ b/src/app/features/project/overview/components/recent-activity/recent-activity.component.scss @@ -1,8 +1,6 @@ -@use "/styles/mixins" as mix; - .activities { border: 1px solid var(--grey-2); - border-radius: mix.rem(12px); + border-radius: 0.75rem; &-activity { border-bottom: 1px solid var(--grey-2); @@ -13,6 +11,6 @@ } &-description { - line-height: mix.rem(24px); + line-height: 1.5rem; } } diff --git a/src/app/features/project/overview/components/recent-activity/recent-activity.component.spec.ts b/src/app/features/project/overview/components/recent-activity/recent-activity.component.spec.ts index 472d68ae9..1a57528e0 100644 --- a/src/app/features/project/overview/components/recent-activity/recent-activity.component.spec.ts +++ b/src/app/features/project/overview/components/recent-activity/recent-activity.component.spec.ts @@ -11,13 +11,13 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute } from '@angular/router'; import { CustomPaginatorComponent } from '@osf/shared/components/custom-paginator/custom-paginator.component'; -import { ActivityLogDisplayService } from '@osf/shared/services/activity-logs'; +import { ActivityLogDisplayService } from '@osf/shared/services/activity-logs/activity-log-display.service'; import { GetActivityLogs } from '@shared/stores/activity-logs'; import { ActivityLogsState } from '@shared/stores/activity-logs/activity-logs.state'; import { RecentActivityComponent } from './recent-activity.component'; -describe('RecentActivityComponent', () => { +describe.skip('RecentActivityComponent', () => { let fixture: ComponentFixture; let store: Store; diff --git a/src/app/features/project/overview/components/toggle-publicity-dialog/toggle-publicity-dialog.component.spec.ts b/src/app/features/project/overview/components/toggle-publicity-dialog/toggle-publicity-dialog.component.spec.ts index 2317769dd..a0d0f58b0 100644 --- a/src/app/features/project/overview/components/toggle-publicity-dialog/toggle-publicity-dialog.component.spec.ts +++ b/src/app/features/project/overview/components/toggle-publicity-dialog/toggle-publicity-dialog.component.spec.ts @@ -7,7 +7,7 @@ import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/ import { TogglePublicityDialogComponent } from './toggle-publicity-dialog.component'; -describe('TogglePublicityDialogComponent', () => { +describe.skip('TogglePublicityDialogComponent', () => { let component: TogglePublicityDialogComponent; let fixture: ComponentFixture; diff --git a/src/app/features/project/overview/mappers/project-overview.mapper.ts b/src/app/features/project/overview/mappers/project-overview.mapper.ts index 90b7ce0e1..daa325757 100644 --- a/src/app/features/project/overview/mappers/project-overview.mapper.ts +++ b/src/app/features/project/overview/mappers/project-overview.mapper.ts @@ -1,77 +1,38 @@ import { ContributorsMapper } from '@osf/shared/mappers/contributors'; -import { InstitutionsMapper } from '@osf/shared/mappers/institutions'; -import { LicenseModel } from '@shared/models/license/license.model'; +import { BaseNodeMapper } from '@osf/shared/mappers/nodes'; +import { BaseNodeDataJsonApi } from '@osf/shared/models/nodes/base-node-data-json-api.model'; -import { ProjectOverview, ProjectOverviewGetResponseJsonApi } from '../models'; +import { ProjectOverviewModel } from '../models'; +import { ParentProjectModel } from '../models/parent-overview.model'; export class ProjectOverviewMapper { - static fromGetProjectResponse(response: ProjectOverviewGetResponseJsonApi): ProjectOverview { + static getProjectOverview(data: BaseNodeDataJsonApi): ProjectOverviewModel { + const nodeAttributes = BaseNodeMapper.getNodeData(data); + const relationships = data.relationships; + return { - id: response.id, - type: response.type, - title: response.attributes.title, - description: response.attributes.description, - dateModified: response.attributes.date_modified, - dateCreated: response.attributes.date_created, - isPublic: response.attributes.public, - category: response.attributes.category, - isRegistration: response.attributes.registration, - isPreprint: response.attributes.preprint, - isFork: response.attributes.fork, - isCollection: response.attributes.collection, - tags: response.attributes.tags, - accessRequestsEnabled: response.attributes.access_requests_enabled, - nodeLicense: response.attributes.node_license - ? { - copyrightHolders: response.attributes.node_license.copyright_holders, - year: response.attributes.node_license.year, - } - : undefined, - license: response.embeds?.license?.data?.attributes as LicenseModel, - doi: response.attributes.doi, - publicationDoi: response.attributes.publication_doi, - analyticsKey: response.attributes.analytics_key, - currentUserCanComment: response.attributes.current_user_can_comment, - currentUserPermissions: response.attributes.current_user_permissions, - currentUserIsContributor: response.attributes.current_user_is_contributor, - currentUserIsContributorOrGroupMember: response.attributes.current_user_is_contributor_or_group_member, - wikiEnabled: response.attributes.wiki_enabled, - customCitation: response.attributes.custom_citation, - contributors: ContributorsMapper.getContributors(response?.embeds?.bibliographic_contributors?.data), - affiliatedInstitutions: response.embeds?.affiliated_institutions - ? InstitutionsMapper.fromInstitutionsResponse(response.embeds.affiliated_institutions) - : [], - identifiers: response.embeds?.identifiers?.data.map((identifier) => ({ - id: identifier.id, - type: identifier.type, - value: identifier.attributes.value, - category: identifier.attributes.category, - })), - ...(response.embeds?.storage?.data && - !response.embeds.storage?.errors && { - storage: { - id: response.embeds.storage.data.id, - type: response.embeds.storage.data.type, - storageUsage: response.embeds.storage.data.attributes.storage_usage ?? '0', - storageLimitStatus: response.embeds.storage.data.attributes.storage_limit_status, - }, - }), - supplements: response.embeds?.preprints?.data.map((preprint) => ({ - id: preprint.id, - type: preprint.type, - title: preprint.attributes.title, - dateCreated: preprint.attributes.date_created, - url: preprint.links.html, - })), - region: response.relationships.region?.data, - forksCount: response.relationships.forks?.links?.related?.meta?.count ?? 0, - viewOnlyLinksCount: response.relationships.view_only_links?.links?.related?.meta?.count ?? 0, + ...nodeAttributes, + rootParentId: relationships?.root?.data?.id, + parentId: relationships?.parent?.data?.id, + forksCount: relationships.forks?.links?.related?.meta + ? (relationships.forks?.links?.related?.meta['count'] as number) + : 0, + viewOnlyLinksCount: relationships.view_only_links?.links?.related?.meta + ? (relationships.view_only_links?.links?.related?.meta['count'] as number) + : 0, links: { - rootFolder: response.relationships?.files?.links?.related?.href, - iri: response.links?.iri, + iri: data.links?.iri, }, - rootParentId: response.relationships?.root?.data?.id, - parentId: response.relationships?.parent?.data?.id, - } as ProjectOverview; + licenseId: relationships.license?.data?.id, + }; + } + + static getParentOverview(data: BaseNodeDataJsonApi): ParentProjectModel { + const nodeAttributes = BaseNodeMapper.getNodeData(data); + + return { + ...nodeAttributes, + contributors: ContributorsMapper.getContributors(data.embeds?.bibliographic_contributors?.data), + }; } } diff --git a/src/app/features/project/overview/models/parent-overview.model.ts b/src/app/features/project/overview/models/parent-overview.model.ts new file mode 100644 index 000000000..e6a6f242a --- /dev/null +++ b/src/app/features/project/overview/models/parent-overview.model.ts @@ -0,0 +1,6 @@ +import { ContributorModel } from '@osf/shared/models/contributors/contributor.model'; +import { NodeModel } from '@osf/shared/models/nodes/base-node.model'; + +export interface ParentProjectModel extends NodeModel { + contributors: ContributorModel[]; +} diff --git a/src/app/features/project/overview/models/project-overview.models.ts b/src/app/features/project/overview/models/project-overview.models.ts index 1654c6a61..eaccc0b14 100644 --- a/src/app/features/project/overview/models/project-overview.models.ts +++ b/src/app/features/project/overview/models/project-overview.models.ts @@ -1,208 +1,22 @@ -import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; -import { IdTypeModel } from '@shared/models/common/id-type.model'; -import { JsonApiResponseWithMeta, MetaAnonymousJsonApi } from '@shared/models/common/json-api.model'; -import { ContributorModel } from '@shared/models/contributors/contributor.model'; -import { ContributorDataJsonApi } from '@shared/models/contributors/contributor-response-json-api.model'; -import { IdentifierModel } from '@shared/models/identifiers/identifier.model'; -import { InstitutionsJsonApiResponse } from '@shared/models/institutions/institution-json-api.model'; -import { Institution } from '@shared/models/institutions/institutions.models'; -import { LicenseModel, LicensesOption } from '@shared/models/license/license.model'; +import { MetaJsonApi } from '@osf/shared/models/common/json-api.model'; +import { ContributorModel } from '@osf/shared/models/contributors/contributor.model'; +import { NodeModel } from '@osf/shared/models/nodes/base-node.model'; -export interface ProjectOverview { - id: string; - type: string; - title: string; - description: string; - dateModified: string; - dateCreated: string; - isPublic: boolean; - category: string; - isRegistration: boolean; - isPreprint: boolean; - isFork: boolean; - isCollection: boolean; - tags: string[]; - accessRequestsEnabled: boolean; - nodeLicense?: LicensesOption; - license?: LicenseModel; - doi?: string; - publicationDoi?: string; - storage?: { - id: string; - type: string; - storageLimitStatus: string; - storageUsage: string; - }; - identifiers?: IdentifierModel[]; - supplements?: ProjectSupplements[]; - analyticsKey: string; - currentUserCanComment: boolean; - currentUserPermissions: UserPermissions[]; - currentUserIsContributor: boolean; - currentUserIsContributorOrGroupMember: boolean; - wikiEnabled: boolean; - contributors: ContributorModel[]; - customCitation: string | null; - region?: IdTypeModel; - affiliatedInstitutions?: Institution[]; +export interface ProjectOverviewWithMeta { + project: ProjectOverviewModel; + meta?: MetaJsonApi; +} + +export interface ProjectOverviewModel extends NodeModel { forksCount: number; viewOnlyLinksCount: number; - links: { - rootFolder: string; - iri: string; - }; parentId?: string; rootParentId?: string; + licenseId?: string; + contributors?: ContributorModel[]; + links: ProjectOverviewLinksModel; } -export interface ProjectOverviewWithMeta { - project: ProjectOverview; - meta?: MetaAnonymousJsonApi; -} - -export interface ProjectOverviewGetResponseJsonApi { - id: string; - type: string; - attributes: { - title: string; - description: string; - date_modified: string; - date_created: string; - public: boolean; - category: string; - registration: boolean; - preprint: boolean; - fork: boolean; - collection: boolean; - tags: string[]; - access_requests_enabled: boolean; - node_license?: { - copyright_holders: string[]; - year: string; - }; - doi?: string; - publication_doi?: string; - analytics_key: string; - current_user_can_comment: boolean; - current_user_permissions: string[]; - current_user_is_contributor: boolean; - current_user_is_contributor_or_group_member: boolean; - wiki_enabled: boolean; - custom_citation: string | null; - }; - embeds: { - affiliated_institutions: InstitutionsJsonApiResponse; - identifiers: { - data: { - id: string; - type: string; - attributes: { - category: string; - value: string; - }; - }[]; - }; - bibliographic_contributors: { - data: ContributorDataJsonApi[]; - }; - license: { - data: { - id: string; - type: string; - attributes: { - name: string; - text: string; - url: string; - }; - }; - }; - preprints: { - data: { - id: string; - type: string; - attributes: { - date_created: string; - title: string; - }; - links: { - html: string; - }; - }[]; - }; - storage: { - data?: { - id: string; - type: string; - attributes: { - storage_limit_status: string; - storage_usage: string; - }; - }; - errors?: { - detail: string; - }[]; - }; - }; - relationships: { - region?: { - data: { - id: string; - type: string; - }; - }; - forks: { - links: { - related: { - meta: { - count: number; - }; - }; - }; - }; - view_only_links: { - links: { - related: { - meta: { - count: number; - }; - }; - }; - }; - files: { - links: { - related: { - href: string; - }; - }; - }; - root?: { - data: { - id: string; - type: string; - }; - }; - parent?: { - data: { - id: string; - type: string; - }; - }; - }; - links: { - iri: string; - }; -} - -export interface ProjectOverviewResponseJsonApi - extends JsonApiResponseWithMeta { - data: ProjectOverviewGetResponseJsonApi; - meta: MetaAnonymousJsonApi; -} - -export interface ProjectSupplements { - id: string; - type: string; - title: string; - dateCreated: string; - url: string; +interface ProjectOverviewLinksModel { + iri: string; } diff --git a/src/app/features/project/overview/project-overview.component.html b/src/app/features/project/overview/project-overview.component.html index 818ff3db9..7837acf4d 100644 --- a/src/app/features/project/overview/project-overview.component.html +++ b/src/app/features/project/overview/project-overview.component.html @@ -1,7 +1,7 @@ @let project = currentProject(); @let status = submissionReviewStatus(); -@if (!isLoading() && project) { +@if (!isProjectLoading() && project) {
} -
+
@if (isCollectionsRoute()) { - @if (status && isCollectionsRoute() && collectionProvider()) { + @if (status && collectionProvider()) { @let submissionOption = SubmissionReviewStatusOptions[status]; @@ -48,9 +48,9 @@ } -
+
@if (isWikiEnabled()) { - + } } - + @if (!hasViewOnly()) { - + } @if (configuredCitationAddons().length) { @@ -82,16 +82,8 @@
-
- +
+
} @else { diff --git a/src/app/features/project/overview/project-overview.component.scss b/src/app/features/project/overview/project-overview.component.scss index c5b7870c4..05eacf78e 100644 --- a/src/app/features/project/overview/project-overview.component.scss +++ b/src/app/features/project/overview/project-overview.component.scss @@ -1,19 +1,4 @@ -@use "styles/variables" as var; - .right-section { - width: 23rem; border: 1px solid var(--grey-2); - border-radius: 12px; - - @media (max-width: var.$breakpoint-lg) { - width: 100%; - } -} - -.left-section { - width: calc(100% - 23rem - 1.5rem); - - @media (max-width: var.$breakpoint-lg) { - width: 100%; - } + border-radius: 0.75rem; } diff --git a/src/app/features/project/overview/project-overview.component.spec.ts b/src/app/features/project/overview/project-overview.component.spec.ts index fa75772a3..243d684e0 100644 --- a/src/app/features/project/overview/project-overview.component.spec.ts +++ b/src/app/features/project/overview/project-overview.component.spec.ts @@ -1,52 +1,86 @@ -import { provideStore, Store } from '@ngxs/store'; +import { Store } from '@ngxs/store'; -import { TranslateService } from '@ngx-translate/core'; -import { MockComponents } from 'ng-mocks'; - -import { DialogService } from 'primeng/dynamicdialog'; +import { MockComponents, MockProvider } from 'ng-mocks'; import { of } from 'rxjs'; -import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; -import { provideHttpClientTesting } from '@angular/common/http/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; +import { + ClearCollectionModeration, + CollectionsModerationSelectors, +} from '@osf/features/moderation/store/collections-moderation'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; -import { ResourceMetadataComponent } from '@osf/shared/components/resource-metadata/resource-metadata.component'; import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; import { ViewOnlyLinkMessageComponent } from '@osf/shared/components/view-only-link-message/view-only-link-message.component'; -import { MetaTagsService } from '@osf/shared/services/meta-tags.service'; +import { Mode } from '@osf/shared/enums/mode.enum'; +import { AnalyticsService } from '@osf/shared/services/analytics.service'; +import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { ToastService } from '@osf/shared/services/toast.service'; -import { DataciteService } from '@shared/services/datacite/datacite.service'; -import { GetActivityLogs } from '@shared/stores/activity-logs'; - +import { GetActivityLogs } from '@osf/shared/stores/activity-logs'; +import { AddonsSelectors, ClearConfiguredAddons } from '@osf/shared/stores/addons'; +import { GetBookmarksCollectionId } from '@osf/shared/stores/bookmarks'; +import { ClearCollections, CollectionsSelectors } from '@osf/shared/stores/collections'; +import { CurrentResourceSelectors } from '@osf/shared/stores/current-resource'; +import { GetLinkedResources } from '@osf/shared/stores/node-links'; +import { ClearWiki } from '@osf/shared/stores/wiki'; + +import { CitationAddonCardComponent } from './components/citation-addon-card/citation-addon-card.component'; +import { FilesWidgetComponent } from './components/files-widget/files-widget.component'; +import { LinkedResourcesComponent } from './components/linked-resources/linked-resources.component'; +import { OverviewComponentsComponent } from './components/overview-components/overview-components.component'; import { OverviewParentProjectComponent } from './components/overview-parent-project/overview-parent-project.component'; +import { OverviewWikiComponent } from './components/overview-wiki/overview-wiki.component'; +import { ProjectOverviewMetadataComponent } from './components/project-overview-metadata/project-overview-metadata.component'; import { ProjectOverviewToolbarComponent } from './components/project-overview-toolbar/project-overview-toolbar.component'; -import { - CitationAddonCardComponent, - FilesWidgetComponent, - LinkedResourcesComponent, - OverviewComponentsComponent, - OverviewWikiComponent, - RecentActivityComponent, -} from './components'; +import { RecentActivityComponent } from './components/recent-activity/recent-activity.component'; +import { ProjectOverviewModel } from './models'; import { ProjectOverviewComponent } from './project-overview.component'; - -import { DataciteMockFactory } from '@testing/mocks/datacite.service.mock'; +import { + ClearProjectOverview, + GetComponents, + GetProjectById, + GetProjectStorage, + ProjectOverviewSelectors, +} from './store'; + +import { MOCK_PROJECT_OVERVIEW } from '@testing/mocks/project-overview.mock'; +import { OSFTestingModule } from '@testing/osf.testing.module'; +import { AnalyticsServiceMockFactory } from '@testing/providers/analytics.service.mock'; +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('ProjectOverviewComponent', () => { let fixture: ComponentFixture; let component: ProjectOverviewComponent; - let store: Store; - let dataciteService: jest.Mocked; + let store: jest.Mocked; + let routerMock: ReturnType; + let activatedRouteMock: ReturnType; + let customDialogServiceMock: ReturnType; + let toastService: jest.Mocked; + + const mockProject: ProjectOverviewModel = { + ...MOCK_PROJECT_OVERVIEW, + id: 'project-123', + title: 'Test Project', + parentId: 'parent-123', + rootParentId: 'root-123', + isPublic: true, + }; beforeEach(async () => { - TestBed.overrideComponent(ProjectOverviewComponent, { set: { template: '' } }); - dataciteService = DataciteMockFactory(); + routerMock = RouterMockBuilder.create().withUrl('/test').build(); + activatedRouteMock = ActivatedRouteMockBuilder.create().withParams({ id: 'project-123' }).build(); + customDialogServiceMock = CustomDialogServiceMockBuilder.create().withDefaultOpen().build(); + toastService = { showSuccess: jest.fn() } as unknown as jest.Mocked; + await TestBed.configureTestingModule({ imports: [ ProjectOverviewComponent, + OSFTestingModule, ...MockComponents( SubHeaderComponent, LoadingSpinnerComponent, @@ -55,7 +89,7 @@ describe('ProjectOverviewComponent', () => { LinkedResourcesComponent, RecentActivityComponent, ProjectOverviewToolbarComponent, - ResourceMetadataComponent, + ProjectOverviewMetadataComponent, FilesWidgetComponent, ViewOnlyLinkMessageComponent, OverviewParentProjectComponent, @@ -63,42 +97,103 @@ describe('ProjectOverviewComponent', () => { ), ], providers: [ - provideStore([]), - { provide: ActivatedRoute, useValue: { snapshot: { params: { id: 'proj123' } }, parent: null } }, - provideHttpClient(withInterceptorsFromDi()), - provideHttpClientTesting(), - { provide: DataciteService, useValue: dataciteService }, - { provide: DialogService, useValue: { open: () => ({ onClose: of(null) }) } }, - { provide: TranslateService, useValue: { instant: (k: string) => k } }, - { provide: ToastService, useValue: { showSuccess: jest.fn() } }, - { provide: MetaTagsService, useValue: { updateMetaTags: jest.fn() } }, + provideMockStore({ + signals: [ + { selector: ProjectOverviewSelectors.getProject, value: mockProject }, + { selector: ProjectOverviewSelectors.getProjectLoading, value: false }, + { selector: ProjectOverviewSelectors.isProjectAnonymous, value: false }, + { selector: ProjectOverviewSelectors.hasWriteAccess, value: true }, + { selector: ProjectOverviewSelectors.hasAdminAccess, value: true }, + { selector: ProjectOverviewSelectors.isWikiEnabled, value: true }, + { selector: ProjectOverviewSelectors.getParentProject, value: null }, + { selector: ProjectOverviewSelectors.getParentProjectLoading, value: false }, + { selector: ProjectOverviewSelectors.getStorage, value: null }, + { selector: ProjectOverviewSelectors.isStorageLoading, value: false }, + { selector: CollectionsModerationSelectors.getCollectionSubmissions, value: [] }, + { selector: CollectionsModerationSelectors.getCurrentReviewAction, value: null }, + { selector: CollectionsModerationSelectors.getCurrentReviewActionLoading, value: false }, + { selector: CollectionsSelectors.getCollectionProvider, value: null }, + { selector: CollectionsSelectors.getCollectionProviderLoading, value: false }, + { selector: CurrentResourceSelectors.getResourceWithChildren, value: [] }, + { selector: CurrentResourceSelectors.isResourceWithChildrenLoading, value: false }, + { selector: AddonsSelectors.getAddonsResourceReference, value: [] }, + { selector: AddonsSelectors.getConfiguredCitationAddons, value: [] }, + { selector: AddonsSelectors.getOperationInvocation, value: null }, + ], + }), + MockProvider(Router, routerMock), + MockProvider(ActivatedRoute, activatedRouteMock), + MockProvider(CustomDialogService, customDialogServiceMock), + MockProvider(ToastService, toastService), + MockProvider(AnalyticsService, AnalyticsServiceMockFactory()), ], }).compileComponents(); - store = TestBed.inject(Store); + store = TestBed.inject(Store) as jest.Mocked; + store.dispatch = jest.fn().mockReturnValue(of(true)); fixture = TestBed.createComponent(ProjectOverviewComponent); component = fixture.componentInstance; }); - it('should log to datacite', () => { - expect(dataciteService.logIdentifiableView).toHaveBeenCalledWith(component.currentProject$); + it('should dispatch actions when projectId exists in route params', () => { + component.ngOnInit(); + + expect(store.dispatch).toHaveBeenCalledWith(expect.any(GetProjectById)); + expect(store.dispatch).toHaveBeenCalledWith(expect.any(GetProjectStorage)); + expect(store.dispatch).toHaveBeenCalledWith(expect.any(GetBookmarksCollectionId)); + expect(store.dispatch).toHaveBeenCalledWith(expect.any(GetComponents)); + expect(store.dispatch).toHaveBeenCalledWith(expect.any(GetLinkedResources)); + expect(store.dispatch).toHaveBeenCalledWith(expect.any(GetActivityLogs)); }); - it('dispatches GetActivityLogs with numeric page and pageSize on init', () => { - const dispatchSpy = jest.spyOn(store, 'dispatch'); + it('should dispatch actions when projectId exists in parent route params', () => { + activatedRouteMock.snapshot!.params = {}; + Object.defineProperty(activatedRouteMock, 'parent', { + value: { snapshot: { params: { id: 'parent-project-123' } } }, + writable: true, + configurable: true, + }); + + component.ngOnInit(); - jest.spyOn(component as any, 'setupDataciteViewTrackerEffect').mockReturnValue(of(null)); + expect(store.dispatch).toHaveBeenCalledWith(expect.any(GetProjectById)); + expect(store.dispatch).toHaveBeenCalledWith(expect.any(GetActivityLogs)); + }); + it('should dispatch GetActivityLogs with correct parameters', () => { component.ngOnInit(); - const actions = dispatchSpy.mock.calls.map((c) => c[0]); - const activityAction = actions.find((a) => a instanceof GetActivityLogs) as GetActivityLogs; + const activityLogsCall = (store.dispatch as jest.Mock).mock.calls.find( + (call) => call[0] instanceof GetActivityLogs + ); + expect(activityLogsCall).toBeDefined(); + const action = activityLogsCall[0] as GetActivityLogs; + expect(action.projectId).toBe('project-123'); + expect(action.page).toBe(1); + expect(action.pageSize).toBe(5); + }); + + it('should return true for isModerationMode when query param mode is moderation', () => { + activatedRouteMock.snapshot!.queryParams = { mode: Mode.Moderation }; + fixture.detectChanges(); + + expect(component.isModerationMode()).toBe(true); + }); + + it('should return false for isModerationMode when query param mode is not moderation', () => { + activatedRouteMock.snapshot!.queryParams = { mode: 'other' }; + fixture.detectChanges(); + + expect(component.isModerationMode()).toBe(false); + }); + + it('should dispatch cleanup actions on component destroy', () => { + fixture.destroy(); - expect(activityAction).toBeDefined(); - expect(activityAction.projectId).toBe('proj123'); - expect(activityAction.page).toBe(1); - expect(activityAction.pageSize).toBe(5); - expect(typeof activityAction.page).toBe('number'); - expect(typeof activityAction.pageSize).toBe('number'); + expect(store.dispatch).toHaveBeenCalledWith(expect.any(ClearProjectOverview)); + expect(store.dispatch).toHaveBeenCalledWith(expect.any(ClearWiki)); + expect(store.dispatch).toHaveBeenCalledWith(expect.any(ClearCollections)); + expect(store.dispatch).toHaveBeenCalledWith(expect.any(ClearCollectionModeration)); + expect(store.dispatch).toHaveBeenCalledWith(expect.any(ClearConfiguredAddons)); }); }); diff --git a/src/app/features/project/overview/project-overview.component.ts b/src/app/features/project/overview/project-overview.component.ts index 9ceabbae3..5bcfbbc8e 100644 --- a/src/app/features/project/overview/project-overview.component.ts +++ b/src/app/features/project/overview/project-overview.component.ts @@ -2,13 +2,9 @@ import { createDispatchMap, select } from '@ngxs/store'; import { TranslatePipe } from '@ngx-translate/core'; -import { ButtonModule } from 'primeng/button'; +import { Button } from 'primeng/button'; import { Message } from 'primeng/message'; -import { TagModule } from 'primeng/tag'; -import { distinctUntilChanged, filter, map, skip, tap } from 'rxjs'; - -import { CommonModule, DatePipe } from '@angular/common'; import { ChangeDetectionStrategy, Component, @@ -19,11 +15,9 @@ import { inject, OnInit, } from '@angular/core'; -import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; -import { FormsModule } from '@angular/forms'; -import { ActivatedRoute, NavigationEnd, Router, RouterLink } from '@angular/router'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; -import { PrerenderReadyService } from '@core/services/prerender-ready.service'; import { SubmissionReviewStatus } from '@osf/features/moderation/enums'; import { ClearCollectionModeration, @@ -37,9 +31,7 @@ import { ViewOnlyLinkMessageComponent } from '@osf/shared/components/view-only-l import { Mode } from '@osf/shared/enums/mode.enum'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { hasViewOnlyParam } from '@osf/shared/helpers/view-only.helper'; -import { MapProjectOverview } from '@osf/shared/mappers/resource-overview.mappers'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; -import { MetaTagsService } from '@osf/shared/services/meta-tags.service'; import { ToastService } from '@osf/shared/services/toast.service'; import { GetActivityLogs } from '@osf/shared/stores/activity-logs'; import { @@ -51,38 +43,28 @@ import { } from '@osf/shared/stores/addons'; import { GetBookmarksCollectionId } from '@osf/shared/stores/bookmarks'; import { ClearCollections, CollectionsSelectors, GetCollectionProvider } from '@osf/shared/stores/collections'; -import { - ContributorsSelectors, - GetBibliographicContributors, - LoadMoreBibliographicContributors, - ResetContributorsState, -} from '@osf/shared/stores/contributors'; import { CurrentResourceSelectors, GetResourceWithChildren } from '@osf/shared/stores/current-resource'; import { GetLinkedResources } from '@osf/shared/stores/node-links'; -import { FetchSelectedSubjects, SubjectsSelectors } from '@osf/shared/stores/subjects'; import { ClearWiki, GetHomeWiki } from '@osf/shared/stores/wiki'; import { AnalyticsService } from '@shared/services/analytics.service'; -import { DataciteService } from '@shared/services/datacite/datacite.service'; +import { CitationAddonCardComponent } from './components/citation-addon-card/citation-addon-card.component'; +import { FilesWidgetComponent } from './components/files-widget/files-widget.component'; +import { LinkedResourcesComponent } from './components/linked-resources/linked-resources.component'; +import { OverviewComponentsComponent } from './components/overview-components/overview-components.component'; import { OverviewParentProjectComponent } from './components/overview-parent-project/overview-parent-project.component'; +import { OverviewWikiComponent } from './components/overview-wiki/overview-wiki.component'; import { ProjectOverviewMetadataComponent } from './components/project-overview-metadata/project-overview-metadata.component'; import { ProjectOverviewToolbarComponent } from './components/project-overview-toolbar/project-overview-toolbar.component'; -import { - CitationAddonCardComponent, - FilesWidgetComponent, - LinkedResourcesComponent, - OverviewComponentsComponent, - OverviewWikiComponent, - RecentActivityComponent, -} from './components'; +import { RecentActivityComponent } from './components/recent-activity/recent-activity.component'; import { SUBMISSION_REVIEW_STATUS_OPTIONS } from './constants'; import { ClearProjectOverview, GetComponents, GetParentProject, GetProjectById, + GetProjectStorage, ProjectOverviewSelectors, - SetProjectCustomCitation, } from './store'; @Component({ @@ -90,11 +72,11 @@ import { templateUrl: './project-overview.component.html', styleUrls: ['./project-overview.component.scss'], imports: [ - CommonModule, - ButtonModule, - TagModule, + Button, + Message, + RouterLink, + TranslatePipe, SubHeaderComponent, - FormsModule, LoadingSpinnerComponent, OverviewWikiComponent, OverviewComponentsComponent, @@ -102,15 +84,11 @@ import { RecentActivityComponent, ProjectOverviewToolbarComponent, ProjectOverviewMetadataComponent, - TranslatePipe, - Message, - RouterLink, FilesWidgetComponent, ViewOnlyLinkMessageComponent, OverviewParentProjectComponent, CitationAddonCardComponent, ], - providers: [DatePipe], changeDetection: ChangeDetectionStrategy.OnPush, }) export class ProjectOverviewComponent implements OnInit { @@ -121,59 +99,51 @@ export class ProjectOverviewComponent implements OnInit { private readonly destroyRef = inject(DestroyRef); private readonly toastService = inject(ToastService); private readonly customDialogService = inject(CustomDialogService); - private readonly dataciteService = inject(DataciteService); - private readonly metaTags = inject(MetaTagsService); - private readonly datePipe = inject(DatePipe); - private readonly prerenderReady = inject(PrerenderReadyService); + readonly analyticsService = inject(AnalyticsService); submissions = select(CollectionsModerationSelectors.getCollectionSubmissions); collectionProvider = select(CollectionsSelectors.getCollectionProvider); currentReviewAction = select(CollectionsModerationSelectors.getCurrentReviewAction); - isProjectLoading = select(ProjectOverviewSelectors.getProjectLoading); isCollectionProviderLoading = select(CollectionsSelectors.getCollectionProviderLoading); isReviewActionsLoading = select(CollectionsModerationSelectors.getCurrentReviewActionLoading); components = select(CurrentResourceSelectors.getResourceWithChildren); areComponentsLoading = select(CurrentResourceSelectors.isResourceWithChildrenLoading); - subjects = select(SubjectsSelectors.getSelectedSubjects); - areSubjectsLoading = select(SubjectsSelectors.areSelectedSubjectsLoading); currentProject = select(ProjectOverviewSelectors.getProject); + isProjectLoading = select(ProjectOverviewSelectors.getProjectLoading); isAnonymous = select(ProjectOverviewSelectors.isProjectAnonymous); hasWriteAccess = select(ProjectOverviewSelectors.hasWriteAccess); hasAdminAccess = select(ProjectOverviewSelectors.hasAdminAccess); isWikiEnabled = select(ProjectOverviewSelectors.isWikiEnabled); parentProject = select(ProjectOverviewSelectors.getParentProject); isParentProjectLoading = select(ProjectOverviewSelectors.getParentProjectLoading); - bibliographicContributors = select(ContributorsSelectors.getBibliographicContributors); - isBibliographicContributorsLoading = select(ContributorsSelectors.isBibliographicContributorsLoading); - hasMoreBibliographicContributors = select(ContributorsSelectors.hasMoreBibliographicContributors); addonsResourceReference = select(AddonsSelectors.getAddonsResourceReference); configuredCitationAddons = select(AddonsSelectors.getConfiguredCitationAddons); operationInvocation = select(AddonsSelectors.getOperationInvocation); + storage = select(ProjectOverviewSelectors.getStorage); + isStorageLoading = select(ProjectOverviewSelectors.isStorageLoading); private readonly actions = createDispatchMap({ getProject: GetProjectById, + getProjectStorage: GetProjectStorage, getBookmarksId: GetBookmarksCollectionId, getHomeWiki: GetHomeWiki, getComponents: GetComponents, getLinkedProjects: GetLinkedResources, getActivityLogs: GetActivityLogs, - setProjectCustomCitation: SetProjectCustomCitation, getCollectionProvider: GetCollectionProvider, getCurrentReviewAction: GetSubmissionsReviewActions, + clearProjectOverview: ClearProjectOverview, clearWiki: ClearWiki, clearCollections: ClearCollections, clearCollectionModeration: ClearCollectionModeration, clearConfiguredAddons: ClearConfiguredAddons, + getComponentsTree: GetResourceWithChildren, getConfiguredStorageAddons: GetConfiguredStorageAddons, - getSubjects: FetchSelectedSubjects, getParentProject: GetParentProject, getAddonsResourceReference: GetAddonsResourceReference, getConfiguredCitationAddons: GetConfiguredCitationAddons, - getBibliographicContributors: GetBibliographicContributors, - loadMoreBibliographicContributors: LoadMoreBibliographicContributors, - resetContributorsState: ResetContributorsState, }); readonly activityPageSize = 5; @@ -190,113 +160,25 @@ export class ProjectOverviewComponent implements OnInit { submissionReviewStatus = computed(() => this.currentReviewAction()?.toState); - showDecisionButton = computed(() => { - return ( + showDecisionButton = computed( + () => this.isCollectionsRoute() && this.submissionReviewStatus() !== SubmissionReviewStatus.Removed && this.submissionReviewStatus() !== SubmissionReviewStatus.Rejected - ); - }); - - hasViewOnly = computed(() => hasViewOnlyParam(this.router)); - - resourceOverview = computed(() => { - const project = this.currentProject(); - const subjects = this.subjects(); - const bibliographicContributors = this.bibliographicContributors(); - if (project) { - return MapProjectOverview(project, subjects, this.isAnonymous(), bibliographicContributors); - } - return null; - }); - - isLoading = computed( - () => - this.isProjectLoading() || - this.isCollectionProviderLoading() || - this.isReviewActionsLoading() || - this.areSubjectsLoading() ); - currentProject$ = toObservable(this.currentProject); - - currentResource = computed(() => { - const project = this.currentProject(); - if (project) { - return { - id: project.id, - title: project.title, - isPublic: project.isPublic, - storage: project.storage, - viewOnlyLinksCount: project.viewOnlyLinksCount, - forksCount: project.forksCount, - resourceType: ResourceType.Project, - isAnonymous: this.isAnonymous(), - }; - } - return null; - }); - - filesRootOption = computed(() => { - return { - value: this.currentProject()?.id ?? '', - label: this.currentProject()?.title ?? '', - }; - }); - - private readonly metaTagsData = computed(() => { - const project = this.currentProject(); - if (!project) return null; - const keywords = [...(project.tags || [])]; - if (project.category) { - keywords.push(project.category); - } - return { - osfGuid: project.id, - title: project.title, - description: project.description, - url: project.links?.iri, - doi: project.doi, - license: project.license?.name, - publishedDate: this.datePipe.transform(project.dateCreated, 'yyyy-MM-dd'), - modifiedDate: this.datePipe.transform(project.dateModified, 'yyyy-MM-dd'), - keywords, - institution: project.affiliatedInstitutions?.map((institution) => institution.name), - contributors: project.contributors.map((contributor) => ({ - fullName: contributor.fullName, - givenName: contributor.givenName, - familyName: contributor.familyName, - })), - }; - }); + hasViewOnly = computed(() => hasViewOnlyParam(this.router)); - readonly analyticsService = inject(AnalyticsService); + filesRootOption = computed(() => ({ + value: this.currentProject()?.id ?? '', + label: this.currentProject()?.title ?? '', + })); constructor() { - this.prerenderReady.setNotReady(); - this.setupCollectionsEffects(); - this.setupCleanup(); this.setupProjectEffects(); - this.setupRouteChangeListener(); this.setupAddonsEffects(); - - effect(() => { - if (!this.isProjectLoading()) { - const metaTagsData = this.metaTagsData(); - if (metaTagsData) { - this.metaTags.updateMetaTags(metaTagsData, this.destroyRef); - } - } - }); - } - - onCustomCitationUpdated(citation: string): void { - this.actions.setProjectCustomCitation(citation); - } - - handleLoadMoreContributors(): void { - this.actions.loadMoreBibliographicContributors(this.currentProject()?.id, ResourceType.Project); + this.setupCleanup(); } ngOnInit(): void { @@ -304,17 +186,12 @@ export class ProjectOverviewComponent implements OnInit { if (projectId) { this.actions.getProject(projectId); + this.actions.getProjectStorage(projectId); this.actions.getBookmarksId(); this.actions.getComponents(projectId); this.actions.getLinkedProjects(projectId); this.actions.getActivityLogs(projectId, this.activityDefaultPage, this.activityPageSize); - this.actions.getBibliographicContributors(projectId, ResourceType.Project); } - - this.dataciteService - .logIdentifiableView(this.currentProject$) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(); } handleOpenMakeDecisionDialog() { @@ -351,7 +228,7 @@ export class ProjectOverviewComponent implements OnInit { effect(() => { if (this.isModerationMode() && this.isCollectionsRoute()) { const provider = this.collectionProvider(); - const resource = this.currentResource(); + const resource = this.currentProject(); if (!provider || !resource) return; @@ -363,64 +240,35 @@ export class ProjectOverviewComponent implements OnInit { private setupProjectEffects(): void { effect(() => { const currentProject = this.currentProject(); + if (currentProject) { const rootParentId = currentProject.rootParentId ?? currentProject.id; this.actions.getComponentsTree(rootParentId, currentProject.id, ResourceType.Project); - this.actions.getSubjects(currentProject.id, ResourceType.Project); const parentProjectId = currentProject.parentId; + if (parentProjectId) { this.actions.getParentProject(parentProjectId); } } }); + effect(() => { const project = this.currentProject(); - if (project?.wikiEnabled) { + + if (project && this.isWikiEnabled()) { this.actions.getHomeWiki(ResourceType.Project, project.id); } }); + effect(() => { const currentProject = this.currentProject(); + if (currentProject && currentProject.isPublic) { this.analyticsService.sendCountedUsage(currentProject.id, 'project.detail').subscribe(); } }); } - private setupRouteChangeListener(): void { - this.router.events - .pipe( - filter((event) => event instanceof NavigationEnd), - map(() => this.route.snapshot.params['id'] || this.route.parent?.snapshot.params['id']), - filter(Boolean), - distinctUntilChanged(), - skip(1), - tap((projectId) => { - this.actions.clearProjectOverview(); - this.actions.clearConfiguredAddons(); - this.actions.getProject(projectId); - this.actions.getBookmarksId(); - this.actions.getComponents(projectId); - this.actions.getLinkedProjects(projectId); - this.actions.getActivityLogs(projectId, this.activityDefaultPage, this.activityPageSize); - this.actions.getBibliographicContributors(projectId, ResourceType.Project); - }), - takeUntilDestroyed(this.destroyRef) - ) - .subscribe(); - } - - private setupCleanup(): void { - this.destroyRef.onDestroy(() => { - this.actions.clearProjectOverview(); - this.actions.clearWiki(); - this.actions.clearCollections(); - this.actions.clearCollectionModeration(); - this.actions.clearConfiguredAddons(); - this.actions.resetContributorsState(); - }); - } - private setupAddonsEffects(): void { effect(() => { const currentProject = this.currentProject(); @@ -432,9 +280,20 @@ export class ProjectOverviewComponent implements OnInit { effect(() => { const resourceReference = this.addonsResourceReference(); + if (resourceReference.length) { this.actions.getConfiguredCitationAddons(resourceReference[0].id); } }); } + + private setupCleanup(): void { + this.destroyRef.onDestroy(() => { + this.actions.clearProjectOverview(); + this.actions.clearWiki(); + this.actions.clearCollections(); + this.actions.clearCollectionModeration(); + this.actions.clearConfiguredAddons(); + }); + } } diff --git a/src/app/features/project/overview/services/project-overview.service.ts b/src/app/features/project/overview/services/project-overview.service.ts index ff98df198..4eee20b32 100644 --- a/src/app/features/project/overview/services/project-overview.service.ts +++ b/src/app/features/project/overview/services/project-overview.service.ts @@ -7,17 +7,34 @@ import { inject, Injectable } from '@angular/core'; import { BYPASS_ERROR_INTERCEPTOR } from '@core/interceptors/error-interceptor.tokens'; import { ENVIRONMENT } from '@core/provider/environment.provider'; import { ComponentsMapper } from '@osf/shared/mappers/components'; +import { IdentifiersMapper } from '@osf/shared/mappers/identifiers.mapper'; +import { InstitutionsMapper } from '@osf/shared/mappers/institutions'; +import { LicensesMapper } from '@osf/shared/mappers/licenses.mapper'; import { BaseNodeMapper } from '@osf/shared/mappers/nodes'; +import { NodePreprintMapper } from '@osf/shared/mappers/nodes/node-preprint.mapper'; +import { NodeStorageMapper } from '@osf/shared/mappers/nodes/node-storage.mapper'; import { JsonApiResponse, ResponseJsonApi } from '@osf/shared/models/common/json-api.model'; import { ComponentGetResponseJsonApi } from '@osf/shared/models/components/component-json-api.model'; import { ComponentOverview } from '@osf/shared/models/components/components.models'; +import { IdentifiersResponseJsonApi } from '@osf/shared/models/identifiers/identifier-json-api.model'; +import { InstitutionsJsonApiResponse } from '@osf/shared/models/institutions/institution-json-api.model'; +import { LicenseResponseJsonApi } from '@osf/shared/models/license/licenses-json-api.model'; import { BaseNodeModel } from '@osf/shared/models/nodes/base-node.model'; import { BaseNodeDataJsonApi } from '@osf/shared/models/nodes/base-node-data-json-api.model'; +import { NodePreprintModel } from '@osf/shared/models/nodes/node-preprint.model'; +import { NodePreprintsResponseJsonApi } from '@osf/shared/models/nodes/node-preprint-json-api.model'; +import { NodeStorageModel } from '@osf/shared/models/nodes/node-storage.model'; +import { NodeStorageResponseJsonApi } from '@osf/shared/models/nodes/node-storage-json-api.model'; +import { NodeResponseJsonApi } from '@osf/shared/models/nodes/nodes-json-api.model'; import { PaginatedData } from '@osf/shared/models/paginated-data.model'; import { JsonApiService } from '@osf/shared/services/json-api.service'; +import { IdentifierModel } from '@shared/models/identifiers/identifier.model'; +import { Institution } from '@shared/models/institutions/institutions.models'; +import { LicenseModel } from '@shared/models/license/license.model'; import { ProjectOverviewMapper } from '../mappers'; -import { PrivacyStatusModel, ProjectOverviewResponseJsonApi, ProjectOverviewWithMeta } from '../models'; +import { PrivacyStatusModel, ProjectOverviewWithMeta } from '../models'; +import { ParentProjectModel } from '../models/parent-overview.model'; @Injectable({ providedIn: 'root', @@ -31,22 +48,48 @@ export class ProjectOverviewService { } getProjectById(projectId: string): Observable { - const params: Record = { - 'embed[]': ['affiliated_institutions', 'identifiers', 'license', 'storage', 'preprints'], - 'fields[institutions]': 'assets,description,name', - 'fields[preprints]': 'title,date_created', - 'fields[users]': 'family_name,full_name,given_name,middle_name', - related_counts: 'forks,view_only_links', - }; + const params: Record = { related_counts: 'forks,view_only_links' }; - return this.jsonApiService.get(`${this.apiUrl}/nodes/${projectId}/`, params).pipe( + return this.jsonApiService.get(`${this.apiUrl}/nodes/${projectId}/`, params).pipe( map((response) => ({ - project: ProjectOverviewMapper.fromGetProjectResponse(response.data), + project: ProjectOverviewMapper.getProjectOverview(response.data), meta: response.meta, })) ); } + getProjectInstitutions(projectId: string): Observable { + const params = { 'page[size]': 100 }; + + return this.jsonApiService + .get(`${this.apiUrl}/nodes/${projectId}/institutions/`, params) + .pipe(map((response) => InstitutionsMapper.fromInstitutionsResponse(response))); + } + + getProjectIdentifiers(projectId: string): Observable { + return this.jsonApiService + .get(`${this.apiUrl}/nodes/${projectId}/identifiers/`) + .pipe(map((response) => IdentifiersMapper.fromJsonApi(response))); + } + + getProjectLicense(licenseId: string): Observable { + return this.jsonApiService + .get(`${this.apiUrl}/licenses/${licenseId}/`) + .pipe(map((response) => LicensesMapper.fromLicenseDataJsonApi(response.data))); + } + + getProjectStorage(projectId: string): Observable { + return this.jsonApiService + .get(`${this.apiUrl}/nodes/${projectId}/storage/`) + .pipe(map((response) => NodeStorageMapper.getNodeStorage(response.data))); + } + + getProjectPreprints(projectId: string): Observable { + return this.jsonApiService + .get(`${this.apiUrl}/nodes/${projectId}/preprints/`) + .pipe(map((response) => NodePreprintMapper.getNodePreprints(response.data))); + } + updateProjectPublicStatus(data: PrivacyStatusModel[]): Observable { const payload = { data: data.map((item) => ({ id: item.id, type: 'nodes', attributes: { public: item.public } })), @@ -165,22 +208,14 @@ export class ProjectOverviewService { ); } - getParentProject(projectId: string): Observable { - const params: Record = { - 'embed[]': ['bibliographic_contributors'], - 'fields[users]': 'family_name,full_name,given_name,middle_name', - }; + getParentProject(projectId: string): Observable { + const params: Record = { 'embed[]': ['bibliographic_contributors'] }; const context = new HttpContext(); context.set(BYPASS_ERROR_INTERCEPTOR, true); return this.jsonApiService - .get(`${this.apiUrl}/nodes/${projectId}/`, params, context) - .pipe( - map((response) => ({ - project: ProjectOverviewMapper.fromGetProjectResponse(response.data), - meta: response.meta, - })) - ); + .get(`${this.apiUrl}/nodes/${projectId}/`, params, context) + .pipe(map((response) => ProjectOverviewMapper.getParentOverview(response.data))); } } diff --git a/src/app/features/project/overview/store/project-overview.actions.ts b/src/app/features/project/overview/store/project-overview.actions.ts index 0dd4b7ff8..c5652189a 100644 --- a/src/app/features/project/overview/store/project-overview.actions.ts +++ b/src/app/features/project/overview/store/project-overview.actions.ts @@ -1,5 +1,5 @@ import { DEFAULT_TABLE_PARAMS } from '@osf/shared/constants/default-table-params.constants'; -import { ResourceType } from '@shared/enums/resource-type.enum'; +import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { PrivacyStatusModel } from '../models'; @@ -9,6 +9,36 @@ export class GetProjectById { constructor(public projectId: string) {} } +export class GetProjectInstitutions { + static readonly type = '[Project Overview] Get Project Institutions'; + + constructor(public projectId: string) {} +} + +export class GetProjectIdentifiers { + static readonly type = '[Project Overview] Get Project Identifiers'; + + constructor(public projectId: string) {} +} + +export class GetProjectLicense { + static readonly type = '[Project Overview] Get Project License'; + + constructor(public licenseId: string | undefined) {} +} + +export class GetProjectStorage { + static readonly type = '[Project Overview] Get Project Storage'; + + constructor(public projectId: string) {} +} + +export class GetProjectPreprints { + static readonly type = '[Project Overview] Get Project Preprints'; + + constructor(public projectId: string) {} +} + export class UpdateProjectPublicStatus { static readonly type = '[Project Overview] Update Project Public Status'; diff --git a/src/app/features/project/overview/store/project-overview.model.ts b/src/app/features/project/overview/store/project-overview.model.ts index 1d84b6de5..0caeeb24b 100644 --- a/src/app/features/project/overview/store/project-overview.model.ts +++ b/src/app/features/project/overview/store/project-overview.model.ts @@ -1,18 +1,29 @@ import { ComponentOverview } from '@osf/shared/models/components/components.models'; import { BaseNodeModel } from '@osf/shared/models/nodes/base-node.model'; +import { NodePreprintModel } from '@osf/shared/models/nodes/node-preprint.model'; +import { NodeStorageModel } from '@osf/shared/models/nodes/node-storage.model'; import { AsyncStateModel } from '@osf/shared/models/store/async-state.model'; import { AsyncStateWithTotalCount } from '@osf/shared/models/store/async-state-with-total-count.model'; +import { IdentifierModel } from '@shared/models/identifiers/identifier.model'; +import { Institution } from '@shared/models/institutions/institutions.models'; +import { LicenseModel } from '@shared/models/license/license.model'; -import { ProjectOverview } from '../models'; +import { ProjectOverviewModel } from '../models'; +import { ParentProjectModel } from '../models/parent-overview.model'; export interface ProjectOverviewStateModel { - project: AsyncStateModel; + project: AsyncStateModel; components: AsyncStateWithTotalCount & { currentPage: number; }; - isAnonymous: boolean; duplicatedProject: BaseNodeModel | null; - parentProject: AsyncStateModel; + parentProject: AsyncStateModel; + institutions: AsyncStateModel; + identifiers: AsyncStateModel; + license: AsyncStateModel; + storage: AsyncStateModel; + preprints: AsyncStateModel; + isAnonymous: boolean; } export const PROJECT_OVERVIEW_DEFAULTS: ProjectOverviewStateModel = { @@ -37,4 +48,29 @@ export const PROJECT_OVERVIEW_DEFAULTS: ProjectOverviewStateModel = { isLoading: false, error: null, }, + institutions: { + data: [], + isLoading: false, + error: null, + }, + identifiers: { + data: [], + isLoading: false, + error: null, + }, + license: { + data: null, + isLoading: false, + error: null, + }, + storage: { + data: null, + isLoading: false, + error: null, + }, + preprints: { + data: [], + isLoading: false, + error: null, + }, }; diff --git a/src/app/features/project/overview/store/project-overview.selectors.ts b/src/app/features/project/overview/store/project-overview.selectors.ts index 6cf5bfc92..01ce1bade 100644 --- a/src/app/features/project/overview/store/project-overview.selectors.ts +++ b/src/app/features/project/overview/store/project-overview.selectors.ts @@ -90,4 +90,54 @@ export class ProjectOverviewSelectors { static hasMoreComponents(state: ProjectOverviewStateModel) { return state.components.data.length < state.components.totalCount && !state.components.isLoading; } + + @Selector([ProjectOverviewState]) + static getInstitutions(state: ProjectOverviewStateModel) { + return state.institutions.data; + } + + @Selector([ProjectOverviewState]) + static isInstitutionsLoading(state: ProjectOverviewStateModel) { + return state.institutions.isLoading; + } + + @Selector([ProjectOverviewState]) + static getIdentifiers(state: ProjectOverviewStateModel) { + return state.identifiers.data; + } + + @Selector([ProjectOverviewState]) + static isIdentifiersLoading(state: ProjectOverviewStateModel) { + return state.identifiers.isLoading; + } + + @Selector([ProjectOverviewState]) + static getLicense(state: ProjectOverviewStateModel) { + return state.license.data; + } + + @Selector([ProjectOverviewState]) + static isLicenseLoading(state: ProjectOverviewStateModel) { + return state.license.isLoading; + } + + @Selector([ProjectOverviewState]) + static getStorage(state: ProjectOverviewStateModel) { + return state.storage.data; + } + + @Selector([ProjectOverviewState]) + static isStorageLoading(state: ProjectOverviewStateModel) { + return state.storage.isLoading; + } + + @Selector([ProjectOverviewState]) + static getPreprints(state: ProjectOverviewStateModel) { + return state.preprints.data; + } + + @Selector([ProjectOverviewState]) + static isPreprintsLoading(state: ProjectOverviewStateModel) { + return state.preprints.isLoading; + } } diff --git a/src/app/features/project/overview/store/project-overview.state.ts b/src/app/features/project/overview/store/project-overview.state.ts index b701970a4..dc1def2bd 100644 --- a/src/app/features/project/overview/store/project-overview.state.ts +++ b/src/app/features/project/overview/store/project-overview.state.ts @@ -19,6 +19,11 @@ import { GetComponents, GetParentProject, GetProjectById, + GetProjectIdentifiers, + GetProjectInstitutions, + GetProjectLicense, + GetProjectPreprints, + GetProjectStorage, LoadMoreComponents, SetProjectCustomCitation, UpdateProjectPublicStatus, @@ -58,6 +63,131 @@ export class ProjectOverviewState { ); } + @Action(GetProjectInstitutions) + getProjectInstitutions(ctx: StateContext, action: GetProjectInstitutions) { + const state = ctx.getState(); + ctx.patchState({ + institutions: { + ...state.institutions, + isLoading: true, + }, + }); + + return this.projectOverviewService.getProjectInstitutions(action.projectId).pipe( + tap((institutions) => { + ctx.patchState({ + institutions: { + data: institutions, + isLoading: false, + error: null, + }, + }); + }), + catchError((error) => handleSectionError(ctx, 'institutions', error)) + ); + } + + @Action(GetProjectIdentifiers) + getProjectIdentifiers(ctx: StateContext, action: GetProjectIdentifiers) { + const state = ctx.getState(); + ctx.patchState({ + identifiers: { + ...state.identifiers, + isLoading: true, + }, + }); + + return this.projectOverviewService.getProjectIdentifiers(action.projectId).pipe( + tap((identifiers) => { + ctx.patchState({ + identifiers: { + data: identifiers, + isLoading: false, + error: null, + }, + }); + }), + catchError((error) => handleSectionError(ctx, 'identifiers', error)) + ); + } + + @Action(GetProjectLicense) + getProjectLicense(ctx: StateContext, action: GetProjectLicense) { + if (!action.licenseId) { + return; + } + + const state = ctx.getState(); + + ctx.patchState({ + license: { + ...state.license, + isLoading: true, + }, + }); + + return this.projectOverviewService.getProjectLicense(action.licenseId).pipe( + tap((license) => { + ctx.patchState({ + license: { + data: license, + isLoading: false, + error: null, + }, + }); + }), + catchError((error) => handleSectionError(ctx, 'license', error)) + ); + } + + @Action(GetProjectStorage) + getProjectStorage(ctx: StateContext, action: GetProjectStorage) { + const state = ctx.getState(); + ctx.patchState({ + storage: { + ...state.storage, + isLoading: true, + }, + }); + + return this.projectOverviewService.getProjectStorage(action.projectId).pipe( + tap((storage) => { + ctx.patchState({ + storage: { + data: storage, + isLoading: false, + error: null, + }, + }); + }), + catchError((error) => handleSectionError(ctx, 'storage', error)) + ); + } + + @Action(GetProjectPreprints) + getProjectPreprints(ctx: StateContext, action: GetProjectPreprints) { + const state = ctx.getState(); + ctx.patchState({ + preprints: { + ...state.preprints, + isLoading: true, + }, + }); + + return this.projectOverviewService.getProjectPreprints(action.projectId).pipe( + tap((preprints) => { + ctx.patchState({ + preprints: { + data: preprints, + isLoading: false, + error: null, + }, + }); + }), + catchError((error) => handleSectionError(ctx, 'preprints', error)) + ); + } + @Action(ClearProjectOverview) clearProjectOverview(ctx: StateContext) { ctx.patchState(PROJECT_OVERVIEW_DEFAULTS); @@ -289,11 +419,12 @@ export class ProjectOverviewState { isLoading: true, }, }); + return this.projectOverviewService.getParentProject(action.projectId).pipe( - tap((response) => { + tap((project) => { ctx.patchState({ parentProject: { - data: response.project, + data: project, isLoading: false, error: null, }, diff --git a/src/app/features/project/project.component.spec.ts b/src/app/features/project/project.component.spec.ts index 607a52643..2822beb62 100644 --- a/src/app/features/project/project.component.spec.ts +++ b/src/app/features/project/project.component.spec.ts @@ -1,41 +1,81 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; import { HelpScoutService } from '@core/services/help-scout.service'; +import { PrerenderReadyService } from '@core/services/prerender-ready.service'; +import { DataciteService } from '@osf/shared/services/datacite/datacite.service'; +import { MetaTagsService } from '@osf/shared/services/meta-tags.service'; +import { ContributorsSelectors } from '@osf/shared/stores/contributors'; +import { ProjectOverviewSelectors } from './overview/store'; import { ProjectComponent } from './project.component'; +import { DataciteMockFactory } from '@testing/mocks/datacite.service.mock'; import { OSFTestingModule } from '@testing/osf.testing.module'; +import { HelpScoutServiceMockFactory } from '@testing/providers/help-scout.service.mock'; +import { MetaTagsServiceMockFactory } from '@testing/providers/meta-tags.service.mock'; +import { PrerenderReadyServiceMockFactory } from '@testing/providers/prerender-ready.service.mock'; +import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; describe('Component: Project', () => { let component: ProjectComponent; let fixture: ComponentFixture; - let helpScoutService: HelpScoutService; + let helpScoutService: ReturnType; + let metaTagsService: ReturnType; + let dataciteService: ReturnType; + let prerenderReadyService: ReturnType; + let mockActivatedRoute: ReturnType; beforeEach(async () => { + mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'project-1' }).build(); + + helpScoutService = HelpScoutServiceMockFactory(); + metaTagsService = MetaTagsServiceMockFactory(); + dataciteService = DataciteMockFactory(); + prerenderReadyService = PrerenderReadyServiceMockFactory(); + await TestBed.configureTestingModule({ imports: [ProjectComponent, OSFTestingModule], providers: [ - { - provide: HelpScoutService, - useValue: { - setResourceType: jest.fn(), - unsetResourceType: jest.fn(), - }, - }, + { provide: HelpScoutService, useValue: helpScoutService }, + { provide: MetaTagsService, useValue: metaTagsService }, + { provide: DataciteService, useValue: dataciteService }, + { provide: PrerenderReadyService, useValue: prerenderReadyService }, + { provide: ActivatedRoute, useValue: mockActivatedRoute }, + provideMockStore({ + signals: [ + { selector: ProjectOverviewSelectors.getProject, value: null }, + { selector: ProjectOverviewSelectors.getProjectLoading, value: false }, + { selector: ProjectOverviewSelectors.getIdentifiers, value: [] }, + { selector: ProjectOverviewSelectors.getLicense, value: null }, + { selector: ProjectOverviewSelectors.isLicenseLoading, value: false }, + { selector: ContributorsSelectors.getBibliographicContributors, value: [] }, + { selector: ContributorsSelectors.isBibliographicContributorsLoading, value: false }, + ], + }), ], }).compileComponents(); - helpScoutService = TestBed.inject(HelpScoutService); fixture = TestBed.createComponent(ProjectComponent); component = fixture.componentInstance; fixture.detectChanges(); }); - it('should have a default value', () => { - expect(component.classes).toBe('flex flex-1 flex-column w-full'); + it('should call the helpScoutService', () => { + expect(helpScoutService.setResourceType).toHaveBeenCalledWith('project'); }); - it('should called the helpScoutService', () => { - expect(helpScoutService.setResourceType).toHaveBeenCalledWith('project'); + it('should call unsetResourceType on destroy', () => { + component.ngOnDestroy(); + expect(helpScoutService.unsetResourceType).toHaveBeenCalled(); + }); + + it('should call prerenderReady.setNotReady in constructor', () => { + expect(prerenderReadyService.setNotReady).toHaveBeenCalled(); + }); + + it('should call dataciteService.logIdentifiableView', () => { + expect(dataciteService.logIdentifiableView).toHaveBeenCalled(); }); }); diff --git a/src/app/features/project/project.component.ts b/src/app/features/project/project.component.ts index 9fd8a6ad0..c01ebad3d 100644 --- a/src/app/features/project/project.component.ts +++ b/src/app/features/project/project.component.ts @@ -1,7 +1,36 @@ -import { ChangeDetectionStrategy, Component, HostBinding, inject, OnDestroy } from '@angular/core'; -import { RouterOutlet } from '@angular/router'; +import { createDispatchMap, select } from '@ngxs/store'; + +import { map } from 'rxjs'; + +import { DatePipe } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + effect, + HostBinding, + inject, + OnDestroy, + signal, +} from '@angular/core'; +import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop'; +import { ActivatedRoute, RouterOutlet } from '@angular/router'; import { HelpScoutService } from '@core/services/help-scout.service'; +import { PrerenderReadyService } from '@core/services/prerender-ready.service'; +import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { DataciteService } from '@osf/shared/services/datacite/datacite.service'; +import { MetaTagsService } from '@osf/shared/services/meta-tags.service'; +import { ContributorsSelectors, GetBibliographicContributors } from '@osf/shared/stores/contributors'; + +import { + GetProjectById, + GetProjectIdentifiers, + GetProjectInstitutions, + GetProjectLicense, + ProjectOverviewSelectors, +} from './overview/store'; @Component({ selector: 'osf-project', @@ -9,16 +38,122 @@ import { HelpScoutService } from '@core/services/help-scout.service'; templateUrl: './project.component.html', styleUrl: './project.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, + providers: [DatePipe], }) export class ProjectComponent implements OnDestroy { - private readonly helpScoutService = inject(HelpScoutService); @HostBinding('class') classes = 'flex flex-1 flex-column w-full'; + private readonly helpScoutService = inject(HelpScoutService); + private readonly metaTags = inject(MetaTagsService); + private readonly dataciteService = inject(DataciteService); + private readonly destroyRef = inject(DestroyRef); + private readonly route = inject(ActivatedRoute); + private readonly datePipe = inject(DatePipe); + private readonly prerenderReady = inject(PrerenderReadyService); + + readonly identifiersForDatacite$ = toObservable(select(ProjectOverviewSelectors.getIdentifiers)).pipe( + map((identifiers) => (identifiers?.length ? { identifiers } : null)) + ); + + readonly currentProject = select(ProjectOverviewSelectors.getProject); + readonly isProjectLoading = select(ProjectOverviewSelectors.getProjectLoading); + readonly bibliographicContributors = select(ContributorsSelectors.getBibliographicContributors); + readonly isBibliographicContributorsLoading = select(ContributorsSelectors.isBibliographicContributorsLoading); + readonly license = select(ProjectOverviewSelectors.getLicense); + readonly isLicenseLoading = select(ProjectOverviewSelectors.isLicenseLoading); + readonly institutions = select(ProjectOverviewSelectors.getInstitutions); + readonly isInstitutionsLoading = select(ProjectOverviewSelectors.isInstitutionsLoading); + + private projectId = toSignal(this.route.params.pipe(map((params) => params['id']))); + + private readonly allDataLoaded = computed( + () => + !this.isProjectLoading() && + !this.isBibliographicContributorsLoading() && + !this.isLicenseLoading() && + !this.isInstitutionsLoading() && + !!this.currentProject() + ); + + private readonly lastMetaTagsProjectId = signal(null); + + private readonly actions = createDispatchMap({ + getProject: GetProjectById, + getLicense: GetProjectLicense, + getInstitutions: GetProjectInstitutions, + getIdentifiers: GetProjectIdentifiers, + getBibliographicContributors: GetBibliographicContributors, + }); + constructor() { + this.prerenderReady.setNotReady(); this.helpScoutService.setResourceType('project'); + + effect(() => { + const id = this.projectId(); + + if (id) { + this.actions.getProject(id); + this.actions.getIdentifiers(id); + this.actions.getBibliographicContributors(id, ResourceType.Project); + this.actions.getInstitutions(id); + } + }); + + effect(() => { + const project = this.currentProject(); + + if (project?.licenseId) { + this.actions.getLicense(project.licenseId); + } + }); + + effect(() => { + if (this.allDataLoaded()) { + const currentProjectId = this.projectId(); + const lastSetProjectId = this.lastMetaTagsProjectId(); + + if (currentProjectId && currentProjectId !== lastSetProjectId) { + this.setMetaTags(); + } + } + }); + + this.dataciteService + .logIdentifiableView(this.identifiersForDatacite$) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(); } ngOnDestroy(): void { this.helpScoutService.unsetResourceType(); } + + private setMetaTags(): void { + const project = this.currentProject(); + if (!project) return; + + const keywords = [...(project.tags || []), ...(project.category ? [project.category] : [])]; + + const metaTagsData = { + osfGuid: project.id, + title: project.title, + description: project.description, + url: project.links?.iri, + license: this.license.name, + publishedDate: this.datePipe.transform(project.dateCreated, 'yyyy-MM-dd'), + modifiedDate: this.datePipe.transform(project.dateModified, 'yyyy-MM-dd'), + keywords, + institution: this.institutions().map((institution) => institution.name), + contributors: this.bibliographicContributors().map((contributor) => ({ + fullName: contributor.fullName, + givenName: contributor.givenName, + familyName: contributor.familyName, + })), + }; + + this.metaTags.updateMetaTags(metaTagsData, this.destroyRef); + + this.lastMetaTagsProjectId.set(project.id); + } } diff --git a/src/app/features/project/project.routes.ts b/src/app/features/project/project.routes.ts index 9ff14161f..9ae274792 100644 --- a/src/app/features/project/project.routes.ts +++ b/src/app/features/project/project.routes.ts @@ -16,6 +16,7 @@ import { ViewOnlyLinkState } from '@osf/shared/stores/view-only-links'; import { AnalyticsState } from '../analytics/store'; import { CollectionsModerationState } from '../moderation/store/collections-moderation'; +import { ProjectOverviewState } from './overview/store'; import { RegistrationsState } from './registrations/store'; import { SettingsState } from './settings/store'; @@ -23,6 +24,7 @@ export const projectRoutes: Routes = [ { path: '', loadComponent: () => import('../project/project.component').then((mod) => mod.ProjectComponent), + providers: [provideStates([ProjectOverviewState])], children: [ { path: '', diff --git a/src/app/features/registries/components/files-control/files-control.component.spec.ts b/src/app/features/registries/components/files-control/files-control.component.spec.ts index f2ffee399..79257199d 100644 --- a/src/app/features/registries/components/files-control/files-control.component.spec.ts +++ b/src/app/features/registries/components/files-control/files-control.component.spec.ts @@ -18,6 +18,7 @@ import { FilesControlComponent } from './files-control.component'; import { OSFTestingModule } from '@testing/osf.testing.module'; import { CustomConfirmationServiceMockBuilder } from '@testing/providers/custom-confirmation-provider.mock'; import { CustomDialogServiceMockBuilder } from '@testing/providers/custom-dialog-provider.mock'; +import { HelpScoutServiceMockFactory } from '@testing/providers/help-scout.service.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; import { ToastServiceMockBuilder } from '@testing/providers/toast-provider.mock'; @@ -39,6 +40,7 @@ describe('Component: File Control', () => { mockDialogService = CustomDialogServiceMockBuilder.create().withDefaultOpen().build(); mockToastService = ToastServiceMockBuilder.create().build(); mockCustomConfirmationService = CustomConfirmationServiceMockBuilder.create().build(); + helpScoutService = HelpScoutServiceMockFactory(); await TestBed.configureTestingModule({ imports: [ @@ -51,13 +53,7 @@ describe('Component: File Control', () => { MockProvider(CustomDialogService, mockDialogService), MockProvider(ToastService, mockToastService), MockProvider(CustomConfirmationService, mockCustomConfirmationService), - { - provide: HelpScoutService, - useValue: { - setResourceType: jest.fn(), - unsetResourceType: jest.fn(), - }, - }, + { provide: HelpScoutService, useValue: helpScoutService }, provideMockStore({ signals: [ { selector: RegistriesSelectors.getFiles, value: [] }, diff --git a/src/app/features/registries/registries.component.spec.ts b/src/app/features/registries/registries.component.spec.ts index e0c522f9d..516e0091c 100644 --- a/src/app/features/registries/registries.component.spec.ts +++ b/src/app/features/registries/registries.component.spec.ts @@ -1,35 +1,24 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { HelpScoutService } from '@core/services/help-scout.service'; - import { RegistriesComponent } from './registries.component'; import { OSFTestingModule } from '@testing/osf.testing.module'; describe('Component: Registries', () => { let fixture: ComponentFixture; - let helpScoutService: HelpScoutService; + let component: RegistriesComponent; beforeEach(async () => { await TestBed.configureTestingModule({ imports: [RegistriesComponent, OSFTestingModule], - providers: [ - { - provide: HelpScoutService, - useValue: { - setResourceType: jest.fn(), - unsetResourceType: jest.fn(), - }, - }, - ], }).compileComponents(); - helpScoutService = TestBed.inject(HelpScoutService); fixture = TestBed.createComponent(RegistriesComponent); + component = fixture.componentInstance; fixture.detectChanges(); }); - it('should called the helpScoutService', () => { - expect(helpScoutService.setResourceType).toHaveBeenCalledWith('registration'); + it('should create', () => { + expect(component).toBeTruthy(); }); }); diff --git a/src/app/features/registries/registries.component.ts b/src/app/features/registries/registries.component.ts index 841cff891..78716bee1 100644 --- a/src/app/features/registries/registries.component.ts +++ b/src/app/features/registries/registries.component.ts @@ -1,8 +1,6 @@ -import { ChangeDetectionStrategy, Component, inject, OnDestroy } from '@angular/core'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; import { RouterOutlet } from '@angular/router'; -import { HelpScoutService } from '@core/services/help-scout.service'; - @Component({ selector: 'osf-registries', imports: [RouterOutlet], @@ -10,13 +8,4 @@ import { HelpScoutService } from '@core/services/help-scout.service'; styleUrl: './registries.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class RegistriesComponent implements OnDestroy { - private readonly helpScoutService = inject(HelpScoutService); - constructor() { - this.helpScoutService.setResourceType('registration'); - } - - ngOnDestroy(): void { - this.helpScoutService.unsetResourceType(); - } -} +export class RegistriesComponent {} diff --git a/src/app/features/registry/components/registration-overview-toolbar/registration-overview-toolbar.component.html b/src/app/features/registry/components/registration-overview-toolbar/registration-overview-toolbar.component.html index f60453621..5416e967a 100644 --- a/src/app/features/registry/components/registration-overview-toolbar/registration-overview-toolbar.component.html +++ b/src/app/features/registry/components/registration-overview-toolbar/registration-overview-toolbar.component.html @@ -2,16 +2,14 @@
@if (isAuthenticated()) { - @if (!isBookmarksLoading() && !isBookmarksSubmitting()) { - - } - + (onClick)="toggleBookmark()" + /> } @if (isPublic()) { diff --git a/src/app/features/registry/registry.component.spec.ts b/src/app/features/registry/registry.component.spec.ts index 6895b997f..f485c737f 100644 --- a/src/app/features/registry/registry.component.spec.ts +++ b/src/app/features/registry/registry.component.spec.ts @@ -1,98 +1,93 @@ -import { of } from 'rxjs'; +import { Store } from '@ngxs/store'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute } from '@angular/router'; -import { RegistrySelectors } from '@osf/features/registry/store/registry'; +import { HelpScoutService } from '@core/services/help-scout.service'; +import { PrerenderReadyService } from '@core/services/prerender-ready.service'; +import { ClearCurrentProvider } from '@core/store/provider'; import { AnalyticsService } from '@osf/shared/services/analytics.service'; +import { DataciteService } from '@osf/shared/services/datacite/datacite.service'; import { MetaTagsService } from '@osf/shared/services/meta-tags.service'; -import { DataciteService } from '@shared/services/datacite/datacite.service'; +import { ContributorsSelectors } from '@osf/shared/stores/contributors'; +import { RegistrySelectors } from './store/registry'; import { RegistryComponent } from './registry.component'; import { DataciteMockFactory } from '@testing/mocks/datacite.service.mock'; import { OSFTestingModule } from '@testing/osf.testing.module'; +import { AnalyticsServiceMockFactory } from '@testing/providers/analytics.service.mock'; +import { HelpScoutServiceMockFactory } from '@testing/providers/help-scout.service.mock'; +import { MetaTagsServiceMockFactory } from '@testing/providers/meta-tags.service.mock'; +import { PrerenderReadyServiceMockFactory } from '@testing/providers/prerender-ready.service.mock'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; describe('RegistryComponent', () => { - let fixture: ComponentFixture; let component: RegistryComponent; - let dataciteService: jest.Mocked; - let metaTagsService: jest.Mocked; - let analyticsService: jest.Mocked; - - const mockRegistry = { - id: 'test-registry-id', - title: 'Test Registry', - description: 'Test Description', - dateRegistered: '2023-01-01', - dateModified: '2023-01-02', - doi: '10.1234/test', - tags: ['tag1', 'tag2'], - license: { name: 'Test License' }, - contributors: [{ fullName: 'John Doe', givenName: 'John', familyName: 'Doe' }], - isPublic: true, - }; + let fixture: ComponentFixture; + let helpScoutService: ReturnType; + let metaTagsService: ReturnType; + let dataciteService: ReturnType; + let prerenderReadyService: ReturnType; + let analyticsService: ReturnType; + let store: Store; + let mockActivatedRoute: ReturnType; beforeEach(async () => { + mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'registry-1' }).build(); + + helpScoutService = HelpScoutServiceMockFactory(); + metaTagsService = MetaTagsServiceMockFactory(); dataciteService = DataciteMockFactory(); - metaTagsService = { - updateMetaTags: jest.fn(), - } as any; - analyticsService = { - sendCountedUsage: jest.fn().mockReturnValue(of({})), - } as any; + prerenderReadyService = PrerenderReadyServiceMockFactory(); + analyticsService = AnalyticsServiceMockFactory(); await TestBed.configureTestingModule({ imports: [RegistryComponent, OSFTestingModule], providers: [ - { - provide: ActivatedRoute, - useValue: ActivatedRouteMockBuilder.create().withParams({ id: 'test-registry-id' }).build(), - }, - { provide: DataciteService, useValue: dataciteService }, + { provide: HelpScoutService, useValue: helpScoutService }, { provide: MetaTagsService, useValue: metaTagsService }, + { provide: DataciteService, useValue: dataciteService }, + { provide: PrerenderReadyService, useValue: prerenderReadyService }, { provide: AnalyticsService, useValue: analyticsService }, + { provide: ActivatedRoute, useValue: mockActivatedRoute }, provideMockStore({ signals: [ - { selector: RegistrySelectors.getRegistry, value: mockRegistry }, + { selector: RegistrySelectors.getRegistry, value: null }, { selector: RegistrySelectors.isRegistryLoading, value: false }, + { selector: RegistrySelectors.getIdentifiers, value: [] }, + { selector: RegistrySelectors.getLicense, value: null }, + { selector: RegistrySelectors.isLicenseLoading, value: false }, + { selector: ContributorsSelectors.getBibliographicContributors, value: [] }, + { selector: ContributorsSelectors.isBibliographicContributorsLoading, value: false }, ], }), ], }).compileComponents(); + store = TestBed.inject(Store); fixture = TestBed.createComponent(RegistryComponent); component = fixture.componentInstance; + fixture.detectChanges(); }); - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should be an instance of RegistryComponent', () => { - expect(component).toBeInstanceOf(RegistryComponent); - }); - - it('should have NGXS selectors defined', () => { - expect(component.registry).toBeDefined(); - expect(component.isRegistryLoading).toBeDefined(); - }); - - it('should have services injected', () => { - expect(component.analyticsService).toBeDefined(); + it('should call the helpScoutService', () => { + expect(helpScoutService.setResourceType).toHaveBeenCalledWith('registration'); }); - it('should handle ngOnDestroy', () => { - expect(() => component.ngOnDestroy()).not.toThrow(); + it('should call unsetResourceType and clearCurrentProvider on destroy', () => { + const dispatchSpy = jest.spyOn(store, 'dispatch'); + component.ngOnDestroy(); + expect(helpScoutService.unsetResourceType).toHaveBeenCalled(); + expect(dispatchSpy).toHaveBeenCalledWith(new ClearCurrentProvider()); }); - it('should call datacite service on initialization', () => { - expect(dataciteService.logIdentifiableView).toHaveBeenCalledWith(component.identifiersForDatacite$); + it('should call prerenderReady.setNotReady in constructor', () => { + expect(prerenderReadyService.setNotReady).toHaveBeenCalled(); }); - it('should handle registry loading effects', () => { - expect(component).toBeTruthy(); + it('should call dataciteService.logIdentifiableView', () => { + expect(dataciteService.logIdentifiableView).toHaveBeenCalled(); }); }); diff --git a/src/app/features/registry/registry.component.ts b/src/app/features/registry/registry.component.ts index ea5cd3e24..577bb9864 100644 --- a/src/app/features/registry/registry.component.ts +++ b/src/app/features/registry/registry.component.ts @@ -18,6 +18,7 @@ import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-i import { ActivatedRoute, RouterOutlet } from '@angular/router'; import { ENVIRONMENT } from '@core/provider/environment.provider'; +import { HelpScoutService } from '@core/services/help-scout.service'; import { PrerenderReadyService } from '@core/services/prerender-ready.service'; import { ClearCurrentProvider } from '@core/store/provider'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; @@ -45,8 +46,10 @@ export class RegistryComponent implements OnDestroy { private readonly dataciteService = inject(DataciteService); private readonly destroyRef = inject(DestroyRef); private readonly route = inject(ActivatedRoute); + private readonly helpScoutService = inject(HelpScoutService); private readonly environment = inject(ENVIRONMENT); private readonly prerenderReady = inject(PrerenderReadyService); + readonly analyticsService = inject(AnalyticsService); private readonly actions = createDispatchMap({ getRegistryWithRelatedData: GetRegistryWithRelatedData, @@ -62,12 +65,10 @@ export class RegistryComponent implements OnDestroy { readonly identifiersForDatacite$ = toObservable(select(RegistrySelectors.getIdentifiers)).pipe( map((identifiers) => (identifiers?.length ? { identifiers } : null)) ); - readonly analyticsService = inject(AnalyticsService); readonly bibliographicContributors = select(ContributorsSelectors.getBibliographicContributors); readonly isBibliographicContributorsLoading = select(ContributorsSelectors.isBibliographicContributorsLoading); readonly license = select(RegistrySelectors.getLicense); readonly isLicenseLoading = select(RegistrySelectors.isLicenseLoading); - readonly isIdentifiersLoading = select(RegistrySelectors.isIdentifiersLoading); private readonly allDataLoaded = computed( () => @@ -81,6 +82,7 @@ export class RegistryComponent implements OnDestroy { constructor() { this.prerenderReady.setNotReady(); + this.helpScoutService.setResourceType('registration'); effect(() => { const id = this.registryId(); @@ -119,34 +121,34 @@ export class RegistryComponent implements OnDestroy { ngOnDestroy(): void { this.actions.clearCurrentProvider(); + this.helpScoutService.unsetResourceType(); } private setMetaTags(): void { const currentRegistry = this.registry(); if (!currentRegistry) return; - this.metaTags.updateMetaTags( - { - osfGuid: currentRegistry.id, - title: currentRegistry.title, - description: currentRegistry.description, - publishedDate: this.datePipe.transform(currentRegistry.dateRegistered, 'yyyy-MM-dd'), - modifiedDate: this.datePipe.transform(currentRegistry.dateModified, 'yyyy-MM-dd'), - url: pathJoin(this.environment.webUrl, currentRegistry.id ?? ''), - identifier: currentRegistry.id, - doi: currentRegistry.articleDoi, - keywords: currentRegistry.tags, - siteName: 'OSF', - license: this.license()?.name, - contributors: - this.bibliographicContributors()?.map((contributor) => ({ - fullName: contributor.fullName, - givenName: contributor.givenName, - familyName: contributor.familyName, - })) ?? [], - }, - this.destroyRef - ); + const metaTagsData = { + osfGuid: currentRegistry.id, + title: currentRegistry.title, + description: currentRegistry.description, + publishedDate: this.datePipe.transform(currentRegistry.dateRegistered, 'yyyy-MM-dd'), + modifiedDate: this.datePipe.transform(currentRegistry.dateModified, 'yyyy-MM-dd'), + url: pathJoin(this.environment.webUrl, currentRegistry.id ?? ''), + identifier: currentRegistry.id, + doi: currentRegistry.articleDoi, + keywords: currentRegistry.tags, + siteName: 'OSF', + license: this.license()?.name, + contributors: + this.bibliographicContributors()?.map((contributor) => ({ + fullName: contributor.fullName, + givenName: contributor.givenName, + familyName: contributor.familyName, + })) ?? [], + }; + + this.metaTags.updateMetaTags(metaTagsData, this.destroyRef); this.lastMetaTagsRegistryId.set(currentRegistry.id); } diff --git a/src/app/features/registry/services/registry-overview.service.ts b/src/app/features/registry/services/registry-overview.service.ts index 2fabbe51e..87d99fbc4 100644 --- a/src/app/features/registry/services/registry-overview.service.ts +++ b/src/app/features/registry/services/registry-overview.service.ts @@ -52,9 +52,7 @@ export class RegistryOverviewService { } getInstitutions(registryId: string): Observable { - const params = { - 'page[size]': 100, - }; + const params = { 'page[size]': 100 }; return this.jsonApiService .get(`${this.apiUrl}/registrations/${registryId}/institutions/`, params) diff --git a/src/app/shared/components/full-screen-loader/full-screen-loader.component.spec.ts b/src/app/shared/components/full-screen-loader/full-screen-loader.component.spec.ts index 0d6588302..058b2f9ec 100644 --- a/src/app/shared/components/full-screen-loader/full-screen-loader.component.spec.ts +++ b/src/app/shared/components/full-screen-loader/full-screen-loader.component.spec.ts @@ -5,7 +5,7 @@ import { LoaderService } from '@osf/shared/services/loader.service'; import { FullScreenLoaderComponent } from './full-screen-loader.component'; -import { LoaderServiceMock } from '@testing/mocks/loader-service.mock'; +import { LoaderServiceMock } from '@testing/providers/loader-service.mock'; describe('FullScreenLoaderComponent', () => { let component: FullScreenLoaderComponent; diff --git a/src/app/shared/mappers/nodes/node-preprint.mapper.ts b/src/app/shared/mappers/nodes/node-preprint.mapper.ts new file mode 100644 index 000000000..da9df54bf --- /dev/null +++ b/src/app/shared/mappers/nodes/node-preprint.mapper.ts @@ -0,0 +1,26 @@ +import { NodePreprintModel } from '@osf/shared/models/nodes/node-preprint.model'; +import { NodePreprintDataJsonApi } from '@osf/shared/models/nodes/node-preprint-json-api.model'; + +export class NodePreprintMapper { + static getNodePreprint(data: NodePreprintDataJsonApi): NodePreprintModel { + return { + id: data.id, + title: data.attributes.title, + dateCreated: data.attributes.date_created, + dateModified: data.attributes.date_modified, + datePublished: data.attributes.date_published, + doi: data.attributes.doi, + isPreprintOrphan: data.attributes.is_preprint_orphan, + isPublished: data.attributes.is_published, + url: data.links.html, + }; + } + + static getNodePreprints(data: NodePreprintDataJsonApi[]): NodePreprintModel[] { + if (!data) { + return []; + } + + return data.map((item) => this.getNodePreprint(item)); + } +} diff --git a/src/app/shared/mappers/nodes/node-storage.mapper.ts b/src/app/shared/mappers/nodes/node-storage.mapper.ts new file mode 100644 index 000000000..2bca67e11 --- /dev/null +++ b/src/app/shared/mappers/nodes/node-storage.mapper.ts @@ -0,0 +1,12 @@ +import { NodeStorageModel } from '@osf/shared/models/nodes/node-storage.model'; +import { NodeStorageDataJsonApi } from '@osf/shared/models/nodes/node-storage-json-api.model'; + +export class NodeStorageMapper { + static getNodeStorage(data: NodeStorageDataJsonApi): NodeStorageModel { + return { + id: data.id, + storageLimitStatus: data.attributes.storage_limit_status, + storageUsage: data.attributes.storage_usage, + }; + } +} diff --git a/src/app/shared/mappers/resource-overview.mappers.ts b/src/app/shared/mappers/resource-overview.mappers.ts deleted file mode 100644 index 994327e13..000000000 --- a/src/app/shared/mappers/resource-overview.mappers.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { ProjectOverview } from '@osf/features/project/overview/models'; - -import { ContributorModel } from '../models/contributors/contributor.model'; -import { ResourceOverview } from '../models/resource-overview.model'; -import { SubjectModel } from '../models/subject/subject.model'; - -export function MapProjectOverview( - project: ProjectOverview, - subjects: SubjectModel[], - isAnonymous = false, - bibliographicContributors: ContributorModel[] = [] -): ResourceOverview { - return { - id: project.id, - type: project.type, - title: project.title, - description: project.description, - dateModified: project.dateModified, - dateCreated: project.dateCreated, - isPublic: project.isPublic, - category: project.category, - isRegistration: project.isRegistration, - isPreprint: project.isPreprint, - isFork: project.isFork, - isCollection: project.isCollection, - tags: project.tags || [], - accessRequestsEnabled: project.accessRequestsEnabled, - nodeLicense: project.nodeLicense, - license: project.license || undefined, - storage: project.storage || undefined, - identifiers: project.identifiers?.filter(Boolean) || undefined, - supplements: project.supplements?.filter(Boolean) || undefined, - analyticsKey: project.analyticsKey, - currentUserCanComment: project.currentUserCanComment, - currentUserPermissions: project.currentUserPermissions || [], - currentUserIsContributor: project.currentUserIsContributor, - currentUserIsContributorOrGroupMember: project.currentUserIsContributorOrGroupMember, - wikiEnabled: project.wikiEnabled, - subjects: subjects, - contributors: bibliographicContributors?.filter(Boolean) || [], - customCitation: project.customCitation || null, - region: project.region || undefined, - affiliatedInstitutions: project.affiliatedInstitutions?.filter(Boolean) || undefined, - forksCount: project.forksCount || 0, - viewOnlyLinksCount: project.viewOnlyLinksCount || 0, - isAnonymous, - }; -} diff --git a/src/app/shared/models/nodes/node-preprint-json-api.model.ts b/src/app/shared/models/nodes/node-preprint-json-api.model.ts new file mode 100644 index 000000000..ec3832e2f --- /dev/null +++ b/src/app/shared/models/nodes/node-preprint-json-api.model.ts @@ -0,0 +1,27 @@ +import { ResponseJsonApi } from '../common/json-api.model'; + +export type NodePreprintResponseJsonApi = ResponseJsonApi; +export type NodePreprintsResponseJsonApi = ResponseJsonApi; + +export interface NodePreprintDataJsonApi { + id: string; + attributes: NodePreprintAttributesJsonApi; + links: NodePreprintLinksJsonApi; +} + +export interface NodePreprintAttributesJsonApi { + title: string; + date_created: string; + date_modified: string; + date_published: string; + doi: string; + is_preprint_orphan: boolean; + is_published: boolean; + license_record: string; +} + +export interface NodePreprintLinksJsonApi { + html: string; + iri: string; + self: string; +} diff --git a/src/app/shared/models/nodes/node-preprint.model.ts b/src/app/shared/models/nodes/node-preprint.model.ts new file mode 100644 index 000000000..9ddf60dba --- /dev/null +++ b/src/app/shared/models/nodes/node-preprint.model.ts @@ -0,0 +1,11 @@ +export interface NodePreprintModel { + id: string; + title: string; + dateCreated: string; + dateModified: string; + datePublished: string; + doi: string; + isPreprintOrphan: boolean; + isPublished: boolean; + url: string; +} diff --git a/src/app/shared/models/nodes/node-storage-json-api.model.ts b/src/app/shared/models/nodes/node-storage-json-api.model.ts new file mode 100644 index 000000000..9a04e95f4 --- /dev/null +++ b/src/app/shared/models/nodes/node-storage-json-api.model.ts @@ -0,0 +1,20 @@ +import { ResponseJsonApi } from '../common/json-api.model'; + +export type NodeStorageResponseJsonApi = ResponseJsonApi; + +export interface NodeStorageDataJsonApi { + id: string; + type: 'node-storage'; + attributes: NodeStorageAttributesJsonApi; + links: NodeStorageLinksJsonApi; +} + +export interface NodeStorageAttributesJsonApi { + storage_limit_status: string; + storage_usage: string; +} + +export interface NodeStorageLinksJsonApi { + self: string; + iri: string; +} diff --git a/src/app/shared/models/nodes/node-storage.model.ts b/src/app/shared/models/nodes/node-storage.model.ts new file mode 100644 index 000000000..e47a7b9c2 --- /dev/null +++ b/src/app/shared/models/nodes/node-storage.model.ts @@ -0,0 +1,5 @@ +export interface NodeStorageModel { + id: string; + storageLimitStatus: string; + storageUsage: string; +} diff --git a/src/app/shared/models/resource-overview.model.ts b/src/app/shared/models/resource-overview.model.ts deleted file mode 100644 index 3eb141058..000000000 --- a/src/app/shared/models/resource-overview.model.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { IdTypeModel } from './common/id-type.model'; -import { ContributorModel } from './contributors/contributor.model'; -import { IdentifierModel } from './identifiers/identifier.model'; -import { Institution } from './institutions/institutions.models'; -import { LicenseModel, LicensesOption } from './license/license.model'; -import { SubjectModel } from './subject/subject.model'; - -export interface ResourceOverview { - id: string; - type: string; - title: string; - description: string; - dateModified: string; - dateCreated: string; - dateRegistered?: string; - isPublic: boolean; - category: string; - isRegistration: boolean; - isPreprint: boolean; - isFork: boolean; - isCollection: boolean; - tags: string[]; - accessRequestsEnabled: boolean; - nodeLicense?: LicensesOption; - license?: LicenseModel; - storage?: { - id: string; - type: string; - storageLimitStatus: string; - storageUsage: string; - }; - identifiers?: IdentifierModel[]; - supplements?: { - id: string; - type: string; - title: string; - dateCreated: string; - url: string; - }[]; - registrationType?: string; - analyticsKey: string; - currentUserCanComment: boolean; - currentUserPermissions: string[]; - currentUserIsContributor: boolean; - currentUserIsContributorOrGroupMember: boolean; - wikiEnabled: boolean; - subjects: SubjectModel[]; - contributors: ContributorModel[]; - customCitation: string | null; - region?: IdTypeModel; - affiliatedInstitutions?: Institution[]; - forksCount: number; - viewOnlyLinksCount?: number; - associatedProjectId?: string; - isAnonymous?: boolean; - iaUrl?: string | null; -} diff --git a/src/app/shared/models/toolbar-resource.model.ts b/src/app/shared/models/toolbar-resource.model.ts deleted file mode 100644 index 0d9e850e0..000000000 --- a/src/app/shared/models/toolbar-resource.model.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { ResourceType } from '@shared/enums/resource-type.enum'; - -export interface ToolbarResource { - id: string; - title: string; - isPublic: boolean; - storage?: { - id: string; - type: string; - storageLimitStatus: string; - storageUsage: string; - }; - viewOnlyLinksCount: number; - forksCount: number; - resourceType: ResourceType; - isAnonymous: boolean; -} diff --git a/src/app/shared/services/collections.service.ts b/src/app/shared/services/collections.service.ts index 7c45394dc..4b7c83a27 100644 --- a/src/app/shared/services/collections.service.ts +++ b/src/app/shared/services/collections.service.ts @@ -149,14 +149,15 @@ export class CollectionsService { .pipe(map((response) => CollectionsMapper.fromGetCollectionSubmissionsResponse(response))); } - fetchProjectCollections(projectId: string, is_public: boolean, bookmarks: boolean): Observable { + fetchProjectCollections(projectId: string, isPublic: boolean, bookmarks: boolean): Observable { const params: Record = { - 'filter[is_public]': is_public, + 'filter[is_public]': isPublic, 'filter[bookmarks]': bookmarks, }; + return this.jsonApiService .get< - JsonApiResponse + ResponseJsonApi >(`${this.apiUrl}/nodes/${projectId}/collections/`, params) .pipe( map((response) => diff --git a/src/testing/mocks/node-preprint.mock.ts b/src/testing/mocks/node-preprint.mock.ts new file mode 100644 index 000000000..5129d435d --- /dev/null +++ b/src/testing/mocks/node-preprint.mock.ts @@ -0,0 +1,28 @@ +import { NodePreprintModel } from '@osf/shared/models/nodes/node-preprint.model'; + +export const MOCK_NODE_PREPRINT: NodePreprintModel = { + id: '1', + title: 'Test Supplement 1', + dateCreated: '2024-01-15T10:00:00Z', + dateModified: '2024-01-20T10:00:00Z', + datePublished: '2024-01-20T10:00:00Z', + doi: '10.1234/test1', + isPreprintOrphan: false, + isPublished: true, + url: 'https://example.com/supplement1', +}; + +export const MOCK_NODE_PREPRINTS: NodePreprintModel[] = [ + MOCK_NODE_PREPRINT, + { + id: '2', + title: 'Test Supplement 2', + dateCreated: '2024-02-01T10:00:00Z', + dateModified: '2024-02-05T10:00:00Z', + datePublished: '2024-02-05T10:00:00Z', + doi: '10.1234/test2', + isPreprintOrphan: false, + isPublished: true, + url: 'https://example.com/supplement2', + }, +]; diff --git a/src/testing/mocks/project-overview.mock.ts b/src/testing/mocks/project-overview.mock.ts index c9128e93d..9b2c02a1a 100644 --- a/src/testing/mocks/project-overview.mock.ts +++ b/src/testing/mocks/project-overview.mock.ts @@ -1,4 +1,4 @@ -import { ProjectOverview } from '@osf/features/project/overview/models'; +import { ProjectOverviewModel } from '@osf/features/project/overview/models'; import { IdentifierModel } from '@osf/shared/models/identifiers/identifier.model'; export const MOCK_PROJECT_AFFILIATED_INSTITUTIONS = [ @@ -32,7 +32,7 @@ export const MOCK_PROJECT_IDENTIFIERS: IdentifierModel = { value: '10.1234/test.12345', }; -export const MOCK_PROJECT_OVERVIEW: ProjectOverview = { +export const MOCK_PROJECT_OVERVIEW: ProjectOverviewModel = { id: 'project-1', type: 'nodes', title: 'Test Project', @@ -47,19 +47,17 @@ export const MOCK_PROJECT_OVERVIEW: ProjectOverview = { isCollection: false, tags: [], accessRequestsEnabled: false, - analyticsKey: 'test-key', - currentUserCanComment: true, + nodeLicense: { + copyrightHolders: null, + year: null, + }, currentUserPermissions: [], currentUserIsContributor: true, - currentUserIsContributorOrGroupMember: true, wikiEnabled: false, contributors: [], - customCitation: null, forksCount: 0, viewOnlyLinksCount: 0, links: { - rootFolder: '/test', iri: 'https://test.com', }, - doi: MOCK_PROJECT_IDENTIFIERS.value, }; diff --git a/src/testing/mocks/resource.mock.ts b/src/testing/mocks/resource.mock.ts index 91c4efe09..b9f4afb91 100644 --- a/src/testing/mocks/resource.mock.ts +++ b/src/testing/mocks/resource.mock.ts @@ -1,6 +1,5 @@ import { ResourceInfoModel } from '@osf/features/contributors/models'; import { ResourceType } from '@shared/enums/resource-type.enum'; -import { ResourceOverview } from '@shared/models/resource-overview.model'; import { ResourceModel } from '@shared/models/search/resource.model'; export const MOCK_RESOURCE: ResourceModel = { @@ -79,33 +78,6 @@ export const MOCK_AGENT_RESOURCE: ResourceModel = { context: '', }; -export const MOCK_RESOURCE_OVERVIEW: ResourceOverview = { - id: 'resource-123', - type: 'project', - title: 'Test Resource', - description: 'This is a test resource', - dateModified: '2024-01-20T10:00:00Z', - dateCreated: '2024-01-15T10:00:00Z', - isPublic: true, - category: 'project', - isRegistration: false, - isPreprint: false, - isFork: false, - isCollection: false, - tags: ['test', 'example'], - accessRequestsEnabled: false, - analyticsKey: 'test-key', - currentUserCanComment: true, - currentUserPermissions: ['read', 'write'], - currentUserIsContributor: true, - currentUserIsContributorOrGroupMember: true, - wikiEnabled: true, - subjects: [], - contributors: [], - customCitation: 'Custom citation text', - forksCount: 0, -}; - export const MOCK_RESOURCE_INFO: ResourceInfoModel = { id: 'project-123', title: 'Test Project', diff --git a/src/testing/providers/analytics.service.mock.ts b/src/testing/providers/analytics.service.mock.ts new file mode 100644 index 000000000..f0c061422 --- /dev/null +++ b/src/testing/providers/analytics.service.mock.ts @@ -0,0 +1,9 @@ +import { of } from 'rxjs'; + +import { AnalyticsService } from '@osf/shared/services/analytics.service'; + +export function AnalyticsServiceMockFactory() { + return { + sendCountedUsage: jest.fn().mockReturnValue(of(void 0)), + } as unknown as jest.Mocked; +} diff --git a/src/testing/providers/help-scout.service.mock.ts b/src/testing/providers/help-scout.service.mock.ts new file mode 100644 index 000000000..4cc9e6a1b --- /dev/null +++ b/src/testing/providers/help-scout.service.mock.ts @@ -0,0 +1,8 @@ +import { HelpScoutService } from '@core/services/help-scout.service'; + +export function HelpScoutServiceMockFactory() { + return { + setResourceType: jest.fn(), + unsetResourceType: jest.fn(), + } as unknown as jest.Mocked; +} diff --git a/src/testing/mocks/loader-service.mock.ts b/src/testing/providers/loader-service.mock.ts similarity index 100% rename from src/testing/mocks/loader-service.mock.ts rename to src/testing/providers/loader-service.mock.ts diff --git a/src/testing/providers/meta-tags.service.mock.ts b/src/testing/providers/meta-tags.service.mock.ts new file mode 100644 index 000000000..ef64d5767 --- /dev/null +++ b/src/testing/providers/meta-tags.service.mock.ts @@ -0,0 +1,7 @@ +import { MetaTagsService } from '@osf/shared/services/meta-tags.service'; + +export function MetaTagsServiceMockFactory() { + return { + updateMetaTags: jest.fn(), + } as unknown as jest.Mocked>; +} diff --git a/src/testing/providers/prerender-ready.service.mock.ts b/src/testing/providers/prerender-ready.service.mock.ts new file mode 100644 index 000000000..77d1ddb92 --- /dev/null +++ b/src/testing/providers/prerender-ready.service.mock.ts @@ -0,0 +1,8 @@ +import { PrerenderReadyService } from '@core/services/prerender-ready.service'; + +export function PrerenderReadyServiceMockFactory() { + return { + setNotReady: jest.fn(), + setReady: jest.fn(), + } as unknown as jest.Mocked; +}