diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..6b3cb391f --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2025 Center for Open Science + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. 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/package-lock.json b/package-lock.json index 71fd20064..9699293ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "osf", - "version": "0.0.0", + "version": "25.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "osf", - "version": "0.0.0", + "version": "25.2.0", "dependencies": { "@angular/animations": "^19.2.0", "@angular/cdk": "^19.2.1", diff --git a/package.json b/package.json index eef0b9b4e..30c9c6215 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "osf", - "version": "25.2.0", + "version": "25.3.0", "scripts": { "ng": "ng", "analyze-bundle": "ng build --configuration=analyze-bundle && source-map-explorer dist/**/*.js --no-border-checks", 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/guards/is-file-provider.guard.ts b/src/app/core/guards/is-file-provider.guard.ts new file mode 100644 index 000000000..0dd88d330 --- /dev/null +++ b/src/app/core/guards/is-file-provider.guard.ts @@ -0,0 +1,9 @@ +import { CanMatchFn, Route, UrlSegment } from '@angular/router'; + +import { FileProvider } from '@osf/features/files/constants'; + +export const isFileProvider: CanMatchFn = (route: Route, segments: UrlSegment[]) => { + const id = segments[0]?.path; + + return !!(id && Object.values(FileProvider).some((provider) => provider === id)); +}; 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/core/store/user/user.selectors.ts b/src/app/core/store/user/user.selectors.ts index 77f285acf..548dddc08 100644 --- a/src/app/core/store/user/user.selectors.ts +++ b/src/app/core/store/user/user.selectors.ts @@ -55,4 +55,9 @@ export class UserSelectors { static isAuthenticated(state: UserStateModel): boolean { return !!state.currentUser.data || !!localStorage.getItem('currentUser'); } + + @Selector([UserState]) + static getActiveFlags(state: UserStateModel): string[] { + return state.activeFlags || []; + } } diff --git a/src/app/core/store/user/user.state.ts b/src/app/core/store/user/user.state.ts index 5bccbc339..c07fb1b5d 100644 --- a/src/app/core/store/user/user.state.ts +++ b/src/app/core/store/user/user.state.ts @@ -34,6 +34,12 @@ export class UserState { @Action(GetCurrentUser) getCurrentUser(ctx: StateContext) { const currentUser = localStorage.getItem('currentUser'); + const activeFlags = localStorage.getItem('activeFlags'); + if (activeFlags) { + ctx.patchState({ + activeFlags: JSON.parse(activeFlags), + }); + } if (currentUser) { const parsedUser = JSON.parse(currentUser); @@ -70,6 +76,9 @@ export class UserState { if (data.currentUser) { localStorage.setItem('currentUser', JSON.stringify(data.currentUser)); } + if (data.activeFlags) { + localStorage.setItem('activeFlags', JSON.stringify(data.activeFlags)); + } }) ); } 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/analytics.component.ts b/src/app/features/analytics/analytics.component.ts index fff8d7072..f27d9b343 100644 --- a/src/app/features/analytics/analytics.component.ts +++ b/src/app/features/analytics/analytics.component.ts @@ -140,6 +140,7 @@ export class AnalyticsComponent implements OnInit { navigateToLinkedProjects() { this.router.navigate(['linked-projects'], { relativeTo: this.route }); } + private setData() { const analytics = this.analytics(); @@ -171,7 +172,14 @@ export class AnalyticsComponent implements OnInit { }, ]; - this.popularPagesLabels = analytics.popularPages.map((item) => item.title); + this.popularPagesLabels = analytics.popularPages.map((item) => { + const parts = item.path.split('/').filter(Boolean); + const resource = parts[1]?.replace('-', ' ') || 'overview'; + let cleanTitle = item.title === 'OSF' ? item.title : item.title.replace(/^OSF \| /, ''); + cleanTitle = cleanTitle.replace(/&/gi, '&').replace(/</gi, '<').replace(/>/gi, '>'); + return cleanTitle.endsWith(resource) ? cleanTitle : `${cleanTitle} | ${resource}`; + }); + this.popularPagesDataset = [ { label: this.translateService.instant('project.analytics.charts.popularPages'), 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..0b19ebb0f 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,10 +59,10 @@

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

