diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 15ab0b05a..e66d321f2 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -68,8 +68,8 @@ export const routes: Routes = [ path: 'my-projects', loadComponent: () => import('./features/my-projects/my-projects.component').then((mod) => mod.MyProjectsComponent), - providers: [provideStates([BookmarksState, ProjectsState])], canActivate: [authGuard], + providers: [provideStates([BookmarksState, ProjectsState])], }, { path: 'my-registrations', diff --git a/src/app/core/components/nav-menu/nav-menu.component.spec.ts b/src/app/core/components/nav-menu/nav-menu.component.spec.ts index 749818228..5cac85132 100644 --- a/src/app/core/components/nav-menu/nav-menu.component.spec.ts +++ b/src/app/core/components/nav-menu/nav-menu.component.spec.ts @@ -4,8 +4,8 @@ import { NO_ERRORS_SCHEMA, signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute, Router } from '@angular/router'; +import { CustomMenuItem } from '@core/models/custom-menu-item.model'; import { AuthService } from '@core/services/auth.service'; -import { CustomMenuItem } from '@osf/core/models'; import { ProviderSelectors } from '@osf/core/store/provider/provider.selectors'; import { UserSelectors } from '@osf/core/store/user/user.selectors'; import { IconComponent } from '@osf/shared/components/icon/icon.component'; 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 7ed3672de..52421f0a1 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 @@ -8,7 +8,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute, Router } from '@angular/router'; import { ProjectOverviewSelectors } from '@osf/features/project/overview/store'; -import { RegistryOverviewSelectors } from '@osf/features/registry/store/registry-overview'; +import { RegistrySelectors } from '@osf/features/registry/store/registry'; import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component'; import { CustomPaginatorComponent } from '@osf/shared/components/custom-paginator/custom-paginator.component'; import { IconComponent } from '@osf/shared/components/icon/icon.component'; @@ -64,8 +64,8 @@ describe('Component: View Duplicates', () => { { selector: DuplicatesSelectors.getDuplicatesTotalCount, value: 0 }, { selector: ProjectOverviewSelectors.getProject, value: MOCK_PROJECT_OVERVIEW }, { selector: ProjectOverviewSelectors.isProjectAnonymous, value: false }, - { selector: RegistryOverviewSelectors.getRegistry, value: undefined }, - { selector: RegistryOverviewSelectors.isRegistryAnonymous, value: false }, + { selector: RegistrySelectors.getRegistry, value: undefined }, + { selector: RegistrySelectors.isRegistryAnonymous, value: false }, ], }), MockProvider(CustomDialogService, mockCustomDialogService), 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 caf3a9093..b114b3c00 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 @@ -25,11 +25,7 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { UserSelectors } from '@core/store/user'; import { DeleteComponentDialogComponent, ForkDialogComponent } from '@osf/features/project/overview/components'; import { ClearProjectOverview, GetProjectById, ProjectOverviewSelectors } from '@osf/features/project/overview/store'; -import { - ClearRegistryOverview, - GetRegistryById, - RegistryOverviewSelectors, -} from '@osf/features/registry/store/registry-overview'; +import { ClearRegistry, GetRegistryById, RegistrySelectors } from '@osf/features/registry/store/registry'; import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component'; import { CustomPaginatorComponent } from '@osf/shared/components/custom-paginator/custom-paginator.component'; import { IconComponent } from '@osf/shared/components/icon/icon.component'; @@ -72,9 +68,9 @@ export class ViewDuplicatesComponent { private router = inject(Router); private destroyRef = inject(DestroyRef); private project = select(ProjectOverviewSelectors.getProject); - private registration = select(RegistryOverviewSelectors.getRegistry); + private registration = select(RegistrySelectors.getRegistry); private isProjectAnonymous = select(ProjectOverviewSelectors.isProjectAnonymous); - private isRegistryAnonymous = select(RegistryOverviewSelectors.isRegistryAnonymous); + private isRegistryAnonymous = select(RegistrySelectors.isRegistryAnonymous); duplicates = select(DuplicatesSelectors.getDuplicates); isDuplicatesLoading = select(DuplicatesSelectors.getDuplicatesLoading); @@ -127,7 +123,7 @@ export class ViewDuplicatesComponent { getDuplicates: GetAllDuplicates, clearDuplicates: ClearDuplicates, clearProject: ClearProjectOverview, - clearRegistration: ClearRegistryOverview, + clearRegistration: ClearRegistry, getComponentsTree: GetResourceWithChildren, }); 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 071c60bcd..b93c254ea 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 @@ -6,7 +6,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute } from '@angular/router'; import { ProjectOverviewSelectors } from '@osf/features/project/overview/store'; -import { RegistryOverviewSelectors } from '@osf/features/registry/store/registry-overview'; +import { RegistrySelectors } from '@osf/features/registry/store/registry'; import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component'; import { CustomPaginatorComponent } from '@osf/shared/components/custom-paginator/custom-paginator.component'; import { IconComponent } from '@osf/shared/components/icon/icon.component'; @@ -54,7 +54,7 @@ describe('Component: View Duplicates', () => { { selector: LinkedProjectsSelectors.getLinkedProjectsLoading, value: false }, { selector: LinkedProjectsSelectors.getLinkedProjectsTotalCount, value: 0 }, { selector: ProjectOverviewSelectors.getProject, value: MOCK_PROJECT_OVERVIEW }, - { selector: RegistryOverviewSelectors.getRegistry, value: undefined }, + { selector: RegistrySelectors.getRegistry, value: undefined }, ], }), MockProvider(ActivatedRoute, activatedRouteMock), 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 a87a77c09..6f7bb79bd 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 @@ -22,11 +22,7 @@ import { toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute, RouterLink } from '@angular/router'; import { ClearProjectOverview, GetProjectById, ProjectOverviewSelectors } from '@osf/features/project/overview/store'; -import { - ClearRegistryOverview, - GetRegistryById, - RegistryOverviewSelectors, -} from '@osf/features/registry/store/registry-overview'; +import { ClearRegistry, GetRegistryById, RegistrySelectors } from '@osf/features/registry/store/registry'; import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component'; import { CustomPaginatorComponent } from '@osf/shared/components/custom-paginator/custom-paginator.component'; import { IconComponent } from '@osf/shared/components/icon/icon.component'; @@ -59,7 +55,7 @@ export class ViewLinkedProjectsComponent { private route = inject(ActivatedRoute); private destroyRef = inject(DestroyRef); private project = select(ProjectOverviewSelectors.getProject); - private registration = select(RegistryOverviewSelectors.getRegistry); + private registration = select(RegistrySelectors.getRegistry); linkedProjects = select(LinkedProjectsSelectors.getLinkedProjects); isLoading = select(LinkedProjectsSelectors.getLinkedProjectsLoading); @@ -93,7 +89,7 @@ export class ViewLinkedProjectsComponent { getLinkedProjects: GetAllLinkedProjects, clearLinkedProjects: ClearLinkedProjects, clearProject: ClearProjectOverview, - clearRegistration: ClearRegistryOverview, + clearRegistration: ClearRegistry, }); constructor() { diff --git a/src/app/features/metadata/components/metadata-affiliated-institutions/metadata-affiliated-institutions.component.html b/src/app/features/metadata/components/metadata-affiliated-institutions/metadata-affiliated-institutions.component.html index 90fb07ee2..b8d7488e4 100644 --- a/src/app/features/metadata/components/metadata-affiliated-institutions/metadata-affiliated-institutions.component.html +++ b/src/app/features/metadata/components/metadata-affiliated-institutions/metadata-affiliated-institutions.component.html @@ -1,6 +1,6 @@
-

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

+

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

@if (!readonly()) { {{ 'project.overview.metadata.affiliatedInstitutions' | translate }}
- +
diff --git a/src/app/features/my-projects/mappers/index.ts b/src/app/features/my-projects/mappers/index.ts deleted file mode 100644 index 649917ac6..000000000 --- a/src/app/features/my-projects/mappers/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { MyResourcesMapper } from './my-resources.mapper'; diff --git a/src/app/features/my-projects/my-projects.component.ts b/src/app/features/my-projects/my-projects.component.ts index 6e87c49cf..89c2af68e 100644 --- a/src/app/features/my-projects/my-projects.component.ts +++ b/src/app/features/my-projects/my-projects.component.ts @@ -28,15 +28,13 @@ import { SearchInputComponent } from '@osf/shared/components/search-input/search import { SelectComponent } from '@osf/shared/components/select/select.component'; import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; import { DEFAULT_TABLE_PARAMS } from '@osf/shared/constants/default-table-params.constants'; -import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { SortOrder } from '@osf/shared/enums/sort-order.enum'; import { IS_MEDIUM } from '@osf/shared/helpers/breakpoints.tokens'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { ProjectRedirectDialogService } from '@osf/shared/services/project-redirect-dialog.service'; -import { BookmarksSelectors, GetBookmarksCollectionId } from '@osf/shared/stores/bookmarks'; +import { BookmarksSelectors, GetAllMyBookmarks, GetBookmarksCollectionId } from '@osf/shared/stores/bookmarks'; import { ClearMyResources, - GetMyBookmarks, GetMyPreprints, GetMyProjects, GetMyRegistrations, @@ -82,7 +80,6 @@ export class MyProjectsComponent implements OnInit { readonly queryService = inject(MyProjectsQueryService); readonly tableParamsService = inject(MyProjectsTableParamsService); - readonly bookmarksPageSize = 100; readonly isLoading = signal(false); readonly isMedium = toSignal(inject(IS_MEDIUM)); readonly tabOptions = MY_PROJECTS_TABS; @@ -104,12 +101,13 @@ export class MyProjectsComponent implements OnInit { readonly projects = select(MyResourcesSelectors.getProjects); readonly registrations = select(MyResourcesSelectors.getRegistrations); readonly preprints = select(MyResourcesSelectors.getPreprints); - readonly bookmarks = select(MyResourcesSelectors.getBookmarks); readonly totalProjectsCount = select(MyResourcesSelectors.getTotalProjects); readonly totalRegistrationsCount = select(MyResourcesSelectors.getTotalRegistrations); readonly totalPreprintsCount = select(MyResourcesSelectors.getTotalPreprints); - readonly totalBookmarksCount = select(MyResourcesSelectors.getTotalBookmarks); + + readonly bookmarks = select(BookmarksSelectors.getBookmarks); readonly bookmarksCollectionId = select(BookmarksSelectors.getBookmarksCollectionId); + readonly totalBookmarksCount = select(BookmarksSelectors.getBookmarksTotalCount); readonly isBookmarks = computed(() => this.selectedTab() === MyProjectsTab.Bookmarks); readonly actions = createDispatchMap({ @@ -118,7 +116,7 @@ export class MyProjectsComponent implements OnInit { getMyProjects: GetMyProjects, getMyRegistrations: GetMyRegistrations, getMyPreprints: GetMyPreprints, - getMyBookmarks: GetMyBookmarks, + getMyBookmarks: GetAllMyBookmarks, }); constructor() { @@ -314,13 +312,7 @@ export class MyProjectsComponent implements OnInit { break; case MyProjectsTab.Bookmarks: if (this.bookmarksCollectionId()) { - action$ = this.actions.getMyBookmarks( - this.bookmarksCollectionId(), - pageNumber, - this.bookmarksPageSize, - filters, - ResourceType.Null - ); + action$ = this.actions.getMyBookmarks(this.bookmarksCollectionId(), filters); } break; } diff --git a/src/app/features/preprints/components/preprint-details/additional-info/additional-info.component.ts b/src/app/features/preprints/components/preprint-details/additional-info/additional-info.component.ts index 2076cd54a..fa1253572 100644 --- a/src/app/features/preprints/components/preprint-details/additional-info/additional-info.component.ts +++ b/src/app/features/preprints/components/preprint-details/additional-info/additional-info.component.ts @@ -43,9 +43,8 @@ export class AdditionalInfoComponent { if (!preprint) return null; return preprint.embeddedLicense; }); - licenseOptionsRecord = computed(() => { - return (this.preprint()?.licenseOptions ?? {}) as Record; - }); + + licenseOptionsRecord = computed(() => (this.preprint()?.licenseOptions ?? {}) as Record); skeletonData = Array.from({ length: 5 }, () => null); diff --git a/src/app/features/preprints/components/preprint-details/general-information/general-information.component.html b/src/app/features/preprints/components/preprint-details/general-information/general-information.component.html index a20d43ff2..06945006e 100644 --- a/src/app/features/preprints/components/preprint-details/general-information/general-information.component.html +++ b/src/app/features/preprints/components/preprint-details/general-information/general-information.component.html @@ -22,7 +22,11 @@

{{ 'preprints.preprintStepper.common.labels.abstract' | translate }}

@if (affiliatedInstitutions().length) { - +
+

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

+ + +
} @if (preprintProvider()?.assertionsEnabled) { 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 2ae83eb00..345fbe669 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 @@ -71,7 +71,14 @@

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

@if (affiliatedInstitutions().length) { - +
+

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

+ + +
} @if (license()) { diff --git a/src/app/features/project/overview/components/delete-component-dialog/delete-component-dialog.component.html b/src/app/features/project/overview/components/delete-component-dialog/delete-component-dialog.component.html index 1fc424cb5..3e7b3eb6c 100644 --- a/src/app/features/project/overview/components/delete-component-dialog/delete-component-dialog.component.html +++ b/src/app/features/project/overview/components/delete-component-dialog/delete-component-dialog.component.html @@ -1,5 +1,5 @@ @if (hasAdminAccessForAllComponents()) { - @if (hasSubcomponents()) { + @if (hasSubComponents()) {

{{ 'project.overview.dialog.deleteComponent.listMessage' | translate }}

@if (isLoading()) { @@ -25,6 +25,7 @@ } @else {

} +
component.permissions?.includes(UserPermissions.Admin)); }); - hasSubcomponents = computed(() => { + hasSubComponents = computed(() => { const components = this.components(); return components && components.length > 1; }); diff --git a/src/app/features/project/overview/components/index.ts b/src/app/features/project/overview/components/index.ts index f1d14c573..8b9a1133d 100644 --- a/src/app/features/project/overview/components/index.ts +++ b/src/app/features/project/overview/components/index.ts @@ -3,14 +3,13 @@ export { CitationAddonCardComponent } from './citation-addon-card/citation-addon 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 { OverviewToolbarComponent } from './overview-toolbar/overview-toolbar.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'; -export { DeleteNodeLinkDialogComponent } from '@osf/features/project/overview/components/delete-node-link-dialog/delete-node-link-dialog.component'; -export { LinkResourceDialogComponent } from '@osf/features/project/overview/components/link-resource-dialog/link-resource-dialog.component'; -export { LinkedResourcesComponent } from '@osf/features/project/overview/components/linked-resources/linked-resources.component'; 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 new file mode 100644 index 000000000..013cba20c --- /dev/null +++ b/src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.html @@ -0,0 +1,116 @@ +@let resource = currentResource(); + +@if (resource) { + +} diff --git a/src/app/shared/components/resource-metadata/resource-metadata.component.scss b/src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.scss similarity index 100% rename from src/app/shared/components/resource-metadata/resource-metadata.component.scss rename to src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.scss diff --git a/src/app/shared/components/resource-metadata/resource-metadata.component.spec.ts b/src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.spec.ts similarity index 69% rename from src/app/shared/components/resource-metadata/resource-metadata.component.spec.ts rename to src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.spec.ts index bf93d77f3..dc43f0628 100644 --- a/src/app/shared/components/resource-metadata/resource-metadata.component.spec.ts +++ b/src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.spec.ts @@ -2,28 +2,26 @@ import { MockComponents } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +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 { ResourceOverview } from '@shared/models/resource-overview.model'; - -import { AffiliatedInstitutionsViewComponent } from '../affiliated-institutions-view/affiliated-institutions-view.component'; -import { ContributorsListComponent } from '../contributors-list/contributors-list.component'; -import { ResourceCitationsComponent } from '../resource-citations/resource-citations.component'; -import { TruncatedTextComponent } from '../truncated-text/truncated-text.component'; - -import { ResourceMetadataComponent } from './resource-metadata.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 { MOCK_RESOURCE_OVERVIEW } from '@testing/mocks/resource.mock'; -describe('ResourceMetadataComponent', () => { - let component: ResourceMetadataComponent; - let fixture: ComponentFixture; +describe('ProjectOverviewMetadataComponent', () => { + let component: ProjectOverviewMetadataComponent; + let fixture: ComponentFixture; const mockResourceOverview: ResourceOverview = MOCK_RESOURCE_OVERVIEW; beforeEach(async () => { await TestBed.configureTestingModule({ imports: [ - ResourceMetadataComponent, + ProjectOverviewMetadataComponent, MockComponents( TruncatedTextComponent, ResourceCitationsComponent, @@ -34,7 +32,7 @@ describe('ResourceMetadataComponent', () => { ], }).compileComponents(); - fixture = TestBed.createComponent(ResourceMetadataComponent); + fixture = TestBed.createComponent(ProjectOverviewMetadataComponent); component = fixture.componentInstance; }); @@ -84,9 +82,4 @@ describe('ResourceMetadataComponent', () => { fixture.componentRef.setInput('canEdit', false); expect(component.canEdit()).toBe(false); }); - - it('should handle true isCollectionsRoute input', () => { - fixture.componentRef.setInput('isCollectionsRoute', true); - expect(component.isCollectionsRoute()).toBe(true); - }); }); 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 new file mode 100644 index 000000000..e91133be7 --- /dev/null +++ b/src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.ts @@ -0,0 +1,68 @@ +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 { 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'; +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 { 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 { OverviewCollectionsComponent } from '../overview-collections/overview-collections.component'; + +@Component({ + selector: 'osf-project-overview-metadata', + imports: [ + Button, + TranslatePipe, + TruncatedTextComponent, + RouterLink, + DatePipe, + ResourceCitationsComponent, + OverviewCollectionsComponent, + AffiliatedInstitutionsViewComponent, + ContributorsListComponent, + ResourceDoiComponent, + ResourceLicenseComponent, + SubjectsListComponent, + TagsListComponent, + ], + 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 resourceType = CurrentResourceType.Projects; + readonly dateFormat = 'MMM d, y, h:mm a'; + readonly webUrl = this.environment.webUrl; + + onCustomCitationUpdated(citation: string): void { + this.customCitationUpdated.emit(citation); + } + + tagClicked(tag: string) { + this.router.navigate(['/search'], { queryParams: { search: tag } }); + } +} diff --git a/src/app/features/project/overview/components/overview-toolbar/overview-toolbar.component.html b/src/app/features/project/overview/components/project-overview-toolbar/project-overview-toolbar.component.html similarity index 94% rename from src/app/features/project/overview/components/overview-toolbar/overview-toolbar.component.html rename to src/app/features/project/overview/components/project-overview-toolbar/project-overview-toolbar.component.html index 221bcb1c1..0687ca960 100644 --- a/src/app/features/project/overview/components/overview-toolbar/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() && !isRegistration) { + @if (!isCollectionsRoute() && canEdit()) {
@@ -23,7 +23,7 @@
} - @if ((isCollectionsRoute() || hasViewOnly() || !canEdit()) && !isRegistration) { + @if (isCollectionsRoute() || hasViewOnly() || !canEdit()) { @if (isPublic()) {
@@ -60,7 +60,7 @@ } - @if (resource.resourceType === ResourceType.Project && !hasViewOnly()) { + @if (!hasViewOnly()) { { - let component: OverviewToolbarComponent; - let fixture: ComponentFixture; +describe('ProjectOverviewToolbarComponent', () => { + let component: ProjectOverviewToolbarComponent; + let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [OverviewToolbarComponent, MockComponent(SocialsShareButtonComponent)], + imports: [ProjectOverviewToolbarComponent, MockComponent(SocialsShareButtonComponent)], }).compileComponents(); - fixture = TestBed.createComponent(OverviewToolbarComponent); + fixture = TestBed.createComponent(ProjectOverviewToolbarComponent); component = fixture.componentInstance; fixture.detectChanges(); }); diff --git a/src/app/features/project/overview/components/overview-toolbar/overview-toolbar.component.ts b/src/app/features/project/overview/components/project-overview-toolbar/project-overview-toolbar.component.ts similarity index 72% rename from src/app/features/project/overview/components/overview-toolbar/overview-toolbar.component.ts rename to src/app/features/project/overview/components/project-overview-toolbar/project-overview-toolbar.component.ts index 3fc03c67e..24e925800 100644 --- a/src/app/features/project/overview/components/overview-toolbar/overview-toolbar.component.ts +++ b/src/app/features/project/overview/components/project-overview-toolbar/project-overview-toolbar.component.ts @@ -1,4 +1,4 @@ -import { createDispatchMap, select, Store } from '@ngxs/store'; +import { createDispatchMap, select } from '@ngxs/store'; import { TranslatePipe } from '@ngx-translate/core'; @@ -24,15 +24,19 @@ import { ToolbarResource } from '@osf/shared/models/toolbar-resource.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'; -import { AddResourceToBookmarks, BookmarksSelectors, RemoveResourceFromBookmarks } from '@osf/shared/stores/bookmarks'; -import { GetMyBookmarks, MyResourcesSelectors } from '@osf/shared/stores/my-resources'; +import { + AddResourceToBookmarks, + BookmarksSelectors, + GetResourceBookmark, + RemoveResourceFromBookmarks, +} from '@osf/shared/stores/bookmarks'; 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'; @Component({ - selector: 'osf-overview-toolbar', + selector: 'osf-project-overview-toolbar', imports: [ ToggleSwitch, TranslatePipe, @@ -45,19 +49,18 @@ import { TogglePublicityDialogComponent } from '../toggle-publicity-dialog/toggl FileSizePipe, SocialsShareButtonComponent, ], - templateUrl: './overview-toolbar.component.html', - styleUrl: './overview-toolbar.component.scss', + templateUrl: './project-overview-toolbar.component.html', + styleUrl: './project-overview-toolbar.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class OverviewToolbarComponent { - private store = inject(Store); +export class ProjectOverviewToolbarComponent { private customDialogService = inject(CustomDialogService); private toastService = inject(ToastService); + private destroyRef = inject(DestroyRef); private readonly router = inject(Router); private readonly route = inject(ActivatedRoute); - destroyRef = inject(DestroyRef); isPublic = signal(false); isBookmarked = signal(false); @@ -67,16 +70,22 @@ export class OverviewToolbarComponent { projectDescription = input(''); showViewOnlyLinks = input(true); - isBookmarksLoading = select(MyResourcesSelectors.getBookmarksLoading); - isBookmarksSubmitting = select(BookmarksSelectors.getBookmarksCollectionIdSubmitting); bookmarksCollectionId = select(BookmarksSelectors.getBookmarksCollectionId); - bookmarkedProjects = select(MyResourcesSelectors.getBookmarks); + bookmarks = select(BookmarksSelectors.getBookmarks); + isBookmarksLoading = select(BookmarksSelectors.areBookmarksLoading); + isBookmarksSubmitting = select(BookmarksSelectors.getBookmarksCollectionIdSubmitting); + duplicatedProject = select(ProjectOverviewSelectors.getDuplicatedProject); isAuthenticated = select(UserSelectors.isAuthenticated); hasViewOnly = computed(() => hasViewOnlyParam(this.router)); - actions = createDispatchMap({ clearDuplicatedProject: ClearDuplicatedProject }); + actions = createDispatchMap({ + getResourceBookmark: GetResourceBookmark, + addResourceToBookmarks: AddResourceToBookmarks, + removeResourceFromBookmarks: RemoveResourceFromBookmarks, + clearDuplicatedProject: ClearDuplicatedProject, + }); readonly ResourceType = ResourceType; @@ -97,20 +106,14 @@ export class OverviewToolbarComponent { }, ]; - get isRegistration(): boolean { - return this.currentResource()?.resourceType === ResourceType.Registration; - } - constructor() { effect(() => { const bookmarksId = this.bookmarksCollectionId(); const resource = this.currentResource(); - if (!bookmarksId || !resource) { - return; - } + if (!bookmarksId || !resource) return; - this.store.dispatch(new GetMyBookmarks(bookmarksId, 1, 100, {}, resource.resourceType)); + this.actions.getResourceBookmark(bookmarksId, resource.id, resource.resourceType); }); effect(() => { @@ -122,7 +125,7 @@ export class OverviewToolbarComponent { effect(() => { const resource = this.currentResource(); - const bookmarks = this.bookmarkedProjects(); + const bookmarks = this.bookmarks(); if (!resource || !bookmarks?.length) { this.isBookmarked.set(false); @@ -163,44 +166,33 @@ export class OverviewToolbarComponent { const newBookmarkState = !this.isBookmarked(); - if (!newBookmarkState) { - this.store - .dispatch(new RemoveResourceFromBookmarks(bookmarksId, resource.id, resource.resourceType)) + if (newBookmarkState) { + this.actions + .addResourceToBookmarks(bookmarksId, resource.id, resource.resourceType) .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe({ - next: () => { - this.isBookmarked.set(newBookmarkState); - this.toastService.showSuccess('project.overview.dialog.toast.bookmark.remove'); - }, + .subscribe(() => { + this.isBookmarked.set(newBookmarkState); + this.toastService.showSuccess('project.overview.dialog.toast.bookmark.add'); }); } else { - this.store - .dispatch(new AddResourceToBookmarks(bookmarksId, resource.id, resource.resourceType)) + this.actions + .removeResourceFromBookmarks(bookmarksId, resource.id, resource.resourceType) .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe({ - next: () => { - this.isBookmarked.set(newBookmarkState); - this.toastService.showSuccess('project.overview.dialog.toast.bookmark.add'); - }, + .subscribe(() => { + this.isBookmarked.set(newBookmarkState); + this.toastService.showSuccess('project.overview.dialog.toast.bookmark.remove'); }); } } private handleForkResource(): void { const resource = this.currentResource(); - const headerTranslation = - resource?.resourceType === ResourceType.Project - ? 'project.overview.dialog.fork.headerProject' - : resource?.resourceType === ResourceType.Registration - ? 'project.overview.dialog.fork.headerRegistry' - : ''; + const headerTranslation = 'project.overview.dialog.fork.headerProject'; if (resource) { this.customDialogService.open(ForkDialogComponent, { header: headerTranslation, - data: { - resource: resource, - }, + data: { resource }, }); } } @@ -211,7 +203,6 @@ export class OverviewToolbarComponent { .onClose.subscribe({ next: () => { const duplicatedProject = this.duplicatedProject(); - if (duplicatedProject) { this.router.navigate(['/', duplicatedProject.id]); } diff --git a/src/app/features/project/overview/project-overview.component.html b/src/app/features/project/overview/project-overview.component.html index f65b70d76..818ff3db9 100644 --- a/src/app/features/project/overview/project-overview.component.html +++ b/src/app/features/project/overview/project-overview.component.html @@ -14,7 +14,7 @@ @if (currentProject()) {
-
- { OverviewComponentsComponent, LinkedResourcesComponent, RecentActivityComponent, - OverviewToolbarComponent, + ProjectOverviewToolbarComponent, ResourceMetadataComponent, FilesWidgetComponent, ViewOnlyLinkMessageComponent, diff --git a/src/app/features/project/overview/project-overview.component.ts b/src/app/features/project/overview/project-overview.component.ts index 2b64641c0..9ceabbae3 100644 --- a/src/app/features/project/overview/project-overview.component.ts +++ b/src/app/features/project/overview/project-overview.component.ts @@ -32,7 +32,6 @@ import { } from '@osf/features/moderation/store/collections-moderation'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; import { MakeDecisionDialogComponent } from '@osf/shared/components/make-decision-dialog/make-decision-dialog.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 { Mode } from '@osf/shared/enums/mode.enum'; @@ -66,12 +65,13 @@ import { AnalyticsService } from '@shared/services/analytics.service'; import { DataciteService } from '@shared/services/datacite/datacite.service'; import { OverviewParentProjectComponent } from './components/overview-parent-project/overview-parent-project.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, - OverviewToolbarComponent, OverviewWikiComponent, RecentActivityComponent, } from './components'; @@ -100,8 +100,8 @@ import { OverviewComponentsComponent, LinkedResourcesComponent, RecentActivityComponent, - OverviewToolbarComponent, - ResourceMetadataComponent, + ProjectOverviewToolbarComponent, + ProjectOverviewMetadataComponent, TranslatePipe, Message, RouterLink, 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 13c035f44..887c3c5ec 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 @@ -98,19 +98,23 @@ export class ConfigureAddonComponent implements OnInit { const currentUrl = this.router.url; return currentUrl.split('/addons')[0]; }); + readonly resourceUri = computed(() => { const id = this.route.parent?.parent?.snapshot.params['id']; return `${this.environment.webUrl}/${id}`; }); + readonly addonTypeString = computed(() => { return getAddonTypeString(this.addon()) as AddonType; }); + readonly selectedItemLabel = computed(() => { const addonType = this.addonTypeString(); return addonType === AddonType.LINK ? 'settings.addons.configureAddon.linkedItem' : 'settings.addons.configureAddon.selectedFolder'; }); + readonly supportedResourceTypes = computed(() => { if (this.linkAddons().length && this.addonTypeString() === AddonType.LINK) { const addon = this.linkAddons().find((a) => this.addon()?.externalServiceName === a.externalServiceName); @@ -118,6 +122,7 @@ export class ConfigureAddonComponent implements OnInit { } return []; }); + readonly actions = createDispatchMap({ createAddonOperationInvocation: CreateAddonOperationInvocation, updateConfiguredAddon: UpdateConfiguredAddon, diff --git a/src/app/features/registries/components/justification-review/justification-review.component.html b/src/app/features/registries/components/justification-review/justification-review.component.html index 480466477..a282a0fcc 100644 --- a/src/app/features/registries/components/justification-review/justification-review.component.html +++ b/src/app/features/registries/components/justification-review/justification-review.component.html @@ -30,7 +30,7 @@

{{ 'registries.justification.updatedList' | translate }}

{{ page.title }}

@if (page.description) { -

{{ page.description }}

+

{{ page.description }}

} @if (page.questions?.length) { diff --git a/src/app/features/registries/components/registries-metadata-step/registries-affiliated-institution/registries-affiliated-institution.component.html b/src/app/features/registries/components/registries-metadata-step/registries-affiliated-institution/registries-affiliated-institution.component.html index 4e5effc82..e6aee0113 100644 --- a/src/app/features/registries/components/registries-metadata-step/registries-affiliated-institution/registries-affiliated-institution.component.html +++ b/src/app/features/registries/components/registries-metadata-step/registries-affiliated-institution/registries-affiliated-institution.component.html @@ -1,9 +1,11 @@
-

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

+

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

+

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

+ @if (userInstitutions().length) {
{{ 'project.overview.metadata.affiliatedInstitutions' | translate }} />
} @else { -

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

+

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

}
diff --git a/src/app/features/registries/components/review/review.component.html b/src/app/features/registries/components/review/review.component.html index 524dcea91..2cd5eeba4 100644 --- a/src/app/features/registries/components/review/review.component.html +++ b/src/app/features/registries/components/review/review.component.html @@ -83,7 +83,7 @@

{{ 'shared.tags.title' | translate }}

{{ page.title }}

@if (page.description) { -

{{ page.description }}

+

{{ page.description }}

} @if (page.questions?.length) { diff --git a/src/app/features/registry/components/archiving-message/archiving-message.component.ts b/src/app/features/registry/components/archiving-message/archiving-message.component.ts index 8e3e96293..3525ed5aa 100644 --- a/src/app/features/registry/components/archiving-message/archiving-message.component.ts +++ b/src/app/features/registry/components/archiving-message/archiving-message.component.ts @@ -8,7 +8,7 @@ import { ChangeDetectionStrategy, Component, inject, input } from '@angular/core import { ENVIRONMENT } from '@core/provider/environment.provider'; import { IconComponent } from '@osf/shared/components/icon/icon.component'; -import { RegistryOverview } from '../../models'; +import { RegistrationOverviewModel } from '../../models'; import { ShortRegistrationInfoComponent } from '../short-registration-info/short-registration-info.component'; @Component({ @@ -21,7 +21,7 @@ import { ShortRegistrationInfoComponent } from '../short-registration-info/short export class ArchivingMessageComponent { private readonly environment = inject(ENVIRONMENT); - registration = input.required(); + registration = input.required(); readonly supportEmail = this.environment.supportEmail; } diff --git a/src/app/features/registry/components/index.ts b/src/app/features/registry/components/index.ts index 0155fd53e..e999974a2 100644 --- a/src/app/features/registry/components/index.ts +++ b/src/app/features/registry/components/index.ts @@ -2,6 +2,7 @@ export * from './add-resource-dialog/add-resource-dialog.component'; export * from './archiving-message/archiving-message.component'; export * from './edit-resource-dialog/edit-resource-dialog.component'; export * from './registration-links-card/registration-links-card.component'; +export * from './registry-blocks-section/registry-blocks-section.component'; export * from './registry-revisions/registry-revisions.component'; export * from './registry-statuses/registry-statuses.component'; export * from './resource-form/resource-form.component'; 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 new file mode 100644 index 000000000..f60453621 --- /dev/null +++ b/src/app/features/registry/components/registration-overview-toolbar/registration-overview-toolbar.component.html @@ -0,0 +1,25 @@ +@if (resourceId()) { +
+ @if (isAuthenticated()) { + + @if (!isBookmarksLoading() && !isBookmarksSubmitting()) { + + } + + } + + @if (isPublic()) { + + } +
+} diff --git a/src/app/features/registry/components/registration-overview-toolbar/registration-overview-toolbar.component.scss b/src/app/features/registry/components/registration-overview-toolbar/registration-overview-toolbar.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/features/registry/components/registration-overview-toolbar/registration-overview-toolbar.component.spec.ts b/src/app/features/registry/components/registration-overview-toolbar/registration-overview-toolbar.component.spec.ts new file mode 100644 index 000000000..8d2935b21 --- /dev/null +++ b/src/app/features/registry/components/registration-overview-toolbar/registration-overview-toolbar.component.spec.ts @@ -0,0 +1,26 @@ +import { MockComponent } from 'ng-mocks'; + +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SocialsShareButtonComponent } from '@osf/shared/components/socials-share-button/socials-share-button.component'; + +import { RegistrationOverviewToolbarComponent } from './registration-overview-toolbar.component'; + +describe('RegistrationRegistrationOverviewToolbarComponent', () => { + let component: RegistrationOverviewToolbarComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [RegistrationOverviewToolbarComponent, MockComponent(SocialsShareButtonComponent)], + }).compileComponents(); + + fixture = TestBed.createComponent(RegistrationOverviewToolbarComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/registry/components/registration-overview-toolbar/registration-overview-toolbar.component.ts b/src/app/features/registry/components/registration-overview-toolbar/registration-overview-toolbar.component.ts new file mode 100644 index 000000000..0038252f3 --- /dev/null +++ b/src/app/features/registry/components/registration-overview-toolbar/registration-overview-toolbar.component.ts @@ -0,0 +1,98 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { TranslatePipe } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { Tooltip } from 'primeng/tooltip'; + +import { ChangeDetectionStrategy, Component, DestroyRef, effect, inject, input, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; + +import { UserSelectors } from '@core/store/user'; +import { SocialsShareButtonComponent } from '@osf/shared/components/socials-share-button/socials-share-button.component'; +import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { ToastService } from '@osf/shared/services/toast.service'; +import { + AddResourceToBookmarks, + BookmarksSelectors, + GetResourceBookmark, + RemoveResourceFromBookmarks, +} from '@osf/shared/stores/bookmarks'; + +@Component({ + selector: 'osf-registration-overview-toolbar', + imports: [Button, Tooltip, SocialsShareButtonComponent, TranslatePipe], + templateUrl: './registration-overview-toolbar.component.html', + styleUrl: './registration-overview-toolbar.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class RegistrationOverviewToolbarComponent { + private toastService = inject(ToastService); + private destroyRef = inject(DestroyRef); + + resourceId = input.required(); + resourceTitle = input.required(); + isPublic = input(false); + + isBookmarked = signal(false); + resourceType = ResourceType.Registration; + + bookmarksCollectionId = select(BookmarksSelectors.getBookmarksCollectionId); + bookmarks = select(BookmarksSelectors.getBookmarks); + isBookmarksLoading = select(BookmarksSelectors.areBookmarksLoading); + isBookmarksSubmitting = select(BookmarksSelectors.getBookmarksCollectionIdSubmitting); + isAuthenticated = select(UserSelectors.isAuthenticated); + + actions = createDispatchMap({ + getResourceBookmark: GetResourceBookmark, + addResourceToBookmarks: AddResourceToBookmarks, + removeResourceFromBookmarks: RemoveResourceFromBookmarks, + }); + + constructor() { + effect(() => { + const bookmarksCollectionId = this.bookmarksCollectionId(); + + if (!bookmarksCollectionId || !this.resourceId()) return; + + this.actions.getResourceBookmark(bookmarksCollectionId, this.resourceId(), this.resourceType); + }); + + effect(() => { + const bookmarks = this.bookmarks(); + + if (!this.resourceId() || !bookmarks?.length) { + this.isBookmarked.set(false); + return; + } + + this.isBookmarked.set(bookmarks.some((bookmark) => bookmark.id === this.resourceId())); + }); + } + + toggleBookmark(): void { + const bookmarksId = this.bookmarksCollectionId(); + + if (!this.resourceId() || !bookmarksId) return; + + const newBookmarkState = !this.isBookmarked(); + + if (newBookmarkState) { + this.actions + .addResourceToBookmarks(bookmarksId, this.resourceId(), this.resourceType) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.isBookmarked.set(newBookmarkState); + this.toastService.showSuccess('project.overview.dialog.toast.bookmark.add'); + }); + } else { + this.actions + .removeResourceFromBookmarks(bookmarksId, this.resourceId(), this.resourceType) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.isBookmarked.set(newBookmarkState); + this.toastService.showSuccess('project.overview.dialog.toast.bookmark.remove'); + }); + } + } +} diff --git a/src/app/features/registry/components/registry-blocks-section/registry-blocks-section.component.html b/src/app/features/registry/components/registry-blocks-section/registry-blocks-section.component.html new file mode 100644 index 000000000..d45a5d930 --- /dev/null +++ b/src/app/features/registry/components/registry-blocks-section/registry-blocks-section.component.html @@ -0,0 +1,47 @@ +@if (isLoading()) { +
+ +
+} @else if (schemaBlocks()) { + @for (page of schemaBlocks()!; track page.id) { +

{{ page.title }}

+ + @if (page.description) { +

{{ page.description }}

+ } + + @if (page.questions?.length) { + + } + + @if (page.sections?.length) { + @for (section of page.sections; track section.id) { +
+

{{ section.title }}

+ + @if (section.description) { +

{{ section.description }}

+ } + + @if (section.questions?.length) { + + } +
+ } + } + } +} diff --git a/src/app/features/registry/components/registry-blocks-section/registry-blocks-section.component.scss b/src/app/features/registry/components/registry-blocks-section/registry-blocks-section.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/features/registry/components/registry-blocks-section/registry-blocks-section.component.spec.ts b/src/app/features/registry/components/registry-blocks-section/registry-blocks-section.component.spec.ts new file mode 100644 index 000000000..ad7e78b25 --- /dev/null +++ b/src/app/features/registry/components/registry-blocks-section/registry-blocks-section.component.spec.ts @@ -0,0 +1,62 @@ +import { MockComponents } from 'ng-mocks'; + +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { RegistrationBlocksDataComponent } from '@osf/shared/components/registration-blocks-data/registration-blocks-data.component'; + +import { RegistryBlocksSectionComponent } from './registry-blocks-section.component'; + +import { OSFTestingModule } from '@testing/osf.testing.module'; + +describe('RegistryBlocksSectionComponent', () => { + let component: RegistryBlocksSectionComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [RegistryBlocksSectionComponent, OSFTestingModule, ...MockComponents(RegistrationBlocksDataComponent)], + }).compileComponents(); + + fixture = TestBed.createComponent(RegistryBlocksSectionComponent); + component = fixture.componentInstance; + + fixture.componentRef.setInput('schemaBlocks', []); + fixture.componentRef.setInput('isSchemaBlocksLoading', false); + fixture.componentRef.setInput('isSchemaResponsesLoading', false); + fixture.componentRef.setInput('schemaResponse', null); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should compute updatedFields from schemaResponse', () => { + const mockSchemaResponse = { + id: 'test-id', + dateCreated: '2024-01-01', + dateSubmitted: null, + dateModified: '2024-01-01', + revisionJustification: 'test', + revisionResponses: {}, + updatedResponseKeys: ['key1', 'key2'], + reviewsState: 'pending' as any, + isPendingCurrentUserApproval: false, + isOriginalResponse: true, + registrationSchemaId: 'schema-id', + registrationId: 'reg-id', + }; + + fixture.componentRef.setInput('schemaResponse', mockSchemaResponse); + fixture.detectChanges(); + + expect(component.updatedFields()).toEqual(['key1', 'key2']); + }); + + it('should return empty array when schemaResponse is null', () => { + fixture.componentRef.setInput('schemaResponse', null); + fixture.detectChanges(); + + expect(component.updatedFields()).toEqual([]); + }); +}); diff --git a/src/app/features/registry/components/registry-blocks-section/registry-blocks-section.component.ts b/src/app/features/registry/components/registry-blocks-section/registry-blocks-section.component.ts new file mode 100644 index 000000000..f14773420 --- /dev/null +++ b/src/app/features/registry/components/registry-blocks-section/registry-blocks-section.component.ts @@ -0,0 +1,22 @@ +import { Skeleton } from 'primeng/skeleton'; + +import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; + +import { RegistrationBlocksDataComponent } from '@osf/shared/components/registration-blocks-data/registration-blocks-data.component'; +import { PageSchema } from '@osf/shared/models/registration/page-schema.model'; +import { SchemaResponse } from '@osf/shared/models/registration/schema-response.model'; + +@Component({ + selector: 'osf-registry-blocks-section', + imports: [Skeleton, RegistrationBlocksDataComponent], + templateUrl: './registry-blocks-section.component.html', + styleUrl: './registry-blocks-section.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class RegistryBlocksSectionComponent { + schemaBlocks = input.required(); + schemaResponse = input.required(); + isLoading = input(false); + + updatedFields = computed(() => (this.schemaResponse() ? this.schemaResponse()!.updatedResponseKeys : [])); +} diff --git a/src/app/features/registry/components/registry-make-decision/registry-make-decision.component.spec.ts b/src/app/features/registry/components/registry-make-decision/registry-make-decision.component.spec.ts index 10a78ca3e..4dea4eade 100644 --- a/src/app/features/registry/components/registry-make-decision/registry-make-decision.component.spec.ts +++ b/src/app/features/registry/components/registry-make-decision/registry-make-decision.component.spec.ts @@ -12,6 +12,8 @@ import { RevisionReviewStates } from '@osf/shared/enums/revision-review-states.e import { ReviewActionTrigger, SchemaResponseActionTrigger } from '@osf/shared/enums/trigger-action.enum'; import { DateAgoPipe } from '@shared/pipes/date-ago.pipe'; +import { RegistrySelectors } from '../../store/registry'; + import { RegistryMakeDecisionComponent } from './registry-make-decision.component'; import { DynamicDialogRefMock } from '@testing/mocks/dynamic-dialog-ref.mock'; @@ -48,8 +50,8 @@ describe('RegistryMakeDecisionComponent', () => { MockProvider(DynamicDialogConfig, mockDialogConfig), provideMockStore({ signals: [ - { selector: 'RegistryOverviewSelectors.getReviewActions', value: [] }, - { selector: 'RegistryOverviewSelectors.isReviewActionSubmitting', value: false }, + { selector: RegistrySelectors.getReviewActions, value: [] }, + { selector: RegistrySelectors.isReviewActionSubmitting, value: false }, ], }), ], diff --git a/src/app/features/registry/components/registry-make-decision/registry-make-decision.component.ts b/src/app/features/registry/components/registry-make-decision/registry-make-decision.component.ts index ff1994ec1..15db71ed9 100644 --- a/src/app/features/registry/components/registry-make-decision/registry-make-decision.component.ts +++ b/src/app/features/registry/components/registry-make-decision/registry-make-decision.component.ts @@ -25,7 +25,7 @@ import { ReviewActionTrigger, SchemaResponseActionTrigger } from '@osf/shared/en import { DateAgoPipe } from '@osf/shared/pipes/date-ago.pipe'; import { RegistryOverview } from '../../models'; -import { RegistryOverviewSelectors, SubmitDecision } from '../../store/registry-overview'; +import { RegistrySelectors, SubmitDecision } from '../../store/registry'; @Component({ selector: 'osf-registry-make-decision', @@ -55,9 +55,9 @@ export class RegistryMakeDecisionComponent { readonly SchemaResponseActionTrigger = SchemaResponseActionTrigger; readonly SubmissionReviewStatus = SubmissionReviewStatus; readonly ModerationDecisionFormControls = ModerationDecisionFormControls; - reviewActions = select(RegistryOverviewSelectors.getReviewActions); + reviewActions = select(RegistrySelectors.getReviewActions); - isSubmitting = select(RegistryOverviewSelectors.isReviewActionSubmitting); + isSubmitting = select(RegistrySelectors.isReviewActionSubmitting); requestForm!: FormGroup; actions = createDispatchMap({ submitDecision: SubmitDecision }); diff --git a/src/app/features/registry/components/registry-overview-metadata/registry-overview-metadata.component.html b/src/app/features/registry/components/registry-overview-metadata/registry-overview-metadata.component.html new file mode 100644 index 000000000..2637d5b9b --- /dev/null +++ b/src/app/features/registry/components/registry-overview-metadata/registry-overview-metadata.component.html @@ -0,0 +1,119 @@ +@let resource = registry(); + +@if (resource && resource.id) { + +} diff --git a/src/app/features/registry/components/registry-overview-metadata/registry-overview-metadata.component.scss b/src/app/features/registry/components/registry-overview-metadata/registry-overview-metadata.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/features/registry/components/registry-overview-metadata/registry-overview-metadata.component.spec.ts b/src/app/features/registry/components/registry-overview-metadata/registry-overview-metadata.component.spec.ts new file mode 100644 index 000000000..ba61aaba3 --- /dev/null +++ b/src/app/features/registry/components/registry-overview-metadata/registry-overview-metadata.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { RegistryOverviewMetadataComponent } from './registry-overview-metadata.component'; + +describe('RegistryOverviewMetadataComponent', () => { + let component: RegistryOverviewMetadataComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [RegistryOverviewMetadataComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(RegistryOverviewMetadataComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/registry/components/registry-overview-metadata/registry-overview-metadata.component.ts b/src/app/features/registry/components/registry-overview-metadata/registry-overview-metadata.component.ts new file mode 100644 index 000000000..ef35929bc --- /dev/null +++ b/src/app/features/registry/components/registry-overview-metadata/registry-overview-metadata.component.ts @@ -0,0 +1,108 @@ +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, 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'; +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 { TruncatedTextComponent } from '@osf/shared/components/truncated-text/truncated-text.component'; +import { CurrentResourceType, ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { ContributorsSelectors, LoadMoreBibliographicContributors } from '@osf/shared/stores/contributors'; +import { FetchSelectedSubjects, SubjectsSelectors } from '@osf/shared/stores/subjects'; + +import { + GetRegistryIdentifiers, + GetRegistryInstitutions, + GetRegistryLicense, + RegistrySelectors, + SetRegistryCustomCitation, +} from '../../store/registry'; + +@Component({ + selector: 'osf-registry-overview-metadata', + imports: [ + Button, + TranslatePipe, + TruncatedTextComponent, + RouterLink, + DatePipe, + ResourceCitationsComponent, + ResourceDoiComponent, + ResourceLicenseComponent, + AffiliatedInstitutionsViewComponent, + ContributorsListComponent, + SubjectsListComponent, + TagsListComponent, + ], + templateUrl: './registry-overview-metadata.component.html', + styleUrl: './registry-overview-metadata.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class RegistryOverviewMetadataComponent { + private readonly environment = inject(ENVIRONMENT); + private readonly router = inject(Router); + + readonly registry = select(RegistrySelectors.getRegistry); + readonly isAnonymous = select(RegistrySelectors.isRegistryAnonymous); + + canEdit = select(RegistrySelectors.hasWriteAccess); + license = select(RegistrySelectors.getLicense); + isLicenseLoading = select(RegistrySelectors.isLicenseLoading); + identifiers = select(RegistrySelectors.getIdentifiers); + isIdentifiersLoading = select(RegistrySelectors.isIdentifiersLoading); + institutions = select(RegistrySelectors.getInstitutions); + isInstitutionsLoading = select(RegistrySelectors.isInstitutionsLoading); + subjects = select(SubjectsSelectors.getSubjects); + isSubjectsLoading = select(SubjectsSelectors.getSubjectsLoading); + + bibliographicContributors = select(ContributorsSelectors.getBibliographicContributors); + isBibliographicContributorsLoading = select(ContributorsSelectors.isBibliographicContributorsLoading); + hasMoreBibliographicContributors = select(ContributorsSelectors.hasMoreBibliographicContributors); + + readonly currentResourceType = CurrentResourceType.Registrations; + readonly dateFormat = 'MMM d, y, h:mm a'; + readonly webUrl = this.environment.webUrl; + + private readonly actions = createDispatchMap({ + getSubjects: FetchSelectedSubjects, + getInstitutions: GetRegistryInstitutions, + getIdentifiers: GetRegistryIdentifiers, + getLicense: GetRegistryLicense, + setCustomCitation: SetRegistryCustomCitation, + loadMoreBibliographicContributors: LoadMoreBibliographicContributors, + }); + + constructor() { + effect(() => { + if (this.registry()?.id) { + this.actions.getInstitutions(this.registry()!.id); + this.actions.getSubjects(this.registry()!.id, ResourceType.Registration); + this.actions.getLicense(this.registry()!.licenseId); + this.actions.getIdentifiers(this.registry()!.id); + } + }); + } + + onCustomCitationUpdated(citation: string): void { + this.actions.setCustomCitation(citation); + } + + handleLoadMoreContributors(): void { + this.actions.loadMoreBibliographicContributors(this.registry()?.id, ResourceType.Registration); + } + + tagClicked(tag: string) { + this.router.navigate(['/search'], { queryParams: { search: tag } }); + } +} diff --git a/src/app/features/registry/components/registry-revisions/registry-revisions.component.html b/src/app/features/registry/components/registry-revisions/registry-revisions.component.html index ad7a78930..bb338aec3 100644 --- a/src/app/features/registry/components/registry-revisions/registry-revisions.component.html +++ b/src/app/features/registry/components/registry-revisions/registry-revisions.component.html @@ -31,7 +31,7 @@ @@ -40,7 +40,7 @@ diff --git a/src/app/features/registry/components/registry-revisions/registry-revisions.component.ts b/src/app/features/registry/components/registry-revisions/registry-revisions.component.ts index 6795f54a2..b3ad25218 100644 --- a/src/app/features/registry/components/registry-revisions/registry-revisions.component.ts +++ b/src/app/features/registry/components/registry-revisions/registry-revisions.component.ts @@ -6,9 +6,10 @@ import { Button } from 'primeng/button'; import { ChangeDetectionStrategy, Component, computed, HostBinding, input, output } from '@angular/core'; import { RouterLink } from '@angular/router'; -import { RegistryOverview } from '@osf/features/registry/models'; +import { RegistrationOverviewModel } from '@osf/features/registry/models'; import { RegistrationReviewStates } from '@osf/shared/enums/registration-review-states.enum'; import { RevisionReviewStates } from '@osf/shared/enums/revision-review-states.enum'; +import { SchemaResponse } from '@osf/shared/models/registration/schema-response.model'; @Component({ selector: 'osf-registry-revisions', @@ -20,7 +21,8 @@ import { RevisionReviewStates } from '@osf/shared/enums/revision-review-states.e export class RegistryRevisionsComponent { @HostBinding('class') classes = 'flex-1 flex'; - registry = input.required(); + registry = input.required(); + schemaResponses = input.required(); selectedRevisionIndex = input.required(); isSubmitting = input(false); isModeration = input(false); @@ -34,7 +36,7 @@ export class RegistryRevisionsComponent { unApprovedRevisionId: string | null = null; revisions = computed(() => { - let schemaResponses = this.registry()?.schemaResponses || []; + let schemaResponses = this.schemaResponses() || []; if (this.registryAcceptedUnapproved) { this.unApprovedRevisionId = @@ -64,16 +66,16 @@ export class RegistryRevisionsComponent { }); get registryInProgress(): boolean { - return this.registry()?.revisionStatus === RevisionReviewStates.RevisionInProgress; + return this.registry()?.revisionState === RevisionReviewStates.RevisionInProgress; } get registryApproved(): boolean { - return this.registry()?.revisionStatus === RevisionReviewStates.Approved; + return this.registry()?.revisionState === RevisionReviewStates.Approved; } get registryAcceptedUnapproved(): boolean { return ( - this.registry()?.revisionStatus === RevisionReviewStates.Unapproved && + this.registry()?.revisionState === RevisionReviewStates.Unapproved && this.registry()?.reviewsState === RegistrationReviewStates.Accepted ); } diff --git a/src/app/features/registry/components/registry-statuses/registry-statuses.component.ts b/src/app/features/registry/components/registry-statuses/registry-statuses.component.ts index 4b67b8e02..9e53298fd 100644 --- a/src/app/features/registry/components/registry-statuses/registry-statuses.component.ts +++ b/src/app/features/registry/components/registry-statuses/registry-statuses.component.ts @@ -15,8 +15,8 @@ import { RevisionReviewStates } from '@osf/shared/enums/revision-review-states.e import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; -import { RegistryOverview } from '../../models'; -import { MakePublic } from '../../store/registry-overview'; +import { RegistrationOverviewModel } from '../../models'; +import { MakePublic } from '../../store/registry'; import { WithdrawDialogComponent } from '../withdraw-dialog/withdraw-dialog.component'; @Component({ @@ -33,7 +33,7 @@ export class RegistryStatusesComponent { readonly supportEmail = this.environment.supportEmail; - registry = input.required(); + registry = input.required(); canEdit = input(false); isModeration = input(false); diff --git a/src/app/features/registry/components/short-registration-info/short-registration-info.component.html b/src/app/features/registry/components/short-registration-info/short-registration-info.component.html index 4487d7184..a5144f4c0 100644 --- a/src/app/features/registry/components/short-registration-info/short-registration-info.component.html +++ b/src/app/features/registry/components/short-registration-info/short-registration-info.component.html @@ -1,7 +1,12 @@

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

- +
@@ -12,7 +17,7 @@

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

{{ 'registry.overview.metadata.type' | translate }}

-

{{ registration().registrationType }}

+

{{ registration().registrationSupplement }}

@@ -29,6 +34,6 @@

{{ 'registry.archiving.createdDate' | translate }}

{{ 'registry.overview.metadata.associatedProject' | translate }}

- {{ associatedProjectUrl }} + {{ associatedProjectUrl() }}
diff --git a/src/app/features/registry/components/short-registration-info/short-registration-info.component.ts b/src/app/features/registry/components/short-registration-info/short-registration-info.component.ts index 1f384f319..1b20c04f9 100644 --- a/src/app/features/registry/components/short-registration-info/short-registration-info.component.ts +++ b/src/app/features/registry/components/short-registration-info/short-registration-info.component.ts @@ -1,13 +1,17 @@ +import { createDispatchMap, select } from '@ngxs/store'; + import { TranslatePipe } from '@ngx-translate/core'; import { DatePipe } from '@angular/common'; -import { ChangeDetectionStrategy, Component, inject, input } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'; import { RouterLink } from '@angular/router'; import { ENVIRONMENT } from '@core/provider/environment.provider'; import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component'; +import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { ContributorsSelectors, LoadMoreBibliographicContributors } from '@osf/shared/stores/contributors'; -import { RegistryOverview } from '../../models'; +import { RegistrationOverviewModel } from '../../models'; @Component({ selector: 'osf-short-registration-info', @@ -19,9 +23,19 @@ import { RegistryOverview } from '../../models'; export class ShortRegistrationInfoComponent { private readonly environment = inject(ENVIRONMENT); - registration = input.required(); + registration = input.required(); + + bibliographicContributors = select(ContributorsSelectors.getBibliographicContributors); + isBibliographicContributorsLoading = select(ContributorsSelectors.isBibliographicContributorsLoading); + hasMoreBibliographicContributors = select(ContributorsSelectors.hasMoreBibliographicContributors); + + private readonly actions = createDispatchMap({ + loadMoreBibliographicContributors: LoadMoreBibliographicContributors, + }); + + associatedProjectUrl = computed(() => `${this.environment.webUrl}/${this.registration().associatedProjectId}`); - get associatedProjectUrl(): string { - return `${this.environment.webUrl}/${this.registration().associatedProjectId}`; + handleLoadMoreContributors(): void { + this.actions.loadMoreBibliographicContributors(this.registration()?.id, ResourceType.Registration); } } diff --git a/src/app/features/registry/components/withdraw-dialog/withdraw-dialog.component.ts b/src/app/features/registry/components/withdraw-dialog/withdraw-dialog.component.ts index 7b0b85ca2..2a24717e6 100644 --- a/src/app/features/registry/components/withdraw-dialog/withdraw-dialog.component.ts +++ b/src/app/features/registry/components/withdraw-dialog/withdraw-dialog.component.ts @@ -10,7 +10,7 @@ import { finalize, take } from 'rxjs'; import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { FormControl, FormGroup } from '@angular/forms'; -import { WithdrawRegistration } from '@osf/features/registry/store/registry-overview'; +import { WithdrawRegistration } from '@osf/features/registry/store/registry'; import { TextInputComponent } from '@osf/shared/components/text-input/text-input.component'; import { InputLimits } from '@osf/shared/constants/input-limits.const'; import { CustomValidators } from '@osf/shared/helpers/custom-form-validators.helper'; diff --git a/src/app/features/registry/components/withdrawn-message/withdrawn-message.component.html b/src/app/features/registry/components/withdrawn-message/withdrawn-message.component.html index e7ba4e4f7..5f644c72c 100644 --- a/src/app/features/registry/components/withdrawn-message/withdrawn-message.component.html +++ b/src/app/features/registry/components/withdrawn-message/withdrawn-message.component.html @@ -5,17 +5,22 @@

{{ 'registry.withdrawn.title' | translate }}

+

{{ 'registry.withdrawn.dateWithdrawn' | translate }}

{{ registration().dateWithdrawn | date }}

+

{{ 'registry.withdrawn.justificationLabel' | translate }}

{{ registration().withdrawalJustification }}

+
+ @if (registry()?.archiving) {
@@ -76,12 +77,13 @@

@@ -89,58 +91,16 @@

}
- @for (page of schemaBlocks(); track page.id) { -

{{ page.title }}

- - @if (page.description) { -

{{ page.description }}

- } - - @if (page.questions?.length) { - - } - - @if (page.sections?.length) { - @for (section of page.sections; track section.id) { -
-

{{ section.title }}

- @if (section.description) { -

{{ section.description }}

- } - @if (section.questions?.length) { - - } -
- } - } - } +

+
- +
diff --git a/src/app/features/registry/pages/registry-overview/registry-overview.component.scss b/src/app/features/registry/pages/registry-overview/registry-overview.component.scss index d4b8c0861..33c372ba4 100644 --- a/src/app/features/registry/pages/registry-overview/registry-overview.component.scss +++ b/src/app/features/registry/pages/registry-overview/registry-overview.component.scss @@ -1,37 +1,19 @@ -@use "styles/mixins" as mix; - .left-section { - flex: 3; display: flex; flex-direction: column; + flex: 3; gap: 1.5rem; } -.accordion-border { - border: 1px solid var(--grey-2); - border-radius: mix.rem(12px); - height: max-content !important; -} - -.blocks-section { +.right-section { border: 1px solid var(--grey-2); - border-radius: mix.rem(12px); + border-radius: 0.75rem; + flex: 1; height: max-content; } -.right-section { - flex: 1; +.blocks-section { border: 1px solid var(--grey-2); - border-radius: mix.rem(12px); + border-radius: 0.75rem; height: max-content; } - -.no-padding { - --p-accordion-header-padding: 0; -} - -.current-revision { - --p-button-info-background: var(--bg-blue-3); - --p-button-info-hover-background: var(--bg-blue-2); - --p-button-info-hover-border-color: transparent; -} diff --git a/src/app/features/registry/pages/registry-overview/registry-overview.component.spec.ts b/src/app/features/registry/pages/registry-overview/registry-overview.component.spec.ts index a99c6fbb4..6176bbe3f 100644 --- a/src/app/features/registry/pages/registry-overview/registry-overview.component.spec.ts +++ b/src/app/features/registry/pages/registry-overview/registry-overview.component.spec.ts @@ -2,19 +2,22 @@ import { MockComponents, MockProvider } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { OverviewToolbarComponent } from '@osf/features/project/overview/components'; -import { RegistriesSelectors } from '@osf/features/registries/store'; import { DataResourcesComponent } from '@osf/shared/components/data-resources/data-resources.component'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; -import { RegistrationBlocksDataComponent } from '@osf/shared/components/registration-blocks-data/registration-blocks-data.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 { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; -import { ArchivingMessageComponent, RegistryRevisionsComponent, RegistryStatusesComponent } from '../../components'; +import { + ArchivingMessageComponent, + RegistryBlocksSectionComponent, + RegistryRevisionsComponent, + RegistryStatusesComponent, +} from '../../components'; +import { RegistrationOverviewToolbarComponent } from '../../components/registration-overview-toolbar/registration-overview-toolbar.component'; +import { RegistryOverviewMetadataComponent } from '../../components/registry-overview-metadata/registry-overview-metadata.component'; import { WithdrawnMessageComponent } from '../../components/withdrawn-message/withdrawn-message.component'; -import { RegistryOverviewSelectors } from '../../store/registry-overview'; +import { RegistrySelectors } from '../../store/registry'; import { RegistryOverviewComponent } from './registry-overview.component'; @@ -35,15 +38,15 @@ describe('RegistryOverviewComponent', () => { OSFTestingModule, ...MockComponents( SubHeaderComponent, - OverviewToolbarComponent, + RegistrationOverviewToolbarComponent, LoadingSpinnerComponent, - ResourceMetadataComponent, + RegistryOverviewMetadataComponent, RegistryRevisionsComponent, RegistryStatusesComponent, DataResourcesComponent, ArchivingMessageComponent, WithdrawnMessageComponent, - RegistrationBlocksDataComponent, + RegistryBlocksSectionComponent, ViewOnlyLinkMessageComponent ), ], @@ -51,21 +54,20 @@ describe('RegistryOverviewComponent', () => { MockProvider(CustomDialogService, mockCustomDialogService), provideMockStore({ signals: [ - { selector: RegistryOverviewSelectors.getRegistry, value: null }, - { selector: RegistryOverviewSelectors.isRegistryLoading, value: false }, - { selector: RegistryOverviewSelectors.isRegistryAnonymous, value: false }, - { selector: RegistryOverviewSelectors.getInstitutions, value: [] }, - { selector: RegistryOverviewSelectors.isInstitutionsLoading, value: false }, - { selector: RegistryOverviewSelectors.getSchemaBlocks, value: [] }, - { selector: RegistryOverviewSelectors.isSchemaBlocksLoading, value: false }, - { selector: RegistryOverviewSelectors.areReviewActionsLoading, value: false }, - { selector: RegistriesSelectors.getSchemaResponse, value: null }, - { selector: RegistriesSelectors.getSchemaResponseLoading, value: false }, - { selector: RegistryOverviewSelectors.hasWriteAccess, value: false }, - { selector: RegistryOverviewSelectors.hasAdminAccess, value: false }, - { selector: RegistryOverviewSelectors.hasNoPermissions, value: true }, - { selector: RegistryOverviewSelectors.getReviewActions, value: [] }, - { selector: RegistryOverviewSelectors.isReviewActionSubmitting, value: false }, + { selector: RegistrySelectors.getRegistry, value: null }, + { selector: RegistrySelectors.isRegistryLoading, value: false }, + { selector: RegistrySelectors.isRegistryAnonymous, value: false }, + { selector: RegistrySelectors.getInstitutions, value: [] }, + { selector: RegistrySelectors.isInstitutionsLoading, value: false }, + { selector: RegistrySelectors.getSchemaBlocks, value: [] }, + { selector: RegistrySelectors.isSchemaBlocksLoading, value: false }, + { selector: RegistrySelectors.areReviewActionsLoading, value: false }, + { selector: RegistrySelectors.getSchemaResponse, value: null }, + { selector: RegistrySelectors.getSchemaResponseLoading, value: false }, + { selector: RegistrySelectors.hasWriteAccess, value: false }, + { selector: RegistrySelectors.hasAdminAccess, value: false }, + { selector: RegistrySelectors.getReviewActions, value: [] }, + { selector: RegistrySelectors.isReviewActionSubmitting, value: false }, ], }), ], @@ -81,16 +83,13 @@ describe('RegistryOverviewComponent', () => { it('should handle loading states', () => { expect(component.isRegistryLoading()).toBe(false); - expect(component.isInstitutionsLoading()).toBe(false); expect(component.isSchemaBlocksLoading()).toBe(false); expect(component.areReviewActionsLoading()).toBe(false); - expect(component.isSchemaResponseLoading()).toBe(false); }); it('should handle registry data', () => { expect(component.registry()).toBeNull(); expect(component.isAnonymous()).toBe(false); - expect(component.institutions()).toEqual([]); expect(component.schemaBlocks()).toEqual([]); expect(component.currentRevision()).toBeNull(); }); @@ -98,7 +97,6 @@ describe('RegistryOverviewComponent', () => { it('should handle permissions', () => { expect(component.hasWriteAccess()).toBe(false); expect(component.hasAdminAccess()).toBe(false); - expect(component.hasNoPermissions()).toBe(true); }); it('should open revision', () => { diff --git a/src/app/features/registry/pages/registry-overview/registry-overview.component.ts b/src/app/features/registry/pages/registry-overview/registry-overview.component.ts index 2f38cb0a9..2638f3f5a 100644 --- a/src/app/features/registry/pages/registry-overview/registry-overview.component.ts +++ b/src/app/features/registry/pages/registry-overview/registry-overview.component.ts @@ -20,61 +20,58 @@ import { import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router } from '@angular/router'; -import { OverviewToolbarComponent } from '@osf/features/project/overview/components'; -import { CreateSchemaResponse, FetchAllSchemaResponses, RegistriesSelectors } from '@osf/features/registries/store'; import { DataResourcesComponent } from '@osf/shared/components/data-resources/data-resources.component'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; -import { RegistrationBlocksDataComponent } from '@osf/shared/components/registration-blocks-data/registration-blocks-data.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 { RegistrationReviewStates } from '@osf/shared/enums/registration-review-states.enum'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { RevisionReviewStates } from '@osf/shared/enums/revision-review-states.enum'; -import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; import { toCamelCase } from '@osf/shared/helpers/camel-case'; import { hasViewOnlyParam } from '@osf/shared/helpers/view-only.helper'; -import { MapRegistryOverview } from '@osf/shared/mappers/resource-overview.mappers'; 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 { GetBookmarksCollectionId } from '@osf/shared/stores/bookmarks'; -import { - ContributorsSelectors, - GetBibliographicContributors, - LoadMoreBibliographicContributors, -} from '@osf/shared/stores/contributors'; -import { FetchSelectedSubjects, SubjectsSelectors } from '@osf/shared/stores/subjects'; +import { ContributorsSelectors, GetBibliographicContributors } from '@osf/shared/stores/contributors'; import { SchemaResponse } from '@shared/models/registration/schema-response.model'; -import { ToolbarResource } from '@shared/models/toolbar-resource.model'; -import { ArchivingMessageComponent, RegistryRevisionsComponent, RegistryStatusesComponent } from '../../components'; +import { + ArchivingMessageComponent, + RegistryBlocksSectionComponent, + RegistryRevisionsComponent, + RegistryStatusesComponent, +} from '../../components'; +import { RegistrationOverviewToolbarComponent } from '../../components/registration-overview-toolbar/registration-overview-toolbar.component'; import { RegistryMakeDecisionComponent } from '../../components/registry-make-decision/registry-make-decision.component'; +import { RegistryOverviewMetadataComponent } from '../../components/registry-overview-metadata/registry-overview-metadata.component'; import { WithdrawnMessageComponent } from '../../components/withdrawn-message/withdrawn-message.component'; import { + CreateSchemaResponse, GetRegistryById, - GetRegistryInstitutions, GetRegistryReviewActions, - RegistryOverviewSelectors, - SetRegistryCustomCitation, -} from '../../store/registry-overview'; + GetRegistrySchemaResponses, + GetSchemaBlocks, + RegistrySelectors, +} from '../../store/registry'; @Component({ selector: 'osf-registry-overview', imports: [ SubHeaderComponent, - OverviewToolbarComponent, LoadingSpinnerComponent, - ResourceMetadataComponent, + RegistryOverviewMetadataComponent, RegistryRevisionsComponent, RegistryStatusesComponent, DataResourcesComponent, ArchivingMessageComponent, TranslatePipe, WithdrawnMessageComponent, - RegistrationBlocksDataComponent, Message, DatePipe, ViewOnlyLinkMessageComponent, + RegistrationOverviewToolbarComponent, + RegistryBlocksSectionComponent, ], templateUrl: './registry-overview.component.html', styleUrl: './registry-overview.component.scss', @@ -87,38 +84,27 @@ export class RegistryOverviewComponent { private readonly destroyRef = inject(DestroyRef); private readonly toastService = inject(ToastService); private readonly customDialogService = inject(CustomDialogService); + private readonly loaderService = inject(LoaderService); + + readonly registry = select(RegistrySelectors.getRegistry); + readonly isRegistryLoading = select(RegistrySelectors.isRegistryLoading); + readonly isAnonymous = select(RegistrySelectors.isRegistryAnonymous); + readonly schemaResponses = select(RegistrySelectors.getSchemaResponses); + readonly isSchemaResponsesLoading = select(RegistrySelectors.isSchemaResponsesLoading); + readonly schemaBlocks = select(RegistrySelectors.getSchemaBlocks); + readonly isSchemaBlocksLoading = select(RegistrySelectors.isSchemaBlocksLoading); + readonly areReviewActionsLoading = select(RegistrySelectors.areReviewActionsLoading); + readonly currentRevision = select(RegistrySelectors.getSchemaResponse); - readonly registry = select(RegistryOverviewSelectors.getRegistry); - readonly isRegistryLoading = select(RegistryOverviewSelectors.isRegistryLoading); - readonly isAnonymous = select(RegistryOverviewSelectors.isRegistryAnonymous); - readonly subjects = select(SubjectsSelectors.getSelectedSubjects); - readonly areSubjectsLoading = select(SubjectsSelectors.areSelectedSubjectsLoading); - readonly institutions = select(RegistryOverviewSelectors.getInstitutions); - readonly isInstitutionsLoading = select(RegistryOverviewSelectors.isInstitutionsLoading); - readonly schemaBlocks = select(RegistryOverviewSelectors.getSchemaBlocks); - readonly isSchemaBlocksLoading = select(RegistryOverviewSelectors.isSchemaBlocksLoading); - readonly areReviewActionsLoading = select(RegistryOverviewSelectors.areReviewActionsLoading); - readonly currentRevision = select(RegistriesSelectors.getSchemaResponse); - readonly isSchemaResponseLoading = select(RegistriesSelectors.getSchemaResponseLoading); bibliographicContributors = select(ContributorsSelectors.getBibliographicContributors); isBibliographicContributorsLoading = select(ContributorsSelectors.isBibliographicContributorsLoading); hasMoreBibliographicContributors = select(ContributorsSelectors.hasMoreBibliographicContributors); - readonly hasWriteAccess = select(RegistryOverviewSelectors.hasWriteAccess); - readonly hasAdminAccess = select(RegistryOverviewSelectors.hasAdminAccess); - readonly hasNoPermissions = select(RegistryOverviewSelectors.hasNoPermissions); + readonly hasWriteAccess = select(RegistrySelectors.hasWriteAccess); + readonly hasAdminAccess = select(RegistrySelectors.hasAdminAccess); revisionInProgress: SchemaResponse | undefined; - isLoading = computed( - () => - this.isRegistryLoading() || - this.isInstitutionsLoading() || - this.isSchemaBlocksLoading() || - this.isSchemaResponseLoading() || - this.areSubjectsLoading() - ); - canMakeDecision = computed(() => !this.registry()?.archiving && !this.registry()?.withdrawn && this.isModeration); isRootRegistration = computed(() => { @@ -129,72 +115,26 @@ export class RegistryOverviewComponent { private registryId = toSignal(this.route.parent?.params.pipe(map((params) => params['id'])) ?? of(undefined)); readonly schemaResponse = computed(() => { - const registry = this.registry(); const index = this.selectedRevisionIndex(); - this.revisionInProgress = registry?.schemaResponses?.find( - (r) => r.reviewsState === RevisionReviewStates.RevisionInProgress - ); + const schemaResponses = this.schemaResponses() || []; - const schemaResponses = registry?.schemaResponses || []; - - if (index !== null) { - return schemaResponses[index]; - } + this.revisionInProgress = schemaResponses?.find((r) => r.reviewsState === RevisionReviewStates.RevisionInProgress); - return null; - }); - - readonly updatedFields = computed(() => { - const schemaResponse = this.schemaResponse(); - if (schemaResponse) { - return schemaResponse.updatedResponseKeys || []; - } - return []; - }); - - readonly resourceOverview = computed(() => { - const registry = this.registry(); - const subjects = this.subjects(); - const institutions = this.institutions(); - - if (registry && subjects && institutions) { - return MapRegistryOverview(registry, subjects, institutions, this.isAnonymous()); - } - - return null; + return index !== null ? schemaResponses[index] : null; }); readonly selectedRevisionIndex = signal(0); showToolbar = computed(() => !this.registry()?.archiving && !this.registry()?.withdrawn); - toolbarResource = computed(() => { - if (this.registry()) { - return { - id: this.registry()!.id, - title: this.registry()?.title, - isPublic: this.registry()!.isPublic, - storage: undefined, - viewOnlyLinksCount: 0, - forksCount: this.registry()!.forksCount, - resourceType: ResourceType.Registration, - isAnonymous: this.isAnonymous(), - } as ToolbarResource; - } - return null; - }); - private readonly actions = createDispatchMap({ getRegistryById: GetRegistryById, getBookmarksId: GetBookmarksCollectionId, - getSubjects: FetchSelectedSubjects, - getInstitutions: GetRegistryInstitutions, - setCustomCitation: SetRegistryCustomCitation, + getSchemaResponses: GetRegistrySchemaResponses, + getSchemaBlocks: GetSchemaBlocks, getRegistryReviewActions: GetRegistryReviewActions, - getSchemaResponse: FetchAllSchemaResponses, createSchemaResponse: CreateSchemaResponse, getBibliographicContributors: GetBibliographicContributors, - loadMoreBibliographicContributors: LoadMoreBibliographicContributors, }); revisionId: string | null = null; @@ -202,15 +142,6 @@ export class RegistryOverviewComponent { hasViewOnly = computed(() => hasViewOnlyParam(this.router)); - canEdit = computed(() => { - const registry = this.registry(); - if (!registry) return false; - return ( - registry.currentUserPermissions.includes(UserPermissions.Admin) || - registry.currentUserPermissions.includes(UserPermissions.Write) - ); - }); - get isInitialState(): boolean { return this.registry()?.reviewsState === RegistrationReviewStates.Initial; } @@ -219,9 +150,9 @@ export class RegistryOverviewComponent { effect(() => { const registry = this.registry(); - if (registry && !registry?.withdrawn) { - this.actions.getSubjects(registry?.id, ResourceType.Registration); - this.actions.getInstitutions(registry?.id); + if (registry?.id && !registry?.withdrawn) { + this.actions.getSchemaBlocks(registry.registrationSchemaLink); + this.actions.getSchemaResponses(registry?.id); } }); @@ -233,9 +164,10 @@ export class RegistryOverviewComponent { }); this.actions.getBookmarksId(); + this.route.queryParams .pipe( - takeUntilDestroyed(this.destroyRef), + takeUntilDestroyed(), map((params) => ({ revisionId: params['revisionId'], mode: params['mode'] })), tap(({ revisionId, mode }) => { this.revisionId = revisionId; @@ -245,19 +177,13 @@ export class RegistryOverviewComponent { .subscribe(); } - navigateToFile(fileId: string): void { - this.router.navigate(['/files', fileId]); - } - openRevision(revisionIndex: number): void { this.selectedRevisionIndex.set(revisionIndex); } - onCustomCitationUpdated(citation: string): void { - this.actions.setCustomCitation(citation); - } - onUpdateRegistration(id: string): void { + this.loaderService.show(); + this.actions .createSchemaResponse(id) .pipe( @@ -270,26 +196,13 @@ export class RegistryOverviewComponent { } onContinueUpdateRegistration(): void { - const { id, unapproved } = { - id: this.registry()?.id || '', - unapproved: this.revisionInProgress?.reviewsState === RevisionReviewStates.Unapproved, - }; - this.actions - .getSchemaResponse(id) - .pipe( - tap(() => { - if (unapproved) { - this.navigateToJustificationReview(); - } else { - this.navigateToJustificationPage(); - } - }) - ) - .subscribe(); - } + const unapproved = this.revisionInProgress?.reviewsState === RevisionReviewStates.Unapproved; - handleLoadMoreContributors(): void { - this.actions.loadMoreBibliographicContributors(this.registry()?.id, ResourceType.Registration); + if (unapproved) { + this.navigateToJustificationReview(); + } else { + this.navigateToJustificationPage(); + } } private navigateToJustificationPage(): void { @@ -325,6 +238,7 @@ export class RegistryOverviewComponent { const action = toCamelCase(data.action); this.toastService.showSuccess(`moderation.makeDecision.${action}Success`); } + const currentUrl = this.router.url.split('?')[0]; this.router.navigateByUrl('/', { skipLocationChange: true }).then(() => { this.router.navigateByUrl(currentUrl); diff --git a/src/app/features/registry/pages/registry-resources/registry-resources.component.spec.ts b/src/app/features/registry/pages/registry-resources/registry-resources.component.spec.ts index a1a581ab0..9c574290a 100644 --- a/src/app/features/registry/pages/registry-resources/registry-resources.component.spec.ts +++ b/src/app/features/registry/pages/registry-resources/registry-resources.component.spec.ts @@ -5,7 +5,6 @@ import { of } from 'rxjs'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute } from '@angular/router'; -import { MetadataSelectors } from '@osf/features/metadata/store'; import { IconComponent } from '@osf/shared/components/icon/icon.component'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; @@ -14,10 +13,12 @@ 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 { RegistrySelectors } from '../../store/registry'; import { RegistryResourcesSelectors } from '../../store/registry-resources'; import { RegistryResourcesComponent } from './registry-resources.component'; +import { MOCK_PROJECT_IDENTIFIERS } from '@testing/mocks/project-overview.mock'; 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'; @@ -61,7 +62,9 @@ describe('RegistryResourcesComponent', () => { { selector: RegistryResourcesSelectors.getResources, value: [] }, { selector: RegistryResourcesSelectors.isResourcesLoading, value: false }, { selector: RegistryResourcesSelectors.getCurrentResource, value: null }, - { selector: MetadataSelectors.getResourceMetadata, value: mockRegistry }, + { selector: RegistrySelectors.getRegistry, value: mockRegistry }, + { selector: RegistrySelectors.getIdentifiers, value: [MOCK_PROJECT_IDENTIFIERS] }, + { selector: RegistrySelectors.hasWriteAccess, value: true }, ], }), ], diff --git a/src/app/features/registry/pages/registry-resources/registry-resources.component.ts b/src/app/features/registry/pages/registry-resources/registry-resources.component.ts index 06d521f71..e9b92b6da 100644 --- a/src/app/features/registry/pages/registry-resources/registry-resources.component.ts +++ b/src/app/features/registry/pages/registry-resources/registry-resources.component.ts @@ -10,18 +10,16 @@ import { ChangeDetectionStrategy, Component, computed, DestroyRef, HostBinding, import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ActivatedRoute } from '@angular/router'; -import { GetResourceMetadata, MetadataSelectors } from '@osf/features/metadata/store'; import { IconComponent } from '@osf/shared/components/icon/icon.component'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; -import { ResourceType } from '@osf/shared/enums/resource-type.enum'; -import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { ToastService } from '@osf/shared/services/toast.service'; import { AddResourceDialogComponent, EditResourceDialogComponent } from '../../components'; import { RegistryResource } from '../../models'; +import { RegistrySelectors } from '../../store/registry'; import { AddRegistryResource, DeleteResource, @@ -47,30 +45,24 @@ export class RegistryResourcesComponent { readonly resources = select(RegistryResourcesSelectors.getResources); readonly isResourcesLoading = select(RegistryResourcesSelectors.isResourcesLoading); readonly currentResource = select(RegistryResourcesSelectors.getCurrentResource); - readonly registry = select(MetadataSelectors.getResourceMetadata); + readonly registry = select(RegistrySelectors.getRegistry); + readonly identifiers = select(RegistrySelectors.getIdentifiers); registryId = this.route.snapshot.parent?.params['id']; isAddingResource = signal(false); doiDomain = 'https://doi.org/'; private readonly actions = createDispatchMap({ - fetchRegistryData: GetResourceMetadata, getResources: GetRegistryResources, addResource: AddRegistryResource, deleteResource: DeleteResource, }); - canEdit = computed(() => { - const registry = this.registry(); - if (!registry) return false; + canEdit = select(RegistrySelectors.hasWriteAccess); - return registry.currentUserPermissions.includes(UserPermissions.Write); - }); - - addButtonVisible = computed(() => !!this.registry()?.identifiers?.length && this.canEdit()); + addButtonVisible = computed(() => !!this.identifiers().length && this.canEdit()); constructor() { - this.actions.fetchRegistryData(this.registryId, ResourceType.Registration); this.actions.getResources(this.registryId); } diff --git a/src/app/features/registry/registry.component.spec.ts b/src/app/features/registry/registry.component.spec.ts index 7e8e7d99a..6895b997f 100644 --- a/src/app/features/registry/registry.component.spec.ts +++ b/src/app/features/registry/registry.component.spec.ts @@ -3,7 +3,7 @@ import { of } from 'rxjs'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute } from '@angular/router'; -import { RegistryOverviewSelectors } from '@osf/features/registry/store/registry-overview'; +import { RegistrySelectors } from '@osf/features/registry/store/registry'; import { AnalyticsService } from '@osf/shared/services/analytics.service'; import { MetaTagsService } from '@osf/shared/services/meta-tags.service'; import { DataciteService } from '@shared/services/datacite/datacite.service'; @@ -56,8 +56,8 @@ describe('RegistryComponent', () => { { provide: AnalyticsService, useValue: analyticsService }, provideMockStore({ signals: [ - { selector: RegistryOverviewSelectors.getRegistry, value: mockRegistry }, - { selector: RegistryOverviewSelectors.isRegistryLoading, value: false }, + { selector: RegistrySelectors.getRegistry, value: mockRegistry }, + { selector: RegistrySelectors.isRegistryLoading, value: false }, ], }), ], @@ -78,7 +78,6 @@ describe('RegistryComponent', () => { it('should have NGXS selectors defined', () => { expect(component.registry).toBeDefined(); expect(component.isRegistryLoading).toBeDefined(); - expect(component.registry$).toBeDefined(); }); it('should have services injected', () => { @@ -90,7 +89,7 @@ describe('RegistryComponent', () => { }); it('should call datacite service on initialization', () => { - expect(dataciteService.logIdentifiableView).toHaveBeenCalledWith(component.registry$); + expect(dataciteService.logIdentifiableView).toHaveBeenCalledWith(component.identifiersForDatacite$); }); it('should handle registry loading effects', () => { diff --git a/src/app/features/registry/registry.component.ts b/src/app/features/registry/registry.component.ts index df0cbcb56..ea5cd3e24 100644 --- a/src/app/features/registry/registry.component.ts +++ b/src/app/features/registry/registry.component.ts @@ -1,21 +1,33 @@ import { createDispatchMap, select } from '@ngxs/store'; -import { map, of } from 'rxjs'; +import { map } from 'rxjs'; import { DatePipe } from '@angular/common'; -import { ChangeDetectionStrategy, Component, DestroyRef, effect, HostBinding, inject, OnDestroy } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + effect, + HostBinding, + inject, + OnDestroy, + signal, +} from '@angular/core'; import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute, RouterOutlet } from '@angular/router'; import { ENVIRONMENT } from '@core/provider/environment.provider'; import { PrerenderReadyService } from '@core/services/prerender-ready.service'; import { ClearCurrentProvider } from '@core/store/provider'; +import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { pathJoin } from '@osf/shared/helpers/path-join.helper'; 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 { GetRegistryById, RegistryOverviewSelectors } from './store/registry-overview'; +import { GetRegistryIdentifiers, GetRegistryWithRelatedData, RegistrySelectors } from './store/registry'; @Component({ selector: 'osf-registry', @@ -37,29 +49,58 @@ export class RegistryComponent implements OnDestroy { private readonly prerenderReady = inject(PrerenderReadyService); private readonly actions = createDispatchMap({ - getRegistryById: GetRegistryById, + getRegistryWithRelatedData: GetRegistryWithRelatedData, clearCurrentProvider: ClearCurrentProvider, + getBibliographicContributors: GetBibliographicContributors, + getIdentifiers: GetRegistryIdentifiers, }); - private registryId = toSignal(this.route.params.pipe(map((params) => params['id'])) ?? of(undefined)); + private registryId = toSignal(this.route.params.pipe(map((params) => params['id']))); - readonly registry = select(RegistryOverviewSelectors.getRegistry); - readonly isRegistryLoading = select(RegistryOverviewSelectors.isRegistryLoading); - readonly registry$ = toObservable(select(RegistryOverviewSelectors.getRegistry)); + 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( + () => + !this.isRegistryLoading() && + !this.isBibliographicContributorsLoading() && + !this.isLicenseLoading() && + !!this.registry() + ); + + private readonly lastMetaTagsRegistryId = signal(null); constructor() { this.prerenderReady.setNotReady(); effect(() => { - if (this.registryId()) { - this.actions.getRegistryById(this.registryId()); + const id = this.registryId(); + + if (id) { + this.actions.getRegistryWithRelatedData(id); + this.actions.getIdentifiers(id); + this.actions.getBibliographicContributors(id, ResourceType.Registration); } }); effect(() => { - if (!this.isRegistryLoading() && this.registry()) { - this.setMetaTags(); + if (this.allDataLoaded()) { + const currentRegistry = this.registry(); + const currentRegistryId = currentRegistry?.id ?? null; + const lastSetRegistryId = this.lastMetaTagsRegistryId(); + + if (currentRegistryId && currentRegistryId !== lastSetRegistryId) { + this.setMetaTags(); + } } }); @@ -70,7 +111,10 @@ export class RegistryComponent implements OnDestroy { } }); - this.dataciteService.logIdentifiableView(this.registry$).pipe(takeUntilDestroyed(this.destroyRef)).subscribe(); + this.dataciteService + .logIdentifiableView(this.identifiersForDatacite$) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(); } ngOnDestroy(): void { @@ -78,21 +122,24 @@ export class RegistryComponent implements OnDestroy { } private setMetaTags(): void { + const currentRegistry = this.registry(); + if (!currentRegistry) return; + this.metaTags.updateMetaTags( { - osfGuid: this.registry()?.id, - title: this.registry()?.title, - description: this.registry()?.description, - publishedDate: this.datePipe.transform(this.registry()?.dateRegistered, 'yyyy-MM-dd'), - modifiedDate: this.datePipe.transform(this.registry()?.dateModified, 'yyyy-MM-dd'), - url: pathJoin(this.environment.webUrl, this.registry()?.id ?? ''), - identifier: this.registry()?.id, - doi: this.registry()?.doi, - keywords: this.registry()?.tags, + 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.registry()?.license?.name, + license: this.license()?.name, contributors: - this.registry()?.contributors?.map((contributor) => ({ + this.bibliographicContributors()?.map((contributor) => ({ fullName: contributor.fullName, givenName: contributor.givenName, familyName: contributor.familyName, @@ -100,5 +147,7 @@ export class RegistryComponent implements OnDestroy { }, this.destroyRef ); + + this.lastMetaTagsRegistryId.set(currentRegistry.id); } } diff --git a/src/app/features/registry/registry.routes.ts b/src/app/features/registry/registry.routes.ts index b75c158bf..db19cd0af 100644 --- a/src/app/features/registry/registry.routes.ts +++ b/src/app/features/registry/registry.routes.ts @@ -6,19 +6,16 @@ import { viewOnlyGuard } from '@core/guards/view-only.guard'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { CitationsState } from '@osf/shared/stores/citations'; import { DuplicatesState } from '@osf/shared/stores/duplicates'; +import { RegistrationProviderState } from '@osf/shared/stores/registration-provider'; import { SubjectsState } from '@osf/shared/stores/subjects'; import { ViewOnlyLinkState } from '@osf/shared/stores/view-only-links'; import { ActivityLogsState } from '@shared/stores/activity-logs'; import { AnalyticsState } from '../analytics/store'; -import { LicensesService } from '../registries/services'; -import { RegistriesState } from '../registries/store'; -import { LicensesHandlers, ProjectsHandlers, ProvidersHandlers } from '../registries/store/handlers'; -import { FilesHandlers } from '../registries/store/handlers/files.handlers'; +import { RegistryState } from './store/registry'; import { RegistryComponentsState } from './store/registry-components'; import { RegistryLinksState } from './store/registry-links'; -import { RegistryOverviewState } from './store/registry-overview'; import { RegistryResourcesState } from './store/registry-resources'; import { RegistryComponent } from './registry.component'; @@ -26,7 +23,7 @@ export const registryRoutes: Routes = [ { path: '', component: RegistryComponent, - providers: [provideStates([RegistryOverviewState, ActivityLogsState])], + providers: [provideStates([RegistryState, RegistrationProviderState])], children: [ { path: '', @@ -37,14 +34,7 @@ export const registryRoutes: Routes = [ path: 'overview', loadComponent: () => import('./pages/registry-overview/registry-overview.component').then((c) => c.RegistryOverviewComponent), - providers: [ - provideStates([RegistriesState, SubjectsState, CitationsState]), - ProvidersHandlers, - ProjectsHandlers, - LicensesHandlers, - FilesHandlers, - LicensesService, - ], + providers: [provideStates([SubjectsState, CitationsState])], }, { path: 'metadata', @@ -116,6 +106,7 @@ export const registryRoutes: Routes = [ import('./pages/registration-recent-activity/registration-recent-activity.component').then( (c) => c.RegistrationRecentActivityComponent ), + providers: [provideStates([ActivityLogsState])], }, ], }, diff --git a/src/app/features/registry/services/registry-overview.service.ts b/src/app/features/registry/services/registry-overview.service.ts index 98f9ce91b..2fabbe51e 100644 --- a/src/app/features/registry/services/registry-overview.service.ts +++ b/src/app/features/registry/services/registry-overview.service.ts @@ -5,21 +5,32 @@ import { inject, Injectable } from '@angular/core'; import { ENVIRONMENT } from '@core/provider/environment.provider'; import { RegistryModerationMapper } from '@osf/features/moderation/mappers'; import { ReviewAction, ReviewActionsResponseJsonApi } from '@osf/features/moderation/models'; +import { IdentifiersMapper } from '@osf/shared/mappers/identifiers.mapper'; import { InstitutionsMapper } from '@osf/shared/mappers/institutions'; -import { PageSchemaMapper } from '@osf/shared/mappers/registration'; +import { LicensesMapper } from '@osf/shared/mappers/licenses.mapper'; +import { PageSchemaMapper, RegistrationMapper } from '@osf/shared/mappers/registration'; import { ReviewActionsMapper } from '@osf/shared/mappers/review-actions.mapper'; +import { IdentifierModel } from '@osf/shared/models/identifiers/identifier.model'; +import { IdentifiersResponseJsonApi } from '@osf/shared/models/identifiers/identifier-json-api.model'; import { InstitutionsJsonApiResponse } from '@osf/shared/models/institutions/institution-json-api.model'; import { Institution } from '@osf/shared/models/institutions/institutions.models'; +import { LicenseModel } from '@osf/shared/models/license/license.model'; +import { LicenseResponseJsonApi } from '@osf/shared/models/license/licenses-json-api.model'; import { PageSchema } from '@osf/shared/models/registration/page-schema.model'; +import { + SchemaResponseJsonApi, + SchemaResponsesJsonApi, +} from '@osf/shared/models/registration/registration-json-api.model'; import { SchemaBlocksResponseJsonApi } from '@osf/shared/models/registration/schema-blocks-json-api.model'; +import { SchemaResponse } from '@osf/shared/models/registration/schema-response.model'; import { ReviewActionPayload } from '@osf/shared/models/review-action/review-action-payload.model'; import { JsonApiService } from '@osf/shared/services/json-api.service'; -import { MapRegistryOverview } from '../mappers'; +import { MapRegistrationOverview } from '../mappers'; import { - GetRegistryOverviewJsonApi, - RegistryOverview, - RegistryOverviewJsonApiData, + RegistrationOverviewDataJsonApi, + RegistrationOverviewModel, + RegistrationOverviewResponse, RegistryOverviewWithMeta, } from '../models'; @@ -35,23 +46,9 @@ export class RegistryOverviewService { } getRegistrationById(id: string): Observable { - const params = { - related_counts: 'forks,linked_nodes,linked_registrations,children,wikis', - 'embed[]': [ - 'bibliographic_contributors', - 'provider', - 'registration_schema', - 'identifiers', - 'root', - 'schema_responses', - 'files', - 'license', - ], - }; - return this.jsonApiService - .get(`${this.apiUrl}/registrations/${id}/`, params) - .pipe(map((response) => ({ registry: MapRegistryOverview(response.data), meta: response.meta }))); + .get(`${this.apiUrl}/registrations/${id}/`) + .pipe(map((response) => ({ registry: MapRegistrationOverview(response.data), meta: response.meta }))); } getInstitutions(registryId: string): Observable { @@ -64,6 +61,18 @@ export class RegistryOverviewService { .pipe(map((response) => InstitutionsMapper.fromInstitutionsResponse(response))); } + getRegistryIdentifiers(registryId: string): Observable { + return this.jsonApiService + .get(`${this.apiUrl}/registrations/${registryId}/identifiers/`) + .pipe(map((response) => IdentifiersMapper.fromJsonApi(response))); + } + + getRegistryLicense(licenseId: string): Observable { + return this.jsonApiService + .get(`${this.apiUrl}/licenses/${licenseId}/`) + .pipe(map((response) => LicensesMapper.fromLicenseDataJsonApi(response.data))); + } + getSchemaBlocks(schemaLink: string): Observable { const params = { 'page[size]': 200, @@ -83,7 +92,41 @@ export class RegistryOverviewService { .pipe(map((response) => PageSchemaMapper.fromSchemaBlocksResponse(response))); } - withdrawRegistration(registryId: string, justification: string): Observable { + getSchemaResponses(registryId: string): Observable { + return this.jsonApiService + .get(`${this.apiUrl}/registrations/${registryId}/schema_responses/`) + .pipe(map((response) => response.data.map((item) => RegistrationMapper.fromSchemaResponse(item)))); + } + + getRegistryReviewActions(id: string): Observable { + const baseUrl = `${this.apiUrl}/registrations/${id}/actions/`; + + return this.jsonApiService + .get(baseUrl) + .pipe(map((response) => response.data.map((x) => RegistryModerationMapper.fromActionResponse(x)))); + } + + createSchemaResponse(registrationId: string): Observable { + const payload = { + data: { + type: 'schema_responses', + relationships: { + registration: { + data: { + type: 'registrations', + id: registrationId, + }, + }, + }, + }, + }; + + return this.jsonApiService + .post(`${this.apiUrl}/schema_responses/`, payload) + .pipe(map((response) => RegistrationMapper.fromSchemaResponse(response.data))); + } + + withdrawRegistration(registryId: string, justification: string): Observable { const payload = { data: { id: registryId, @@ -97,11 +140,11 @@ export class RegistryOverviewService { }; return this.jsonApiService - .patch(`${this.apiUrl}/registrations/${registryId}/`, payload) - .pipe(map((response) => MapRegistryOverview(response))); + .patch(`${this.apiUrl}/registrations/${registryId}/`, payload) + .pipe(map((response) => MapRegistrationOverview(response))); } - makePublic(registryId: string): Observable { + makePublic(registryId: string): Observable { const payload = { data: { id: registryId, @@ -114,16 +157,8 @@ export class RegistryOverviewService { }; return this.jsonApiService - .patch(`${this.apiUrl}/registrations/${registryId}/`, payload) - .pipe(map((response) => MapRegistryOverview(response))); - } - - getRegistryReviewActions(id: string): Observable { - const baseUrl = `${this.apiUrl}/registrations/${id}/actions/`; - - return this.jsonApiService - .get(baseUrl) - .pipe(map((response) => response.data.map((x) => RegistryModerationMapper.fromActionResponse(x)))); + .patch(`${this.apiUrl}/registrations/${registryId}/`, payload) + .pipe(map((response) => MapRegistrationOverview(response))); } submitDecision(payload: ReviewActionPayload, isRevision: boolean): Observable { diff --git a/src/app/features/registry/services/registry-resources.service.ts b/src/app/features/registry/services/registry-resources.service.ts index 8c70dc07e..39366b50b 100644 --- a/src/app/features/registry/services/registry-resources.service.ts +++ b/src/app/features/registry/services/registry-resources.service.ts @@ -39,11 +39,9 @@ export class RegistryResourcesService { addRegistryResource(registryId: string): Observable { const body = toAddResourceRequestBody(registryId); - return this.jsonApiService.post(`${this.apiUrl}/resources/`, body).pipe( - map((response) => { - return MapRegistryResource(response.data); - }) - ); + return this.jsonApiService + .post(`${this.apiUrl}/resources/`, body) + .pipe(map((response) => MapRegistryResource(response.data))); } previewRegistryResource(resourceId: string, resource: AddResource): Observable { @@ -51,11 +49,7 @@ export class RegistryResourcesService { return this.jsonApiService .patch(`${this.apiUrl}/resources/${resourceId}/`, payload) - .pipe( - map((response) => { - return MapRegistryResource(response); - }) - ); + .pipe(map((response) => MapRegistryResource(response))); } confirmAddingResource(resourceId: string, resource: ConfirmAddResource): Observable { @@ -63,11 +57,7 @@ export class RegistryResourcesService { return this.jsonApiService .patch(`${this.apiUrl}/resources/${resourceId}/`, payload) - .pipe( - map((response) => { - return MapRegistryResource(response); - }) - ); + .pipe(map((response) => MapRegistryResource(response))); } deleteResource(resourceId: string): Observable { diff --git a/src/app/features/registry/store/registry-overview/index.ts b/src/app/features/registry/store/registry-overview/index.ts deleted file mode 100644 index 1fb540721..000000000 --- a/src/app/features/registry/store/registry-overview/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './registry-overview.actions'; -export * from './registry-overview.model'; -export * from './registry-overview.selectors'; -export * from './registry-overview.state'; diff --git a/src/app/features/registry/store/registry-overview/registry-overview.actions.ts b/src/app/features/registry/store/registry-overview/registry-overview.actions.ts deleted file mode 100644 index 1083536fe..000000000 --- a/src/app/features/registry/store/registry-overview/registry-overview.actions.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { ReviewActionPayload } from '@osf/shared/models/review-action/review-action-payload.model'; - -export class GetRegistryById { - static readonly type = '[Registry Overview] Get Registry By Id'; - - constructor(public id: string) {} -} - -export class GetRegistryInstitutions { - static readonly type = '[Registry Overview] Get Registry Institutions'; - - constructor(public registryId: string) {} -} - -export class GetSchemaBlocks { - static readonly type = '[Registry Overview] Get Schema Blocks'; - - constructor(public schemaLink: string) {} -} - -export class WithdrawRegistration { - static readonly type = '[Registry Overview] Withdraw Registration'; - - constructor( - public registryId: string, - public justification: string - ) {} -} - -export class MakePublic { - static readonly type = '[Registry Overview] Make Public'; - - constructor(public registryId: string) {} -} - -export class SetRegistryCustomCitation { - static readonly type = '[Registry Overview] Set Registry Custom Citation'; - - constructor(public citation: string) {} -} - -export class GetRegistryReviewActions { - static readonly type = '[Registry Overview] Get Registry Review Actions'; - - constructor(public registryId: string) {} -} - -export class SubmitDecision { - static readonly type = '[Registry Overview] Submit Decision'; - - constructor( - public payload: ReviewActionPayload, - public isRevision: boolean - ) {} -} - -export class ClearRegistryOverview { - static readonly type = '[Registry Overview] Clear Registry Overview'; -} diff --git a/src/app/features/registry/store/registry-overview/registry-overview.model.ts b/src/app/features/registry/store/registry-overview/registry-overview.model.ts deleted file mode 100644 index 754d3ea9b..000000000 --- a/src/app/features/registry/store/registry-overview/registry-overview.model.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { ReviewAction } from '@osf/features/moderation/models'; -import { Institution } from '@osf/shared/models/institutions/institutions.models'; -import { PageSchema } from '@osf/shared/models/registration/page-schema.model'; -import { AsyncStateModel } from '@osf/shared/models/store/async-state.model'; - -import { RegistryOverview } from '../../models'; - -export interface RegistryOverviewStateModel { - registry: AsyncStateModel; - institutions: AsyncStateModel; - schemaBlocks: AsyncStateModel; - moderationActions: AsyncStateModel; - isAnonymous: boolean; -} - -export const REGISTRY_OVERVIEW_DEFAULTS: RegistryOverviewStateModel = { - registry: { - data: null, - isLoading: false, - error: null, - }, - institutions: { - data: [], - isLoading: false, - error: null, - }, - schemaBlocks: { - data: [], - isLoading: false, - error: null, - }, - moderationActions: { - data: [], - isLoading: false, - isSubmitting: false, - error: null, - }, - isAnonymous: false, -}; diff --git a/src/app/features/registry/store/registry-overview/registry-overview.selectors.ts b/src/app/features/registry/store/registry-overview/registry-overview.selectors.ts deleted file mode 100644 index f2e0f3565..000000000 --- a/src/app/features/registry/store/registry-overview/registry-overview.selectors.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { Selector } from '@ngxs/store'; - -import { ReviewAction } from '@osf/features/moderation/models'; -import { RegistryOverview } from '@osf/features/registry/models'; -import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; -import { Institution } from '@shared/models/institutions/institutions.models'; -import { PageSchema } from '@shared/models/registration/page-schema.model'; - -import { RegistryOverviewStateModel } from './registry-overview.model'; -import { RegistryOverviewState } from './registry-overview.state'; - -export class RegistryOverviewSelectors { - @Selector([RegistryOverviewState]) - static getRegistry(state: RegistryOverviewStateModel): RegistryOverview | null { - return state.registry.data; - } - - @Selector([RegistryOverviewState]) - static isRegistryLoading(state: RegistryOverviewStateModel): boolean { - return state.registry.isLoading; - } - - @Selector([RegistryOverviewState]) - static isRegistryAnonymous(state: RegistryOverviewStateModel): boolean { - return state.isAnonymous; - } - - @Selector([RegistryOverviewState]) - static getInstitutions(state: RegistryOverviewStateModel): Institution[] | null { - return state.institutions.data; - } - - @Selector([RegistryOverviewState]) - static isInstitutionsLoading(state: RegistryOverviewStateModel): boolean { - return state.institutions.isLoading; - } - - @Selector([RegistryOverviewState]) - static getSchemaBlocks(state: RegistryOverviewStateModel): PageSchema[] | null { - return state.schemaBlocks.data; - } - - @Selector([RegistryOverviewState]) - static isSchemaBlocksLoading(state: RegistryOverviewStateModel): boolean { - return state.schemaBlocks.isLoading; - } - - @Selector([RegistryOverviewState]) - static getReviewActions(state: RegistryOverviewStateModel): ReviewAction[] { - return state.moderationActions.data; - } - - @Selector([RegistryOverviewState]) - static areReviewActionsLoading(state: RegistryOverviewStateModel): boolean { - return state.moderationActions.isLoading; - } - - @Selector([RegistryOverviewState]) - static isReviewActionSubmitting(state: RegistryOverviewStateModel): boolean { - return state.moderationActions.isSubmitting || false; - } - - @Selector([RegistryOverviewState]) - static hasReadAccess(state: RegistryOverviewStateModel): boolean { - return state.registry.data?.currentUserPermissions.includes(UserPermissions.Read) || false; - } - - @Selector([RegistryOverviewState]) - static hasWriteAccess(state: RegistryOverviewStateModel): boolean { - return state.registry.data?.currentUserPermissions.includes(UserPermissions.Write) || false; - } - - @Selector([RegistryOverviewState]) - static hasAdminAccess(state: RegistryOverviewStateModel): boolean { - return state.registry.data?.currentUserPermissions.includes(UserPermissions.Admin) || false; - } - - @Selector([RegistryOverviewState]) - static hasNoPermissions(state: RegistryOverviewStateModel): boolean { - return !state.registry.data?.currentUserPermissions.length; - } -} diff --git a/src/app/features/registry/store/registry-overview/registry-overview.state.ts b/src/app/features/registry/store/registry-overview/registry-overview.state.ts deleted file mode 100644 index 312a8b817..000000000 --- a/src/app/features/registry/store/registry-overview/registry-overview.state.ts +++ /dev/null @@ -1,255 +0,0 @@ -import { Action, State, StateContext } from '@ngxs/store'; - -import { tap } from 'rxjs'; -import { catchError } from 'rxjs/operators'; - -import { inject, Injectable } from '@angular/core'; - -import { SetCurrentProvider } from '@core/store/provider'; -import { CurrentResourceType } from '@osf/shared/enums/resource-type.enum'; -import { handleSectionError } from '@osf/shared/helpers/state-error.handler'; - -import { RegistryOverviewService } from '../../services'; - -import { - ClearRegistryOverview, - GetRegistryById, - GetRegistryInstitutions, - GetRegistryReviewActions, - GetSchemaBlocks, - MakePublic, - SetRegistryCustomCitation, - SubmitDecision, - WithdrawRegistration, -} from './registry-overview.actions'; -import { REGISTRY_OVERVIEW_DEFAULTS, RegistryOverviewStateModel } from './registry-overview.model'; - -@Injectable() -@State({ - name: 'registryOverview', - defaults: REGISTRY_OVERVIEW_DEFAULTS, -}) -export class RegistryOverviewState { - private readonly registryOverviewService = inject(RegistryOverviewService); - - @Action(GetRegistryById) - getRegistryById(ctx: StateContext, action: GetRegistryById) { - const state = ctx.getState(); - - if (state.registry.isLoading) { - return; - } - - ctx.patchState({ - registry: { - ...state.registry, - isLoading: true, - }, - }); - - return this.registryOverviewService.getRegistrationById(action.id).pipe( - tap((response) => { - const registryOverview = response.registry; - - if (registryOverview?.provider) { - ctx.dispatch( - new SetCurrentProvider({ - id: registryOverview.provider.id, - name: registryOverview.provider.name, - type: CurrentResourceType.Registrations, - permissions: registryOverview.provider.permissions, - }) - ); - } - - ctx.patchState({ - registry: { - data: registryOverview, - isLoading: false, - error: null, - }, - isAnonymous: response.meta?.anonymous ?? false, - }); - - if (registryOverview?.registrationSchemaLink) { - ctx.dispatch(new GetSchemaBlocks(registryOverview.registrationSchemaLink)); - } - }), - catchError((error) => handleSectionError(ctx, 'registry', error)) - ); - } - - @Action(GetRegistryInstitutions) - getRegistryInstitutions(ctx: StateContext, action: GetRegistryInstitutions) { - const state = ctx.getState(); - ctx.patchState({ - institutions: { - ...state.institutions, - isLoading: true, - }, - }); - - return this.registryOverviewService.getInstitutions(action.registryId).pipe( - tap((institutions) => { - ctx.patchState({ - institutions: { - data: institutions, - isLoading: false, - error: null, - }, - }); - }), - catchError((error) => handleSectionError(ctx, 'institutions', error)) - ); - } - - @Action(GetSchemaBlocks) - getSchemaBlocks(ctx: StateContext, action: GetSchemaBlocks) { - const state = ctx.getState(); - ctx.patchState({ - schemaBlocks: { - ...state.schemaBlocks, - isLoading: true, - }, - }); - - return this.registryOverviewService.getSchemaBlocks(action.schemaLink).pipe( - tap((schemaBlocks) => { - ctx.patchState({ - schemaBlocks: { - data: schemaBlocks, - isLoading: false, - error: null, - }, - }); - }), - catchError((error) => handleSectionError(ctx, 'schemaBlocks', error)) - ); - } - - @Action(WithdrawRegistration) - withdrawRegistration(ctx: StateContext, action: WithdrawRegistration) { - const state = ctx.getState(); - ctx.patchState({ - registry: { - ...state.registry, - isLoading: true, - }, - }); - - return this.registryOverviewService.withdrawRegistration(action.registryId, action.justification).pipe( - tap((registryOverview) => { - ctx.patchState({ - registry: { - data: registryOverview, - isLoading: false, - error: null, - }, - }); - - if (registryOverview?.registrationSchemaLink) { - ctx.dispatch(new GetSchemaBlocks(registryOverview.registrationSchemaLink)); - } - }), - catchError((error) => handleSectionError(ctx, 'registry', error)) - ); - } - - @Action(MakePublic) - makePublic(ctx: StateContext, action: MakePublic) { - const state = ctx.getState(); - ctx.patchState({ - registry: { - ...state.registry, - isLoading: true, - }, - }); - - return this.registryOverviewService.makePublic(action.registryId).pipe( - tap((registryOverview) => { - ctx.patchState({ - registry: { - data: registryOverview, - isLoading: false, - error: null, - }, - }); - - if (registryOverview?.registrationSchemaLink) { - ctx.dispatch(new GetSchemaBlocks(registryOverview.registrationSchemaLink)); - } - }), - catchError((error) => handleSectionError(ctx, 'registry', error)) - ); - } - - @Action(SetRegistryCustomCitation) - setRegistryCustomCitation(ctx: StateContext, action: SetRegistryCustomCitation) { - const state = ctx.getState(); - ctx.patchState({ - registry: { - ...state.registry, - data: { - ...state.registry.data!, - customCitation: action.citation, - }, - }, - }); - } - - @Action(GetRegistryReviewActions) - getRegistryReviewActions(ctx: StateContext, action: GetRegistryReviewActions) { - ctx.patchState({ - moderationActions: { - data: [], - isLoading: true, - isSubmitting: false, - error: null, - }, - }); - - return this.registryOverviewService.getRegistryReviewActions(action.registryId).pipe( - tap((reviewActions) => { - ctx.patchState({ - moderationActions: { - data: reviewActions, - isLoading: false, - error: null, - }, - }); - }), - catchError((error) => handleSectionError(ctx, 'moderationActions', error)) - ); - } - - @Action(SubmitDecision) - submitDecision(ctx: StateContext, action: SubmitDecision) { - ctx.patchState({ - moderationActions: { - data: [], - isLoading: true, - isSubmitting: true, - error: null, - }, - }); - - return this.registryOverviewService.submitDecision(action.payload, action.isRevision).pipe( - tap(() => { - ctx.patchState({ - moderationActions: { - data: [], - isLoading: false, - isSubmitting: false, - error: null, - }, - }); - }), - catchError((error) => handleSectionError(ctx, 'moderationActions', error)) - ); - } - - @Action(ClearRegistryOverview) - clearRegistryOverview(ctx: StateContext) { - ctx.patchState(REGISTRY_OVERVIEW_DEFAULTS); - } -} diff --git a/src/app/features/registry/store/registry/index.ts b/src/app/features/registry/store/registry/index.ts new file mode 100644 index 000000000..cc9be2a13 --- /dev/null +++ b/src/app/features/registry/store/registry/index.ts @@ -0,0 +1,4 @@ +export * from './registry.actions'; +export * from './registry.model'; +export * from './registry.selectors'; +export * from './registry.state'; diff --git a/src/app/features/registry/store/registry/registry.actions.ts b/src/app/features/registry/store/registry/registry.actions.ts new file mode 100644 index 000000000..8a32ffe8c --- /dev/null +++ b/src/app/features/registry/store/registry/registry.actions.ts @@ -0,0 +1,89 @@ +import { ReviewActionPayload } from '@osf/shared/models/review-action/review-action-payload.model'; + +export class GetRegistryById { + static readonly type = '[Registry] Get Registry By Id'; + + constructor(public id: string) {} +} + +export class GetRegistryWithRelatedData { + static readonly type = '[Registry] Get Registry With Related Data'; + + constructor(public id: string) {} +} + +export class GetRegistryInstitutions { + static readonly type = '[Registry] Get Registry Institutions'; + + constructor(public registryId: string) {} +} + +export class GetRegistryIdentifiers { + static readonly type = '[Registry] Get Registry Identifiers'; + + constructor(public registryId: string) {} +} + +export class GetRegistryLicense { + static readonly type = '[Registry] Get Registry License'; + + constructor(public licenseId: string) {} +} + +export class GetSchemaBlocks { + static readonly type = '[Registry] Get Schema Blocks'; + + constructor(public schemaLink: string) {} +} + +export class GetRegistrySchemaResponses { + static readonly type = '[Registry] Get Registry Schema Responses'; + + constructor(public registryId: string) {} +} + +export class CreateSchemaResponse { + static readonly type = '[Registry] Create Schema Response'; + + constructor(public registryId: string) {} +} + +export class GetRegistryReviewActions { + static readonly type = '[Registry] Get Registry Review Actions'; + + constructor(public registryId: string) {} +} + +export class SetRegistryCustomCitation { + static readonly type = '[Registry] Set Registry Custom Citation'; + + constructor(public citation: string) {} +} + +export class WithdrawRegistration { + static readonly type = '[Registry] Withdraw Registration'; + + constructor( + public registryId: string, + public justification: string + ) {} +} + +export class MakePublic { + static readonly type = '[Registry] Make Public'; + + constructor(public registryId: string) {} +} + +export class SubmitDecision { + static readonly type = '[Registry] Submit Decision'; + + constructor( + public payload: ReviewActionPayload, + public isRevision: boolean + ) {} +} + +export class ClearRegistry { + static readonly type = '[Registry] Clear Registry'; +} diff --git a/src/app/features/registry/store/registry/registry.model.ts b/src/app/features/registry/store/registry/registry.model.ts new file mode 100644 index 000000000..027992ed3 --- /dev/null +++ b/src/app/features/registry/store/registry/registry.model.ts @@ -0,0 +1,66 @@ +import { ReviewAction } from '@osf/features/moderation/models'; +import { IdentifierModel } from '@osf/shared/models/identifiers/identifier.model'; +import { Institution } from '@osf/shared/models/institutions/institutions.models'; +import { LicenseModel } from '@osf/shared/models/license/license.model'; +import { PageSchema } from '@osf/shared/models/registration/page-schema.model'; +import { SchemaResponse } from '@osf/shared/models/registration/schema-response.model'; +import { AsyncStateModel } from '@osf/shared/models/store/async-state.model'; + +import { RegistrationOverviewModel } from '../../models'; + +export interface RegistryStateModel { + registry: AsyncStateModel; + institutions: AsyncStateModel; + identifiers: AsyncStateModel; + license: AsyncStateModel; + schemaBlocks: AsyncStateModel; + schemaResponses: AsyncStateModel; + currentSchemaResponse: AsyncStateModel; + moderationActions: AsyncStateModel; + isAnonymous: boolean; +} + +export const REGISTRY_DEFAULTS: RegistryStateModel = { + registry: { + data: null, + isLoading: false, + error: null, + }, + institutions: { + data: [], + isLoading: false, + error: null, + }, + moderationActions: { + data: [], + isLoading: false, + isSubmitting: false, + error: null, + }, + identifiers: { + data: [], + isLoading: false, + error: null, + }, + license: { + data: null, + isLoading: false, + error: null, + }, + schemaBlocks: { + data: [], + isLoading: false, + error: null, + }, + schemaResponses: { + data: [], + isLoading: false, + error: null, + }, + currentSchemaResponse: { + data: null, + isLoading: false, + error: null, + }, + isAnonymous: false, +}; diff --git a/src/app/features/registry/store/registry/registry.selectors.ts b/src/app/features/registry/store/registry/registry.selectors.ts new file mode 100644 index 000000000..3f5e6a729 --- /dev/null +++ b/src/app/features/registry/store/registry/registry.selectors.ts @@ -0,0 +1,115 @@ +import { Selector } from '@ngxs/store'; + +import { ReviewAction } from '@osf/features/moderation/models'; +import { RegistrationOverviewModel } from '@osf/features/registry/models'; +import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; +import { IdentifierModel } from '@osf/shared/models/identifiers/identifier.model'; +import { LicenseModel } from '@osf/shared/models/license/license.model'; +import { SchemaResponse } from '@osf/shared/models/registration/schema-response.model'; +import { Institution } from '@shared/models/institutions/institutions.models'; +import { PageSchema } from '@shared/models/registration/page-schema.model'; + +import { RegistryStateModel } from './registry.model'; +import { RegistryState } from './registry.state'; + +export class RegistrySelectors { + @Selector([RegistryState]) + static getRegistry(state: RegistryStateModel): RegistrationOverviewModel | null { + return state.registry.data; + } + + @Selector([RegistryState]) + static isRegistryLoading(state: RegistryStateModel): boolean { + return state.registry.isLoading; + } + + @Selector([RegistryState]) + static isRegistryAnonymous(state: RegistryStateModel): boolean { + return state.isAnonymous; + } + + @Selector([RegistryState]) + static getInstitutions(state: RegistryStateModel): Institution[] | null { + return state.institutions.data; + } + + @Selector([RegistryState]) + static isInstitutionsLoading(state: RegistryStateModel): boolean { + return state.institutions.isLoading; + } + + @Selector([RegistryState]) + static getSchemaBlocks(state: RegistryStateModel): PageSchema[] | null { + return state.schemaBlocks.data; + } + + @Selector([RegistryState]) + static isSchemaBlocksLoading(state: RegistryStateModel): boolean { + return state.schemaBlocks.isLoading; + } + + @Selector([RegistryState]) + static getReviewActions(state: RegistryStateModel): ReviewAction[] { + return state.moderationActions.data; + } + + @Selector([RegistryState]) + static areReviewActionsLoading(state: RegistryStateModel): boolean { + return state.moderationActions.isLoading; + } + + @Selector([RegistryState]) + static isReviewActionSubmitting(state: RegistryStateModel): boolean { + return state.moderationActions.isSubmitting || false; + } + + @Selector([RegistryState]) + static getIdentifiers(state: RegistryStateModel): IdentifierModel[] { + return state.identifiers.data; + } + + @Selector([RegistryState]) + static isIdentifiersLoading(state: RegistryStateModel): boolean { + return state.identifiers.isLoading; + } + + @Selector([RegistryState]) + static getLicense(state: RegistryStateModel): LicenseModel | null { + return state.license.data; + } + + @Selector([RegistryState]) + static isLicenseLoading(state: RegistryStateModel): boolean { + return state.license.isLoading; + } + + @Selector([RegistryState]) + static getSchemaResponses(state: RegistryStateModel): SchemaResponse[] { + return state.schemaResponses.data; + } + + @Selector([RegistryState]) + static isSchemaResponsesLoading(state: RegistryStateModel): boolean { + return state.schemaResponses.isLoading; + } + + @Selector([RegistryState]) + static hasWriteAccess(state: RegistryStateModel): boolean { + return state.registry.data?.currentUserPermissions.includes(UserPermissions.Write) || false; + } + + @Selector([RegistryState]) + static hasAdminAccess(state: RegistryStateModel): boolean { + return state.registry.data?.currentUserPermissions.includes(UserPermissions.Admin) || false; + } + + @Selector([RegistryState]) + static getSchemaResponse(state: RegistryStateModel): SchemaResponse | null { + return state.currentSchemaResponse.data; + } + + @Selector([RegistryState]) + static getSchemaResponseLoading(state: RegistryStateModel): boolean { + return state.currentSchemaResponse.isLoading || !!state.currentSchemaResponse.isSubmitting; + } +} diff --git a/src/app/features/registry/store/registry/registry.state.ts b/src/app/features/registry/store/registry/registry.state.ts new file mode 100644 index 000000000..0540bcff8 --- /dev/null +++ b/src/app/features/registry/store/registry/registry.state.ts @@ -0,0 +1,365 @@ +import { Action, State, StateContext } from '@ngxs/store'; + +import { tap } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +import { inject, Injectable } from '@angular/core'; + +import { handleSectionError } from '@osf/shared/helpers/state-error.handler'; +import { GetRegistryProvider } from '@osf/shared/stores/registration-provider'; + +import { RegistryOverviewService } from '../../services'; + +import { + ClearRegistry, + CreateSchemaResponse, + GetRegistryById, + GetRegistryIdentifiers, + GetRegistryInstitutions, + GetRegistryLicense, + GetRegistryReviewActions, + GetRegistrySchemaResponses, + GetRegistryWithRelatedData, + GetSchemaBlocks, + MakePublic, + SetRegistryCustomCitation, + SubmitDecision, + WithdrawRegistration, +} from './registry.actions'; +import { REGISTRY_DEFAULTS, RegistryStateModel } from './registry.model'; + +@Injectable() +@State({ + name: 'registry', + defaults: REGISTRY_DEFAULTS, +}) +export class RegistryState { + private readonly registryOverviewService = inject(RegistryOverviewService); + + @Action(GetRegistryById) + getRegistryById(ctx: StateContext, action: GetRegistryById) { + const state = ctx.getState(); + + ctx.patchState({ + registry: { + ...state.registry, + isLoading: true, + }, + }); + + return this.registryOverviewService.getRegistrationById(action.id).pipe( + tap((response) => { + const registryOverview = response.registry; + + ctx.patchState({ + registry: { + data: registryOverview, + isLoading: false, + error: null, + }, + isAnonymous: response.meta?.anonymous ?? false, + }); + }), + catchError((error) => handleSectionError(ctx, 'registry', error)) + ); + } + + @Action(GetRegistryWithRelatedData) + getRegistryWithRelatedData(ctx: StateContext, action: GetRegistryWithRelatedData) { + const state = ctx.getState(); + + ctx.patchState({ + registry: { + ...state.registry, + isLoading: true, + }, + }); + + return this.registryOverviewService.getRegistrationById(action.id).pipe( + tap((response) => { + const registryOverview = response.registry; + + ctx.patchState({ + registry: { + data: registryOverview, + isLoading: false, + error: null, + }, + isAnonymous: response.meta?.anonymous ?? false, + }); + + if (registryOverview.providerId) { + ctx.dispatch(new GetRegistryProvider(registryOverview.providerId)); + } + if (registryOverview.licenseId) { + ctx.dispatch(new GetRegistryLicense(registryOverview.licenseId)); + } + }), + catchError((error) => handleSectionError(ctx, 'registry', error)) + ); + } + + @Action(GetRegistryInstitutions) + getRegistryInstitutions(ctx: StateContext, action: GetRegistryInstitutions) { + const state = ctx.getState(); + ctx.patchState({ + institutions: { + ...state.institutions, + isLoading: true, + }, + }); + + return this.registryOverviewService.getInstitutions(action.registryId).pipe( + tap((institutions) => { + ctx.patchState({ + institutions: { + data: institutions, + isLoading: false, + error: null, + }, + }); + }), + catchError((error) => handleSectionError(ctx, 'institutions', error)) + ); + } + + @Action(GetRegistryIdentifiers) + getRegistryIdentifiers(ctx: StateContext, action: GetRegistryIdentifiers) { + const state = ctx.getState(); + ctx.patchState({ + identifiers: { + ...state.identifiers, + isLoading: true, + }, + }); + + return this.registryOverviewService.getRegistryIdentifiers(action.registryId).pipe( + tap((identifiers) => { + ctx.patchState({ + identifiers: { + data: identifiers, + isLoading: false, + error: null, + }, + }); + }), + catchError((error) => handleSectionError(ctx, 'identifiers', error)) + ); + } + + @Action(GetRegistryLicense) + getRegistryLicense(ctx: StateContext, action: GetRegistryLicense) { + const state = ctx.getState(); + ctx.patchState({ + license: { + ...state.license, + isLoading: true, + }, + }); + + return this.registryOverviewService.getRegistryLicense(action.licenseId).pipe( + tap((license) => { + ctx.patchState({ + license: { + data: license, + isLoading: false, + error: null, + }, + }); + }), + catchError((error) => handleSectionError(ctx, 'license', error)) + ); + } + + @Action(GetSchemaBlocks) + getSchemaBlocks(ctx: StateContext, action: GetSchemaBlocks) { + const state = ctx.getState(); + ctx.patchState({ + schemaBlocks: { + ...state.schemaBlocks, + isLoading: true, + }, + }); + + return this.registryOverviewService.getSchemaBlocks(action.schemaLink).pipe( + tap((schemaBlocks) => { + ctx.patchState({ + schemaBlocks: { + data: schemaBlocks, + isLoading: false, + error: null, + }, + }); + }), + catchError((error) => handleSectionError(ctx, 'schemaBlocks', error)) + ); + } + + @Action(GetRegistrySchemaResponses) + getSchemaResponses(ctx: StateContext, action: GetRegistrySchemaResponses) { + const state = ctx.getState(); + ctx.patchState({ + schemaResponses: { + ...state.schemaResponses, + isLoading: true, + }, + }); + + return this.registryOverviewService.getSchemaResponses(action.registryId).pipe( + tap((schemaResponses) => { + ctx.patchState({ + schemaResponses: { + data: schemaResponses, + isLoading: false, + error: null, + }, + }); + }), + catchError((error) => handleSectionError(ctx, 'schemaResponses', error)) + ); + } + + @Action(CreateSchemaResponse) + createSchemaResponse(ctx: StateContext, { registryId }: CreateSchemaResponse) { + const state = ctx.getState(); + + ctx.patchState({ + currentSchemaResponse: { + ...state.currentSchemaResponse, + isLoading: true, + error: null, + }, + }); + + return this.registryOverviewService.createSchemaResponse(registryId).pipe( + tap((schemaResponse) => { + ctx.patchState({ + currentSchemaResponse: { + data: schemaResponse, + isLoading: false, + error: null, + }, + }); + }), + catchError((error) => handleSectionError(ctx, 'currentSchemaResponse', error)) + ); + } + + @Action(WithdrawRegistration) + withdrawRegistration(ctx: StateContext, action: WithdrawRegistration) { + const state = ctx.getState(); + ctx.patchState({ + registry: { + ...state.registry, + isLoading: true, + }, + }); + + return this.registryOverviewService.withdrawRegistration(action.registryId, action.justification).pipe( + tap((registryOverview) => { + ctx.patchState({ + registry: { + data: registryOverview, + isLoading: false, + error: null, + }, + }); + }), + catchError((error) => handleSectionError(ctx, 'registry', error)) + ); + } + + @Action(MakePublic) + makePublic(ctx: StateContext, action: MakePublic) { + const state = ctx.getState(); + ctx.patchState({ + registry: { + ...state.registry, + isLoading: true, + }, + }); + + return this.registryOverviewService.makePublic(action.registryId).pipe( + tap((registryOverview) => { + ctx.patchState({ + registry: { + data: registryOverview, + isLoading: false, + error: null, + }, + }); + }), + catchError((error) => handleSectionError(ctx, 'registry', error)) + ); + } + + @Action(SetRegistryCustomCitation) + setRegistryCustomCitation(ctx: StateContext, action: SetRegistryCustomCitation) { + const state = ctx.getState(); + ctx.patchState({ + registry: { + ...state.registry, + data: { + ...state.registry.data!, + customCitation: action.citation, + }, + }, + }); + } + + @Action(GetRegistryReviewActions) + getRegistryReviewActions(ctx: StateContext, action: GetRegistryReviewActions) { + ctx.patchState({ + moderationActions: { + data: [], + isLoading: true, + isSubmitting: false, + error: null, + }, + }); + + return this.registryOverviewService.getRegistryReviewActions(action.registryId).pipe( + tap((reviewActions) => { + ctx.patchState({ + moderationActions: { + data: reviewActions, + isLoading: false, + error: null, + }, + }); + }), + catchError((error) => handleSectionError(ctx, 'moderationActions', error)) + ); + } + + @Action(SubmitDecision) + submitDecision(ctx: StateContext, action: SubmitDecision) { + ctx.patchState({ + moderationActions: { + data: [], + isLoading: true, + isSubmitting: true, + error: null, + }, + }); + + return this.registryOverviewService.submitDecision(action.payload, action.isRevision).pipe( + tap(() => { + ctx.patchState({ + moderationActions: { + data: [], + isLoading: false, + isSubmitting: false, + error: null, + }, + }); + }), + catchError((error) => handleSectionError(ctx, 'moderationActions', error)) + ); + } + + @Action(ClearRegistry) + clearRegistry(ctx: StateContext) { + ctx.patchState(REGISTRY_DEFAULTS); + } +} diff --git a/src/app/features/settings/tokens/pages/token-details/token-details.component.ts b/src/app/features/settings/tokens/pages/token-details/token-details.component.ts index 59f78cced..85890dd8f 100644 --- a/src/app/features/settings/tokens/pages/token-details/token-details.component.ts +++ b/src/app/features/settings/tokens/pages/token-details/token-details.component.ts @@ -4,6 +4,7 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { Card } from 'primeng/card'; +import { DynamicDialogRef } from 'primeng/dynamicdialog'; import { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal } from '@angular/core'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; @@ -22,6 +23,7 @@ import { DeleteToken, GetTokenById, TokensSelectors } from '../../store'; changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: './token-details.component.html', styleUrls: ['./token-details.component.scss'], + providers: [DynamicDialogRef], }) export class TokenDetailsComponent implements OnInit { private readonly customConfirmationService = inject(CustomConfirmationService); diff --git a/src/app/shared/components/affiliated-institutions-view/affiliated-institutions-view.component.html b/src/app/shared/components/affiliated-institutions-view/affiliated-institutions-view.component.html index fda72eac6..fc9f2e2dd 100644 --- a/src/app/shared/components/affiliated-institutions-view/affiliated-institutions-view.component.html +++ b/src/app/shared/components/affiliated-institutions-view/affiliated-institutions-view.component.html @@ -1,23 +1,19 @@ -
- @if (showTitle()) { -

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

- } - -
- @if (institutions().length) { - @for (institution of institutions(); track institution.id) { - - institution logo - - } - } @else { -

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

+
+ @if (isLoading()) { + + } @else { + @for (institution of institutions(); track institution.id) { + + Institution logo + + } @empty { +

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

} -
-
+ } +
diff --git a/src/app/shared/components/affiliated-institutions-view/affiliated-institutions-view.component.spec.ts b/src/app/shared/components/affiliated-institutions-view/affiliated-institutions-view.component.spec.ts index c076d3026..497468a59 100644 --- a/src/app/shared/components/affiliated-institutions-view/affiliated-institutions-view.component.spec.ts +++ b/src/app/shared/components/affiliated-institutions-view/affiliated-institutions-view.component.spec.ts @@ -57,27 +57,27 @@ describe('AffiliatedInstitutionsViewComponent', () => { expect(component.institutions().length).toBe(3); }); - it('should have default true for showTitle', () => { + it('should have default false for isLoading', () => { fixture.componentRef.setInput('institutions', mockInstitutions); fixture.detectChanges(); - expect(component.showTitle()).toBe(true); + expect(component.isLoading()).toBe(false); }); - it('should accept showTitle input as true', () => { + it('should accept isLoading input as true', () => { fixture.componentRef.setInput('institutions', mockInstitutions); - fixture.componentRef.setInput('showTitle', true); + fixture.componentRef.setInput('isLoading', true); fixture.detectChanges(); - expect(component.showTitle()).toBe(true); + expect(component.isLoading()).toBe(true); }); - it('should accept showTitle input as false', () => { + it('should accept isLoading input as false', () => { fixture.componentRef.setInput('institutions', mockInstitutions); - fixture.componentRef.setInput('showTitle', false); + fixture.componentRef.setInput('isLoading', false); fixture.detectChanges(); - expect(component.showTitle()).toBe(false); + expect(component.isLoading()).toBe(false); }); it('should update when institutions input changes', () => { @@ -97,16 +97,16 @@ describe('AffiliatedInstitutionsViewComponent', () => { expect(component.institutions()[0].name).toBe('Updated Institution'); }); - it('should update when showTitle input changes', () => { + it('should update when isLoading input changes', () => { fixture.componentRef.setInput('institutions', mockInstitutions); - fixture.componentRef.setInput('showTitle', true); + fixture.componentRef.setInput('isLoading', false); fixture.detectChanges(); - expect(component.showTitle()).toBe(true); + expect(component.isLoading()).toBe(false); - fixture.componentRef.setInput('showTitle', false); + fixture.componentRef.setInput('isLoading', true); fixture.detectChanges(); - expect(component.showTitle()).toBe(false); + expect(component.isLoading()).toBe(true); }); }); diff --git a/src/app/shared/components/affiliated-institutions-view/affiliated-institutions-view.component.ts b/src/app/shared/components/affiliated-institutions-view/affiliated-institutions-view.component.ts index 6f7e2bd2e..8edecaf23 100644 --- a/src/app/shared/components/affiliated-institutions-view/affiliated-institutions-view.component.ts +++ b/src/app/shared/components/affiliated-institutions-view/affiliated-institutions-view.component.ts @@ -1,5 +1,6 @@ import { TranslatePipe } from '@ngx-translate/core'; +import { Skeleton } from 'primeng/skeleton'; import { Tooltip } from 'primeng/tooltip'; import { ChangeDetectionStrategy, Component, input } from '@angular/core'; @@ -9,12 +10,12 @@ import { Institution } from '@osf/shared/models/institutions/institutions.models @Component({ selector: 'osf-affiliated-institutions-view', - imports: [TranslatePipe, RouterLink, Tooltip], + imports: [TranslatePipe, RouterLink, Tooltip, Skeleton], templateUrl: './affiliated-institutions-view.component.html', styleUrl: './affiliated-institutions-view.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class AffiliatedInstitutionsViewComponent { - showTitle = input(true); institutions = input.required(); + isLoading = input(false); } diff --git a/src/app/shared/components/password-input-hint/password-input-hint.component.spec.ts b/src/app/shared/components/password-input-hint/password-input-hint.component.spec.ts index cbb980968..18924152f 100644 --- a/src/app/shared/components/password-input-hint/password-input-hint.component.spec.ts +++ b/src/app/shared/components/password-input-hint/password-input-hint.component.spec.ts @@ -1,4 +1,5 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormControl, Validators } from '@angular/forms'; import { PasswordInputHintComponent } from './password-input-hint.component'; @@ -15,16 +16,66 @@ describe('PasswordInputHintComponent', () => { fixture = TestBed.createComponent(PasswordInputHintComponent); component = fixture.componentInstance; - fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); - it('should display password requirements text', () => { - const compiled = fixture.nativeElement as HTMLElement; - const smallElement = compiled.querySelector('small'); - expect(smallElement).toBeTruthy(); + it('should have default control input value as null', () => { + expect(component.control()).toBeNull(); + }); + + it('should set control input correctly', () => { + const mockControl = new FormControl(''); + fixture.componentRef.setInput('control', mockControl); + expect(component.control()).toBe(mockControl); + }); + + it('should return null for validationError when control is null', () => { + fixture.componentRef.setInput('control', null); + expect(component.validationError).toBeNull(); + }); + + it('should return null for validationError when control has no errors', () => { + const mockControl = new FormControl('valid'); + fixture.componentRef.setInput('control', mockControl); + expect(component.validationError).toBeNull(); + }); + + it('should return null for validationError when control is not touched', () => { + const mockControl = new FormControl('', Validators.required); + mockControl.setErrors({ required: true }); + fixture.componentRef.setInput('control', mockControl); + expect(component.validationError).toBeNull(); + }); + + it('should return required for validationError when errors.required exists and control is touched', () => { + const mockControl = new FormControl('', Validators.required); + mockControl.markAsTouched(); + fixture.componentRef.setInput('control', mockControl); + expect(component.validationError).toBe('required'); + }); + + it('should return minlength for validationError when errors.minlength exists and control is touched', () => { + const mockControl = new FormControl('ab', Validators.minLength(5)); + mockControl.markAsTouched(); + fixture.componentRef.setInput('control', mockControl); + expect(component.validationError).toBe('minlength'); + }); + + it('should return pattern for validationError when errors.pattern exists and control is touched', () => { + const mockControl = new FormControl('invalid', Validators.pattern(/[A-Z]/)); + mockControl.markAsTouched(); + fixture.componentRef.setInput('control', mockControl); + expect(component.validationError).toBe('pattern'); + }); + + it('should return null for validationError when control has other errors and is touched', () => { + const mockControl = new FormControl(''); + mockControl.setErrors({ customError: true }); + mockControl.markAsTouched(); + fixture.componentRef.setInput('control', mockControl); + expect(component.validationError).toBeNull(); }); }); diff --git a/src/app/shared/components/resource-citations/resource-citations.component.html b/src/app/shared/components/resource-citations/resource-citations.component.html index 9cdce7d8c..eb4b31678 100644 --- a/src/app/shared/components/resource-citations/resource-citations.component.html +++ b/src/app/shared/components/resource-citations/resource-citations.component.html @@ -1,36 +1,39 @@ -@let resource = currentResource(); +@let customCitation = customCitations(); -@if (resource) { +@if (resourceId()) { } +

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

+ {{ citation.title }} @if (styledCitation()) {

{{ styledCitation()?.citation }}

} + + @if (!hasViewOnly || canEdit()) { } } @@ -87,29 +94,32 @@

{{ citation.title }}

class="w-full" [formControl]="customCitationInput" > +
+ +
diff --git a/src/app/shared/components/resource-citations/resource-citations.component.spec.ts b/src/app/shared/components/resource-citations/resource-citations.component.spec.ts index afb3d80e0..df5fc86f8 100644 --- a/src/app/shared/components/resource-citations/resource-citations.component.spec.ts +++ b/src/app/shared/components/resource-citations/resource-citations.component.spec.ts @@ -3,15 +3,16 @@ import { MockProvider } from 'ng-mocks'; import { Clipboard } from '@angular/cdk/clipboard'; import { signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { CurrentResourceType } from '@osf/shared/enums/resource-type.enum'; import { ToastService } from '@osf/shared/services/toast.service'; -import { ResourceOverview } from '@shared/models/resource-overview.model'; import { CitationsSelectors } from '@shared/stores/citations'; import { ResourceCitationsComponent } from './resource-citations.component'; -import { MOCK_RESOURCE_OVERVIEW } from '@testing/mocks/resource.mock'; import { OSFTestingModule } from '@testing/osf.testing.module'; +import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; import { ToastServiceMockBuilder } from '@testing/providers/toast-provider.mock'; @@ -20,14 +21,18 @@ describe('ResourceCitationsComponent', () => { let fixture: ComponentFixture; let mockClipboard: jest.Mocked; let mockToastService: ReturnType; + let mockRouter: ReturnType; - const mockResource: ResourceOverview = MOCK_RESOURCE_OVERVIEW; + const mockResourceId = 'resource-123'; + const mockResourceType = CurrentResourceType.Projects; + const mockCustomCitation = 'Custom citation text'; beforeEach(async () => { mockClipboard = { copy: jest.fn(), } as any; mockToastService = ToastServiceMockBuilder.create().build(); + mockRouter = RouterMockBuilder.create().build(); await TestBed.configureTestingModule({ imports: [ResourceCitationsComponent, OSFTestingModule], @@ -44,6 +49,7 @@ describe('ResourceCitationsComponent', () => { }), MockProvider(Clipboard, mockClipboard), MockProvider(ToastService, mockToastService), + MockProvider(Router, mockRouter), ], }).compileComponents(); @@ -52,24 +58,28 @@ describe('ResourceCitationsComponent', () => { }); it('should create', () => { - fixture.componentRef.setInput('currentResource', mockResource); + fixture.componentRef.setInput('resourceId', mockResourceId); + fixture.componentRef.setInput('resourceType', mockResourceType); fixture.detectChanges(); expect(component).toBeTruthy(); }); - it('should have currentResource as required input', () => { - fixture.componentRef.setInput('currentResource', mockResource); + it('should have canEdit input with default value false', () => { + fixture.componentRef.setInput('resourceId', mockResourceId); + fixture.componentRef.setInput('resourceType', mockResourceType); fixture.detectChanges(); - expect(component.currentResource()).toEqual(mockResource); + expect(component.canEdit()).toBe(false); }); - it('should have canEdit input with default value false', () => { - fixture.componentRef.setInput('currentResource', mockResource); + it('should have customCitations input', () => { + fixture.componentRef.setInput('resourceId', mockResourceId); + fixture.componentRef.setInput('resourceType', mockResourceType); + fixture.componentRef.setInput('customCitations', mockCustomCitation); fixture.detectChanges(); - expect(component.canEdit()).toBe(false); + expect(component.customCitations()).toBe(mockCustomCitation); }); it('should prevent default event and not throw error', () => { @@ -78,74 +88,48 @@ describe('ResourceCitationsComponent', () => { filter: 'apa', } as any; - fixture.componentRef.setInput('currentResource', mockResource); + fixture.componentRef.setInput('resourceId', mockResourceId); + fixture.componentRef.setInput('resourceType', mockResourceType); fixture.detectChanges(); expect(() => component.handleCitationStyleFilterSearch(mockEvent)).not.toThrow(); expect(mockEvent.originalEvent.preventDefault).toHaveBeenCalled(); }); - it('should call action when resource exists', () => { - const mockEvent = { - value: { id: 'citation-style-id' }, - } as any; - - fixture.componentRef.setInput('currentResource', mockResource); - fixture.detectChanges(); - - expect(() => component.handleGetStyledCitation(mockEvent)).not.toThrow(); - }); - - it('should not throw when resource is null', () => { + it('should not throw when resourceId is empty', () => { const mockEvent = { value: { id: 'citation-style-id' }, } as any; - fixture.componentRef.setInput('currentResource', null); + fixture.componentRef.setInput('resourceId', ''); + fixture.componentRef.setInput('resourceType', mockResourceType); fixture.detectChanges(); expect(() => component.handleGetStyledCitation(mockEvent)).not.toThrow(); }); - it('should call handleUpdateCustomCitation without errors when citation is valid', () => { - fixture.componentRef.setInput('currentResource', mockResource); - component.customCitationInput.setValue('New custom citation'); - - expect(() => component.handleUpdateCustomCitation()).not.toThrow(); - }); - it('should not emit when citation text is empty', () => { - fixture.componentRef.setInput('currentResource', mockResource); + fixture.componentRef.setInput('resourceId', mockResourceId); + fixture.componentRef.setInput('resourceType', mockResourceType); component.customCitationInput.setValue(' '); - const emitSpy = jest.spyOn(component.customCitation, 'emit'); + const emitSpy = jest.spyOn(component.customCitationChange, 'emit'); component.handleUpdateCustomCitation(); expect(emitSpy).not.toHaveBeenCalled(); }); - it('should not throw when resource is null', () => { - fixture.componentRef.setInput('currentResource', null); - component.customCitationInput.setValue('Some citation'); - - expect(() => component.handleUpdateCustomCitation()).not.toThrow(); - }); - - it('should call handleDeleteCustomCitation without errors', () => { - fixture.componentRef.setInput('currentResource', mockResource); - - expect(() => component.handleDeleteCustomCitation()).not.toThrow(); - }); - - it('should not throw handleDeleteCustomCitation when resource is null', () => { - fixture.componentRef.setInput('currentResource', null); + it('should not throw handleDeleteCustomCitation when resourceId is empty', () => { + fixture.componentRef.setInput('resourceId', ''); + fixture.componentRef.setInput('resourceType', mockResourceType); expect(() => component.handleDeleteCustomCitation()).not.toThrow(); }); it('should toggle isEditMode from false to true', () => { - fixture.componentRef.setInput('currentResource', mockResource); + fixture.componentRef.setInput('resourceId', mockResourceId); + fixture.componentRef.setInput('resourceType', mockResourceType); fixture.detectChanges(); expect(component.isEditMode()).toBe(false); @@ -156,7 +140,8 @@ describe('ResourceCitationsComponent', () => { }); it('should toggle isEditMode from true to false', () => { - fixture.componentRef.setInput('currentResource', mockResource); + fixture.componentRef.setInput('resourceId', mockResourceId); + fixture.componentRef.setInput('resourceType', mockResourceType); fixture.detectChanges(); component.isEditMode.set(true); @@ -166,20 +151,10 @@ describe('ResourceCitationsComponent', () => { expect(component.isEditMode()).toBe(false); }); - it('should call toggleEditMode without errors', () => { - fixture.componentRef.setInput('currentResource', mockResource); - fixture.detectChanges(); - - expect(() => component.toggleEditMode()).not.toThrow(); - }); - - it('should copy citation to clipboard when customCitation exists', () => { - const resourceWithCitation = { - ...mockResource, - customCitation: 'Citation to copy', - }; - - fixture.componentRef.setInput('currentResource', resourceWithCitation); + it('should copy citation to clipboard when customCitations exists', () => { + fixture.componentRef.setInput('resourceId', mockResourceId); + fixture.componentRef.setInput('resourceType', mockResourceType); + fixture.componentRef.setInput('customCitations', 'Citation to copy'); fixture.detectChanges(); component.copyCitation(); @@ -188,13 +163,10 @@ describe('ResourceCitationsComponent', () => { expect(mockToastService.showSuccess).toHaveBeenCalledWith('settings.developerApps.messages.copied'); }); - it('should not copy when customCitation is empty', () => { - const resourceWithoutCitation = { - ...mockResource, - customCitation: '', - }; - - fixture.componentRef.setInput('currentResource', resourceWithoutCitation); + it('should not copy when customCitations is empty', () => { + fixture.componentRef.setInput('resourceId', mockResourceId); + fixture.componentRef.setInput('resourceType', mockResourceType); + fixture.componentRef.setInput('customCitations', ''); fixture.detectChanges(); component.copyCitation(); @@ -203,11 +175,15 @@ describe('ResourceCitationsComponent', () => { expect(mockToastService.showSuccess).not.toHaveBeenCalled(); }); - it('should not throw when resource is null', () => { - fixture.componentRef.setInput('currentResource', null); + it('should not copy when customCitations is null', () => { + fixture.componentRef.setInput('resourceId', mockResourceId); + fixture.componentRef.setInput('resourceType', mockResourceType); + fixture.componentRef.setInput('customCitations', null); fixture.detectChanges(); - expect(() => component.copyCitation()).not.toThrow(); + component.copyCitation(); + expect(mockClipboard.copy).not.toHaveBeenCalled(); + expect(mockToastService.showSuccess).not.toHaveBeenCalled(); }); }); diff --git a/src/app/shared/components/resource-citations/resource-citations.component.ts b/src/app/shared/components/resource-citations/resource-citations.component.ts index ddadf3715..406c82261 100644 --- a/src/app/shared/components/resource-citations/resource-citations.component.ts +++ b/src/app/shared/components/resource-citations/resource-citations.component.ts @@ -9,7 +9,7 @@ import { Select, SelectChangeEvent, SelectFilterEvent } from 'primeng/select'; import { Skeleton } from 'primeng/skeleton'; import { Textarea } from 'primeng/textarea'; -import { debounceTime, distinctUntilChanged, Subject, takeUntil } from 'rxjs'; +import { debounceTime, distinctUntilChanged, Subject } from 'rxjs'; import { Clipboard } from '@angular/cdk/clipboard'; import { @@ -23,13 +23,14 @@ import { output, signal, } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormControl, ReactiveFormsModule } from '@angular/forms'; import { Router } from '@angular/router'; +import { CurrentResourceType } from '@osf/shared/enums/resource-type.enum'; import { hasViewOnlyParam } from '@osf/shared/helpers/view-only.helper'; import { ToastService } from '@osf/shared/services/toast.service'; import { CitationStyle } from '@shared/models/citations/citation-style.model'; -import { ResourceOverview } from '@shared/models/resource-overview.model'; import { CustomOption } from '@shared/models/select-option.model'; import { CitationsSelectors, @@ -63,15 +64,16 @@ export class ResourceCitationsComponent { private readonly destroyRef = inject(DestroyRef); private readonly router = inject(Router); - currentResource = input.required(); + resourceId = input.required(); + resourceType = input.required(); + customCitations = input(); canEdit = input(false); private readonly clipboard = inject(Clipboard); private readonly toastService = inject(ToastService); - private readonly destroy$ = new Subject(); private readonly filterSubject = new Subject(); - customCitation = output(); + customCitationChange = output(); defaultCitations = select(CitationsSelectors.getDefaultCitations); isCitationsLoading = select(CitationsSelectors.getDefaultCitationsLoading); citationStyles = select(CitationsSelectors.getCitationStyles); @@ -102,16 +104,17 @@ export class ResourceCitationsComponent { this.setupFilterDebounce(); this.setupDefaultCitationsEffect(); this.setupCitationStylesEffect(); - this.setupCleanup(); } setupDefaultCitationsEffect(): void { effect(() => { - const resource = this.currentResource(); + const customCitations = this.customCitations(); + const resourceId = this.resourceId(); + const resourceType = this.resourceType(); - if (resource) { - this.actions.getDefaultCitations(resource.type, resource.id); - this.customCitationInput.setValue(resource.customCitation); + if (resourceId && resourceType) { + this.actions.getDefaultCitations(resourceType, resourceId); + this.customCitationInput.setValue(customCitations ?? ''); } }); } @@ -122,27 +125,29 @@ export class ResourceCitationsComponent { } handleGetStyledCitation(event: SelectChangeEvent) { - const resource = this.currentResource(); + const resourceId = this.resourceId(); + const resourceType = this.resourceType(); - if (resource) { - this.actions.getStyledCitation(resource.type, resource.id, event.value.id); + if (resourceId && resourceType) { + this.actions.getStyledCitation(resourceType, resourceId, event.value.id); } } handleUpdateCustomCitation(): void { - const resource = this.currentResource(); + const resourceId = this.resourceId(); + const resourceType = this.resourceType(); const customCitationText = this.customCitationInput.value?.trim(); - if (resource && customCitationText) { + if (resourceId && resourceType && customCitationText) { const payload = { - id: resource.id, - type: resource.type, + id: resourceId, + type: resourceType, citationText: customCitationText, }; this.actions.updateCustomCitation(payload).subscribe({ next: () => { - this.customCitation.emit(customCitationText); + this.customCitationChange.emit(customCitationText); }, complete: () => { this.toggleEditMode(); @@ -152,18 +157,19 @@ export class ResourceCitationsComponent { } handleDeleteCustomCitation(): void { - const resource = this.currentResource(); + const resourceId = this.resourceId(); + const resourceType = this.resourceType(); - if (resource) { + if (resourceId && resourceType) { const payload = { - id: resource.id, - type: resource.type, + id: resourceId, + type: resourceType, citationText: '', }; this.actions.updateCustomCitation(payload).subscribe({ next: () => { - this.customCitation.emit(''); + this.customCitationChange.emit(''); }, complete: () => { this.toggleEditMode(); @@ -180,20 +186,18 @@ export class ResourceCitationsComponent { } copyCitation(): void { - const resource = this.currentResource(); + const customCitations = this.customCitations(); - if (resource?.customCitation) { - this.clipboard.copy(resource.customCitation); + if (customCitations) { + this.clipboard.copy(customCitations); this.toastService.showSuccess('settings.developerApps.messages.copied'); } } private setupFilterDebounce(): void { this.filterSubject - .pipe(debounceTime(300), distinctUntilChanged(), takeUntil(this.destroy$)) - .subscribe((filterValue) => { - this.actions.getCitationStyles(filterValue); - }); + .pipe(debounceTime(300), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)) + .subscribe((filterValue) => this.actions.getCitationStyles(filterValue)); } private setupCitationStylesEffect(): void { @@ -204,14 +208,8 @@ export class ResourceCitationsComponent { label: style.title, value: style, })); - this.citationStylesOptions.set(options); - }); - } - private setupCleanup(): void { - this.destroyRef.onDestroy(() => { - this.destroy$.next(); - this.destroy$.complete(); + this.citationStylesOptions.set(options); }); } } diff --git a/src/app/shared/components/resource-doi/resource-doi.component.html b/src/app/shared/components/resource-doi/resource-doi.component.html new file mode 100644 index 000000000..7793ad01a --- /dev/null +++ b/src/app/shared/components/resource-doi/resource-doi.component.html @@ -0,0 +1,13 @@ +@if (isLoading()) { + +} @else { + @for (identifier of identifiers(); track identifier.id) { + @if (identifier.category === 'doi') { + + {{ identifier.value }} + + } + } @empty { +

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

+ } +} diff --git a/src/app/shared/components/resource-doi/resource-doi.component.scss b/src/app/shared/components/resource-doi/resource-doi.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/shared/components/resource-doi/resource-doi.component.spec.ts b/src/app/shared/components/resource-doi/resource-doi.component.spec.ts new file mode 100644 index 000000000..5dbf0fb17 --- /dev/null +++ b/src/app/shared/components/resource-doi/resource-doi.component.spec.ts @@ -0,0 +1,46 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { IdentifierModel } from '@osf/shared/models/identifiers/identifier.model'; + +import { ResourceDoiComponent } from './resource-doi.component'; + +import { MOCK_PROJECT_IDENTIFIERS } from '@testing/mocks/project-overview.mock'; + +describe('ResourceDoiComponent', () => { + let component: ResourceDoiComponent; + let fixture: ComponentFixture; + + const mockIdentifiers: IdentifierModel[] = [ + MOCK_PROJECT_IDENTIFIERS, + { + id: 'identifier-2', + type: 'identifiers', + category: 'doi', + value: '10.5678/another.doi', + }, + ]; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ResourceDoiComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(ResourceDoiComponent); + component = fixture.componentInstance; + }); + + it('should have default input values', () => { + expect(component.identifiers()).toEqual([]); + expect(component.isLoading()).toBe(false); + }); + + it('should set identifiers input correctly', () => { + fixture.componentRef.setInput('identifiers', mockIdentifiers); + expect(component.identifiers()).toEqual(mockIdentifiers); + }); + + it('should set isLoading input correctly', () => { + fixture.componentRef.setInput('isLoading', true); + expect(component.isLoading()).toBe(true); + }); +}); diff --git a/src/app/shared/components/resource-doi/resource-doi.component.ts b/src/app/shared/components/resource-doi/resource-doi.component.ts new file mode 100644 index 000000000..df013d293 --- /dev/null +++ b/src/app/shared/components/resource-doi/resource-doi.component.ts @@ -0,0 +1,19 @@ +import { TranslatePipe } from '@ngx-translate/core'; + +import { Skeleton } from 'primeng/skeleton'; + +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; + +import { IdentifierModel } from '@osf/shared/models/identifiers/identifier.model'; + +@Component({ + selector: 'osf-resource-doi', + imports: [Skeleton, TranslatePipe], + templateUrl: './resource-doi.component.html', + styleUrl: './resource-doi.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ResourceDoiComponent { + identifiers = input([]); + isLoading = input(false); +} diff --git a/src/app/shared/components/resource-license/resource-license.component.html b/src/app/shared/components/resource-license/resource-license.component.html new file mode 100644 index 000000000..8a0c6955c --- /dev/null +++ b/src/app/shared/components/resource-license/resource-license.component.html @@ -0,0 +1,5 @@ +@if (isLoading()) { + +} @else { +
{{ license()?.name ?? ('common.labels.noLicense' | translate) }}
+} diff --git a/src/app/shared/components/resource-license/resource-license.component.scss b/src/app/shared/components/resource-license/resource-license.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/shared/components/resource-license/resource-license.component.spec.ts b/src/app/shared/components/resource-license/resource-license.component.spec.ts new file mode 100644 index 000000000..c2765d03e --- /dev/null +++ b/src/app/shared/components/resource-license/resource-license.component.spec.ts @@ -0,0 +1,56 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; + +import { ResourceLicenseComponent } from './resource-license.component'; + +import { MOCK_LICENSE } from '@testing/mocks/license.mock'; +import { OSFTestingModule } from '@testing/osf.testing.module'; + +describe('ResourceLicenseComponent', () => { + let component: ResourceLicenseComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ResourceLicenseComponent, OSFTestingModule], + }).compileComponents(); + + fixture = TestBed.createComponent(ResourceLicenseComponent); + component = fixture.componentInstance; + }); + + it('should have default input values', () => { + expect(component.license()).toBeUndefined(); + expect(component.isLoading()).toBe(false); + }); + + it('should set license input correctly with LicenseModel', () => { + fixture.componentRef.setInput('license', MOCK_LICENSE); + expect(component.license()).toEqual(MOCK_LICENSE); + }); + + it('should set license input correctly with null value', () => { + fixture.componentRef.setInput('license', null); + expect(component.license()).toBeNull(); + }); + + it('should set license input correctly with undefined value', () => { + fixture.componentRef.setInput('license', undefined); + expect(component.license()).toBeUndefined(); + }); + + it('should set isLoading input correctly', () => { + fixture.componentRef.setInput('isLoading', true); + expect(component.isLoading()).toBe(true); + }); + + it('should display license name when license is provided and isLoading is false', () => { + fixture.componentRef.setInput('license', MOCK_LICENSE); + fixture.componentRef.setInput('isLoading', false); + fixture.detectChanges(); + + const divElement = fixture.debugElement.query(By.css('div')); + expect(divElement).toBeTruthy(); + expect(divElement.nativeElement.textContent.trim()).toBe('Apache License, 2.0'); + }); +}); diff --git a/src/app/shared/components/resource-license/resource-license.component.ts b/src/app/shared/components/resource-license/resource-license.component.ts new file mode 100644 index 000000000..cd58b0082 --- /dev/null +++ b/src/app/shared/components/resource-license/resource-license.component.ts @@ -0,0 +1,19 @@ +import { TranslatePipe } from '@ngx-translate/core'; + +import { Skeleton } from 'primeng/skeleton'; + +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; + +import { LicenseModel } from '@osf/shared/models/license/license.model'; + +@Component({ + selector: 'osf-resource-license', + imports: [Skeleton, TranslatePipe], + templateUrl: './resource-license.component.html', + styleUrl: './resource-license.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ResourceLicenseComponent { + license = input(); + isLoading = input(false); +} diff --git a/src/app/shared/components/resource-metadata/resource-metadata.component.html b/src/app/shared/components/resource-metadata/resource-metadata.component.html deleted file mode 100644 index 7e79448a9..000000000 --- a/src/app/shared/components/resource-metadata/resource-metadata.component.html +++ /dev/null @@ -1,178 +0,0 @@ -@let resource = currentResource(); - -@if (resource) { - -} diff --git a/src/app/shared/components/resource-metadata/resource-metadata.component.ts b/src/app/shared/components/resource-metadata/resource-metadata.component.ts deleted file mode 100644 index 049dd7f91..000000000 --- a/src/app/shared/components/resource-metadata/resource-metadata.component.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { TranslatePipe } from '@ngx-translate/core'; - -import { Button } from 'primeng/button'; -import { Tag } from 'primeng/tag'; - -import { DatePipe } from '@angular/common'; -import { ChangeDetectionStrategy, Component, computed, inject, input, output } from '@angular/core'; -import { Router, RouterLink } from '@angular/router'; - -import { ENVIRONMENT } from '@core/provider/environment.provider'; -import { OverviewCollectionsComponent } from '@osf/features/project/overview/components/overview-collections/overview-collections.component'; -import { CurrentResourceType } from '@osf/shared/enums/resource-type.enum'; -import { ContributorModel } from '@shared/models/contributors/contributor.model'; -import { ResourceOverview } from '@shared/models/resource-overview.model'; - -import { AffiliatedInstitutionsViewComponent } from '../affiliated-institutions-view/affiliated-institutions-view.component'; -import { ContributorsListComponent } from '../contributors-list/contributors-list.component'; -import { ResourceCitationsComponent } from '../resource-citations/resource-citations.component'; -import { TruncatedTextComponent } from '../truncated-text/truncated-text.component'; - -@Component({ - selector: 'osf-resource-metadata', - imports: [ - Button, - TranslatePipe, - TruncatedTextComponent, - RouterLink, - Tag, - DatePipe, - ResourceCitationsComponent, - OverviewCollectionsComponent, - AffiliatedInstitutionsViewComponent, - ContributorsListComponent, - ], - templateUrl: './resource-metadata.component.html', - styleUrl: './resource-metadata.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class ResourceMetadataComponent { - private readonly environment = inject(ENVIRONMENT); - private readonly router = inject(Router); - - currentResource = input.required(); - customCitationUpdated = output(); - isCollectionsRoute = input(false); - canEdit = input.required(); - showEditButton = input(); - bibliographicContributors = input([]); - isBibliographicContributorsLoading = input(false); - hasMoreBibliographicContributors = input(false); - loadMoreContributors = output(); - - readonly resourceTypes = CurrentResourceType; - readonly dateFormat = 'MMM d, y, h:mm a'; - readonly webUrl = this.environment.webUrl; - - isProject = computed(() => this.currentResource()?.type === CurrentResourceType.Projects); - isRegistration = computed(() => this.currentResource()?.type === CurrentResourceType.Registrations); - - onCustomCitationUpdated(citation: string): void { - this.customCitationUpdated.emit(citation); - } - - tagClicked(tag: string) { - this.router.navigate(['/search'], { queryParams: { search: tag } }); - } -} diff --git a/src/app/shared/components/subjects-list/subjects-list.component.html b/src/app/shared/components/subjects-list/subjects-list.component.html new file mode 100644 index 000000000..6afb23da7 --- /dev/null +++ b/src/app/shared/components/subjects-list/subjects-list.component.html @@ -0,0 +1,11 @@ +
+ @if (isLoading()) { + + } @else { + @for (subject of subjects(); track subject.id) { + + } @empty { +

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

+ } + } +
diff --git a/src/app/shared/components/subjects-list/subjects-list.component.scss b/src/app/shared/components/subjects-list/subjects-list.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/shared/components/subjects-list/subjects-list.component.spec.ts b/src/app/shared/components/subjects-list/subjects-list.component.spec.ts new file mode 100644 index 000000000..9033cb5ea --- /dev/null +++ b/src/app/shared/components/subjects-list/subjects-list.component.spec.ts @@ -0,0 +1,67 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; + +import { SubjectsListComponent } from './subjects-list.component'; + +import { SUBJECTS_MOCK } from '@testing/mocks/subject.mock'; +import { OSFTestingModule } from '@testing/osf.testing.module'; + +describe('SubjectsListComponent', () => { + let component: SubjectsListComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SubjectsListComponent, OSFTestingModule], + }).compileComponents(); + + fixture = TestBed.createComponent(SubjectsListComponent); + component = fixture.componentInstance; + }); + + it('should have default input values', () => { + expect(component.subjects()).toEqual([]); + expect(component.isLoading()).toBe(false); + }); + + it('should set subjects input correctly', () => { + fixture.componentRef.setInput('subjects', SUBJECTS_MOCK); + expect(component.subjects()).toEqual(SUBJECTS_MOCK); + }); + + it('should set isLoading input correctly', () => { + fixture.componentRef.setInput('isLoading', true); + expect(component.isLoading()).toBe(true); + }); + + it('should render subjects when subjects array has items and isLoading is false', () => { + fixture.componentRef.setInput('subjects', SUBJECTS_MOCK); + fixture.componentRef.setInput('isLoading', false); + fixture.detectChanges(); + + const tagElements = fixture.debugElement.queryAll(By.css('p-tag')); + expect(tagElements.length).toBe(2); + }); + + it('should render none message when subjects array is empty and isLoading is false', () => { + fixture.componentRef.setInput('subjects', []); + fixture.componentRef.setInput('isLoading', false); + fixture.detectChanges(); + + const messageElement = fixture.debugElement.query(By.css('p')); + expect(messageElement).toBeTruthy(); + expect(messageElement.nativeElement.textContent).toContain('common.labels.none'); + }); + + it('should show skeleton and not subjects when isLoading is true', () => { + fixture.componentRef.setInput('subjects', SUBJECTS_MOCK); + fixture.componentRef.setInput('isLoading', true); + fixture.detectChanges(); + + const skeleton = fixture.debugElement.query(By.css('p-skeleton')); + const tagElements = fixture.debugElement.queryAll(By.css('p-tag')); + + expect(skeleton).toBeTruthy(); + expect(tagElements.length).toBe(0); + }); +}); diff --git a/src/app/shared/components/subjects-list/subjects-list.component.ts b/src/app/shared/components/subjects-list/subjects-list.component.ts new file mode 100644 index 000000000..0927a649c --- /dev/null +++ b/src/app/shared/components/subjects-list/subjects-list.component.ts @@ -0,0 +1,20 @@ +import { TranslatePipe } from '@ngx-translate/core'; + +import { Skeleton } from 'primeng/skeleton'; +import { Tag } from 'primeng/tag'; + +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; + +import { SubjectModel } from '@osf/shared/models/subject/subject.model'; + +@Component({ + selector: 'osf-subjects-list', + imports: [Tag, Skeleton, TranslatePipe], + templateUrl: './subjects-list.component.html', + styleUrl: './subjects-list.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SubjectsListComponent { + subjects = input([]); + isLoading = input(false); +} diff --git a/src/app/shared/components/subjects/subjects.component.spec.ts b/src/app/shared/components/subjects/subjects.component.spec.ts index 5bd39eb59..29e8a7866 100644 --- a/src/app/shared/components/subjects/subjects.component.spec.ts +++ b/src/app/shared/components/subjects/subjects.component.spec.ts @@ -1,7 +1,9 @@ import { MockComponent } from 'ng-mocks'; +import { signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { SubjectModel } from '@osf/shared/models/subject/subject.model'; import { SubjectsSelectors } from '@osf/shared/stores/subjects'; import { SearchInputComponent } from '../search-input/search-input.component'; @@ -15,16 +17,40 @@ describe('SubjectsComponent', () => { let component: SubjectsComponent; let fixture: ComponentFixture; + const mockParentSubject: SubjectModel = { + id: 'parent-1', + name: 'Parent Subject', + children: [], + parent: null, + }; + + const mockChildSubject: SubjectModel = { + id: 'child-1', + name: 'Child Subject', + children: [], + parent: mockParentSubject, + }; + + const mockSubjectWithChildren: SubjectModel = { + id: 'parent-2', + name: 'Parent with Children', + children: [mockChildSubject], + parent: null, + }; + + const mockSubjects: SubjectModel[] = [mockParentSubject, mockSubjectWithChildren]; + const mockSearchedSubjects: SubjectModel[] = [mockChildSubject]; + beforeEach(async () => { await TestBed.configureTestingModule({ imports: [SubjectsComponent, OSFTestingStoreModule, MockComponent(SearchInputComponent)], providers: [ provideMockStore({ signals: [ - { selector: SubjectsSelectors.getSubjects, value: [] }, - { selector: SubjectsSelectors.getSubjectsLoading, value: false }, - { selector: SubjectsSelectors.getSearchedSubjects, value: [] }, - { selector: SubjectsSelectors.getSearchedSubjectsLoading, value: false }, + { selector: SubjectsSelectors.getSubjects, value: signal(mockSubjects) }, + { selector: SubjectsSelectors.getSubjectsLoading, value: signal(false) }, + { selector: SubjectsSelectors.getSearchedSubjects, value: signal(mockSearchedSubjects) }, + { selector: SubjectsSelectors.getSearchedSubjectsLoading, value: signal(false) }, ], }), ], @@ -32,17 +58,232 @@ describe('SubjectsComponent', () => { fixture = TestBed.createComponent(SubjectsComponent); component = fixture.componentInstance; - fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); - it('should render with label and description', () => { - const headerElement = fixture.nativeElement.querySelector('h2'); - expect(headerElement.textContent).toEqual('shared.subjects.title'); - const descriptionElement = fixture.nativeElement.querySelector('p'); - expect(descriptionElement.textContent).toEqual('shared.subjects.description'); + it('should initialize FormControl with empty string', () => { + expect(component.searchControl.value).toBe(''); + }); + + it('should have default areSubjectsUpdating input value as false', () => { + expect(component.areSubjectsUpdating()).toBe(false); + }); + + it('should have default selected input value as empty array', () => { + expect(component.selected()).toEqual([]); + }); + + it('should have default readonly input value as false', () => { + expect(component.readonly()).toBe(false); + }); + + it('should set areSubjectsUpdating input correctly', () => { + fixture.componentRef.setInput('areSubjectsUpdating', true); + expect(component.areSubjectsUpdating()).toBe(true); + }); + + it('should set selected input correctly', () => { + const selectedSubjects = [mockParentSubject]; + fixture.componentRef.setInput('selected', selectedSubjects); + expect(component.selected()).toEqual(selectedSubjects); + }); + + it('should set readonly input correctly', () => { + fixture.componentRef.setInput('readonly', true); + expect(component.readonly()).toBe(true); + }); + + it('should compute subjectsTree correctly', () => { + fixture.detectChanges(); + const tree = component.subjectsTree(); + expect(tree.length).toBe(2); + expect(tree[0].label).toBe('Parent Subject'); + expect(tree[0].data).toBe(mockParentSubject); + expect(tree[0].key).toBe('parent-1'); + expect(tree[1].children?.length).toBe(1); + }); + + it('should compute selectedTree correctly', () => { + fixture.componentRef.setInput('selected', [mockParentSubject]); + fixture.detectChanges(); + const tree = component.selectedTree(); + expect(tree.length).toBe(1); + expect(tree[0].label).toBe('Parent Subject'); + expect(tree[0].data).toBe(mockParentSubject); + }); + + it('should compute searchedList correctly with parents', () => { + fixture.detectChanges(); + const list = component.searchedList(); + expect(list.length).toBe(1); + expect(list[0].length).toBeGreaterThan(0); + expect(list[0][list[0].length - 1]).toBe(mockChildSubject); + }); + + it('should compute childrenIdsMap correctly', () => { + fixture.detectChanges(); + const map = component.childrenIdsMap(); + expect(map).toBeDefined(); + expect(typeof map).toBe('object'); + }); + + it('should update expanded state and emit loadChildren when loadNode is called with empty children', () => { + const emitSpy = jest.spyOn(component.loadChildren, 'emit'); + const mockTreeNode = { + data: { id: 'parent-1', children: [] }, + } as any; + + component.loadNode(mockTreeNode); + + expect(component.expanded['parent-1']).toBe(true); + expect(emitSpy).toHaveBeenCalledWith('parent-1'); + }); + + it('should not emit loadChildren when loadNode is called with non-empty children', () => { + const emitSpy = jest.spyOn(component.loadChildren, 'emit'); + const mockTreeNode = { + data: { id: 'parent-2', children: [mockChildSubject] }, + } as any; + + component.loadNode(mockTreeNode); + + expect(component.expanded['parent-2']).toBe(true); + expect(emitSpy).not.toHaveBeenCalled(); + }); + + it('should update expanded state when collapseNode is called', () => { + component.expanded['parent-1'] = true; + const mockTreeNode = { + data: { id: 'parent-1' }, + } as any; + + component.collapseNode(mockTreeNode); + + expect(component.expanded['parent-1']).toBe(false); + }); + + it('should emit updateSelection when selectSubject is called and readonly is false', () => { + const emitSpy = jest.spyOn(component.updateSelection, 'emit'); + fixture.componentRef.setInput('readonly', false); + fixture.detectChanges(); + + component.selectSubject(mockParentSubject); + + expect(emitSpy).toHaveBeenCalled(); + }); + + it('should not emit updateSelection when selectSubject is called and readonly is true', () => { + const emitSpy = jest.spyOn(component.updateSelection, 'emit'); + fixture.componentRef.setInput('readonly', true); + fixture.detectChanges(); + + component.selectSubject(mockParentSubject); + + expect(emitSpy).not.toHaveBeenCalled(); + }); + + it('should emit updateSelection when removeSubject is called and readonly is false', () => { + const emitSpy = jest.spyOn(component.updateSelection, 'emit'); + fixture.componentRef.setInput('selected', [mockParentSubject]); + fixture.componentRef.setInput('readonly', false); + fixture.detectChanges(); + + component.removeSubject(mockParentSubject); + + expect(emitSpy).toHaveBeenCalled(); + }); + + it('should not emit updateSelection when removeSubject is called and readonly is true', () => { + const emitSpy = jest.spyOn(component.updateSelection, 'emit'); + fixture.componentRef.setInput('selected', [mockParentSubject]); + fixture.componentRef.setInput('readonly', true); + fixture.detectChanges(); + + component.removeSubject(mockParentSubject); + + expect(emitSpy).not.toHaveBeenCalled(); + }); + + it('should emit updateSelection when selectSearched is called with checked true', () => { + const emitSpy = jest.spyOn(component.updateSelection, 'emit'); + fixture.componentRef.setInput('readonly', false); + fixture.detectChanges(); + + const mockEvent = { + checked: true, + } as any; + + component.selectSearched(mockEvent, [mockParentSubject]); + + expect(emitSpy).toHaveBeenCalled(); + }); + + it('should emit updateSelection when selectSearched is called with checked false', () => { + const emitSpy = jest.spyOn(component.updateSelection, 'emit'); + fixture.componentRef.setInput('selected', [mockParentSubject]); + fixture.componentRef.setInput('readonly', false); + fixture.detectChanges(); + + const mockEvent = { + checked: false, + } as any; + + component.selectSearched(mockEvent, [mockParentSubject]); + + expect(emitSpy).toHaveBeenCalled(); + }); + + it('should not emit updateSelection when selectSearched is called and readonly is true', () => { + const emitSpy = jest.spyOn(component.updateSelection, 'emit'); + fixture.componentRef.setInput('readonly', true); + fixture.detectChanges(); + + const mockEvent = { + checked: true, + } as any; + + component.selectSearched(mockEvent, [mockParentSubject]); + + expect(emitSpy).not.toHaveBeenCalled(); + }); + + it('should return true for isChecked when all subjects are selected', () => { + fixture.componentRef.setInput('selected', [mockParentSubject, mockChildSubject]); + fixture.detectChanges(); + + const result = component.isChecked([mockParentSubject, mockChildSubject]); + + expect(result).toBe(true); + }); + + it('should return false for isChecked when not all subjects are selected', () => { + fixture.componentRef.setInput('selected', [mockParentSubject]); + fixture.detectChanges(); + + const result = component.isChecked([mockParentSubject, mockChildSubject]); + + expect(result).toBe(false); + }); + + it('should return false for isChecked when subjects array is empty', () => { + fixture.detectChanges(); + + const result = component.isChecked([]); + + expect(result).toBe(false); + }); + + it('should emit searchChanged with debounce when searchControl value changes', () => { + jest.useFakeTimers(); + const emitSpy = jest.spyOn(component.searchChanged, 'emit'); + + component.searchControl.setValue('test search'); + jest.advanceTimersByTime(300); + + expect(emitSpy).toHaveBeenCalledWith('test search'); + jest.useRealTimers(); }); }); diff --git a/src/app/shared/components/tags-list/tags-list.component.html b/src/app/shared/components/tags-list/tags-list.component.html new file mode 100644 index 000000000..69ea7d7ec --- /dev/null +++ b/src/app/shared/components/tags-list/tags-list.component.html @@ -0,0 +1,11 @@ +
+ @if (isLoading()) { + + } @else { + @for (tag of tags(); track tag) { + + } @empty { +

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

+ } + } +
diff --git a/src/app/shared/components/tags-list/tags-list.component.scss b/src/app/shared/components/tags-list/tags-list.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/shared/components/tags-list/tags-list.component.spec.ts b/src/app/shared/components/tags-list/tags-list.component.spec.ts new file mode 100644 index 000000000..7ace3f459 --- /dev/null +++ b/src/app/shared/components/tags-list/tags-list.component.spec.ts @@ -0,0 +1,65 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; + +import { TagsListComponent } from './tags-list.component'; + +import { OSFTestingModule } from '@testing/osf.testing.module'; + +describe('TagsListComponent', () => { + let component: TagsListComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TagsListComponent, OSFTestingModule], + }).compileComponents(); + + fixture = TestBed.createComponent(TagsListComponent); + component = fixture.componentInstance; + }); + + it('should have default input values', () => { + expect(component.tags()).toEqual([]); + expect(component.isLoading()).toBe(false); + }); + + it('should set tags input correctly', () => { + const mockTags = ['tag1', 'tag2', 'tag3']; + fixture.componentRef.setInput('tags', mockTags); + expect(component.tags()).toEqual(mockTags); + }); + + it('should set isLoading input correctly', () => { + fixture.componentRef.setInput('isLoading', true); + expect(component.isLoading()).toBe(true); + }); + + it('should render tags when tags array has items and isLoading is false', () => { + const mockTags = ['tag1', 'tag2', 'tag3']; + fixture.componentRef.setInput('tags', mockTags); + fixture.componentRef.setInput('isLoading', false); + fixture.detectChanges(); + + const tagElements = fixture.debugElement.queryAll(By.css('p-tag')); + expect(tagElements.length).toBe(3); + }); + + it('should have tagClick output defined', () => { + expect(component.tagClick).toBeDefined(); + }); + + it('should emit tagClick with correct tag value when tag is clicked', () => { + const mockTags = ['tag1', 'tag2', 'tag3']; + fixture.componentRef.setInput('tags', mockTags); + fixture.componentRef.setInput('isLoading', false); + fixture.detectChanges(); + + const emitSpy = jest.spyOn(component.tagClick, 'emit'); + const tagElements = fixture.debugElement.queryAll(By.css('p-tag')); + + tagElements[0].triggerEventHandler('click', null); + + expect(emitSpy).toHaveBeenCalledTimes(1); + expect(emitSpy).toHaveBeenCalledWith('tag1'); + }); +}); diff --git a/src/app/shared/components/tags-list/tags-list.component.ts b/src/app/shared/components/tags-list/tags-list.component.ts new file mode 100644 index 000000000..b1a3b4a56 --- /dev/null +++ b/src/app/shared/components/tags-list/tags-list.component.ts @@ -0,0 +1,23 @@ +import { TranslatePipe } from '@ngx-translate/core'; + +import { Skeleton } from 'primeng/skeleton'; +import { Tag } from 'primeng/tag'; + +import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; + +@Component({ + selector: 'osf-tags-list', + imports: [Tag, Skeleton, TranslatePipe], + templateUrl: './tags-list.component.html', + styleUrl: './tags-list.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class TagsListComponent { + tags = input([]); + isLoading = input(false); + tagClick = output(); + + onTagClick(tag: string): void { + this.tagClick.emit(tag); + } +} diff --git a/src/app/features/my-projects/mappers/my-resources.mapper.ts b/src/app/shared/mappers/my-resources.mapper.ts similarity index 71% rename from src/app/features/my-projects/mappers/my-resources.mapper.ts rename to src/app/shared/mappers/my-resources.mapper.ts index 80be683b6..b924d9a5c 100644 --- a/src/app/features/my-projects/mappers/my-resources.mapper.ts +++ b/src/app/shared/mappers/my-resources.mapper.ts @@ -1,8 +1,6 @@ -import { ContributorsMapper } from '@osf/shared/mappers/contributors'; -import { - MyResourcesItem, - MyResourcesItemGetResponseJsonApi, -} from '@osf/shared/models/my-resources/my-resources.models'; +import { MyResourcesItem, MyResourcesItemGetResponseJsonApi } from '../models/my-resources/my-resources.models'; + +import { ContributorsMapper } from './contributors'; export class MyResourcesMapper { static fromResponse(response: MyResourcesItemGetResponseJsonApi): MyResourcesItem { diff --git a/src/app/shared/mappers/registration-provider.mapper.ts b/src/app/shared/mappers/registration-provider.mapper.ts index 6c6505520..36d19175c 100644 --- a/src/app/shared/mappers/registration-provider.mapper.ts +++ b/src/app/shared/mappers/registration-provider.mapper.ts @@ -12,7 +12,7 @@ export class RegistrationProviderMapper { } static fromRegistryProvider(response: RegistryProviderDetailsJsonApi): RegistryProviderDetails { - const brandRaw = response.embeds!.brand.data; + const brandRaw = response.embeds?.brand.data; return { id: response.id, diff --git a/src/app/shared/mappers/registration/map-registry-status.mapper.ts b/src/app/shared/mappers/registration/map-registry-status.mapper.ts index 05515bf4d..308fc334f 100644 --- a/src/app/shared/mappers/registration/map-registry-status.mapper.ts +++ b/src/app/shared/mappers/registration/map-registry-status.mapper.ts @@ -1,11 +1,11 @@ -import { RegistryOverviewJsonApiAttributes } from '@osf/features/registry/models'; import { RegistrationReviewStates } from '@osf/shared/enums/registration-review-states.enum'; import { RegistryStatus } from '@osf/shared/enums/registry-status.enum'; import { RevisionReviewStates } from '@osf/shared/enums/revision-review-states.enum'; +import { RegistrationNodeAttributesJsonApi } from '@osf/shared/models/registration/registration-node-json-api.model'; import { RegistrationAttributesJsonApi } from '@shared/models/registration/registration-json-api.model'; export function MapRegistryStatus( - registry: RegistryOverviewJsonApiAttributes | RegistrationAttributesJsonApi + registry: RegistrationNodeAttributesJsonApi | RegistrationAttributesJsonApi ): RegistryStatus { if (registry.pending_embargo_approval) { return RegistryStatus.PendingEmbargoApproval; diff --git a/src/app/shared/mappers/registration/registration-node.mapper.ts b/src/app/shared/mappers/registration/registration-node.mapper.ts index feae85630..fda7ddd86 100644 --- a/src/app/shared/mappers/registration/registration-node.mapper.ts +++ b/src/app/shared/mappers/registration/registration-node.mapper.ts @@ -65,8 +65,8 @@ export class RegistrationNodeMapper { static getRegistrationResponses(response: RegistrationResponsesJsonApi): RegistrationResponses { return { - summary: response.summary, - uploader: response.uploader.map((uploadItem) => ({ + summary: response?.summary, + uploader: response?.uploader?.map((uploadItem) => ({ fileId: uploadItem.file_id, fileName: uploadItem.file_name, fileUrls: uploadItem.file_urls, diff --git a/src/app/shared/mappers/resource-overview.mappers.ts b/src/app/shared/mappers/resource-overview.mappers.ts index 8a1ef1fac..994327e13 100644 --- a/src/app/shared/mappers/resource-overview.mappers.ts +++ b/src/app/shared/mappers/resource-overview.mappers.ts @@ -1,8 +1,6 @@ import { ProjectOverview } from '@osf/features/project/overview/models'; -import { RegistryOverview } from '@osf/features/registry/models'; import { ContributorModel } from '../models/contributors/contributor.model'; -import { Institution } from '../models/institutions/institutions.models'; import { ResourceOverview } from '../models/resource-overview.model'; import { SubjectModel } from '../models/subject/subject.model'; @@ -48,47 +46,3 @@ export function MapProjectOverview( isAnonymous, }; } - -export function MapRegistryOverview( - registry: RegistryOverview, - subjects: SubjectModel[], - institutions: Institution[], - isAnonymous = false -): ResourceOverview { - return { - id: registry.id, - title: registry.title, - type: registry.type, - description: registry.description, - dateModified: registry.dateModified, - dateCreated: registry.dateCreated, - dateRegistered: registry.dateRegistered, - isPublic: registry.isPublic, - category: registry.category, - isRegistration: true, - isPreprint: false, - isCollection: false, - isFork: registry.isFork, - tags: registry.tags || [], - accessRequestsEnabled: registry.accessRequestsEnabled, - nodeLicense: registry.nodeLicense, - license: registry.license || undefined, - identifiers: registry.identifiers?.filter(Boolean) || undefined, - analyticsKey: registry.analyticsKey, - registrationType: registry.registrationType, - currentUserCanComment: registry.currentUserCanComment, - currentUserPermissions: registry.currentUserPermissions || [], - currentUserIsContributor: registry.currentUserIsContributor, - currentUserIsContributorOrGroupMember: registry.currentUserIsContributorOrGroupMember, - wikiEnabled: registry.wikiEnabled, - contributors: registry.contributors?.filter(Boolean) || [], - region: registry.region || undefined, - forksCount: registry.forksCount, - subjects: subjects, - customCitation: registry.customCitation, - affiliatedInstitutions: institutions, - associatedProjectId: registry.associatedProjectId, - isAnonymous, - iaUrl: registry.iaUrl, - }; -} diff --git a/src/app/shared/models/resource-overview.model.ts b/src/app/shared/models/resource-overview.model.ts index 0b0bf5a16..3eb141058 100644 --- a/src/app/shared/models/resource-overview.model.ts +++ b/src/app/shared/models/resource-overview.model.ts @@ -2,7 +2,7 @@ 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 { LicensesOption } from './license/license.model'; +import { LicenseModel, LicensesOption } from './license/license.model'; import { SubjectModel } from './subject/subject.model'; export interface ResourceOverview { @@ -22,11 +22,7 @@ export interface ResourceOverview { tags: string[]; accessRequestsEnabled: boolean; nodeLicense?: LicensesOption; - license?: { - name: string; - text: string; - url: string; - }; + license?: LicenseModel; storage?: { id: string; type: string; diff --git a/src/app/shared/services/bookmarks.service.ts b/src/app/shared/services/bookmarks.service.ts index ccf89670c..8fa7885a0 100644 --- a/src/app/shared/services/bookmarks.service.ts +++ b/src/app/shared/services/bookmarks.service.ts @@ -1,11 +1,20 @@ -import { map, Observable } from 'rxjs'; +import { forkJoin, map, Observable } from 'rxjs'; import { inject, Injectable } from '@angular/core'; import { ENVIRONMENT } from '@core/provider/environment.provider'; import { ResourceType } from '../enums/resource-type.enum'; +import { SortOrder } from '../enums/sort-order.enum'; +import { MyResourcesMapper } from '../mappers/my-resources.mapper'; import { SparseCollectionsResponseJsonApi } from '../models/collections/collections-json-api.models'; +import { + MyResourcesItem, + MyResourcesItemGetResponseJsonApi, + MyResourcesResponseJsonApi, +} from '../models/my-resources/my-resources.models'; +import { MyResourcesSearchFilters } from '../models/my-resources/my-resources-search-filters.models'; +import { PaginatedData } from '../models/paginated-data.model'; import { JsonApiService } from './json-api.service'; @@ -30,6 +39,11 @@ export class BookmarksService { [ResourceType.Registration, 'registrations'], ]); + private sortFieldMap: Record = { + title: 'title', + dateModified: 'date_modified', + }; + getBookmarksCollectionId(): Observable { const params: Record = { 'fields[collections]': 'title,bookmarks', @@ -45,6 +59,35 @@ export class BookmarksService { ); } + getAllBookmarks(collectionId: string, filters?: MyResourcesSearchFilters) { + const params = this.buildCommonParams(filters); + + return forkJoin({ + projects: this.getResourceBookmarks(collectionId, ResourceType.Project, params), + registrations: this.getResourceBookmarks(collectionId, ResourceType.Registration, params), + }).pipe( + map(({ projects, registrations }) => { + const items = [...projects.data, ...registrations.data]; + const data = this.sortBookmarks(items, filters?.sortColumn, filters?.sortOrder); + const totalCount = projects.meta.total + registrations.meta.total; + + return { data, totalCount, pageSize: projects.meta.per_page } as PaginatedData; + }) + ); + } + + getResourceBookmarks(collectionId: string, resourceType: ResourceType, params: Record = {}) { + const url = `${this.apiUrl}/collections/${collectionId}/${this.urlMap.get(resourceType)}/`; + + return this.jsonApiService.get(url, params).pipe( + map((response: MyResourcesResponseJsonApi) => ({ + data: response.data.map((item: MyResourcesItemGetResponseJsonApi) => MyResourcesMapper.fromResponse(item)), + links: response.links, + meta: response.meta, + })) + ); + } + addResourceToBookmarks(bookmarksId: string, resourceId: string, resourceType: ResourceType): Observable { const url = `${this.apiUrl}/collections/${bookmarksId}/relationships/${this.urlMap.get(resourceType)}/`; const payload = { data: [{ type: this.resourceMap.get(resourceType), id: resourceId }] }; @@ -58,4 +101,38 @@ export class BookmarksService { return this.jsonApiService.delete(url, payload); } + + private buildCommonParams(filters?: MyResourcesSearchFilters): Record { + const params: Record = { + 'embed[]': ['bibliographic_contributors'], + pageNumber: 1, + pageSize: 100, + }; + + if (filters?.searchValue && filters.searchFields?.length) { + params[`filter[${filters.searchFields.join(',')}]`] = filters.searchValue; + } + + if (filters?.sortColumn && this.sortFieldMap[filters.sortColumn]) { + const apiField = this.sortFieldMap[filters.sortColumn]; + const sortPrefix = filters.sortOrder === SortOrder.Desc ? '-' : ''; + params['sort'] = `${sortPrefix}${apiField}`; + } else { + params['sort'] = '-date_modified'; + } + + return params; + } + + private sortBookmarks(items: MyResourcesItem[], sortColumn = 'dateModified', direction = SortOrder.Desc) { + return items.sort((a, b) => { + if (sortColumn === 'title') { + return direction * (a.title ?? '').localeCompare(b.title ?? '', undefined, { sensitivity: 'base' }); + } + + const aTime = a.dateModified ? new Date(a.dateModified).getTime() : 0; + const bTime = b.dateModified ? new Date(b.dateModified).getTime() : 0; + return direction * (aTime - bTime); + }); + } } diff --git a/src/app/shared/services/my-resources.service.ts b/src/app/shared/services/my-resources.service.ts index 80f06edff..b36e20f37 100644 --- a/src/app/shared/services/my-resources.service.ts +++ b/src/app/shared/services/my-resources.service.ts @@ -1,14 +1,13 @@ -import { EMPTY, Observable } from 'rxjs'; +import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { inject, Injectable } from '@angular/core'; import { ENVIRONMENT } from '@core/provider/environment.provider'; -import { MyResourcesMapper } from '@osf/features/my-projects/mappers'; import { ResourceSearchMode } from '../enums/resource-search-mode.enum'; -import { ResourceType } from '../enums/resource-type.enum'; import { SortOrder } from '../enums/sort-order.enum'; +import { MyResourcesMapper } from '../mappers/my-resources.mapper'; import { JsonApiResponse } from '../models/common/json-api.model'; import { MyResourcesItem, @@ -29,7 +28,6 @@ export class MyResourcesService { private sortFieldMap: Record = { title: 'title', dateModified: 'date_modified', - dateCreated: 'date_created', }; private readonly jsonApiService = inject(JsonApiService); @@ -94,12 +92,8 @@ export class MyResourcesService { params['filter[root][ne]'] = rootProjectId; } - let url; - if (searchMode === ResourceSearchMode.All) { - url = `${this.apiUrl}/${endpoint}`; - } else { - url = endpoint.startsWith('collections/') ? `${this.apiUrl}/${endpoint}` : `${this.apiUrl}/users/me/${endpoint}`; - } + const url = + searchMode === ResourceSearchMode.All ? `${this.apiUrl}/${endpoint}` : `${this.apiUrl}/users/me/${endpoint}`; if (searchMode === ResourceSearchMode.Component) { params['filter[parent][ne]'] = null; @@ -154,37 +148,6 @@ export class MyResourcesService { return this.getResources('preprints/', filters, pageNumber, pageSize, 'preprints'); } - getMyBookmarks( - collectionId: string, - resourceType: ResourceType, - filters?: MyResourcesSearchFilters, - pageNumber?: number, - pageSize?: number - ): Observable { - switch (resourceType) { - case ResourceType.Project: - return this.getResources(`collections/${collectionId}/linked_nodes/`, filters, pageNumber, pageSize, 'nodes'); - case ResourceType.Registration: - return this.getResources( - `collections/${collectionId}/linked_registrations/`, - filters, - pageNumber, - pageSize, - 'registrations' - ); - case ResourceType.Preprint: - return this.getResources( - `collections/${collectionId}/linked_preprints/`, - filters, - pageNumber, - pageSize, - 'preprints' - ); - default: - return EMPTY; - } - } - createProject( title: string, description: string, diff --git a/src/app/shared/stores/bookmarks/bookmarks.actions.ts b/src/app/shared/stores/bookmarks/bookmarks.actions.ts index 28d14ea5c..12759a916 100644 --- a/src/app/shared/stores/bookmarks/bookmarks.actions.ts +++ b/src/app/shared/stores/bookmarks/bookmarks.actions.ts @@ -1,14 +1,34 @@ import { ResourceType } from '@shared/enums/resource-type.enum'; +import { MyResourcesSearchFilters } from '@shared/models/my-resources/my-resources-search-filters.models'; export class GetBookmarksCollectionId { static readonly type = '[Bookmarks] Get Bookmarks Collection Id'; } +export class GetAllMyBookmarks { + static readonly type = '[Bookmarks] Get Bookmarks'; + + constructor( + public bookmarkCollectionId: string, + public filters?: MyResourcesSearchFilters + ) {} +} + +export class GetResourceBookmark { + static readonly type = '[Bookmarks] Get Resource Bookmark'; + + constructor( + public bookmarkCollectionId: string, + public resourceId: string, + public resourceType: ResourceType + ) {} +} + export class AddResourceToBookmarks { static readonly type = '[Bookmarks] Add Resource To Bookmarks'; constructor( - public bookmarksId: string, + public bookmarkCollectionId: string, public resourceId: string, public resourceType: ResourceType ) {} @@ -18,7 +38,7 @@ export class RemoveResourceFromBookmarks { static readonly type = '[Bookmarks] Remove Resource From Bookmarks'; constructor( - public bookmarksId: string, + public bookmarkCollectionId: string, public resourceId: string, public resourceType: ResourceType ) {} diff --git a/src/app/shared/stores/bookmarks/bookmarks.model.ts b/src/app/shared/stores/bookmarks/bookmarks.model.ts index 8305c82d4..919d2b347 100644 --- a/src/app/shared/stores/bookmarks/bookmarks.model.ts +++ b/src/app/shared/stores/bookmarks/bookmarks.model.ts @@ -1,14 +1,24 @@ -import { AsyncStateModel } from '@shared/models/store/async-state.model'; +import { MyResourcesItem } from '@osf/shared/models/my-resources/my-resources.models'; +import { AsyncStateModel } from '@osf/shared/models/store/async-state.model'; +import { AsyncStateWithTotalCount } from '@osf/shared/models/store/async-state-with-total-count.model'; export interface BookmarksStateModel { - bookmarksId: AsyncStateModel; + bookmarkCollectionId: AsyncStateModel; + items: AsyncStateWithTotalCount; } export const BOOKMARKS_DEFAULTS: BookmarksStateModel = { - bookmarksId: { + bookmarkCollectionId: { data: '', isLoading: false, isSubmitting: false, error: null, }, + items: { + data: [], + isLoading: false, + isSubmitting: false, + error: null, + totalCount: 0, + }, }; diff --git a/src/app/shared/stores/bookmarks/bookmarks.selectors.ts b/src/app/shared/stores/bookmarks/bookmarks.selectors.ts index bb3781f10..6736207d0 100644 --- a/src/app/shared/stores/bookmarks/bookmarks.selectors.ts +++ b/src/app/shared/stores/bookmarks/bookmarks.selectors.ts @@ -6,16 +6,31 @@ import { BookmarksState } from './bookmarks.state'; export class BookmarksSelectors { @Selector([BookmarksState]) static getBookmarksCollectionId(state: BookmarksStateModel) { - return state.bookmarksId.data; + return state.bookmarkCollectionId.data; } @Selector([BookmarksState]) static getBookmarksCollectionIdLoading(state: BookmarksStateModel) { - return state.bookmarksId.isLoading; + return state.bookmarkCollectionId.isLoading; } @Selector([BookmarksState]) static getBookmarksCollectionIdSubmitting(state: BookmarksStateModel) { - return state.bookmarksId.isSubmitting; + return state.bookmarkCollectionId.isSubmitting; + } + + @Selector([BookmarksState]) + static getBookmarks(state: BookmarksStateModel) { + return state.items.data; + } + + @Selector([BookmarksState]) + static areBookmarksLoading(state: BookmarksStateModel) { + return state.items.isLoading; + } + + @Selector([BookmarksState]) + static getBookmarksTotalCount(state: BookmarksStateModel) { + return state.items.totalCount; } } diff --git a/src/app/shared/stores/bookmarks/bookmarks.state.ts b/src/app/shared/stores/bookmarks/bookmarks.state.ts index e02ccf142..58815a9e3 100644 --- a/src/app/shared/stores/bookmarks/bookmarks.state.ts +++ b/src/app/shared/stores/bookmarks/bookmarks.state.ts @@ -7,7 +7,13 @@ import { inject, Injectable } from '@angular/core'; import { handleSectionError } from '@osf/shared/helpers/state-error.handler'; import { BookmarksService } from '@osf/shared/services/bookmarks.service'; -import { AddResourceToBookmarks, GetBookmarksCollectionId, RemoveResourceFromBookmarks } from './bookmarks.actions'; +import { + AddResourceToBookmarks, + GetAllMyBookmarks, + GetBookmarksCollectionId, + GetResourceBookmark, + RemoveResourceFromBookmarks, +} from './bookmarks.actions'; import { BOOKMARKS_DEFAULTS, BookmarksStateModel } from './bookmarks.model'; @State({ @@ -16,14 +22,14 @@ import { BOOKMARKS_DEFAULTS, BookmarksStateModel } from './bookmarks.model'; }) @Injectable() export class BookmarksState { - bookmarksService = inject(BookmarksService); + private readonly bookmarksService = inject(BookmarksService); @Action(GetBookmarksCollectionId) getBookmarksCollectionId(ctx: StateContext) { const state = ctx.getState(); ctx.patchState({ - bookmarksId: { - ...state.bookmarksId, + bookmarkCollectionId: { + ...state.bookmarkCollectionId, isLoading: true, }, }); @@ -31,15 +37,67 @@ export class BookmarksState { return this.bookmarksService.getBookmarksCollectionId().pipe( tap((res) => { ctx.patchState({ - bookmarksId: { + bookmarkCollectionId: { data: res, isLoading: false, - isSubmitting: false, error: null, }, }); }), - catchError((error) => handleSectionError(ctx, 'bookmarksId', error)) + catchError((error) => handleSectionError(ctx, 'bookmarkCollectionId', error)) + ); + } + + @Action(GetAllMyBookmarks) + getAllMyBookmarks(ctx: StateContext, action: GetAllMyBookmarks) { + const state = ctx.getState(); + + ctx.patchState({ + items: { + ...state.items, + isLoading: true, + error: null, + }, + }); + + return this.bookmarksService.getAllBookmarks(action.bookmarkCollectionId, action.filters).pipe( + tap((results) => { + ctx.patchState({ + items: { + data: results.data, + isLoading: false, + error: null, + totalCount: results.totalCount, + }, + }); + }), + catchError((error) => handleSectionError(ctx, 'items', error)) + ); + } + + @Action(GetResourceBookmark) + getBookmarkResource(ctx: StateContext, action: GetResourceBookmark) { + ctx.patchState({ + items: { + data: [], + isLoading: true, + error: null, + totalCount: 0, + }, + }); + + return this.bookmarksService.getResourceBookmarks(action.bookmarkCollectionId, action.resourceType).pipe( + tap((res) => { + ctx.patchState({ + items: { + data: res.data, + isLoading: false, + error: null, + totalCount: res.meta.total, + }, + }); + }), + catchError((error) => handleSectionError(ctx, 'items', error)) ); } @@ -47,24 +105,24 @@ export class BookmarksState { addResourceToBookmarks(ctx: StateContext, action: AddResourceToBookmarks) { const state = ctx.getState(); ctx.patchState({ - bookmarksId: { - ...state.bookmarksId, + bookmarkCollectionId: { + ...state.bookmarkCollectionId, isSubmitting: true, }, }); return this.bookmarksService - .addResourceToBookmarks(action.bookmarksId, action.resourceId, action.resourceType) + .addResourceToBookmarks(action.bookmarkCollectionId, action.resourceId, action.resourceType) .pipe( tap(() => { ctx.patchState({ - bookmarksId: { - ...state.bookmarksId, + bookmarkCollectionId: { + ...state.bookmarkCollectionId, isSubmitting: false, }, }); }), - catchError((error) => handleSectionError(ctx, 'bookmarksId', error)) + catchError((error) => handleSectionError(ctx, 'bookmarkCollectionId', error)) ); } @@ -72,24 +130,24 @@ export class BookmarksState { removeResourceFromBookmarks(ctx: StateContext, action: RemoveResourceFromBookmarks) { const state = ctx.getState(); ctx.patchState({ - bookmarksId: { - ...state.bookmarksId, + bookmarkCollectionId: { + ...state.bookmarkCollectionId, isSubmitting: true, }, }); return this.bookmarksService - .removeResourceFromBookmarks(action.bookmarksId, action.resourceId, action.resourceType) + .removeResourceFromBookmarks(action.bookmarkCollectionId, action.resourceId, action.resourceType) .pipe( tap(() => { ctx.patchState({ - bookmarksId: { - ...state.bookmarksId, + bookmarkCollectionId: { + ...state.bookmarkCollectionId, isSubmitting: false, }, }); }), - catchError((error) => handleSectionError(ctx, 'bookmarksId', error)) + catchError((error) => handleSectionError(ctx, 'bookmarkCollectionId', error)) ); } } diff --git a/src/app/shared/stores/my-resources/my-resources.actions.ts b/src/app/shared/stores/my-resources/my-resources.actions.ts index 738358897..b56720213 100644 --- a/src/app/shared/stores/my-resources/my-resources.actions.ts +++ b/src/app/shared/stores/my-resources/my-resources.actions.ts @@ -1,5 +1,4 @@ import { ResourceSearchMode } from '@osf/shared/enums/resource-search-mode.enum'; -import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { MyResourcesSearchFilters } from '@osf/shared/models/my-resources/my-resources-search-filters.models'; export class GetMyProjects { @@ -36,18 +35,6 @@ export class GetMyPreprints { ) {} } -export class GetMyBookmarks { - static readonly type = '[My Resources] Get Bookmarks'; - - constructor( - public bookmarksId: string, - public pageNumber: number, - public pageSize: number, - public filters: MyResourcesSearchFilters, - public resourceType: ResourceType - ) {} -} - export class ClearMyResources { static readonly type = '[My Resources] Clear My Resources'; } diff --git a/src/app/shared/stores/my-resources/my-resources.model.ts b/src/app/shared/stores/my-resources/my-resources.model.ts index ad3db88c6..47d41db9f 100644 --- a/src/app/shared/stores/my-resources/my-resources.model.ts +++ b/src/app/shared/stores/my-resources/my-resources.model.ts @@ -1,15 +1,10 @@ import { MyResourcesItem } from '@osf/shared/models/my-resources/my-resources.models'; -import { AsyncStateModel } from '@osf/shared/models/store/async-state.model'; +import { AsyncStateWithTotalCount } from '@osf/shared/models/store/async-state-with-total-count.model'; export interface MyResourcesStateModel { - projects: AsyncStateModel; - registrations: AsyncStateModel; - preprints: AsyncStateModel; - bookmarks: AsyncStateModel; - totalProjects: number; - totalRegistrations: number; - totalPreprints: number; - totalBookmarks: number; + projects: AsyncStateWithTotalCount; + registrations: AsyncStateWithTotalCount; + preprints: AsyncStateWithTotalCount; } export const MY_RESOURCES_STATE_DEFAULTS: MyResourcesStateModel = { @@ -17,24 +12,18 @@ export const MY_RESOURCES_STATE_DEFAULTS: MyResourcesStateModel = { data: [], isLoading: false, error: null, + totalCount: 0, }, registrations: { data: [], isLoading: false, error: null, + totalCount: 0, }, preprints: { data: [], isLoading: false, error: null, + totalCount: 0, }, - bookmarks: { - data: [], - isLoading: false, - error: null, - }, - totalProjects: 0, - totalRegistrations: 0, - totalPreprints: 0, - totalBookmarks: 0, }; diff --git a/src/app/shared/stores/my-resources/my-resources.selectors.ts b/src/app/shared/stores/my-resources/my-resources.selectors.ts index 4a62e3fa5..344e64d3a 100644 --- a/src/app/shared/stores/my-resources/my-resources.selectors.ts +++ b/src/app/shared/stores/my-resources/my-resources.selectors.ts @@ -36,33 +36,18 @@ export class MyResourcesSelectors { return state.preprints.data; } - @Selector([MyResourcesState]) - static getBookmarks(state: MyResourcesStateModel): MyResourcesItem[] { - return state.bookmarks.data; - } - @Selector([MyResourcesState]) static getTotalProjects(state: MyResourcesStateModel): number { - return state.totalProjects; + return state.projects.totalCount; } @Selector([MyResourcesState]) static getTotalRegistrations(state: MyResourcesStateModel): number { - return state.totalRegistrations; + return state.registrations.totalCount; } @Selector([MyResourcesState]) static getTotalPreprints(state: MyResourcesStateModel): number { - return state.totalPreprints; - } - - @Selector([MyResourcesState]) - static getTotalBookmarks(state: MyResourcesStateModel): number { - return state.totalBookmarks; - } - - @Selector([MyResourcesState]) - static getBookmarksLoading(state: MyResourcesStateModel): boolean { - return state.bookmarks.isLoading; + return state.preprints.totalCount; } } diff --git a/src/app/shared/stores/my-resources/my-resources.state.ts b/src/app/shared/stores/my-resources/my-resources.state.ts index 61a8c36dc..8425f9c09 100644 --- a/src/app/shared/stores/my-resources/my-resources.state.ts +++ b/src/app/shared/stores/my-resources/my-resources.state.ts @@ -1,17 +1,15 @@ import { Action, State, StateContext } from '@ngxs/store'; -import { catchError, forkJoin, tap } from 'rxjs'; +import { catchError, tap } from 'rxjs'; import { inject, Injectable } from '@angular/core'; -import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { handleSectionError } from '@osf/shared/helpers/state-error.handler'; import { MyResourcesService } from '@osf/shared/services/my-resources.service'; import { ClearMyResources, CreateProject, - GetMyBookmarks, GetMyPreprints, GetMyProjects, GetMyRegistrations, @@ -45,8 +43,8 @@ export class MyResourcesState { data: res.data, isLoading: false, error: null, + totalCount: res.meta.total, }, - totalProjects: res.meta.total, }); }), catchError((error) => handleSectionError(ctx, 'projects', error)) @@ -78,8 +76,8 @@ export class MyResourcesState { data: res.data, isLoading: false, error: null, + totalCount: res.meta.total, }, - totalRegistrations: res.meta.total, }); }), catchError((error) => handleSectionError(ctx, 'registrations', error)) @@ -103,76 +101,14 @@ export class MyResourcesState { data: res.data, isLoading: false, error: null, + totalCount: res.meta.total, }, - totalPreprints: res.meta.total, }); }), catchError((error) => handleSectionError(ctx, 'preprints', error)) ); } - @Action(GetMyBookmarks) - getBookmarks(ctx: StateContext, action: GetMyBookmarks) { - const state = ctx.getState(); - ctx.patchState({ - bookmarks: { - ...state.bookmarks, - isLoading: true, - error: null, - }, - }); - - if (action.resourceType !== ResourceType.Null) { - return this.myResourcesService - .getMyBookmarks(action.bookmarksId, action.resourceType, action.filters, action.pageNumber, action.pageSize) - .pipe( - tap((res) => { - ctx.patchState({ - bookmarks: { - data: res.data, - isLoading: false, - error: null, - }, - totalBookmarks: res.meta.total, - }); - }), - catchError((error) => handleSectionError(ctx, 'bookmarks', error)) - ); - } else { - return forkJoin({ - projects: this.myResourcesService.getMyBookmarks( - action.bookmarksId, - ResourceType.Project, - action.filters, - action.pageNumber, - action.pageSize - ), - registrations: this.myResourcesService.getMyBookmarks( - action.bookmarksId, - ResourceType.Registration, - action.filters, - action.pageNumber, - action.pageSize - ), - }).pipe( - tap((results) => { - const allData = [...results.projects.data, ...results.registrations.data]; - const totalCount = results.projects.meta.total + results.registrations.meta.total; - - ctx.patchState({ - bookmarks: { - data: allData, - isLoading: false, - error: null, - }, - totalBookmarks: totalCount, - }); - }), - catchError((error) => handleSectionError(ctx, 'bookmarks', error)) - ); - } - } - @Action(ClearMyResources) clearMyResources(ctx: StateContext) { ctx.patchState(MY_RESOURCES_STATE_DEFAULTS); @@ -198,8 +134,8 @@ export class MyResourcesState { isLoading: false, isSubmitting: false, error: null, + totalCount: state.projects.totalCount + 1, }, - totalProjects: state.totalProjects + 1, }); }), catchError((error) => handleSectionError(ctx, 'projects', error)) diff --git a/src/app/shared/stores/registration-provider/registration-provider.state.ts b/src/app/shared/stores/registration-provider/registration-provider.state.ts index c627a435a..efa235f3d 100644 --- a/src/app/shared/stores/registration-provider/registration-provider.state.ts +++ b/src/app/shared/stores/registration-provider/registration-provider.state.ts @@ -28,6 +28,7 @@ export class RegistrationProviderState { const state = ctx.getState(); const currentProvider = state.currentBrandedProvider.data; + if (currentProvider && currentProvider?.id === action.providerId) { ctx.dispatch( new SetCurrentProvider({ diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 11e832513..70cab840c 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -137,7 +137,12 @@ "updated": "Updated", "dateUpdated": "Date Updated", "dateCreated": "Date Created", - "license": "License" + "license": "License", + "noDoi": "No DOI", + "subjects": "Subjects", + "noLicense": "No License", + "affiliatedInstitutions": "Affiliated Institutions", + "noAffiliatedInstitutions": "No affiliated institutions" }, "deleteConfirmation": { "header": "Delete", @@ -1472,7 +1477,8 @@ "rejectSubmissionSuccess": "Submission has been rejected successfully", "forceWithdrawSuccess": "Submission has been force withdrawn successfully", "removeSuccess": "Submission has been withdrawn successfully", - "justificationPlaceholder": "Provide justification for withdrawal" + "justificationPlaceholder": "Provide justification for withdrawal", + "acceptWithdrawalSuccess": "Submission has been withdrawn successfully" }, "submissionReview": { "submitted": "Submitted",