-
+
{{ 'common.labels.contributors' | translate }}: - +
@if (duplicate.description) { 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..52b284ecd 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,10 +27,10 @@

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

-
+
{{ 'common.labels.contributors' | translate }}: - +
@if (duplicate.description) { 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/collections/components/add-to-collection/project-contributors-step/project-contributors-step.component.ts b/src/app/features/collections/components/add-to-collection/project-contributors-step/project-contributors-step.component.ts index 35af2dc55..6fcba6f9a 100644 --- a/src/app/features/collections/components/add-to-collection/project-contributors-step/project-contributors-step.component.ts +++ b/src/app/features/collections/components/add-to-collection/project-contributors-step/project-contributors-step.component.ts @@ -37,7 +37,6 @@ import { CustomConfirmationService } from '@osf/shared/services/custom-confirmat import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { ToastService } from '@osf/shared/services/toast.service'; import { - AddContributor, BulkAddContributors, BulkUpdateContributors, ContributorsSelectors, @@ -92,7 +91,6 @@ export class ProjectContributorsStepComponent { contributorsSaved = output(); actions = createDispatchMap({ - addContributor: AddContributor, bulkAddContributors: BulkAddContributors, bulkUpdateContributors: BulkUpdateContributors, deleteContributor: DeleteContributor, @@ -207,7 +205,7 @@ export class ProjectContributorsStepComponent { } else { const params = { name: res.data[0].fullName }; - this.actions.addContributor(this.selectedProject()?.id, ResourceType.Project, res.data[0]).subscribe({ + this.actions.bulkAddContributors(this.selectedProject()?.id, ResourceType.Project, res.data).subscribe({ next: () => this.toastService.showSuccess('project.contributors.toastMessages.addSuccessMessage', params), }); } diff --git a/src/app/features/contributors/contributors.component.ts b/src/app/features/contributors/contributors.component.ts index 8c2ef4b23..118d380f1 100644 --- a/src/app/features/contributors/contributors.component.ts +++ b/src/app/features/contributors/contributors.component.ts @@ -45,7 +45,6 @@ import { LoaderService } from '@osf/shared/services/loader.service'; import { ToastService } from '@osf/shared/services/toast.service'; import { AcceptRequestAccess, - AddContributor, BulkAddContributors, BulkAddContributorsFromParentProject, BulkUpdateContributors, @@ -176,7 +175,6 @@ export class ContributorsComponent implements OnInit, OnDestroy { bulkUpdateContributors: BulkUpdateContributors, bulkAddContributors: BulkAddContributors, bulkAddContributorsFromParentProject: BulkAddContributorsFromParentProject, - addContributor: AddContributor, createViewOnlyLink: CreateViewOnlyLink, deleteViewOnlyLink: DeleteViewOnlyLink, getRequestAccessContributors: GetRequestAccessContributors, @@ -327,7 +325,7 @@ export class ContributorsComponent implements OnInit, OnDestroy { private addContributorsToComponents(result: ContributorDialogAddModel): void { this.actions - .bulkAddContributors(this.resourceId(), this.resourceType(), result.data, result.childNodeIds) + .bulkAddContributors(this.resourceId(), this.resourceType(), result.data) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(() => this.toastService.showSuccess('project.contributors.toastMessages.multipleAddSuccessMessage')); } @@ -355,7 +353,7 @@ export class ContributorsComponent implements OnInit, OnDestroy { } else { const params = { name: res.data[0].fullName }; - this.actions.addContributor(this.resourceId(), this.resourceType(), res.data[0]).subscribe({ + this.actions.bulkAddContributors(this.resourceId(), this.resourceType(), res.data).subscribe({ next: () => this.toastService.showSuccess('project.contributors.toastMessages.addSuccessMessage', params), }); } diff --git a/src/app/features/files/components/confirm-move-file-dialog/confirm-move-file-dialog.component.html b/src/app/features/files/components/confirm-move-file-dialog/confirm-move-file-dialog.component.html new file mode 100644 index 000000000..1b67f9d6a --- /dev/null +++ b/src/app/features/files/components/confirm-move-file-dialog/confirm-move-file-dialog.component.html @@ -0,0 +1,11 @@ +
+
+
+ + + + +
+
diff --git a/src/app/features/files/components/confirm-move-file-dialog/confirm-move-file-dialog.component.scss b/src/app/features/files/components/confirm-move-file-dialog/confirm-move-file-dialog.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/features/files/components/confirm-move-file-dialog/confirm-move-file-dialog.component.spec.ts b/src/app/features/files/components/confirm-move-file-dialog/confirm-move-file-dialog.component.spec.ts new file mode 100644 index 000000000..88f0214fa --- /dev/null +++ b/src/app/features/files/components/confirm-move-file-dialog/confirm-move-file-dialog.component.spec.ts @@ -0,0 +1,82 @@ +import { TranslatePipe } from '@ngx-translate/core'; +import { MockComponents, MockPipe } from 'ng-mocks'; + +import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; + +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { FileSelectDestinationComponent } from '@osf/shared/components/file-select-destination/file-select-destination.component'; +import { IconComponent } from '@osf/shared/components/icon/icon.component'; +import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; +import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; +import { FilesService } from '@osf/shared/services/files.service'; +import { ToastService } from '@osf/shared/services/toast.service'; + +import { FilesSelectors } from '../../store'; + +import { ConfirmMoveFileDialogComponent } from './confirm-move-file-dialog.component'; + +import { OSFTestingModule } from '@testing/osf.testing.module'; +import { CustomConfirmationServiceMock } from '@testing/providers/custom-confirmation-provider.mock'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { ToastServiceMock } from '@testing/providers/toast-provider.mock'; + +describe('ConfirmConfirmMoveFileDialogComponent', () => { + let component: ConfirmMoveFileDialogComponent; + let fixture: ComponentFixture; + + const mockFilesService = { + moveFiles: jest.fn(), + getMoveDialogFiles: jest.fn(), + }; + + beforeEach(async () => { + const dialogRefMock = { + close: jest.fn(), + }; + + const dialogConfigMock = { + data: { files: [], destination: { name: 'files' } }, + }; + + await TestBed.configureTestingModule({ + imports: [ + ConfirmMoveFileDialogComponent, + OSFTestingModule, + ...MockComponents(IconComponent, LoadingSpinnerComponent, FileSelectDestinationComponent), + MockPipe(TranslatePipe), + ], + providers: [ + { provide: DynamicDialogRef, useValue: dialogRefMock }, + { provide: DynamicDialogConfig, useValue: dialogConfigMock }, + { provide: FilesService, useValue: mockFilesService }, + { provide: ToastService, useValue: ToastServiceMock.simple() }, + { provide: CustomConfirmationService, useValue: CustomConfirmationServiceMock.simple() }, + provideMockStore({ + signals: [ + { selector: FilesSelectors.getMoveDialogFiles, value: [] }, + { selector: FilesSelectors.getProvider, value: null }, + ], + }), + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ConfirmMoveFileDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should initialize with correct properties', () => { + expect(component.config).toBeDefined(); + expect(component.dialogRef).toBeDefined(); + expect(component.files).toBeDefined(); + }); + + it('should get files from store', () => { + expect(component.files()).toEqual([]); + }); +}); diff --git a/src/app/features/files/components/confirm-move-file-dialog/confirm-move-file-dialog.component.ts b/src/app/features/files/components/confirm-move-file-dialog/confirm-move-file-dialog.component.ts new file mode 100644 index 000000000..82667ec46 --- /dev/null +++ b/src/app/features/files/components/confirm-move-file-dialog/confirm-move-file-dialog.component.ts @@ -0,0 +1,154 @@ +import { select } from '@ngxs/store'; + +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; + +import { finalize, forkJoin, of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +import { ChangeDetectionStrategy, Component, DestroyRef, inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; + +import { FilesSelectors } from '@osf/features/files/store'; +import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; +import { FilesService } from '@osf/shared/services/files.service'; +import { ToastService } from '@osf/shared/services/toast.service'; +import { FileMenuType } from '@shared/enums/file-menu-type.enum'; +import { FileModel } from '@shared/models/files/file.model'; + +@Component({ + selector: 'osf-move-file-dialog', + imports: [Button, TranslatePipe], + templateUrl: './confirm-move-file-dialog.component.html', + styleUrl: './confirm-move-file-dialog.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ConfirmMoveFileDialogComponent { + readonly config = inject(DynamicDialogConfig); + readonly dialogRef = inject(DynamicDialogRef); + private readonly filesService = inject(FilesService); + private readonly destroyRef = inject(DestroyRef); + private readonly translateService = inject(TranslateService); + private readonly toastService = inject(ToastService); + private readonly customConfirmationService = inject(CustomConfirmationService); + + readonly files = select(FilesSelectors.getMoveDialogFiles); + readonly provider = this.config.data.storageProvider; + + private fileProjectId = this.config.data.resourceId; + protected currentFolder = this.config.data.destination; + + get dragNodeName() { + const filesCount = this.config.data.files.length; + if (filesCount > 1) { + return this.translateService.instant('files.dialogs.moveFile.multipleFiles', { count: filesCount }); + } else { + return this.config.data.files[0]?.name; + } + } + + copyFiles(): void { + return this.copyOrMoveFiles(FileMenuType.Copy); + } + + moveFiles(): void { + return this.copyOrMoveFiles(FileMenuType.Move); + } + + private copyOrMoveFiles(action: FileMenuType): void { + const path = this.currentFolder.path; + if (!path) { + throw new Error(this.translateService.instant('files.dialogs.moveFile.pathError')); + } + const isMoveAction = action === FileMenuType.Move; + + const headerKey = isMoveAction ? 'files.dialogs.moveFile.movingHeader' : 'files.dialogs.moveFile.copingHeader'; + this.config.header = this.translateService.instant(headerKey); + const files: FileModel[] = this.config.data.files; + const totalFiles = files.length; + let completed = 0; + const conflictFiles: { file: FileModel; link: string }[] = []; + + files.forEach((file) => { + const link = file.links.move; + this.filesService + .moveFile(link, path, this.fileProjectId, this.provider, action) + .pipe( + takeUntilDestroyed(this.destroyRef), + catchError((error) => { + if (error.status === 409) { + conflictFiles.push({ file, link }); + } else { + this.showErrorToast(action, error.error?.message); + } + return of(null); + }), + finalize(() => { + completed++; + if (completed === totalFiles) { + if (conflictFiles.length > 0) { + this.openReplaceMoveDialog(conflictFiles, path, action); + } else { + this.showSuccessToast(action); + this.config.header = this.translateService.instant('files.dialogs.moveFile.title'); + this.completeMove(); + } + } + }) + ) + .subscribe(); + }); + } + + private openReplaceMoveDialog( + conflictFiles: { file: FileModel; link: string }[], + path: string, + action: string + ): void { + this.customConfirmationService.confirmDelete({ + headerKey: conflictFiles.length > 1 ? 'files.dialogs.replaceFile.multiple' : 'files.dialogs.replaceFile.single', + messageKey: 'files.dialogs.replaceFile.message', + messageParams: { + name: conflictFiles.map((c) => c.file.name).join(', '), + }, + acceptLabelKey: 'common.buttons.replace', + onConfirm: () => { + const replaceRequests$ = conflictFiles.map(({ link }) => + this.filesService.moveFile(link, path, this.fileProjectId, this.provider, action, true).pipe( + takeUntilDestroyed(this.destroyRef), + catchError(() => of(null)) + ) + ); + forkJoin(replaceRequests$).subscribe({ + next: () => { + this.showSuccessToast(action); + this.completeMove(); + }, + }); + }, + onReject: () => { + const totalFiles = this.config.data.files.length; + if (totalFiles > conflictFiles.length) { + this.showErrorToast(action); + } + this.completeMove(); + }, + }); + } + + private showSuccessToast(action: string) { + const messageType = action === 'move' ? 'moveFile' : 'copyFile'; + this.toastService.showSuccess(`files.dialogs.${messageType}.success`); + } + + private showErrorToast(action: string, errorMessage?: string) { + const messageType = action === 'move' ? 'moveFile' : 'copyFile'; + this.toastService.showError(errorMessage ?? `files.dialogs.${messageType}.error`); + } + + private completeMove(): void { + this.dialogRef.close(true); + } +} diff --git a/src/app/features/files/components/move-file-dialog/move-file-dialog.component.spec.ts b/src/app/features/files/components/move-file-dialog/move-file-dialog.component.spec.ts index 3e5c8e56f..2dfcb0406 100644 --- a/src/app/features/files/components/move-file-dialog/move-file-dialog.component.spec.ts +++ b/src/app/features/files/components/move-file-dialog/move-file-dialog.component.spec.ts @@ -3,7 +3,6 @@ import { MockComponents, MockPipe } from 'ng-mocks'; import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; -import { signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FileSelectDestinationComponent } from '@osf/shared/components/file-select-destination/file-select-destination.component'; @@ -56,14 +55,14 @@ describe('MoveFileDialogComponent', () => { { provide: CustomConfirmationService, useValue: CustomConfirmationServiceMock.simple() }, provideMockStore({ signals: [ - { selector: FilesSelectors.getMoveDialogFiles, value: signal([]) }, - { selector: FilesSelectors.getMoveDialogFilesTotalCount, value: signal(0) }, - { selector: FilesSelectors.isMoveDialogFilesLoading, value: signal(false) }, - { selector: FilesSelectors.getMoveDialogCurrentFolder, value: signal(null) }, - { selector: CurrentResourceSelectors.getCurrentResource, value: signal(null) }, - { selector: CurrentResourceSelectors.getResourceWithChildren, value: signal([]) }, - { selector: CurrentResourceSelectors.isResourceWithChildrenLoading, value: signal(false) }, - { selector: FilesSelectors.isMoveDialogConfiguredStorageAddonsLoading, value: signal(false) }, + { selector: FilesSelectors.getMoveDialogFiles, value: [] }, + { selector: FilesSelectors.getMoveDialogFilesTotalCount, value: 0 }, + { selector: FilesSelectors.isMoveDialogFilesLoading, value: false }, + { selector: FilesSelectors.getMoveDialogCurrentFolder, value: null }, + { selector: CurrentResourceSelectors.getCurrentResource, value: null }, + { selector: CurrentResourceSelectors.getResourceWithChildren, value: [] }, + { selector: CurrentResourceSelectors.isResourceWithChildrenLoading, value: false }, + { selector: FilesSelectors.isMoveDialogConfiguredStorageAddonsLoading, value: false }, ], }), ], diff --git a/src/app/features/files/components/move-file-dialog/move-file-dialog.component.ts b/src/app/features/files/components/move-file-dialog/move-file-dialog.component.ts index e3b33a856..4374dade2 100644 --- a/src/app/features/files/components/move-file-dialog/move-file-dialog.component.ts +++ b/src/app/features/files/components/move-file-dialog/move-file-dialog.component.ts @@ -211,7 +211,7 @@ export class MoveFileDialogComponent { if (error.status === 409) { conflictFiles.push({ file, link }); } else { - this.toastService.showError(error.error?.message ?? 'Error'); + this.showErrorToast(action, error.error?.message ?? 'Error'); } return of(null); }), @@ -221,7 +221,7 @@ export class MoveFileDialogComponent { if (conflictFiles.length > 0) { this.openReplaceMoveDialog(conflictFiles, path, action); } else { - this.showToast(action); + this.showSuccessToast(action); this.config.header = this.translateService.instant('files.dialogs.moveFile.title'); this.completeMove(); } @@ -254,7 +254,7 @@ export class MoveFileDialogComponent { forkJoin(replaceRequests$).subscribe({ next: () => { - this.showToast(action); + this.showSuccessToast(action); this.completeMove(); }, }); @@ -262,18 +262,23 @@ export class MoveFileDialogComponent { onReject: () => { const totalFiles = this.config.data.files.length; if (totalFiles > conflictFiles.length) { - this.showToast(action); + this.showErrorToast(action); } this.completeMove(); }, }); } - private showToast(action: string): void { + private showSuccessToast(action: string) { const messageType = action === 'move' ? 'moveFile' : 'copyFile'; this.toastService.showSuccess(`files.dialogs.${messageType}.success`); } + private showErrorToast(action: string, errorMessage?: string) { + const messageType = action === 'move' ? 'moveFile' : 'copyFile'; + this.toastService.showError(errorMessage ?? `files.dialogs.${messageType}.error`); + } + private completeMove(): void { this.isFilesUpdating.set(false); this.actions.setCurrentFolder(this.initialFolder); diff --git a/src/app/features/files/constants/file-provider.constants.ts b/src/app/features/files/constants/file-provider.constants.ts index 18f9514f1..2f9a5f383 100644 --- a/src/app/features/files/constants/file-provider.constants.ts +++ b/src/app/features/files/constants/file-provider.constants.ts @@ -11,4 +11,6 @@ export const FileProvider = { GitLab: 'gitlab', Figshare: 'figshare', Dataverse: 'dataverse', + OwnCloud: 'owncloud', + AzureBlobStorage: 'azureblobstorage', }; diff --git a/src/app/features/files/files.routes.ts b/src/app/features/files/files.routes.ts index e21cd5ef5..5c14d10a2 100644 --- a/src/app/features/files/files.routes.ts +++ b/src/app/features/files/files.routes.ts @@ -1,5 +1,6 @@ import { Routes } from '@angular/router'; +import { isFileProvider } from '@core/guards/is-file-provider.guard'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { FilesContainerComponent } from './pages/files-container/files-container.component'; @@ -11,6 +12,12 @@ export const filesRoutes: Routes = [ children: [ { path: '', + pathMatch: 'full', + redirectTo: 'osfstorage', + }, + { + path: ':fileProvider', + canMatch: [isFileProvider], loadComponent: () => import('@osf/features/files/pages/files/files.component').then((c) => c.FilesComponent), }, { diff --git a/src/app/features/files/pages/files/files.component.html b/src/app/features/files/pages/files/files.component.html index a47132833..631eceea2 100644 --- a/src/app/features/files/pages/files/files.component.html +++ b/src/app/features/files/pages/files/files.component.html @@ -13,6 +13,7 @@ inputId="in_label" [options]="rootFoldersOptions()" [(ngModel)]="currentRootFolder" + (onChange)="handleRootFolderChange($event.value)" styleClass="w-full" variant="filled" > @@ -149,7 +150,7 @@ (setCurrentFolder)="setCurrentFolder($event)" (setMoveDialogCurrentFolder)="setMoveDialogCurrentFolder($event)" (updateFoldersStack)="onUpdateFoldersStack($event)" - (resetFilesProvider)="resetProvider()" + (resetFilesProvider)="resetOnDialogClose()" >
diff --git a/src/app/features/files/pages/files/files.component.ts b/src/app/features/files/pages/files/files.component.ts index 7e47f9dc1..3653caa67 100644 --- a/src/app/features/files/pages/files/files.component.ts +++ b/src/app/features/files/pages/files/files.component.ts @@ -7,7 +7,18 @@ import { Button } from 'primeng/button'; import { Select } from 'primeng/select'; import { TableModule } from 'primeng/table'; -import { catchError, debounceTime, distinctUntilChanged, filter, finalize, forkJoin, of, switchMap, take } from 'rxjs'; +import { + catchError, + debounceTime, + distinctUntilChanged, + filter, + finalize, + forkJoin, + map, + of, + switchMap, + take, +} from 'rxjs'; import { HttpEventType, HttpResponse } from '@angular/common/http'; import { @@ -22,7 +33,7 @@ import { signal, viewChild, } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; @@ -244,6 +255,11 @@ export class FilesComponent { () => this.isButtonDisabled() || (this.googleFilePickerComponent()?.isGFPDisabled() ?? false) ); + private route = inject(ActivatedRoute); + readonly providerName = toSignal( + this.route?.params?.pipe(map((params) => params['fileProvider'])) ?? of('osfstorage') + ); + constructor() { this.activeRoute.parent?.parent?.parent?.params.subscribe((params) => { if (params['id']) { @@ -264,15 +280,18 @@ export class FilesComponent { }); effect(() => { - const rootFolders = this.rootFolders(); - if (rootFolders) { - const osfRootFolder = rootFolders.find( - (folder: FileFolderModel) => folder.provider === FileProvider.OsfStorage - ); - if (osfRootFolder) { + const rootFoldersOptions = this.rootFoldersOptions(); + const providerName = this.providerName(); + + if (rootFoldersOptions && rootFoldersOptions.length && providerName) { + const rootFoldersOption = rootFoldersOptions.find((option) => option.folder.provider === providerName); + + if (!rootFoldersOption) { + this.router.navigate([`/${this.resourceId()}/files`, FileProvider.OsfStorage]); + } else { this.currentRootFolder.set({ - label: this.translateService.instant('files.storageLocation'), - folder: osfRootFolder, + label: rootFoldersOption.label, + folder: rootFoldersOption.folder, }); } } @@ -426,7 +445,8 @@ export class FilesComponent { } onFileTreeSelected(file: FileModel): void { - this.filesSelection = [...this.filesSelection, file]; + this.filesSelection.push(file); + this.filesSelection = [...new Set(this.filesSelection)]; } onFileTreeUnselected(file: FileModel): void { @@ -523,6 +543,12 @@ export class FilesComponent { } } + resetOnDialogClose(): void { + this.onClearSelection(); + this.resetProvider(); + this.updateFilesList(); + } + createFolder(): void { const currentFolder = this.currentFolder(); const newFolderLink = currentFolder?.links.newFolder; @@ -652,4 +678,10 @@ export class FilesComponent { onUpdateFoldersStack(newStack: FileFolderModel[]): void { this.foldersStack = [...newStack]; } + + handleRootFolderChange(selectedFolder: FileLabelModel) { + const provider = selectedFolder.folder?.provider; + const resourceId = this.resourceId(); + this.router.navigate([`/${resourceId}/files`, provider]); + } } diff --git a/src/app/features/metadata/dialogs/contributors-dialog/contributors-dialog.component.ts b/src/app/features/metadata/dialogs/contributors-dialog/contributors-dialog.component.ts index 896cbae48..f9a6d0b3d 100644 --- a/src/app/features/metadata/dialogs/contributors-dialog/contributors-dialog.component.ts +++ b/src/app/features/metadata/dialogs/contributors-dialog/contributors-dialog.component.ts @@ -36,7 +36,6 @@ import { CustomConfirmationService } from '@osf/shared/services/custom-confirmat import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { ToastService } from '@osf/shared/services/toast.service'; import { - AddContributor, BulkAddContributors, BulkUpdateContributors, ContributorsSelectors, @@ -96,7 +95,6 @@ export class ContributorsDialogComponent implements OnInit { updatePermissionFilter: UpdatePermissionFilter, updateBibliographyFilter: UpdateBibliographyFilter, deleteContributor: DeleteContributor, - addContributor: AddContributor, bulkAddContributors: BulkAddContributors, bulkUpdateContributors: BulkUpdateContributors, loadMoreContributors: LoadMoreContributors, @@ -178,7 +176,7 @@ export class ContributorsDialogComponent implements OnInit { } else { const params = { name: res.data[0].fullName }; - this.actions.addContributor(this.resourceId, this.resourceType, res.data[0]).subscribe({ + this.actions.bulkAddContributors(this.resourceId, this.resourceType, res.data).subscribe({ next: () => { this.changesMade.set(true); this.toastService.showSuccess('project.contributors.toastMessages.addSuccessMessage', params); diff --git a/src/app/features/metadata/dialogs/funding-dialog/funding-dialog.component.html b/src/app/features/metadata/dialogs/funding-dialog/funding-dialog.component.html index 55c97ab71..b0288a118 100644 --- a/src/app/features/metadata/dialogs/funding-dialog/funding-dialog.component.html +++ b/src/app/features/metadata/dialogs/funding-dialog/funding-dialog.component.html @@ -20,7 +20,7 @@ [showClear]="true" [loading]="fundersLoading()" [emptyFilterMessage]="filterMessage() | translate" - [emptyMessage]="filterMessage()" + [emptyMessage]="filterMessage() | translate" [autoOptionFocus]="false" (onChange)="onFunderSelected($event.value, $index)" (onFilter)="onFunderSearch($event.filter)" diff --git a/src/app/features/moderation/components/preprint-submission-item/preprint-submission-item.component.html b/src/app/features/moderation/components/preprint-submission-item/preprint-submission-item.component.html index ab1ad39ef..6542e9d12 100644 --- a/src/app/features/moderation/components/preprint-submission-item/preprint-submission-item.component.html +++ b/src/app/features/moderation/components/preprint-submission-item/preprint-submission-item.component.html @@ -10,7 +10,7 @@ class="link-btn-no-padding" styleClass="text-left" link - [label]="submission().title" + [label]="submission().title | fixSpecialChar" (onClick)="selected.emit()" /> @for (action of showAll ? submission().actions : submission().actions.slice(0, limitValue); track $index) { diff --git a/src/app/features/moderation/components/preprint-submission-item/preprint-submission-item.component.ts b/src/app/features/moderation/components/preprint-submission-item/preprint-submission-item.component.ts index 35e537c49..a20e7b46f 100644 --- a/src/app/features/moderation/components/preprint-submission-item/preprint-submission-item.component.ts +++ b/src/app/features/moderation/components/preprint-submission-item/preprint-submission-item.component.ts @@ -10,6 +10,7 @@ import { IconComponent } from '@osf/shared/components/icon/icon.component'; import { TruncatedTextComponent } from '@osf/shared/components/truncated-text/truncated-text.component'; import { StopPropagationDirective } from '@osf/shared/directives/stop-propagation.directive'; import { DateAgoPipe } from '@osf/shared/pipes/date-ago.pipe'; +import { FixSpecialCharPipe } from '@osf/shared/pipes/fix-special-char.pipe'; import { PREPRINT_ACTION_LABEL, ReviewStatusIcon } from '../../constants'; import { ActionStatus, SubmissionReviewStatus } from '../../enums'; @@ -29,6 +30,7 @@ import { PreprintSubmissionModel, PreprintWithdrawalSubmission } from '../../mod AccordionContent, ContributorsListComponent, StopPropagationDirective, + FixSpecialCharPipe, ], templateUrl: './preprint-submission-item.component.html', styleUrl: './preprint-submission-item.component.scss', diff --git a/src/app/features/moderation/components/preprint-submissions/preprint-submissions.component.ts b/src/app/features/moderation/components/preprint-submissions/preprint-submissions.component.ts index f4fe157e8..af97cb132 100644 --- a/src/app/features/moderation/components/preprint-submissions/preprint-submissions.component.ts +++ b/src/app/features/moderation/components/preprint-submissions/preprint-submissions.component.ts @@ -97,6 +97,8 @@ export class PreprintSubmissionsComponent implements OnInit { } changeReviewStatus(value: SubmissionReviewStatus): void { + if (!value) return; + this.selectedReviewOption.set(value); this.router.navigate([], { relativeTo: this.route, diff --git a/src/app/features/moderation/components/preprint-withdrawal-submissions/preprint-withdrawal-submissions.component.ts b/src/app/features/moderation/components/preprint-withdrawal-submissions/preprint-withdrawal-submissions.component.ts index 7dd69df25..e8b38dcbe 100644 --- a/src/app/features/moderation/components/preprint-withdrawal-submissions/preprint-withdrawal-submissions.component.ts +++ b/src/app/features/moderation/components/preprint-withdrawal-submissions/preprint-withdrawal-submissions.component.ts @@ -94,6 +94,8 @@ export class PreprintWithdrawalSubmissionsComponent implements OnInit { } changeReviewStatus(value: SubmissionReviewStatus): void { + if (!value) return; + this.selectedReviewOption.set(value); this.router.navigate([], { relativeTo: this.route, @@ -118,7 +120,9 @@ export class PreprintWithdrawalSubmissionsComponent implements OnInit { navigateToPreprint(item: PreprintWithdrawalSubmission) { const url = this.router.serializeUrl( - this.router.createUrlTree(['/preprints/', this.providerId(), item.id], { queryParams: { mode: 'moderator' } }) + this.router.createUrlTree(['/preprints/', this.providerId(), item.preprintId], { + queryParams: { mode: 'moderator' }, + }) ); window.open(url, '_blank'); diff --git a/src/app/features/moderation/services/moderators.service.ts b/src/app/features/moderation/services/moderators.service.ts index 7c32d8461..420251188 100644 --- a/src/app/features/moderation/services/moderators.service.ts +++ b/src/app/features/moderation/services/moderators.service.ts @@ -85,7 +85,7 @@ export class ModeratorsService { } searchUsers(value: string, page = 1): Observable> { - const baseUrl = `${this.apiUrl}/users/?filter[full_name]=${value}&page=${page}`; + const baseUrl = `${this.apiUrl}/search/users/?q=${value}*&page=${page}`; return this.jsonApiService .get>(baseUrl) diff --git a/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-contributors/preprints-contributors.component.ts b/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-contributors/preprints-contributors.component.ts index e54f8787d..3fbfa30d6 100644 --- a/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-contributors/preprints-contributors.component.ts +++ b/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-contributors/preprints-contributors.component.ts @@ -36,7 +36,6 @@ import { CustomConfirmationService } from '@osf/shared/services/custom-confirmat import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { ToastService } from '@osf/shared/services/toast.service'; import { - AddContributor, BulkAddContributors, BulkUpdateContributors, ContributorsSelectors, @@ -84,7 +83,6 @@ export class PreprintsContributorsComponent implements OnInit { deleteContributor: DeleteContributor, bulkUpdateContributors: BulkUpdateContributors, bulkAddContributors: BulkAddContributors, - addContributor: AddContributor, loadMoreContributors: LoadMoreContributors, }); @@ -157,7 +155,7 @@ export class PreprintsContributorsComponent implements OnInit { } else { const params = { name: res.data[0].fullName }; - this.actions.addContributor(this.preprintId(), ResourceType.Preprint, res.data[0]).subscribe({ + this.actions.bulkAddContributors(this.preprintId(), ResourceType.Preprint, res.data).subscribe({ next: () => this.toastService.showSuccess('project.contributors.toastMessages.addSuccessMessage', params), }); } 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 }}

{ - const currentPreprint = this.preprint(); - - if (currentPreprint && currentPreprint.isPublic) { - this.analyticsService.sendCountedUsage(currentPreprint.id, 'preprint.detail').subscribe(); - } - }); - effect(() => { const preprint = this.preprint(); const contributors = this.contributors(); diff --git a/src/app/features/preprints/preprints.component.spec.ts b/src/app/features/preprints/preprints.component.spec.ts index f68f5a73d..48f6ec95d 100644 --- a/src/app/features/preprints/preprints.component.spec.ts +++ b/src/app/features/preprints/preprints.component.spec.ts @@ -5,23 +5,18 @@ import { HelpScoutService } from '@core/services/help-scout.service'; import { PreprintsComponent } from './preprints.component'; import { OSFTestingModule } from '@testing/osf.testing.module'; +import { HelpScoutServiceMockFactory } from '@testing/providers/help-scout.service.mock'; describe('Component: Preprint', () => { 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/component-card/component-card.component.html b/src/app/features/project/overview/components/component-card/component-card.component.html new file mode 100644 index 000000000..74e9de3f6 --- /dev/null +++ b/src/app/features/project/overview/components/component-card/component-card.component.html @@ -0,0 +1,50 @@ +
+
+

+ + + + {{ component().title }} + +

+ + @if (component().currentUserIsContributor) { + + } +
+ +
+

{{ 'common.labels.contributors' | translate }}:

+ + +
+ + @if (component().description) { + + } +
diff --git a/src/app/features/project/overview/components/component-card/component-card.component.scss b/src/app/features/project/overview/components/component-card/component-card.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/features/project/overview/components/component-card/component-card.component.spec.ts b/src/app/features/project/overview/components/component-card/component-card.component.spec.ts new file mode 100644 index 000000000..d0c1a3577 --- /dev/null +++ b/src/app/features/project/overview/components/component-card/component-card.component.spec.ts @@ -0,0 +1,77 @@ +import { MockComponents } from 'ng-mocks'; + +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component'; +import { IconComponent } from '@osf/shared/components/icon/icon.component'; + +import { ComponentCardComponent } from './component-card.component'; + +import { MOCK_NODE_WITH_ADMIN, MOCK_NODE_WITHOUT_ADMIN } from '@testing/mocks/node.mock'; + +describe('ComponentCardComponent', () => { + let component: ComponentCardComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ComponentCardComponent, ...MockComponents(IconComponent, ContributorsListComponent)], + }).compileComponents(); + + fixture = TestBed.createComponent(ComponentCardComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('component', MOCK_NODE_WITH_ADMIN); + fixture.componentRef.setInput('anonymous', false); + fixture.detectChanges(); + }); + + it('should emit navigate when handleNavigate is called', () => { + const emitSpy = jest.spyOn(component.navigate, 'emit'); + component.handleNavigate('test-id'); + expect(emitSpy).toHaveBeenCalledWith('test-id'); + }); + + it('should emit menuAction when handleMenuAction is called', () => { + const emitSpy = jest.spyOn(component.menuAction, 'emit'); + component.handleMenuAction('settings'); + expect(emitSpy).toHaveBeenCalledWith('settings'); + }); + + describe('componentActionItems', () => { + it('should return base items for any component', () => { + fixture.componentRef.setInput('component', MOCK_NODE_WITHOUT_ADMIN); + fixture.detectChanges(); + const items = component.componentActionItems(); + expect(items).toHaveLength(2); + expect(items[0].action).toBe('manageContributors'); + expect(items[1].action).toBe('settings'); + }); + + it('should include delete action when component has Admin permission', () => { + fixture.componentRef.setInput('component', MOCK_NODE_WITH_ADMIN); + fixture.detectChanges(); + const items = component.componentActionItems(); + expect(items).toHaveLength(3); + expect(items[0].action).toBe('manageContributors'); + expect(items[1].action).toBe('settings'); + expect(items[2].action).toBe('delete'); + }); + + it('should exclude delete action when component does not have Admin permission', () => { + fixture.componentRef.setInput('component', MOCK_NODE_WITHOUT_ADMIN); + fixture.detectChanges(); + const items = component.componentActionItems(); + expect(items).toHaveLength(2); + expect(items.every((item) => item.action !== 'delete')).toBe(true); + }); + + it('should exclude delete action when hideDeleteAction is true', () => { + fixture.componentRef.setInput('component', MOCK_NODE_WITH_ADMIN); + fixture.componentRef.setInput('hideDeleteAction', true); + fixture.detectChanges(); + const items = component.componentActionItems(); + expect(items).toHaveLength(2); + expect(items.every((item) => item.action !== 'delete')).toBe(true); + }); + }); +}); diff --git a/src/app/features/project/overview/components/component-card/component-card.component.ts b/src/app/features/project/overview/components/component-card/component-card.component.ts new file mode 100644 index 000000000..2bba7d860 --- /dev/null +++ b/src/app/features/project/overview/components/component-card/component-card.component.ts @@ -0,0 +1,60 @@ +import { TranslatePipe } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { Menu } from 'primeng/menu'; + +import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core'; + +import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component'; +import { IconComponent } from '@osf/shared/components/icon/icon.component'; +import { TruncatedTextComponent } from '@osf/shared/components/truncated-text/truncated-text.component'; +import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; +import { NodeModel } from '@osf/shared/models/nodes/base-node.model'; + +@Component({ + selector: 'osf-component-card', + imports: [Button, Menu, TranslatePipe, TruncatedTextComponent, IconComponent, ContributorsListComponent], + templateUrl: './component-card.component.html', + styleUrl: './component-card.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ComponentCardComponent { + component = input.required(); + anonymous = input.required(); + hideDeleteAction = input(false); + + navigate = output(); + menuAction = output(); + + readonly componentActionItems = computed(() => { + const component = this.component(); + + const baseItems = [ + { + label: 'project.overview.actions.manageContributors', + action: 'manageContributors', + }, + { + label: 'project.overview.actions.settings', + action: 'settings', + }, + ]; + + if (!this.hideDeleteAction() && component.currentUserPermissions.includes(UserPermissions.Admin)) { + baseItems.push({ + label: 'project.overview.actions.delete', + action: 'delete', + }); + } + + return baseItems; + }); + + handleNavigate(componentId: string): void { + this.navigate.emit(componentId); + } + + handleMenuAction(action: string): void { + this.menuAction.emit(action); + } +} 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.html b/src/app/features/project/overview/components/linked-resources/linked-resources.component.html index 4d3afc46c..b02a69622 100644 --- a/src/app/features/project/overview/components/linked-resources/linked-resources.component.html +++ b/src/app/features/project/overview/components/linked-resources/linked-resources.component.html @@ -18,7 +18,7 @@

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

- + {{ linkedResource.title }}

@if (canEdit()) { @@ -39,7 +39,7 @@

{{ 'common.labels.contributors' | translate }}:

- +
@if (linkedResource.description) { { let component: LinkedResourcesComponent; let fixture: ComponentFixture; + let customDialogServiceMock: ReturnType; + + const mockLinkedResources = [ + { ...MOCK_NODE_WITH_ADMIN, id: 'resource-1', title: 'Linked Resource 1' }, + { ...MOCK_NODE_WITH_ADMIN, id: 'resource-2', title: 'Linked Resource 2' }, + { ...MOCK_NODE_WITH_ADMIN, id: 'resource-3', title: 'Linked Resource 3' }, + ]; beforeEach(async () => { + customDialogServiceMock = CustomDialogServiceMockBuilder.create().withDefaultOpen().build(); + await TestBed.configureTestingModule({ imports: [ LinkedResourcesComponent, - ...MockComponents(TruncatedTextComponent, IconComponent, ContributorsListComponent), + OSFTestingModule, + ...MockComponents(IconComponent, ContributorsListComponent), + ], + providers: [ + provideMockStore({ + signals: [ + { selector: NodeLinksSelectors.getLinkedResources, value: mockLinkedResources }, + { selector: NodeLinksSelectors.getLinkedResourcesLoading, value: false }, + ], + }), + MockProvider(CustomDialogService, customDialogServiceMock), ], }).compileComponents(); fixture = TestBed.createComponent(LinkedResourcesComponent); component = fixture.componentInstance; + fixture.componentRef.setInput('canEdit', true); fixture.detectChanges(); }); - it('should create', () => { - expect(component).toBeTruthy(); + it('should open LinkResourceDialogComponent with correct config', () => { + component.openLinkProjectModal(); + + expect(customDialogServiceMock.open).toHaveBeenCalledWith(LinkResourceDialogComponent, { + header: 'project.overview.dialog.linkProject.header', + width: '850px', + }); + }); + + it('should find resource by id and open DeleteNodeLinkDialogComponent with correct config when resource exists', () => { + component.openDeleteResourceModal('resource-2'); + + expect(customDialogServiceMock.open).toHaveBeenCalledWith(DeleteNodeLinkDialogComponent, { + header: 'project.overview.dialog.deleteNodeLink.header', + width: '650px', + data: { currentLink: mockLinkedResources[1] }, + }); + }); + + it('should return early and not open dialog when resource is not found', () => { + customDialogServiceMock.open.mockClear(); + + component.openDeleteResourceModal('non-existent-id'); + + expect(customDialogServiceMock.open).not.toHaveBeenCalled(); }); }); 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..16625cea3 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 @@ -11,72 +11,41 @@

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

}

-
+
@if (isComponentsLoading()) { } @else { @if (components().length) { - @for (component of components(); track component.id) { -
-
-

- - - {{ component.title }} - -

- - @if (component.currentUserIsContributor) { - - } -
- -
-

{{ 'common.labels.contributors' | translate }}:

- - + @for (component of reorderedComponents(); track component.id) { +
+ -
- - @if (component.description) { - - } -
- } +
+ } +
@if (hasMoreComponents()) {
diff --git a/src/app/features/project/overview/components/overview-components/overview-components.component.scss b/src/app/features/project/overview/components/overview-components/overview-components.component.scss index e69de29bb..a7950ea0b 100644 --- a/src/app/features/project/overview/components/overview-components/overview-components.component.scss +++ b/src/app/features/project/overview/components/overview-components/overview-components.component.scss @@ -0,0 +1,26 @@ +.components-drop-list { + max-height: 300px; + overflow-y: auto; +} + +.component-drag-item { + cursor: move; + + &.cdk-drag-preview { + background-color: var(--white); + border-radius: 0.6rem; + box-shadow: 0 2px 4px var(--grey-outline); + } + + &.cdk-drag-placeholder { + opacity: 0.3; + } + + &.cdk-drag-animating { + transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); + } +} + +.components-drop-list.cdk-drop-list-dragging .component-drag-item:not(.cdk-drag-placeholder) { + transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); +} 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..36b82701a 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 @@ -1,31 +1,268 @@ -import { MockComponents } 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 { Router } from '@angular/router'; import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component'; import { IconComponent } from '@osf/shared/components/icon/icon.component'; -import { TruncatedTextComponent } from '@osf/shared/components/truncated-text/truncated-text.component'; +import { NodeModel } from '@osf/shared/models/nodes/base-node.model'; +import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; +import { LoaderService } from '@osf/shared/services/loader.service'; +import { ToastService } from '@osf/shared/services/toast.service'; +import { GetResourceWithChildren } from '@osf/shared/stores/current-resource'; + +import { LoadMoreComponents, ProjectOverviewSelectors, ReorderComponents } from '../../store'; +import { AddComponentDialogComponent } from '../add-component-dialog/add-component-dialog.component'; import { OverviewComponentsComponent } from './overview-components.component'; +import { MOCK_NODE_WITH_ADMIN } from '@testing/mocks/node.mock'; +import { OSFTestingModule } from '@testing/osf.testing.module'; +import { CustomDialogServiceMockBuilder } from '@testing/providers/custom-dialog-provider.mock'; +import { LoaderServiceMock } from '@testing/providers/loader-service.mock'; +import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; + describe('ProjectComponentsComponent', () => { let component: OverviewComponentsComponent; let fixture: ComponentFixture; + let store: jest.Mocked; + let routerMock: ReturnType; + let customDialogServiceMock: ReturnType; + let loaderServiceMock: LoaderServiceMock; + let toastService: jest.Mocked; + let createUrlTreeSpy: jest.Mock; + let serializeUrlSpy: jest.Mock; + let navigateSpy: jest.Mock; + + const mockComponents: NodeModel[] = [ + { ...MOCK_NODE_WITH_ADMIN, id: 'comp-1', title: 'Component 1' }, + { ...MOCK_NODE_WITH_ADMIN, id: 'comp-2', title: 'Component 2' }, + { ...MOCK_NODE_WITH_ADMIN, id: 'comp-3', title: 'Component 3' }, + ]; + + const mockProject = { ...MOCK_NODE_WITH_ADMIN, id: 'project-123', rootParentId: 'root-123' }; beforeEach(async () => { + const mockUrlTree = {} as any; + createUrlTreeSpy = jest.fn().mockReturnValue(mockUrlTree); + serializeUrlSpy = jest.fn().mockReturnValue('/comp-1'); + navigateSpy = jest.fn().mockResolvedValue(true); + + routerMock = RouterMockBuilder.create().withCreateUrlTree(createUrlTreeSpy).build(); + routerMock.serializeUrl = serializeUrlSpy; + routerMock.navigate = navigateSpy; + + customDialogServiceMock = CustomDialogServiceMockBuilder.create().withDefaultOpen().build(); + loaderServiceMock = new LoaderServiceMock(); + toastService = { showSuccess: jest.fn() } as unknown as jest.Mocked; + await TestBed.configureTestingModule({ imports: [ OverviewComponentsComponent, - ...MockComponents(TruncatedTextComponent, IconComponent, ContributorsListComponent), + OSFTestingModule, + ...MockComponents(IconComponent, ContributorsListComponent), + ], + providers: [ + provideMockStore({ + signals: [ + { selector: ProjectOverviewSelectors.getComponents, value: mockComponents }, + { selector: ProjectOverviewSelectors.getComponentsLoading, value: false }, + { selector: ProjectOverviewSelectors.getComponentsSubmitting, value: false }, + { selector: ProjectOverviewSelectors.hasMoreComponents, value: true }, + { selector: ProjectOverviewSelectors.getProject, value: mockProject }, + ], + }), + MockProvider(Router, routerMock), + MockProvider(CustomDialogService, customDialogServiceMock), + { provide: LoaderService, useValue: loaderServiceMock }, + MockProvider(ToastService, toastService), ], }).compileComponents(); + store = TestBed.inject(Store) as jest.Mocked; + store.dispatch = jest.fn().mockReturnValue(of(true)); + fixture = TestBed.createComponent(OverviewComponentsComponent); component = fixture.componentInstance; + fixture.componentRef.setInput('canEdit', true); fixture.detectChanges(); }); - it('should create', () => { - expect(component).toBeTruthy(); + it('should sync reorderedComponents signal with components selector on init', () => { + expect(component.reorderedComponents()).toEqual(mockComponents); + expect(component.reorderedComponents().length).toBe(3); + }); + + it('should be true when canEdit is false and reorderedComponents.length <= 1', () => { + component.reorderedComponents.set([mockComponents[0]]); + fixture.componentRef.setInput('canEdit', false); + fixture.detectChanges(); + + expect(component.isDragDisabled()).toBe(true); + }); + + it('should be false when canEdit is true and isComponentsSubmitting is false and reorderedComponents.length > 1', () => { + fixture.componentRef.setInput('canEdit', true); + fixture.detectChanges(); + + expect(component.isDragDisabled()).toBe(false); + }); + + it('should navigate to contributors route when action is manageContributors', () => { + component.handleMenuAction('manageContributors', 'comp-1'); + + expect(navigateSpy).toHaveBeenCalledWith(['comp-1', 'contributors']); + }); + + it('should navigate to settings route when action is settings', () => { + component.handleMenuAction('settings', 'comp-1'); + + expect(navigateSpy).toHaveBeenCalledWith(['comp-1', 'settings']); + }); + + it('should call handleDeleteComponent when action is delete', () => { + store.dispatch = jest.fn().mockReturnValue(of(true)); + component.handleMenuAction('delete', 'comp-1'); + + expect(loaderServiceMock.show).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + constructor: GetResourceWithChildren, + }) + ); + }); + + it('should open AddComponentDialogComponent with correct config', () => { + component.handleAddComponent(); + + expect(customDialogServiceMock.open).toHaveBeenCalledWith(AddComponentDialogComponent, { + header: 'project.overview.dialog.addComponent.header', + width: '850px', + }); + }); + + it('should create URL tree with correct path and queryParamsHandling', () => { + const windowOpenSpy = jest.spyOn(window, 'open').mockImplementation(() => null); + + component.handleComponentNavigate('comp-1'); + + expect(createUrlTreeSpy).toHaveBeenCalledWith(['/', 'comp-1'], { + queryParamsHandling: 'preserve', + }); + + windowOpenSpy.mockRestore(); + }); + + it('should serialize URL and open in same window', () => { + const mockUrlTree = {} as any; + createUrlTreeSpy.mockReturnValue(mockUrlTree); + const windowOpenSpy = jest.spyOn(window, 'open').mockImplementation(() => null); + + component.handleComponentNavigate('comp-1'); + + expect(serializeUrlSpy).toHaveBeenCalledWith(mockUrlTree); + expect(windowOpenSpy).toHaveBeenCalledWith('/comp-1', '_self'); + + windowOpenSpy.mockRestore(); + }); + + it('should dispatch loadMoreComponents action with project id when project exists', () => { + component.loadMoreComponents(); + + expect(store.dispatch).toHaveBeenCalledWith(expect.any(LoadMoreComponents)); + const dispatchedAction = (store.dispatch as jest.Mock).mock.calls[0][0]; + expect(dispatchedAction.projectId).toBe(mockProject.id); + }); + + it('should reorder components and dispatch reorderComponents action when project exists and canEdit is true', () => { + const event = { + previousIndex: 0, + currentIndex: 2, + container: { data: mockComponents }, + previousContainer: { data: mockComponents }, + } as any; + + store.dispatch = jest.fn().mockReturnValue(of(true)); + + component.onReorder(event); + + expect(component.reorderedComponents()[0].id).toBe('comp-2'); + expect(component.reorderedComponents()[2].id).toBe('comp-1'); + expect(store.dispatch).toHaveBeenCalledWith(expect.any(ReorderComponents)); + const dispatchedAction = (store.dispatch as jest.Mock).mock.calls[0][0]; + expect(dispatchedAction.projectId).toBe(mockProject.id); + expect(dispatchedAction.componentIds).toEqual(['comp-2', 'comp-3', 'comp-1']); + }); + + it('should show success toast after successful reorder', () => { + const event = { + previousIndex: 0, + currentIndex: 1, + container: { data: mockComponents }, + previousContainer: { data: mockComponents }, + } as any; + + store.dispatch = jest.fn().mockReturnValue(of(true)); + + component.onReorder(event); + + expect(toastService.showSuccess).toHaveBeenCalledWith('project.overview.dialog.toast.reorderComponents.success'); + }); + + it('should return early when canEdit is false', () => { + fixture.componentRef.setInput('canEdit', false); + fixture.detectChanges(); + + const event = { + previousIndex: 0, + currentIndex: 1, + container: { data: mockComponents }, + previousContainer: { data: mockComponents }, + } as any; + + store.dispatch.mockClear(); + + component.onReorder(event); + + expect(store.dispatch).not.toHaveBeenCalled(); + }); + + it('should show and hide loader on error', () => { + loaderServiceMock.hide.mockClear(); + + let subscribeError: ((error: any) => void) | undefined; + const mockObservable = { + subscribe: jest.fn((callbacks: any) => { + subscribeError = callbacks.error; + return { unsubscribe: jest.fn() }; + }), + }; + + store.dispatch = jest.fn().mockReturnValue(mockObservable as any); + + component.handleMenuAction('delete', 'comp-1'); + + expect(loaderServiceMock.show).toHaveBeenCalled(); + + if (subscribeError) { + subscribeError(new Error('Test error')); + } + + expect(loaderServiceMock.hide).toHaveBeenCalled(); + }); + + it('should use rootParentId if available, otherwise project id', () => { + store.dispatch = jest.fn().mockReturnValue(of(true)); + + component.handleMenuAction('delete', 'comp-1'); + + expect(store.dispatch).toHaveBeenCalled(); + const dispatchedAction = (store.dispatch as jest.Mock).mock.calls[0][0]; + expect(dispatchedAction.rootParentId).toBe(mockProject.rootParentId); }); }); diff --git a/src/app/features/project/overview/components/overview-components/overview-components.component.ts b/src/app/features/project/overview/components/overview-components/overview-components.component.ts index f440c8476..0ab0bdfd4 100644 --- a/src/app/features/project/overview/components/overview-components/overview-components.component.ts +++ b/src/app/features/project/overview/components/overview-components/overview-components.component.ts @@ -3,29 +3,27 @@ import { createDispatchMap, select } from '@ngxs/store'; import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; -import { Menu } from 'primeng/menu'; import { Skeleton } from 'primeng/skeleton'; -import { ChangeDetectionStrategy, Component, inject, input } from '@angular/core'; +import { CdkDrag, CdkDragDrop, CdkDropList, moveItemInArray } from '@angular/cdk/drag-drop'; +import { ChangeDetectionStrategy, Component, computed, effect, inject, input, signal } from '@angular/core'; import { Router } from '@angular/router'; -import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component'; -import { IconComponent } from '@osf/shared/components/icon/icon.component'; -import { TruncatedTextComponent } from '@osf/shared/components/truncated-text/truncated-text.component'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; -import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; +import { NodeModel } from '@osf/shared/models/nodes/base-node.model'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { LoaderService } from '@osf/shared/services/loader.service'; +import { ToastService } from '@osf/shared/services/toast.service'; import { GetResourceWithChildren } from '@osf/shared/stores/current-resource'; -import { ComponentOverview } from '@shared/models/components/components.models'; -import { LoadMoreComponents, ProjectOverviewSelectors } from '../../store'; +import { LoadMoreComponents, ProjectOverviewSelectors, ReorderComponents } from '../../store'; import { AddComponentDialogComponent } from '../add-component-dialog/add-component-dialog.component'; +import { ComponentCardComponent } from '../component-card/component-card.component'; import { DeleteComponentDialogComponent } from '../delete-component-dialog/delete-component-dialog.component'; @Component({ selector: 'osf-project-components', - imports: [Button, Menu, Skeleton, TranslatePipe, TruncatedTextComponent, IconComponent, ContributorsListComponent], + imports: [Button, CdkDrag, CdkDropList, Skeleton, TranslatePipe, ComponentCardComponent], templateUrl: './overview-components.component.html', styleUrl: './overview-components.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -34,46 +32,35 @@ export class OverviewComponentsComponent { private router = inject(Router); private customDialogService = inject(CustomDialogService); private loaderService = inject(LoaderService); + private toastService = inject(ToastService); canEdit = input.required(); anonymous = input(false); components = select(ProjectOverviewSelectors.getComponents); isComponentsLoading = select(ProjectOverviewSelectors.getComponentsLoading); + isComponentsSubmitting = select(ProjectOverviewSelectors.getComponentsSubmitting); hasMoreComponents = select(ProjectOverviewSelectors.hasMoreComponents); project = select(ProjectOverviewSelectors.getProject); + reorderedComponents = signal([]); + actions = createDispatchMap({ getComponentsTree: GetResourceWithChildren, loadMoreComponents: LoadMoreComponents, + reorderComponents: ReorderComponents, }); - readonly UserPermissions = UserPermissions; - - readonly componentActionItems = (component: ComponentOverview) => { - const baseItems = [ - { - label: 'project.overview.actions.manageContributors', - action: 'manageContributors', - componentId: component.id, - }, - { - label: 'project.overview.actions.settings', - action: 'settings', - componentId: component.id, - }, - ]; - - if (component.currentUserPermissions.includes(UserPermissions.Admin)) { - baseItems.push({ - label: 'project.overview.actions.delete', - action: 'delete', - componentId: component.id, - }); - } + isDragDisabled = computed( + () => this.isComponentsSubmitting() || (!this.canEdit() && this.reorderedComponents().length <= 1) + ); - return baseItems; - }; + constructor() { + effect(() => { + const componentsData = this.components(); + this.reorderedComponents.set([...componentsData]); + }); + } handleMenuAction(action: string, componentId: string): void { switch (action) { @@ -96,7 +83,7 @@ export class OverviewComponentsComponent { }); } - navigateToComponent(componentId: string): void { + handleComponentNavigate(componentId: string): void { const url = this.router.serializeUrl( this.router.createUrlTree(['/', componentId], { queryParamsHandling: 'preserve' }) ); @@ -111,6 +98,22 @@ export class OverviewComponentsComponent { this.actions.loadMoreComponents(project.id); } + onReorder(event: CdkDragDrop): void { + const project = this.project(); + if (!project || !this.canEdit()) return; + + const components = [...this.reorderedComponents()]; + moveItemInArray(components, event.previousIndex, event.currentIndex); + this.reorderedComponents.set(components); + + const componentIds = components.map((component) => component.id); + this.actions.reorderComponents(project.id, componentIds).subscribe({ + next: () => { + this.toastService.showSuccess('project.overview.dialog.toast.reorderComponents.success'); + }, + }); + } + private handleDeleteComponent(componentId: string): void { const project = this.project(); if (!project) return; @@ -126,9 +129,7 @@ export class OverviewComponentsComponent { data: { componentId, resourceType: ResourceType.Project }, }); }, - error: () => { - this.loaderService.hide(); - }, + error: () => this.loaderService.hide(), }); } } diff --git a/src/app/features/project/overview/components/overview-parent-project/overview-parent-project.component.html b/src/app/features/project/overview/components/overview-parent-project/overview-parent-project.component.html index ac7c66a10..537a09491 100644 --- a/src/app/features/project/overview/components/overview-parent-project/overview-parent-project.component.html +++ b/src/app/features/project/overview/components/overview-parent-project/overview-parent-project.component.html @@ -7,54 +7,14 @@

{{ 'project.overview.parentProject' | translate }}

@if (isLoading()) { } @else { -
-
-

- - {{ project().title }} -

- - @if (project().currentUserIsContributor) { - - } -
- -
-

{{ 'common.labels.contributors' | translate }}:

- - -
- - @if (project().description) { - - } -
+ }
diff --git a/src/app/features/project/overview/components/overview-parent-project/overview-parent-project.component.spec.ts b/src/app/features/project/overview/components/overview-parent-project/overview-parent-project.component.spec.ts index 849f3a136..a5ebbf43c 100644 --- a/src/app/features/project/overview/components/overview-parent-project/overview-parent-project.component.spec.ts +++ b/src/app/features/project/overview/components/overview-parent-project/overview-parent-project.component.spec.ts @@ -1,31 +1,95 @@ import { MockComponents } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; -import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component'; -import { IconComponent } from '@osf/shared/components/icon/icon.component'; -import { TruncatedTextComponent } from '@osf/shared/components/truncated-text/truncated-text.component'; +import { ComponentCardComponent } from '../component-card/component-card.component'; import { OverviewParentProjectComponent } from './overview-parent-project.component'; -describe.skip('OverviewParentProjectComponent', () => { +import { MOCK_NODE_WITH_ADMIN } from '@testing/mocks/node.mock'; +import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; + +describe('OverviewParentProjectComponent', () => { let component: OverviewParentProjectComponent; let fixture: ComponentFixture; + let createUrlTreeSpy: jest.Mock; + let serializeUrlSpy: jest.Mock; + let navigateSpy: jest.Mock; beforeEach(async () => { + const mockUrlTree = {} as any; + createUrlTreeSpy = jest.fn().mockReturnValue(mockUrlTree); + serializeUrlSpy = jest.fn().mockReturnValue('/test-id'); + navigateSpy = jest.fn().mockResolvedValue(true); + + const routerMock = RouterMockBuilder.create().withCreateUrlTree(createUrlTreeSpy).build(); + + routerMock.serializeUrl = serializeUrlSpy; + routerMock.navigate = navigateSpy; + await TestBed.configureTestingModule({ - imports: [ - OverviewParentProjectComponent, - ...MockComponents(TruncatedTextComponent, IconComponent, ContributorsListComponent), - ], + imports: [OverviewParentProjectComponent, ...MockComponents(ComponentCardComponent)], + providers: [{ provide: Router, useValue: routerMock }], }).compileComponents(); fixture = TestBed.createComponent(OverviewParentProjectComponent); component = fixture.componentInstance; + fixture.componentRef.setInput('project', MOCK_NODE_WITH_ADMIN); fixture.detectChanges(); }); - it('should create', () => { - expect(component).toBeTruthy(); + it('should create URL tree with correct path and queryParamsHandling', () => { + const windowOpenSpy = jest.spyOn(window, 'open').mockImplementation(() => null); + + component.navigateToParent(); + + expect(createUrlTreeSpy).toHaveBeenCalledWith(['/', MOCK_NODE_WITH_ADMIN.id], { + queryParamsHandling: 'preserve', + }); + + windowOpenSpy.mockRestore(); + }); + + it('should serialize URL tree and open in same window', () => { + const mockUrlTree = {} as any; + createUrlTreeSpy.mockReturnValue(mockUrlTree); + const windowOpenSpy = jest.spyOn(window, 'open').mockImplementation(() => null); + + component.navigateToParent(); + + expect(serializeUrlSpy).toHaveBeenCalledWith(mockUrlTree); + expect(windowOpenSpy).toHaveBeenCalledWith('/test-id', '_self'); + + windowOpenSpy.mockRestore(); + }); + + it('should navigate to contributors route when action is manageContributors', () => { + component.handleMenuAction('manageContributors'); + + expect(navigateSpy).toHaveBeenCalledWith([MOCK_NODE_WITH_ADMIN.id, 'contributors']); + }); + + it('should navigate to settings route when action is settings', () => { + component.handleMenuAction('settings'); + + expect(navigateSpy).toHaveBeenCalledWith([MOCK_NODE_WITH_ADMIN.id, 'settings']); + }); + + it('should return early if projectId is undefined', () => { + fixture.componentRef.setInput('project', { ...MOCK_NODE_WITH_ADMIN, id: undefined } as any); + fixture.detectChanges(); + + component.handleMenuAction('manageContributors'); + + expect(navigateSpy).not.toHaveBeenCalled(); + }); + + it('should not navigate for unknown actions', () => { + navigateSpy.mockClear(); + + component.handleMenuAction('unknownAction'); + + expect(navigateSpy).not.toHaveBeenCalled(); }); }); 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..406ff4391 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 @@ -1,48 +1,28 @@ -import { select } from '@ngxs/store'; - import { TranslatePipe } from '@ngx-translate/core'; -import { Button } from 'primeng/button'; -import { Menu } from 'primeng/menu'; import { Skeleton } from 'primeng/skeleton'; import { ChangeDetectionStrategy, Component, inject, input } from '@angular/core'; import { Router } from '@angular/router'; -import { UserSelectors } from '@core/store/user'; -import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component'; -import { IconComponent } from '@osf/shared/components/icon/icon.component'; -import { TruncatedTextComponent } from '@osf/shared/components/truncated-text/truncated-text.component'; +import { NodeModel } from '@osf/shared/models/nodes/base-node.model'; -import { ProjectOverview } from '../../models'; +import { ComponentCardComponent } from '../component-card/component-card.component'; @Component({ selector: 'osf-overview-parent-project', - imports: [Skeleton, TranslatePipe, IconComponent, TruncatedTextComponent, Button, Menu, ContributorsListComponent], + imports: [Skeleton, TranslatePipe, ComponentCardComponent], templateUrl: './overview-parent-project.component.html', styleUrl: './overview-parent-project.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class OverviewParentProjectComponent { - project = input.required(); + project = input.required(); anonymous = input(false); isLoading = input(false); router = inject(Router); - currentUser = select(UserSelectors.getCurrentUser); - - menuItems = [ - { - label: 'project.overview.actions.manageContributors', - action: 'manageContributors', - }, - { - label: 'project.overview.actions.settings', - action: 'settings', - }, - ]; - navigateToParent(): void { const url = this.router.serializeUrl( this.router.createUrlTree(['/', this.project().id], { queryParamsHandling: 'preserve' }) 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..c6fabdfe8 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,27 @@ -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'; 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, + }; } } 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..2c155b330 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 { BaseNodeModel } 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 BaseNodeModel { 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..eff3c50f8 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,80 @@ -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 { ClearProjectOverview, GetComponents, GetProjectById, ProjectOverviewSelectors } from './store'; -import { DataciteMockFactory } from '@testing/mocks/datacite.service.mock'; +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 +83,7 @@ describe('ProjectOverviewComponent', () => { LinkedResourcesComponent, RecentActivityComponent, ProjectOverviewToolbarComponent, - ResourceMetadataComponent, + ProjectOverviewMetadataComponent, FilesWidgetComponent, ViewOnlyLinkMessageComponent, OverviewParentProjectComponent, @@ -63,42 +91,102 @@ 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(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..b4ee62c9a 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,48 @@ 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); 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 +157,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 { @@ -308,13 +187,7 @@ export class ProjectOverviewComponent implements OnInit { 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 +224,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,61 +236,32 @@ 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(); - } + effect(() => { + const project = this.currentProject(); - private setupCleanup(): void { - this.destroyRef.onDestroy(() => { - this.actions.clearProjectOverview(); - this.actions.clearWiki(); - this.actions.clearCollections(); - this.actions.clearCollectionModeration(); - this.actions.clearConfiguredAddons(); - this.actions.resetContributorsState(); + if (project && this.hasWriteAccess()) { + this.actions.getProjectStorage(project.id); + } }); } @@ -432,9 +276,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..f923aa0a1 100644 --- a/src/app/features/project/overview/services/project-overview.service.ts +++ b/src/app/features/project/overview/services/project-overview.service.ts @@ -6,18 +6,31 @@ 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 { 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 { BaseNodeModel } from '@osf/shared/models/nodes/base-node.model'; +import { NodePreprintMapper } from '@osf/shared/mappers/nodes/node-preprint.mapper'; +import { NodeStorageMapper } from '@osf/shared/mappers/nodes/node-storage.mapper'; +import { JsonApiResponse } from '@osf/shared/models/common/json-api.model'; +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, NodeModel } 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, NodesResponseJsonApi } 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'; @Injectable({ providedIn: 'root', @@ -31,22 +44,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 } })), @@ -146,41 +185,51 @@ export class ProjectOverviewService { return this.jsonApiService.delete(`${this.apiUrl}/nodes/${componentId}/`); } - getComponents(projectId: string, page = 1, pageSize = 10): Observable> { + getComponents(projectId: string, page = 1, pageSize = 10): Observable> { const params: Record = { embed: 'bibliographic_contributors', 'fields[users]': 'family_name,full_name,given_name,middle_name', page: page, 'page[size]': pageSize, + sort: '_order', }; return this.jsonApiService - .get>(`${this.apiUrl}/nodes/${projectId}/children/`, params) - .pipe( - map((response) => ({ - data: response.data.map((item) => ComponentsMapper.fromGetComponentResponse(item)), - totalCount: response.meta?.total || 0, - pageSize: response.meta?.per_page || pageSize, - })) - ); + .get(`${this.apiUrl}/nodes/${projectId}/children/`, params) + .pipe(map((response) => BaseNodeMapper.getNodesWithEmbedsAndTotalData(response))); } - 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) => BaseNodeMapper.getNodeWithEmbedContributors(response.data))); + } + + reorderComponents(projectId: string, componentIds: string[]): Observable { + const payload = { + data: componentIds.map((id, index) => ({ + type: 'nodes', + id, + attributes: { + _order: index, + }, + })), + }; + + const headers = { + 'Content-Type': 'application/vnd.api+json; ext=bulk', + }; + + return this.jsonApiService.patch( + `${this.apiUrl}/nodes/${projectId}/reorder_components/`, + payload, + undefined, + headers + ); } } 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..19c78cbef 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'; @@ -88,3 +118,12 @@ export class GetParentProject { constructor(public projectId: string) {} } + +export class ReorderComponents { + static readonly type = '[Project Overview] Reorder Components'; + + constructor( + public projectId: string, + public componentIds: string[] + ) {} +} 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..8675ce272 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,27 @@ -import { ComponentOverview } from '@osf/shared/models/components/components.models'; -import { BaseNodeModel } from '@osf/shared/models/nodes/base-node.model'; +import { BaseNodeModel, NodeModel } 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'; export interface ProjectOverviewStateModel { - project: AsyncStateModel; - components: AsyncStateWithTotalCount & { + 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 +46,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..08090fb32 100644 --- a/src/app/features/project/overview/store/project-overview.state.ts +++ b/src/app/features/project/overview/store/project-overview.state.ts @@ -19,7 +19,13 @@ import { GetComponents, GetParentProject, GetProjectById, + GetProjectIdentifiers, + GetProjectInstitutions, + GetProjectLicense, + GetProjectPreprints, + GetProjectStorage, LoadMoreComponents, + ReorderComponents, SetProjectCustomCitation, UpdateProjectPublicStatus, } from './project-overview.actions'; @@ -58,6 +64,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 +420,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, }, @@ -302,4 +434,32 @@ export class ProjectOverviewState { catchError((error) => handleSectionError(ctx, 'parentProject', error)) ); } + + @Action(ReorderComponents) + reorderComponents(ctx: StateContext, action: ReorderComponents) { + const state = ctx.getState(); + ctx.patchState({ + components: { + ...state.components, + isSubmitting: true, + }, + }); + + return this.projectOverviewService.reorderComponents(action.projectId, action.componentIds).pipe( + tap(() => { + const reorderedComponents = action.componentIds + .map((id) => state.components.data.find((c) => c.id === id)) + .filter((c) => c !== undefined); + + ctx.patchState({ + components: { + ...state.components, + data: reorderedComponents, + isSubmitting: false, + }, + }); + }), + catchError((error) => handleSectionError(ctx, 'components', error)) + ); + } } diff --git a/src/app/features/project/project-addons/components/configure-addon/configure-addon.component.ts b/src/app/features/project/project-addons/components/configure-addon/configure-addon.component.ts index 887c3c5ec..4b5621389 100644 --- a/src/app/features/project/project-addons/components/configure-addon/configure-addon.component.ts +++ b/src/app/features/project/project-addons/components/configure-addon/configure-addon.component.ts @@ -116,8 +116,9 @@ export class ConfigureAddonComponent implements OnInit { }); readonly supportedResourceTypes = computed(() => { - if (this.linkAddons().length && this.addonTypeString() === AddonType.LINK) { - const addon = this.linkAddons().find((a) => this.addon()?.externalServiceName === a.externalServiceName); + const linkAddons = this.linkAddons(); + if (linkAddons?.length && this.addonTypeString() === AddonType.LINK) { + const addon = linkAddons.find((a) => this.addon()?.externalServiceName === a.externalServiceName); return addon?.supportedResourceTypes || []; } return []; diff --git a/src/app/features/project/project-addons/project-addons.component.ts b/src/app/features/project/project-addons/project-addons.component.ts index 5ad91d482..c175b3a06 100644 --- a/src/app/features/project/project-addons/project-addons.component.ts +++ b/src/app/features/project/project-addons/project-addons.component.ts @@ -242,8 +242,9 @@ export class ProjectAddonsComponent implements OnInit { filteredAddonCards = computed((): AddonCardModel[] => { const searchValue = this.searchValue().toLowerCase(); const configuredAddons = this.allConfiguredAddonsForCheck(); + const addons = this.currentAddonsState() ?? []; - const addonCards = this.currentAddonsState() + const addonCards = addons .filter( (card) => card.externalServiceName.toLowerCase().includes(searchValue) || @@ -300,7 +301,7 @@ export class ProjectAddonsComponent implements OnInit { const addons = this.currentAddonsState(); const isLoading = this.currentAddonsLoading(); - if (!addons?.length && !isLoading) { + if (!addons && !isLoading) { action(); } } diff --git a/src/app/features/project/project.component.spec.ts b/src/app/features/project/project.component.spec.ts index 607a52643..72d6774ba 100644 --- a/src/app/features/project/project.component.spec.ts +++ b/src/app/features/project/project.component.spec.ts @@ -1,41 +1,83 @@ 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 { CurrentResourceSelectors } from '@osf/shared/stores/current-resource'; +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 }, + { selector: CurrentResourceSelectors.getCurrentResource, value: null }, + ], + }), ], }).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..0071a1733 100644 --- a/src/app/features/project/project.component.ts +++ b/src/app/features/project/project.component.ts @@ -1,7 +1,38 @@ -import { ChangeDetectionStrategy, Component, HostBinding, inject, OnDestroy } from '@angular/core'; -import { RouterOutlet } from '@angular/router'; +import { createDispatchMap, select } from '@ngxs/store'; + +import { filter, 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, NavigationEnd, Router, 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 { AnalyticsService } from '@shared/services/analytics.service'; +import { CurrentResourceSelectors } from '@shared/stores/current-resource'; + +import { + GetProjectById, + GetProjectIdentifiers, + GetProjectInstitutions, + GetProjectLicense, + ProjectOverviewSelectors, +} from './overview/store'; @Component({ selector: 'osf-project', @@ -9,16 +40,137 @@ 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); + private readonly router = inject(Router); + private readonly analyticsService = inject(AnalyticsService); + currentResource = select(CurrentResourceSelectors.getCurrentResource); + + 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(); + + this.router.events + .pipe( + filter((event) => event instanceof NavigationEnd), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe((event: NavigationEnd) => { + this.analyticsService.sendCountedUsageForRegistrationAndProjects( + event.urlAfterRedirects, + this.currentResource() + ); + }); } 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/project/wiki/wiki.component.html b/src/app/features/project/wiki/wiki.component.html index d3b1d94eb..ff8a434b5 100644 --- a/src/app/features/project/wiki/wiki.component.html +++ b/src/app/features/project/wiki/wiki.component.html @@ -36,6 +36,7 @@ [canEdit]="hasWriteAccess()" (createWiki)="onCreateWiki()" (deleteWiki)="onDeleteWiki()" + (renameWiki)="onRenameWiki()" > @if (wikiModes().view) { this.navigateToWiki(this.currentWikiId()))); } 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/components/registries-metadata-step/registries-contributors/registries-contributors.component.ts b/src/app/features/registries/components/registries-metadata-step/registries-contributors/registries-contributors.component.ts index 9423f6a95..c69bc4e41 100644 --- a/src/app/features/registries/components/registries-metadata-step/registries-contributors/registries-contributors.component.ts +++ b/src/app/features/registries/components/registries-metadata-step/registries-contributors/registries-contributors.component.ts @@ -37,7 +37,6 @@ import { CustomConfirmationService } from '@osf/shared/services/custom-confirmat import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { ToastService } from '@osf/shared/services/toast.service'; import { - AddContributor, BulkAddContributors, BulkUpdateContributors, ContributorsSelectors, @@ -90,7 +89,6 @@ export class RegistriesContributorsComponent implements OnInit, OnDestroy { deleteContributor: DeleteContributor, bulkUpdateContributors: BulkUpdateContributors, bulkAddContributors: BulkAddContributors, - addContributor: AddContributor, loadMoreContributors: LoadMoreContributors, resetContributorsState: ResetContributorsState, }); @@ -176,7 +174,7 @@ export class RegistriesContributorsComponent implements OnInit, OnDestroy { } else { const params = { name: res.data[0].fullName }; - this.actions.addContributor(this.draftId(), ResourceType.DraftRegistration, res.data[0]).subscribe({ + this.actions.bulkAddContributors(this.draftId(), ResourceType.DraftRegistration, res.data).subscribe({ next: () => this.toastService.showSuccess('project.contributors.toastMessages.addSuccessMessage', params), }); } 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..edd9ae630 100644 --- a/src/app/features/registry/registry.component.ts +++ b/src/app/features/registry/registry.component.ts @@ -1,6 +1,6 @@ import { createDispatchMap, select } from '@ngxs/store'; -import { map } from 'rxjs'; +import { filter, map } from 'rxjs'; import { DatePipe } from '@angular/common'; import { @@ -15,9 +15,10 @@ import { signal, } from '@angular/core'; import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop'; -import { ActivatedRoute, RouterOutlet } from '@angular/router'; +import { ActivatedRoute, NavigationEnd, Router, 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'; @@ -26,6 +27,7 @@ import { AnalyticsService } from '@osf/shared/services/analytics.service'; import { MetaTagsService } from '@osf/shared/services/meta-tags.service'; import { ContributorsSelectors, GetBibliographicContributors } from '@osf/shared/stores/contributors'; import { DataciteService } from '@shared/services/datacite/datacite.service'; +import { CurrentResourceSelectors } from '@shared/stores/current-resource'; import { GetRegistryIdentifiers, GetRegistryWithRelatedData, RegistrySelectors } from './store/registry'; @@ -45,8 +47,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, @@ -56,18 +60,16 @@ export class RegistryComponent implements OnDestroy { }); private registryId = toSignal(this.route.params.pipe(map((params) => params['id']))); - + readonly currentResource = select(CurrentResourceSelectors.getCurrentResource); readonly registry = select(RegistrySelectors.getRegistry); readonly isRegistryLoading = select(RegistrySelectors.isRegistryLoading); 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( () => @@ -78,9 +80,11 @@ export class RegistryComponent implements OnDestroy { ); private readonly lastMetaTagsRegistryId = signal(null); + readonly router = inject(Router); constructor() { this.prerenderReady.setNotReady(); + this.helpScoutService.setResourceType('registration'); effect(() => { const id = this.registryId(); @@ -104,49 +108,54 @@ export class RegistryComponent implements OnDestroy { } }); - effect(() => { - const currentRegistry = this.registry(); - if (currentRegistry && currentRegistry.isPublic) { - this.analyticsService.sendCountedUsage(currentRegistry.id, 'registry.detail').subscribe(); - } - }); - this.dataciteService .logIdentifiableView(this.identifiersForDatacite$) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(); + + this.router.events + .pipe( + filter((event) => event instanceof NavigationEnd), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe((event: NavigationEnd) => { + this.analyticsService.sendCountedUsageForRegistrationAndProjects( + event.urlAfterRedirects, + this.currentResource() + ); + }); } 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/features/settings/settings-addons/settings-addons.component.ts b/src/app/features/settings/settings-addons/settings-addons.component.ts index 6b967c8a5..c040078a8 100644 --- a/src/app/features/settings/settings-addons/settings-addons.component.ts +++ b/src/app/features/settings/settings-addons/settings-addons.component.ts @@ -213,7 +213,8 @@ export class SettingsAddonsComponent implements OnInit { readonly filteredAddonCards = computed(() => { const searchValue = this.searchValue().toLowerCase(); - const filteredAddons = this.currentAddonsState().filter( + const addons = this.currentAddonsState() ?? []; + const filteredAddons = addons.filter( (card) => card.externalServiceName.toLowerCase().includes(searchValue) || card.displayName.toLowerCase().includes(searchValue) @@ -259,7 +260,7 @@ export class SettingsAddonsComponent implements OnInit { const action = this.currentAction(); const addons = this.currentAddonsState(); - if (!addons?.length) { + if (!addons) { action(); } } diff --git a/src/app/shared/components/contributors/add-contributor-dialog/add-contributor-dialog.component.ts b/src/app/shared/components/contributors/add-contributor-dialog/add-contributor-dialog.component.ts index fd4d1dd00..dd6c908a5 100644 --- a/src/app/shared/components/contributors/add-contributor-dialog/add-contributor-dialog.component.ts +++ b/src/app/shared/components/contributors/add-contributor-dialog/add-contributor-dialog.component.ts @@ -85,8 +85,12 @@ export class AddContributorDialogComponent implements OnInit, OnDestroy { readonly isSearchState = computed(() => this.currentState() === AddDialogState.Search); readonly isDetailsState = computed(() => this.currentState() === AddDialogState.Details); readonly isComponentsState = computed(() => this.currentState() === AddDialogState.Components); - readonly hasComponents = computed(() => this.components().length > 0); - readonly buttonLabel = computed(() => (this.isComponentsState() ? 'common.buttons.done' : 'common.buttons.next')); + readonly hasComponents = computed(() => this.components().length > 1); + readonly buttonLabel = computed(() => + (this.isDetailsState() && !this.hasComponents()) || this.isComponentsState() + ? 'common.buttons.done' + : 'common.buttons.next' + ); constructor() { this.setupEffects(); diff --git a/src/app/shared/components/files-tree/files-tree.component.html b/src/app/shared/components/files-tree/files-tree.component.html index 18ac14c55..24856541c 100644 --- a/src/app/shared/components/files-tree/files-tree.component.html +++ b/src/app/shared/components/files-tree/files-tree.component.html @@ -25,6 +25,8 @@
@if (file.previousFolder) { diff --git a/src/app/shared/components/files-tree/files-tree.component.spec.ts b/src/app/shared/components/files-tree/files-tree.component.spec.ts index 4190c6f9d..e868ab604 100644 --- a/src/app/shared/components/files-tree/files-tree.component.spec.ts +++ b/src/app/shared/components/files-tree/files-tree.component.spec.ts @@ -1,5 +1,6 @@ import { MockComponents, MockProvider } from 'ng-mocks'; +import { TreeDragDropService } from 'primeng/api'; import { DialogService } from 'primeng/dynamicdialog'; import { signal } from '@angular/core'; @@ -53,6 +54,7 @@ describe('FilesTreeComponent', () => { MockProvider(ToastService), MockProvider(CustomConfirmationService), MockProvider(DialogService), + TreeDragDropService, ], }).compileComponents(); diff --git a/src/app/shared/components/files-tree/files-tree.component.ts b/src/app/shared/components/files-tree/files-tree.component.ts index 4b30a486b..083b19bdc 100644 --- a/src/app/shared/components/files-tree/files-tree.component.ts +++ b/src/app/shared/components/files-tree/files-tree.component.ts @@ -3,7 +3,7 @@ import { select } from '@ngxs/store'; import { TranslatePipe } from '@ngx-translate/core'; import { PrimeTemplate } from 'primeng/api'; -import { Tree, TreeNodeSelectEvent, TreeScrollIndexChangeEvent } from 'primeng/tree'; +import { Tree, TreeNodeDropEvent, TreeNodeSelectEvent, TreeScrollIndexChangeEvent } from 'primeng/tree'; import { Clipboard } from '@angular/cdk/clipboard'; import { DatePipe } from '@angular/common'; @@ -27,6 +27,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router } from '@angular/router'; import { ENVIRONMENT } from '@core/provider/environment.provider'; +import { ConfirmMoveFileDialogComponent } from '@osf/features/files/components/confirm-move-file-dialog/confirm-move-file-dialog.component'; import { MoveFileDialogComponent } from '@osf/features/files/components/move-file-dialog/move-file-dialog.component'; import { RenameFileDialogComponent } from '@osf/features/files/components/rename-file-dialog/rename-file-dialog.component'; import { embedDynamicJs, embedStaticHtml } from '@osf/features/files/constants'; @@ -458,7 +459,38 @@ export class FilesTreeComponent implements OnDestroy, AfterViewInit { this.lastSelectedFile = selectedNode; } + onNodeDrop(event: TreeNodeDropEvent) { + const dropFile = event.dropNode as FileModel; + if (dropFile.kind !== FileKind.Folder) { + return; + } + const files = this.selectedFiles(); + const dragFile = event.dragNode as FileModel; + if (!files.includes(dragFile)) { + this.selectFile.emit(dragFile); + } + this.moveFilesTo(files, dropFile); + } + onNodeUnselect(event: TreeNodeSelectEvent) { this.unselectFile.emit(event.node as FileModel); } + + private moveFilesTo(files: FileModel[], destination: FileModel) { + const isMultiple = files.length > 1; + this.customDialogService + .open(ConfirmMoveFileDialogComponent, { + header: isMultiple ? 'files.dialogs.moveFile.dialogTitleMultiple' : 'files.dialogs.moveFile.dialogTitle', + width: '552px', + data: { + files, + destination, + resourceId: this.resourceId(), + storageProvider: this.storage()?.folder.provider, + }, + }) + .onClose.subscribe(() => { + this.resetFilesProvider.emit(); + }); + } } 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/components/generic-filter/generic-filter.component.ts b/src/app/shared/components/generic-filter/generic-filter.component.ts index 482e2227b..c225bc1f8 100644 --- a/src/app/shared/components/generic-filter/generic-filter.component.ts +++ b/src/app/shared/components/generic-filter/generic-filter.component.ts @@ -70,9 +70,8 @@ export class GenericFilterComponent { this.updateStableArray(newOptions); return this.stableOptionsArray; }); - selectedOptionValues = computed(() => { - return this.selectedOptions().map((option) => option.value); - }); + + selectedOptionValues = computed(() => this.selectedOptions().map((option) => option.value)); constructor() { effect(() => { diff --git a/src/app/shared/components/resource-card/resource-card.component.html b/src/app/shared/components/resource-card/resource-card.component.html index 279ab27ff..3a73344dd 100644 --- a/src/app/shared/components/resource-card/resource-card.component.html +++ b/src/app/shared/components/resource-card/resource-card.component.html @@ -32,7 +32,12 @@

@if (affiliatedEntities().length > 0) {
@for (affiliatedEntity of affiliatedEntities().slice(0, limit); track $index) { - + {{ affiliatedEntity.name }}{{ $last ? '' : ', ' }} } diff --git a/src/app/shared/components/wiki/rename-wiki-dialog/rename-wiki-dialog.component.html b/src/app/shared/components/wiki/rename-wiki-dialog/rename-wiki-dialog.component.html new file mode 100644 index 000000000..47d0c2771 --- /dev/null +++ b/src/app/shared/components/wiki/rename-wiki-dialog/rename-wiki-dialog.component.html @@ -0,0 +1,18 @@ +
+ + + +
+ + +
+
diff --git a/src/app/shared/components/wiki/rename-wiki-dialog/rename-wiki-dialog.component.scss b/src/app/shared/components/wiki/rename-wiki-dialog/rename-wiki-dialog.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/shared/components/wiki/rename-wiki-dialog/rename-wiki-dialog.component.spec.ts b/src/app/shared/components/wiki/rename-wiki-dialog/rename-wiki-dialog.component.spec.ts new file mode 100644 index 000000000..56399a64d --- /dev/null +++ b/src/app/shared/components/wiki/rename-wiki-dialog/rename-wiki-dialog.component.spec.ts @@ -0,0 +1,121 @@ +import { MockComponent, MockProvider } from 'ng-mocks'; + +import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; + +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ToastService } from '@osf/shared/services/toast.service'; +import { WikiSelectors } from '@osf/shared/stores/wiki'; + +import { TextInputComponent } from '../../text-input/text-input.component'; + +import { RenameWikiDialogComponent } from './rename-wiki-dialog.component'; + +import { TranslateServiceMock } from '@testing/mocks/translate.service.mock'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; + +describe('RenameWikiDialogComponent', () => { + let component: RenameWikiDialogComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [RenameWikiDialogComponent, MockComponent(TextInputComponent)], + providers: [ + TranslateServiceMock, + MockProvider(DynamicDialogRef), + MockProvider(DynamicDialogConfig, { + data: { + resourceId: 'project-123', + wikiName: 'Wiki Name', + }, + }), + MockProvider(ToastService), + provideMockStore({ + selectors: [{ selector: WikiSelectors.getWikiSubmitting, value: false }], + }), + ], + }).compileComponents(); + + fixture = TestBed.createComponent(RenameWikiDialogComponent); + component = fixture.componentInstance; + + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should initialize form with current name', () => { + expect(component.renameWikiForm.get('name')?.value).toBe('Wiki Name'); + }); + + it('should have required validation on name field', () => { + const nameControl = component.renameWikiForm.get('name'); + nameControl?.setValue(''); + + expect(nameControl?.hasError('required')).toBe(true); + }); + + it('should validate name field with valid input', () => { + const nameControl = component.renameWikiForm.get('name'); + nameControl?.setValue('Test Wiki Name'); + + expect(nameControl?.valid).toBe(true); + }); + + it('should validate name field with whitespace only', () => { + const nameControl = component.renameWikiForm.get('name'); + nameControl?.setValue(' '); + + expect(nameControl?.hasError('required')).toBe(true); + }); + + it('should validate name field with max length', () => { + const nameControl = component.renameWikiForm.get('name'); + const longName = 'a'.repeat(256); + nameControl?.setValue(longName); + + expect(nameControl?.hasError('maxlength')).toBe(true); + }); + + it('should close dialog on cancel', () => { + const dialogRef = TestBed.inject(DynamicDialogRef); + const closeSpy = jest.spyOn(dialogRef, 'close'); + + dialogRef.close(); + + expect(closeSpy).toHaveBeenCalled(); + }); + + it('should not submit form when invalid', () => { + const dialogRef = TestBed.inject(DynamicDialogRef); + const toastService = TestBed.inject(ToastService); + + const closeSpy = jest.spyOn(dialogRef, 'close'); + const showSuccessSpy = jest.spyOn(toastService, 'showSuccess'); + + component.renameWikiForm.patchValue({ name: '' }); + + component.submitForm(); + + expect(showSuccessSpy).not.toHaveBeenCalled(); + expect(closeSpy).not.toHaveBeenCalled(); + }); + + it('should handle form submission with empty name', () => { + const dialogRef = TestBed.inject(DynamicDialogRef); + const toastService = TestBed.inject(ToastService); + + const closeSpy = jest.spyOn(dialogRef, 'close'); + const showSuccessSpy = jest.spyOn(toastService, 'showSuccess'); + + component.renameWikiForm.patchValue({ name: ' ' }); + + component.submitForm(); + + expect(showSuccessSpy).not.toHaveBeenCalled(); + expect(closeSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/shared/components/wiki/rename-wiki-dialog/rename-wiki-dialog.component.ts b/src/app/shared/components/wiki/rename-wiki-dialog/rename-wiki-dialog.component.ts new file mode 100644 index 000000000..11d905b65 --- /dev/null +++ b/src/app/shared/components/wiki/rename-wiki-dialog/rename-wiki-dialog.component.ts @@ -0,0 +1,56 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { TranslatePipe } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; + +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; + +import { InputLimits } from '@osf/shared/constants/input-limits.const'; +import { CustomValidators } from '@osf/shared/helpers/custom-form-validators.helper'; +import { ToastService } from '@osf/shared/services/toast.service'; +import { RenameWiki, WikiSelectors } from '@osf/shared/stores/wiki'; + +import { TextInputComponent } from '../../text-input/text-input.component'; + +@Component({ + selector: 'osf-rename-wiki-dialog-component', + imports: [Button, ReactiveFormsModule, TranslatePipe, TextInputComponent], + templateUrl: './rename-wiki-dialog.component.html', + styleUrl: './rename-wiki-dialog.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class RenameWikiDialogComponent { + readonly dialogRef = inject(DynamicDialogRef); + readonly config = inject(DynamicDialogConfig); + private toastService = inject(ToastService); + + actions = createDispatchMap({ renameWiki: RenameWiki }); + isSubmitting = select(WikiSelectors.getWikiSubmitting); + inputLimits = InputLimits; + + renameWikiForm = new FormGroup({ + name: new FormControl(this.config.data.wikiName, { + nonNullable: true, + validators: [CustomValidators.requiredTrimmed(), Validators.maxLength(InputLimits.fullName.maxLength)], + }), + }); + + submitForm(): void { + if (this.renameWikiForm.valid) { + this.actions.renameWiki(this.config.data.wikiId, this.renameWikiForm.value.name ?? '').subscribe({ + next: () => { + this.toastService.showSuccess('project.wiki.renameWikiSuccess'); + this.dialogRef.close(true); + }, + error: (err) => { + if (err?.status === 409) { + this.toastService.showError('project.wiki.renameWikiConflict'); + } + }, + }); + } + } +} diff --git a/src/app/shared/components/wiki/wiki-list/wiki-list.component.html b/src/app/shared/components/wiki/wiki-list/wiki-list.component.html index 22645ceb6..41f09c1c1 100644 --- a/src/app/shared/components/wiki/wiki-list/wiki-list.component.html +++ b/src/app/shared/components/wiki/wiki-list/wiki-list.component.html @@ -62,9 +62,20 @@

{{ item.label | translate }}

{{ item.label | translate }} } @default { -
- - {{ item.label }} +
+
+ + {{ item.label }} +
+ + +
} } diff --git a/src/app/shared/components/wiki/wiki-list/wiki-list.component.scss b/src/app/shared/components/wiki/wiki-list/wiki-list.component.scss index 49c80f9ae..74bdcf60c 100644 --- a/src/app/shared/components/wiki/wiki-list/wiki-list.component.scss +++ b/src/app/shared/components/wiki/wiki-list/wiki-list.component.scss @@ -7,9 +7,4 @@ min-width: 300px; width: 300px; } - - .active { - background-color: var(--pr-blue-1); - color: var(--white); - } } diff --git a/src/app/shared/components/wiki/wiki-list/wiki-list.component.ts b/src/app/shared/components/wiki/wiki-list/wiki-list.component.ts index 7c55dec8e..97f1b7baa 100644 --- a/src/app/shared/components/wiki/wiki-list/wiki-list.component.ts +++ b/src/app/shared/components/wiki/wiki-list/wiki-list.component.ts @@ -14,6 +14,7 @@ import { WikiItemType } from '@osf/shared/models/wiki/wiki-type.model'; import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { ComponentWiki } from '@osf/shared/stores/wiki'; +import { RenameWikiDialogComponent } from '@shared/components/wiki/rename-wiki-dialog/rename-wiki-dialog.component'; import { AddWikiDialogComponent } from '../add-wiki-dialog/add-wiki-dialog.component'; @@ -35,6 +36,7 @@ export class WikiListComponent { readonly deleteWiki = output(); readonly createWiki = output(); + readonly renameWiki = output(); private readonly customDialogService = inject(CustomDialogService); private readonly customConfirmationService = inject(CustomConfirmationService); @@ -97,6 +99,19 @@ export class WikiListComponent { .onClose.subscribe(() => this.createWiki.emit()); } + openRenameWikiDialog(wikiId: string, wikiName: string) { + this.customDialogService + .open(RenameWikiDialogComponent, { + header: 'project.wiki.renameWiki', + width: '448px', + data: { + wikiId: wikiId, + wikiName: wikiName, + }, + }) + .onClose.subscribe(() => this.renameWiki.emit()); + } + openDeleteWikiDialog(): void { this.customConfirmationService.confirmDelete({ headerKey: 'project.wiki.deleteWiki', diff --git a/src/app/shared/mappers/components/components.mapper.ts b/src/app/shared/mappers/components/components.mapper.ts deleted file mode 100644 index 14cb0f5a3..000000000 --- a/src/app/shared/mappers/components/components.mapper.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { ComponentGetResponseJsonApi } from '@osf/shared/models/components/component-json-api.model'; -import { ComponentOverview } from '@osf/shared/models/components/components.models'; - -import { ContributorsMapper } from '../contributors'; - -export class ComponentsMapper { - static fromGetComponentResponse(response: ComponentGetResponseJsonApi): ComponentOverview { - return { - id: response.id, - type: response.type, - title: response.attributes.title, - description: response.attributes.description, - public: response.attributes.public, - currentUserIsContributor: response.attributes.current_user_is_contributor, - contributors: ContributorsMapper.getContributors(response?.embeds?.bibliographic_contributors?.data), - currentUserPermissions: response.attributes?.current_user_permissions || [], - parentId: response.relationships.parent?.data?.id, - }; - } -} diff --git a/src/app/shared/mappers/contributors/contributors.mapper.ts b/src/app/shared/mappers/contributors/contributors.mapper.ts index b0546f98e..97f400f7d 100644 --- a/src/app/shared/mappers/contributors/contributors.mapper.ts +++ b/src/app/shared/mappers/contributors/contributors.mapper.ts @@ -110,4 +110,25 @@ export class ContributorsMapper { }; } } + + static toContributorUpdateRequest(model: ContributorModel): ContributorAddRequestModel { + return { + id: model.id, + type: 'contributors', + attributes: { + bibliographic: model.isBibliographic, + permission: model.permission, + index: model.index, + id: model.userId, + }, + relationships: { + users: { + data: { + id: model.id, + type: 'users', + }, + }, + }, + }; + } } diff --git a/src/app/shared/mappers/nodes/base-node.mapper.ts b/src/app/shared/mappers/nodes/base-node.mapper.ts index 3fc17f94b..e839fcf0f 100644 --- a/src/app/shared/mappers/nodes/base-node.mapper.ts +++ b/src/app/shared/mappers/nodes/base-node.mapper.ts @@ -11,13 +11,9 @@ export class BaseNodeMapper { return data.map((item) => this.getNodeData(item)); } - static getNodesWithEmbedsData(data: BaseNodeDataJsonApi[]): NodeModel[] { - return data.map((item) => this.getNodeWithEmbedsData(item)); - } - static getNodesWithEmbedsAndTotalData(response: ResponseJsonApi): PaginatedData { return { - data: BaseNodeMapper.getNodesWithEmbedsData(response.data), + data: BaseNodeMapper.getNodesWithEmbedContributors(response.data), totalCount: response.meta.total, pageSize: response.meta.per_page, }; @@ -62,8 +58,13 @@ export class BaseNodeMapper { }; } - static getNodeWithEmbedsData(data: BaseNodeDataJsonApi): NodeModel { + static getNodesWithEmbedContributors(data: BaseNodeDataJsonApi[]): NodeModel[] { + return data.map((item) => this.getNodeWithEmbedContributors(item)); + } + + static getNodeWithEmbedContributors(data: BaseNodeDataJsonApi): NodeModel { const baseNode = BaseNodeMapper.getNodeData(data); + return { ...baseNode, bibliographicContributors: ContributorsMapper.getContributors(data.embeds?.bibliographic_contributors?.data), 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/mappers/wiki/wiki.mapper.ts b/src/app/shared/mappers/wiki/wiki.mapper.ts index aa79c9911..e79ea6c30 100644 --- a/src/app/shared/mappers/wiki/wiki.mapper.ts +++ b/src/app/shared/mappers/wiki/wiki.mapper.ts @@ -1,3 +1,6 @@ +import { TranslateService } from '@ngx-translate/core'; + +import { UserMapper } from '@osf/shared/mappers/user/user.mapper'; import { ComponentsWikiGetResponse, HomeWiki, @@ -10,6 +13,12 @@ import { import { ComponentWiki } from '@osf/shared/stores/wiki'; export class WikiMapper { + private static translate: TranslateService; + + static init(translate: TranslateService): void { + WikiMapper.translate = translate; + } + static fromCreateWikiResponse(response: WikiGetResponse): WikiModel { return { id: response.id, @@ -47,7 +56,9 @@ export class WikiMapper { return { id: response.id, createdAt: response.attributes.date_created, - createdBy: response.embeds.user.data.attributes.full_name, + createdBy: + UserMapper.getUserInfo(response.embeds.user)?.fullName || + WikiMapper.translate.instant('project.wiki.version.unknownAuthor'), }; } diff --git a/src/app/shared/models/components/component-json-api.model.ts b/src/app/shared/models/components/component-json-api.model.ts deleted file mode 100644 index 6930d78e8..000000000 --- a/src/app/shared/models/components/component-json-api.model.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { ContributorDataJsonApi } from '../contributors/contributor-response-json-api.model'; -import { BaseNodeDataJsonApi } from '../nodes/base-node-data-json-api.model'; - -export interface ComponentGetResponseJsonApi extends BaseNodeDataJsonApi { - embeds: { - bibliographic_contributors: { - data: ContributorDataJsonApi[]; - }; - }; -} diff --git a/src/app/shared/models/components/components.models.ts b/src/app/shared/models/components/components.models.ts deleted file mode 100644 index 517a7098c..000000000 --- a/src/app/shared/models/components/components.models.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; - -import { ContributorModel } from '../contributors/contributor.model'; - -export interface ComponentOverview { - id: string; - type: string; - title: string; - description: string; - public: boolean; - contributors: ContributorModel[]; - currentUserIsContributor: boolean; - currentUserPermissions: UserPermissions[]; - parentId?: string; -} diff --git a/src/app/shared/models/contributors/contributor-response-json-api.model.ts b/src/app/shared/models/contributors/contributor-response-json-api.model.ts index 295d8608c..72f5fc024 100644 --- a/src/app/shared/models/contributors/contributor-response-json-api.model.ts +++ b/src/app/shared/models/contributors/contributor-response-json-api.model.ts @@ -33,6 +33,7 @@ export interface ContributorEmbedsJsonApi { export interface ContributorAddRequestModel { type: 'contributors'; + id?: string; attributes: { bibliographic: boolean; permission: string; diff --git a/src/app/shared/models/current-resource.model.ts b/src/app/shared/models/current-resource.model.ts index 0bd36f242..775a03258 100644 --- a/src/app/shared/models/current-resource.model.ts +++ b/src/app/shared/models/current-resource.model.ts @@ -8,4 +8,5 @@ export interface CurrentResource { rootResourceId?: string; wikiEnabled?: boolean; permissions: UserPermissions[]; + title?: string; } diff --git a/src/app/shared/models/guid-response-json-api.model.ts b/src/app/shared/models/guid-response-json-api.model.ts index 708c61e77..451eb9ece 100644 --- a/src/app/shared/models/guid-response-json-api.model.ts +++ b/src/app/shared/models/guid-response-json-api.model.ts @@ -11,6 +11,7 @@ interface GuidDataJsonApi { guid: string; wiki_enabled: boolean; current_user_permissions: UserPermissions[]; + title?: string; }; relationships: { target?: { diff --git a/src/app/shared/models/nodes/base-node.model.ts b/src/app/shared/models/nodes/base-node.model.ts index eabe83d65..569eaa418 100644 --- a/src/app/shared/models/nodes/base-node.model.ts +++ b/src/app/shared/models/nodes/base-node.model.ts @@ -27,5 +27,5 @@ export interface BaseNodeModel { } export interface NodeModel extends BaseNodeModel { - bibliographicContributors?: ContributorModel[]; + bibliographicContributors: ContributorModel[]; } 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/models/wiki/wiki.model.ts b/src/app/shared/models/wiki/wiki.model.ts index 6223fde49..d868fc993 100644 --- a/src/app/shared/models/wiki/wiki.model.ts +++ b/src/app/shared/models/wiki/wiki.model.ts @@ -1,3 +1,5 @@ +import { UserDataErrorResponseJsonApi } from '@shared/models/user/user-json-api.model'; + import { JsonApiResponse, JsonApiResponseWithMeta, MetaAnonymousJsonApi } from '../common/json-api.model'; export enum WikiModes { @@ -78,14 +80,7 @@ export interface WikiVersionJsonApi { date_created: string; }; embeds: { - user: { - data: { - id: string; - attributes: { - full_name: string; - }; - }; - }; + user: UserDataErrorResponseJsonApi; }; } diff --git a/src/app/shared/pipes/file-size.pipe.ts b/src/app/shared/pipes/file-size.pipe.ts index e90a89dd3..c833772d9 100644 --- a/src/app/shared/pipes/file-size.pipe.ts +++ b/src/app/shared/pipes/file-size.pipe.ts @@ -4,17 +4,24 @@ import { Pipe, PipeTransform } from '@angular/core'; name: 'fileSize', }) export class FileSizePipe implements PipeTransform { - transform(bytes: number): string { - if (!bytes) { - return ''; - } else if (bytes < 1024) { + private readonly SI_UNITS = ['B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + private readonly BINARY_UNITS = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']; + + transform(bytes: number | null | undefined, si = true): string { + if (bytes == null) return '0 B'; + + const threshold = si ? 1000 : 1024; + const units = si ? this.SI_UNITS : this.BINARY_UNITS; + const absBytes = Math.abs(bytes); + + if (absBytes < threshold) { return `${bytes} B`; - } else if (bytes < 1024 ** 2) { - return `${(bytes / 1024).toFixed(1)} kB`; - } else if (bytes < 1024 ** 3) { - return `${(bytes / 1024 ** 2).toFixed(1)} MB`; - } else { - return `${(bytes / 1024 ** 3).toFixed(1)} GB`; } + + const exponent = Math.min(Math.floor(Math.log(absBytes) / Math.log(threshold)), units.length - 1); + + const value = bytes / Math.pow(threshold, exponent); + + return `${value.toFixed(1)} ${units[exponent]}`; } } diff --git a/src/app/shared/services/analytics.service.ts b/src/app/shared/services/analytics.service.ts index 50364a5f1..e5b428fa8 100644 --- a/src/app/shared/services/analytics.service.ts +++ b/src/app/shared/services/analytics.service.ts @@ -3,8 +3,8 @@ import { Observable } from 'rxjs'; import { inject, Injectable } from '@angular/core'; import { ENVIRONMENT } from '@core/provider/environment.provider'; - -import { JsonApiService } from './json-api.service'; +import { CurrentResource } from '@osf/shared/models/current-resource.model'; +import { JsonApiService } from '@osf/shared/services/json-api.service'; @Injectable({ providedIn: 'root' }) export class AnalyticsService { @@ -15,23 +15,41 @@ export class AnalyticsService { return `${this.environment.apiDomainUrl}/_/metrics/events/counted_usage/`; } - sendCountedUsage(guid: string, routeName: string): Observable { - const payload = { + getPageviewPayload(resource: CurrentResource, routeName: string) { + const all_attrs = { item_guid: resource?.id } as const; + const attributes = Object.fromEntries( + Object.entries(all_attrs).filter(([_, value]: [unknown, unknown]) => typeof value !== 'undefined') + ); + const pageTitle = document.title === 'OSF' ? `OSF | ${resource.title}` : document.title; + return { data: { type: 'counted-usage', attributes: { - item_guid: guid, + ...attributes, action_labels: ['web', 'view'], pageview_info: { page_url: document.URL, - page_title: document.title, + page_title: pageTitle, referer_url: document.referrer, route_name: `angular-osf-web.${routeName}`, }, }, }, }; + } + sendCountedUsage(resource: CurrentResource, route: string): Observable { + const payload = this.getPageviewPayload(resource, route); return this.jsonApiService.post(this.apiDomainUrl, payload); } + + sendCountedUsageForRegistrationAndProjects(urlPath: string, resource: CurrentResource | null) { + if (resource) { + let route = urlPath.split('/').filter(Boolean).join('.'); + if (resource?.type) { + route = `${resource?.type}.${route}`; + } + this.sendCountedUsage(resource, route).subscribe(); + } + } } 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/app/shared/services/contributors.service.ts b/src/app/shared/services/contributors.service.ts index 97c0fdcfb..df2af3cf0 100644 --- a/src/app/shared/services/contributors.service.ts +++ b/src/app/shared/services/contributors.service.ts @@ -1,4 +1,4 @@ -import { forkJoin, map, Observable, of } from 'rxjs'; +import { map, Observable, of } from 'rxjs'; import { inject, Injectable } from '@angular/core'; @@ -10,11 +10,7 @@ import { ContributorsMapper } from '../mappers/contributors'; import { ResponseJsonApi } from '../models/common/json-api.model'; import { ContributorModel } from '../models/contributors/contributor.model'; import { ContributorAddModel } from '../models/contributors/contributor-add.model'; -import { - ContributorDataJsonApi, - ContributorResponseJsonApi, - ContributorsResponseJsonApi, -} from '../models/contributors/contributor-response-json-api.model'; +import { ContributorsResponseJsonApi } from '../models/contributors/contributor-response-json-api.model'; import { PaginatedData } from '../models/paginated-data.model'; import { UserDataJsonApi } from '../models/user/user-json-api.model'; @@ -95,8 +91,7 @@ export class ContributorsService { } searchUsers(value: string, page = 1): Observable> { - const baseUrl = `${this.apiUrl}/users/?filter[full_name]=${value}&page=${page}`; - + const baseUrl = `${this.apiUrl}/search/users/?q=${value}*&page=${page}`; return this.jsonApiService .get>(baseUrl) .pipe(map((response) => ContributorsMapper.getPaginatedUsers(response))); @@ -111,25 +106,19 @@ export class ContributorsService { return of([]); } - const updateRequests = contributors.map((contributor) => - this.updateContributor(resourceType, resourceId, contributor) - ); - - return forkJoin(updateRequests); - } + const baseUrl = `${this.getBaseUrl(resourceType, resourceId)}/`; - updateContributor( - resourceType: ResourceType, - resourceId: string, - data: ContributorModel - ): Observable { - const baseUrl = `${this.getBaseUrl(resourceType, resourceId)}/${data.userId}/`; + const contributorData = { + data: contributors.map((contributor) => ContributorsMapper.toContributorUpdateRequest(contributor)), + }; - const contributorData = { data: ContributorsMapper.toContributorAddRequest(data) }; + const headers = { + 'Content-Type': 'application/vnd.api+json; ext=bulk', + }; return this.jsonApiService - .patch(baseUrl, contributorData) - .pipe(map((contributor) => ContributorsMapper.getContributor(contributor))); + .patch(baseUrl, contributorData, undefined, headers) + .pipe(map((response) => ContributorsMapper.getContributors(response.data))); } bulkAddContributors( @@ -142,27 +131,22 @@ export class ContributorsService { return of([]); } - const addRequests = contributors.map((contributor) => - this.addContributor(resourceType, resourceId, contributor, childNodeIds) - ); - - return forkJoin(addRequests); - } - - addContributor( - resourceType: ResourceType, - resourceId: string, - data: ContributorAddModel, - childNodeIds?: string[] - ): Observable { const baseUrl = `${this.getBaseUrl(resourceType, resourceId)}/`; - const type = data.id ? AddContributorType.Registered : AddContributorType.Unregistered; - const contributorData = { data: ContributorsMapper.toContributorAddRequest(data, type, childNodeIds) }; + const contributorData = { + data: contributors.map((contributor) => { + const type = contributor.id ? AddContributorType.Registered : AddContributorType.Unregistered; + return ContributorsMapper.toContributorAddRequest(contributor, type, childNodeIds); + }), + }; + + const headers = { + 'Content-Type': 'application/vnd.api+json; ext=bulk', + }; return this.jsonApiService - .post(baseUrl, contributorData) - .pipe(map((contributor) => ContributorsMapper.getContributor(contributor.data))); + .post(baseUrl, contributorData, undefined, headers) + .pipe(map((response) => ContributorsMapper.getContributors(response.data))); } addContributorsFromProject(resourceType: ResourceType, resourceId: string): Observable { diff --git a/src/app/shared/services/duplicates.service.ts b/src/app/shared/services/duplicates.service.ts index b4c779424..5d25f3166 100644 --- a/src/app/shared/services/duplicates.service.ts +++ b/src/app/shared/services/duplicates.service.ts @@ -6,9 +6,8 @@ import { inject, Injectable } from '@angular/core'; import { ENVIRONMENT } from '@core/provider/environment.provider'; import { BaseNodeMapper } from '../mappers/nodes'; -import { ResponseJsonApi } from '../models/common/json-api.model'; import { NodeModel } from '../models/nodes/base-node.model'; -import { BaseNodeDataJsonApi } from '../models/nodes/base-node-data-json-api.model'; +import { NodesResponseJsonApi } from '../models/nodes/nodes-json-api.model'; import { PaginatedData } from '../models/paginated-data.model'; import { JsonApiService } from './json-api.service'; @@ -44,7 +43,7 @@ export class DuplicatesService { } return this.jsonApiService - .get>(`${this.apiUrl}/${resourceType}/${resourceId}/forks/`, params) + .get(`${this.apiUrl}/${resourceType}/${resourceId}/forks/`, params) .pipe(map((res) => BaseNodeMapper.getNodesWithEmbedsAndTotalData(res))); } } diff --git a/src/app/shared/services/json-api.service.ts b/src/app/shared/services/json-api.service.ts index cb5735df8..09865805c 100644 --- a/src/app/shared/services/json-api.service.ts +++ b/src/app/shared/services/json-api.service.ts @@ -35,8 +35,13 @@ export class JsonApiService { return httpParams; } - post(url: string, body?: unknown, params?: Record): Observable { - return this.http.post(url, body, { params: this.buildHttpParams(params) }); + post( + url: string, + body?: unknown, + params?: Record, + headers?: Record + ): Observable { + return this.http.post(url, body, { params: this.buildHttpParams(params), headers }); } patch( @@ -48,7 +53,7 @@ export class JsonApiService { ): Observable { return this.http .patch>(url, body, { params: this.buildHttpParams(params), headers, context }) - .pipe(map((response) => response.data)); + .pipe(map((response) => response?.data)); } put(url: string, body: unknown, params?: Record): Observable { diff --git a/src/app/shared/services/linked-projects.service.ts b/src/app/shared/services/linked-projects.service.ts index 0c1ae59c1..006671eed 100644 --- a/src/app/shared/services/linked-projects.service.ts +++ b/src/app/shared/services/linked-projects.service.ts @@ -6,9 +6,8 @@ import { inject, Injectable } from '@angular/core'; import { ENVIRONMENT } from '@core/provider/environment.provider'; import { BaseNodeMapper } from '../mappers/nodes'; -import { ResponseJsonApi } from '../models/common/json-api.model'; import { NodeModel } from '../models/nodes/base-node.model'; -import { BaseNodeDataJsonApi } from '../models/nodes/base-node-data-json-api.model'; +import { NodesResponseJsonApi } from '../models/nodes/nodes-json-api.model'; import { PaginatedData } from '../models/paginated-data.model'; import { JsonApiService } from './json-api.service'; @@ -44,9 +43,7 @@ export class LinkedProjectsService { } return this.jsonApiService - .get< - ResponseJsonApi - >(`${this.apiUrl}/${resourceType}/${resourceId}/linked_by_nodes/`, params) + .get(`${this.apiUrl}/${resourceType}/${resourceId}/linked_by_nodes/`, params) .pipe(map((res) => BaseNodeMapper.getNodesWithEmbedsAndTotalData(res))); } } diff --git a/src/app/shared/services/node-links.service.ts b/src/app/shared/services/node-links.service.ts index e2a9c7056..3ced22bb2 100644 --- a/src/app/shared/services/node-links.service.ts +++ b/src/app/shared/services/node-links.service.ts @@ -5,12 +5,12 @@ import { inject, Injectable } from '@angular/core'; import { ENVIRONMENT } from '@core/provider/environment.provider'; -import { ComponentsMapper } from '../mappers/components'; +import { BaseNodeMapper } from '../mappers/nodes'; import { JsonApiResponse } from '../models/common/json-api.model'; -import { ComponentGetResponseJsonApi } from '../models/components/component-json-api.model'; -import { ComponentOverview } from '../models/components/components.models'; import { MyResourcesItem } from '../models/my-resources/my-resources.models'; import { NodeLinkJsonApi } from '../models/node-links/node-link-json-api.model'; +import { NodeModel } from '../models/nodes/base-node.model'; +import { NodesResponseJsonApi } from '../models/nodes/nodes-json-api.model'; import { JsonApiService } from './json-api.service'; @@ -44,7 +44,7 @@ export class NodeLinksService { ); } - deleteNodeLink(projectId: string, resource: ComponentOverview): Observable { + deleteNodeLink(projectId: string, resource: NodeModel): Observable { const payload = { data: [ { @@ -60,29 +60,25 @@ export class NodeLinksService { ); } - fetchLinkedProjects(projectId: string): Observable { + fetchLinkedProjects(projectId: string): Observable { const params: Record = { embed: 'bibliographic_contributors', 'fields[users]': 'family_name,full_name,given_name,middle_name', }; return this.jsonApiService - .get< - JsonApiResponse - >(`${this.apiUrl}/nodes/${projectId}/linked_nodes/`, params) - .pipe(map((response) => response.data.map((item) => ComponentsMapper.fromGetComponentResponse(item)))); + .get(`${this.apiUrl}/nodes/${projectId}/linked_nodes/`, params) + .pipe(map((response) => BaseNodeMapper.getNodesWithEmbedContributors(response.data))); } - fetchLinkedRegistrations(projectId: string): Observable { + fetchLinkedRegistrations(projectId: string): Observable { const params: Record = { embed: 'bibliographic_contributors', 'fields[users]': 'family_name,full_name,given_name,middle_name', }; return this.jsonApiService - .get< - JsonApiResponse - >(`${this.apiUrl}/nodes/${projectId}/linked_registrations/`, params) - .pipe(map((response) => response.data.map((item) => ComponentsMapper.fromGetComponentResponse(item)))); + .get(`${this.apiUrl}/nodes/${projectId}/linked_registrations/`, params) + .pipe(map((response) => BaseNodeMapper.getNodesWithEmbedContributors(response.data))); } } diff --git a/src/app/shared/services/resource.service.ts b/src/app/shared/services/resource.service.ts index ceb161c5a..3b1d51fb2 100644 --- a/src/app/shared/services/resource.service.ts +++ b/src/app/shared/services/resource.service.ts @@ -6,12 +6,13 @@ import { ENVIRONMENT } from '@core/provider/environment.provider'; import { CurrentResourceType, ResourceType } from '../enums/resource-type.enum'; import { BaseNodeMapper } from '../mappers/nodes'; -import { ResponseDataJsonApi, ResponseJsonApi } from '../models/common/json-api.model'; +import { ResponseDataJsonApi } from '../models/common/json-api.model'; import { CurrentResource } from '../models/current-resource.model'; import { GuidedResponseJsonApi } from '../models/guid-response-json-api.model'; import { BaseNodeModel } from '../models/nodes/base-node.model'; import { BaseNodeDataJsonApi } from '../models/nodes/base-node-data-json-api.model'; import { NodeShortInfoModel } from '../models/nodes/node-with-children.model'; +import { NodesResponseJsonApi } from '../models/nodes/nodes-json-api.model'; import { JsonApiService } from './json-api.service'; import { LoaderService } from './loader.service'; @@ -55,6 +56,7 @@ export class ResourceGuidService { wikiEnabled: res.data.attributes.wiki_enabled, permissions: res.data.attributes.current_user_permissions, rootResourceId: res.data.relationships.root?.data?.id, + title: res.data.attributes?.title, }) as CurrentResource ), finalize(() => this.loaderService.hide()) @@ -79,9 +81,7 @@ export class ResourceGuidService { const resourcePath = this.urlMap.get(resourceType); return this.jsonApiService - .get< - ResponseJsonApi - >(`${this.apiUrl}/${resourcePath}/?filter[root]=${rootParentId}&page[size]=100`) + .get(`${this.apiUrl}/${resourcePath}/?filter[root]=${rootParentId}&page[size]=100`) .pipe(map((response) => BaseNodeMapper.getNodesWithChildren(response.data, resourceId))); } } diff --git a/src/app/shared/services/wiki.service.ts b/src/app/shared/services/wiki.service.ts index f2d02a306..0909d7d96 100644 --- a/src/app/shared/services/wiki.service.ts +++ b/src/app/shared/services/wiki.service.ts @@ -64,6 +64,21 @@ export class WikiService { .pipe(map((response) => WikiMapper.fromCreateWikiResponse(response.data))); } + renameWiki(id: string, name: string): Observable { + const body = { + data: { + type: 'wikis', + attributes: { + id, + name, + }, + }, + }; + return this.jsonApiService + .patch(`${this.apiUrl}/wikis/${id}/`, body) + .pipe(map((response) => WikiMapper.fromCreateWikiResponse(response))); + } + deleteWiki(wikiId: string): Observable { return this.jsonApiService.delete(`${this.apiUrl}/wikis/${wikiId}/`); } diff --git a/src/app/shared/stores/addons/addons.models.ts b/src/app/shared/stores/addons/addons.models.ts index 4e795944f..f663db563 100644 --- a/src/app/shared/stores/addons/addons.models.ts +++ b/src/app/shared/stores/addons/addons.models.ts @@ -10,9 +10,9 @@ import { OperationInvocation } from '@osf/shared/models/addons/operation-invocat import { AsyncStateModel } from '@osf/shared/models/store/async-state.model'; export interface AddonsStateModel { - storageAddons: AsyncStateModel; - citationAddons: AsyncStateModel; - linkAddons: AsyncStateModel; + storageAddons: AsyncStateModel; + citationAddons: AsyncStateModel; + linkAddons: AsyncStateModel; authorizedStorageAddons: AsyncStateModel; authorizedCitationAddons: AsyncStateModel; authorizedLinkAddons: AsyncStateModel; @@ -30,17 +30,17 @@ export interface AddonsStateModel { export const ADDONS_DEFAULTS: AddonsStateModel = { storageAddons: { - data: [], + data: null, isLoading: false, error: null, }, citationAddons: { - data: [], + data: null, isLoading: false, error: null, }, linkAddons: { - data: [], + data: null, isLoading: false, error: null, }, diff --git a/src/app/shared/stores/addons/addons.selectors.ts b/src/app/shared/stores/addons/addons.selectors.ts index bd8158094..c1893f729 100644 --- a/src/app/shared/stores/addons/addons.selectors.ts +++ b/src/app/shared/stores/addons/addons.selectors.ts @@ -16,13 +16,13 @@ import { AddonsState } from './addons.state'; export class AddonsSelectors { @Selector([AddonsState]) - static getStorageAddons(state: AddonsStateModel): AddonModel[] { + static getStorageAddons(state: AddonsStateModel): AddonModel[] | null { return state.storageAddons.data; } static getStorageAddon(id: string): (state: AddonsStateModel) => AddonModel | null { return createSelector([AddonsState], (state: AddonsStateModel): AddonModel | null => { - return state.storageAddons.data.find((addon: AddonModel) => addon.id === id) || null; + return state.storageAddons.data?.find((addon: AddonModel) => addon.id === id) || null; }); } @@ -32,7 +32,7 @@ export class AddonsSelectors { } @Selector([AddonsState]) - static getCitationAddons(state: AddonsStateModel): AddonModel[] { + static getCitationAddons(state: AddonsStateModel): AddonModel[] | null { return state.citationAddons.data; } @@ -42,7 +42,7 @@ export class AddonsSelectors { } @Selector([AddonsState]) - static getLinkAddons(state: AddonsStateModel): AddonModel[] { + static getLinkAddons(state: AddonsStateModel): AddonModel[] | null { return state.linkAddons.data; } diff --git a/src/app/shared/stores/addons/addons.state.spec.ts b/src/app/shared/stores/addons/addons.state.spec.ts index ebfbf99a8..f86ead712 100644 --- a/src/app/shared/stores/addons/addons.state.spec.ts +++ b/src/app/shared/stores/addons/addons.state.spec.ts @@ -35,7 +35,7 @@ describe('State: Addons', () => { it('should fetch storage addons and update state and selector output', inject( [HttpTestingController], (httpMock: HttpTestingController) => { - let result: any[] = []; + let result: any[] | null = []; store.dispatch(new GetStorageAddons()).subscribe(() => { result = store.selectSnapshot(AddonsSelectors.getStorageAddons); }); @@ -106,7 +106,7 @@ describe('State: Addons', () => { req.flush({ message: 'Internal Server Error' }, { status: 500, statusText: 'Server Error' }); expect(result).toEqual({ - data: [], + data: null, error: 'Http failure response for http://addons.localhost:8000/external-storage-services: 500 Server Error', isLoading: false, isSubmitting: false, diff --git a/src/app/shared/stores/contributors/contributors.actions.ts b/src/app/shared/stores/contributors/contributors.actions.ts index 2bb634cfc..470948c3a 100644 --- a/src/app/shared/stores/contributors/contributors.actions.ts +++ b/src/app/shared/stores/contributors/contributors.actions.ts @@ -33,16 +33,6 @@ export class UpdateBibliographyFilter { constructor(public bibliographyFilter: boolean | null) {} } -export class AddContributor { - static readonly type = '[Contributors] Add Contributor'; - - constructor( - public resourceId: string | undefined | null, - public resourceType: ResourceType | undefined, - public contributor: ContributorAddModel - ) {} -} - export class BulkUpdateContributors { static readonly type = '[Contributors] Bulk Update Contributors'; diff --git a/src/app/shared/stores/contributors/contributors.state.ts b/src/app/shared/stores/contributors/contributors.state.ts index 0e738afec..768983661 100644 --- a/src/app/shared/stores/contributors/contributors.state.ts +++ b/src/app/shared/stores/contributors/contributors.state.ts @@ -10,7 +10,6 @@ import { RequestAccessService } from '@osf/shared/services/request-access.servic import { AcceptRequestAccess, - AddContributor, BulkAddContributors, BulkAddContributorsFromParentProject, BulkUpdateContributors, @@ -145,28 +144,6 @@ export class ContributorsState { ); } - @Action(AddContributor) - addContributor(ctx: StateContext, action: AddContributor) { - const state = ctx.getState(); - - if (!action.resourceId || !action.resourceType) { - return; - } - - ctx.patchState({ - contributorsList: { ...state.contributorsList, isLoading: true, error: null }, - }); - - return this.contributorsService.addContributor(action.resourceType, action.resourceId, action.contributor).pipe( - tap(() => { - ctx.dispatch( - new GetAllContributors(action.resourceId, action.resourceType, 1, state.contributorsList.pageSize) - ); - }), - catchError((error) => handleSectionError(ctx, 'contributorsList', error)) - ); - } - @Action(BulkUpdateContributors) bulkUpdateContributors(ctx: StateContext, action: BulkUpdateContributors) { const state = ctx.getState(); @@ -204,7 +181,7 @@ export class ContributorsState { }); return this.contributorsService - .bulkAddContributors(action.resourceType, action.resourceId, action.contributors, action.childNodeIds) + .bulkAddContributors(action.resourceType, action.resourceId, action.contributors) .pipe( tap(() => { ctx.dispatch( diff --git a/src/app/shared/stores/node-links/node-links.actions.ts b/src/app/shared/stores/node-links/node-links.actions.ts index 1c4fdc53a..ed3d10145 100644 --- a/src/app/shared/stores/node-links/node-links.actions.ts +++ b/src/app/shared/stores/node-links/node-links.actions.ts @@ -1,5 +1,5 @@ -import { ComponentOverview } from '@osf/shared/models/components/components.models'; import { MyResourcesItem } from '@osf/shared/models/my-resources/my-resources.models'; +import { NodeModel } from '@osf/shared/models/nodes/base-node.model'; export class CreateNodeLink { static readonly type = '[Node Links] Create Node Link'; @@ -21,7 +21,7 @@ export class DeleteNodeLink { constructor( public projectId: string, - public linkedResource: ComponentOverview + public linkedResource: NodeModel ) {} } diff --git a/src/app/shared/stores/node-links/node-links.model.ts b/src/app/shared/stores/node-links/node-links.model.ts index eec2c3bc7..5e892a858 100644 --- a/src/app/shared/stores/node-links/node-links.model.ts +++ b/src/app/shared/stores/node-links/node-links.model.ts @@ -1,8 +1,8 @@ -import { ComponentOverview } from '@osf/shared/models/components/components.models'; +import { NodeModel } from '@osf/shared/models/nodes/base-node.model'; import { AsyncStateModel } from '@osf/shared/models/store/async-state.model'; export interface NodeLinksStateModel { - linkedResources: AsyncStateModel; + linkedResources: AsyncStateModel; } export const NODE_LINKS_DEFAULTS: NodeLinksStateModel = { diff --git a/src/app/shared/stores/wiki/wiki.actions.ts b/src/app/shared/stores/wiki/wiki.actions.ts index c143793fc..3d0ec159d 100644 --- a/src/app/shared/stores/wiki/wiki.actions.ts +++ b/src/app/shared/stores/wiki/wiki.actions.ts @@ -11,6 +11,15 @@ export class CreateWiki { ) {} } +export class RenameWiki { + static readonly type = '[Wiki] Rename Wiki'; + + constructor( + public wikiId: string, + public name: string + ) {} +} + export class DeleteWiki { static readonly type = '[Wiki] Delete Wiki'; constructor(public wikiId: string) {} diff --git a/src/app/shared/stores/wiki/wiki.state.ts b/src/app/shared/stores/wiki/wiki.state.ts index 86fbf7fd4..6c0b109ca 100644 --- a/src/app/shared/stores/wiki/wiki.state.ts +++ b/src/app/shared/stores/wiki/wiki.state.ts @@ -17,6 +17,7 @@ import { GetWikiList, GetWikiVersionContent, GetWikiVersions, + RenameWiki, SetCurrentWiki, ToggleMode, UpdateWikiPreviewContent, @@ -55,6 +56,32 @@ export class WikiState { ); } + @Action(RenameWiki) + renameWiki(ctx: StateContext, action: RenameWiki) { + const state = ctx.getState(); + ctx.patchState({ + wikiList: { + ...state.wikiList, + isSubmitting: true, + }, + }); + return this.wikiService.renameWiki(action.wikiId, action.name).pipe( + tap((wiki) => { + const updatedWiki = wiki.id === action.wikiId ? { ...wiki, name: action.name } : wiki; + const updatedList = state.wikiList.data.map((w) => (w.id === updatedWiki.id ? updatedWiki : w)); + ctx.patchState({ + wikiList: { + ...state.wikiList, + data: [...updatedList], + isSubmitting: false, + }, + currentWikiId: updatedWiki.id, + }); + }), + catchError((error) => this.handleError(ctx, error)) + ); + } + @Action(DeleteWiki) deleteWiki(ctx: StateContext, action: DeleteWiki) { const state = ctx.getState(); diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 70cab840c..5e7b617d2 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -807,6 +807,9 @@ "bookmark": { "add": "Successfully added to bookmarks", "remove": "Successfully removed from bookmarks" + }, + "reorderComponents": { + "success": "Components have been reordered successfully." } }, "linkProject": { @@ -904,10 +907,13 @@ }, "wiki": { "addNewWiki": "Add new wiki page", + "renameWiki": "Rename wiki page", "deleteWiki": "Delete wiki page", "deleteWikiMessage": "Are you sure you want to delete this wiki page?", "addNewWikiPlaceholder": "New wiki name", "addWikiSuccess": "Wiki page has been added successfully", + "renameWikiSuccess": "Wiki page has been renamed successfully", + "renameWikiConflict": "That wiki name already exists.", "view": "View", "edit": "Edit", "compare": "Compare", @@ -920,7 +926,8 @@ "preview": "Preview", "current": "Current", "successSaved": "Wiki version has been saved successfully", - "noContent": "Add important information, links, or images here to describe your project." + "noContent": "Add important information, links, or images here to describe your project.", + "unknownAuthor": "Unknown author" }, "list": { "header": "Project Wiki Pages", @@ -1160,13 +1167,17 @@ "moveFile": { "cannotMove": "Cannot move or copy to this folder", "title": "Select Destination", + "dialogTitle": "Move file", + "dialogTitleMultiple": "Move files", "message": "Are you sure you want to move {{dragNodeName}} to {{dropNodeName}} ?", + "multipleFiles": "{{count}} files", "storage": "OSF Storage", "pathError": "Path is not specified!", "success": "Successfully moved.", "noMovePermission": "Cannot move or copy to this file provider", + "error": "Failed to move or copy files, please try again later", "movingHeader": "Moving...", - "copingHeader": "Coping..." + "copingHeader": "Copying..." }, "copyFile": { "success": "File successfully copied." @@ -1912,8 +1923,7 @@ "emailPlaceholder": "email@example.com" }, "buttons": { - "cancel": "Cancel", - "add": "Add" + "cancel": "Cancel" }, "messages": { "success": "Alternative email added successfully", @@ -2966,11 +2976,11 @@ "fallbackWithoutNode": "{{user}} performed action \"{{action}}\"" }, "activities": { - "addon_added": "{{user}} added addon {{addon}} to {{node}}", + "addon_added": "{{user}} enabled {{addon}} to {{node}}", "addon_file_copied": "{{user}} copied {{source}} to {{destination}} in {{node}}", "addon_file_moved": "{{user}} moved {{source}} to {{destination}} in {{node}}", "addon_file_renamed": "{{user}} renamed {{source}} to {{destination}} in {{node}}", - "addon_removed": "{{user}} removed addon {{addon}} from {{node}}", + "addon_removed": "{{user}} disabled {{addon}} from {{node}}", "affiliated_institution_added": "{{user}} added {{institution}} affiliation to {{node}}", "affiliated_institution_removed": "{{user}} removed {{institution}} affiliation from {{node}}", "article_doi_updated": "{{user}} changed the article_doi of {{node}}", 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/node.mock.ts b/src/testing/mocks/node.mock.ts new file mode 100644 index 000000000..fbcaff986 --- /dev/null +++ b/src/testing/mocks/node.mock.ts @@ -0,0 +1,52 @@ +import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; +import { NodeModel } from '@osf/shared/models/nodes/base-node.model'; + +export const MOCK_NODE_WITH_ADMIN: NodeModel = { + id: 'test-id-1', + type: 'nodes', + title: 'Test Component', + description: 'Test Description', + category: 'project', + dateCreated: '2024-01-01T00:00:00.000Z', + dateModified: '2024-01-02T00:00:00.000Z', + isRegistration: false, + isPreprint: false, + isFork: false, + isCollection: false, + isPublic: true, + tags: [], + accessRequestsEnabled: true, + nodeLicense: { + copyrightHolders: null, + year: null, + }, + currentUserPermissions: [UserPermissions.Admin], + currentUserIsContributor: true, + wikiEnabled: true, + bibliographicContributors: [], +}; + +export const MOCK_NODE_WITHOUT_ADMIN: NodeModel = { + id: 'test-id-2', + type: 'nodes', + title: 'Test Component 2', + description: 'Test Description 2', + category: 'project', + dateCreated: '2024-01-01T00:00:00.000Z', + dateModified: '2024-01-02T00:00:00.000Z', + isRegistration: false, + isPreprint: false, + isFork: false, + isCollection: false, + isPublic: false, + tags: [], + accessRequestsEnabled: true, + nodeLicense: { + copyrightHolders: null, + year: null, + }, + currentUserPermissions: [UserPermissions.Read, UserPermissions.Write], + currentUserIsContributor: true, + wikiEnabled: true, + bibliographicContributors: [], +}; 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; +}