diff --git a/config/config.example.yml b/config/config.example.yml index 69a9ffd320..4b9c1a27ac 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -75,7 +75,7 @@ cache: anonymousCache: # Maximum number of pages to cache. Default is zero (0) which means anonymous user cache is disabled. # As all pages are cached in server memory, increasing this value will increase memory needs. - # Individual cached pages are usually small (<100KB), so a value of max=1000 would only require ~100MB of memory. + # Individual cached pages are usually small (<100KB), so a value of max=1000 would only require ~100MB of memory. max: 0 # Amount of time after which cached pages are considered stale (in ms). After becoming stale, the cached # copy is automatically refreshed on the next request. @@ -382,7 +382,13 @@ vocabularies: vocabulary: 'srsc' enabled: true -# Default collection/community sorting order at Advanced search, Create/update community and collection when there are not a query. +# Default collection/community sorting order at Advanced search, Create/update community and collection when there are not a query. comcolSelectionSort: sortField: 'dc.title' sortDirection: 'ASC' + +# Example of fallback collection for suggestions import +# suggestion: + # - collectionId: 8f7df5ca-f9c2-47a4-81ec-8a6393d6e5af + # source: "openaire" + diff --git a/cypress/e2e/browse-by-author.cy.ts b/cypress/e2e/browse-by-author.cy.ts index 07c20ad7c9..32c470231d 100644 --- a/cypress/e2e/browse-by-author.cy.ts +++ b/cypress/e2e/browse-by-author.cy.ts @@ -5,9 +5,9 @@ describe('Browse By Author', () => { cy.visit('/browse/author'); // Wait for to be visible - cy.get('ds-browse-by-metadata-page').should('be.visible'); + cy.get('ds-browse-by-metadata').should('be.visible'); // Analyze for accessibility - testA11y('ds-browse-by-metadata-page'); + testA11y('ds-browse-by-metadata'); }); }); diff --git a/cypress/e2e/browse-by-dateissued.cy.ts b/cypress/e2e/browse-by-dateissued.cy.ts index 4d22420227..7966f1c82e 100644 --- a/cypress/e2e/browse-by-dateissued.cy.ts +++ b/cypress/e2e/browse-by-dateissued.cy.ts @@ -5,9 +5,9 @@ describe('Browse By Date Issued', () => { cy.visit('/browse/dateissued'); // Wait for to be visible - cy.get('ds-browse-by-date-page').should('be.visible'); + cy.get('ds-browse-by-date').should('be.visible'); // Analyze for accessibility - testA11y('ds-browse-by-date-page'); + testA11y('ds-browse-by-date'); }); }); diff --git a/cypress/e2e/browse-by-subject.cy.ts b/cypress/e2e/browse-by-subject.cy.ts index 89b791f03c..57ca88d103 100644 --- a/cypress/e2e/browse-by-subject.cy.ts +++ b/cypress/e2e/browse-by-subject.cy.ts @@ -5,9 +5,9 @@ describe('Browse By Subject', () => { cy.visit('/browse/subject'); // Wait for to be visible - cy.get('ds-browse-by-metadata-page').should('be.visible'); + cy.get('ds-browse-by-metadata').should('be.visible'); // Analyze for accessibility - testA11y('ds-browse-by-metadata-page'); + testA11y('ds-browse-by-metadata'); }); }); diff --git a/cypress/e2e/browse-by-title.cy.ts b/cypress/e2e/browse-by-title.cy.ts index e4e027586a..09195c30df 100644 --- a/cypress/e2e/browse-by-title.cy.ts +++ b/cypress/e2e/browse-by-title.cy.ts @@ -5,9 +5,9 @@ describe('Browse By Title', () => { cy.visit('/browse/title'); // Wait for to be visible - cy.get('ds-browse-by-title-page').should('be.visible'); + cy.get('ds-browse-by-title').should('be.visible'); // Analyze for accessibility - testA11y('ds-browse-by-title-page'); + testA11y('ds-browse-by-title'); }); }); diff --git a/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page-resolver.service.ts b/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page-resolver.service.ts new file mode 100644 index 0000000000..add9a504dd --- /dev/null +++ b/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page-resolver.service.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; + +/** + * Interface for the route parameters. + */ +export interface AdminNotificationsPublicationClaimPageParams { + pageId?: string; + pageSize?: number; + currentPage?: number; +} + +/** + * This class represents a resolver that retrieve the route data before the route is activated. + */ +@Injectable() +export class AdminNotificationsPublicationClaimPageResolver implements Resolve { + + /** + * Method for resolving the parameters in the current route. + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + * @param {RouterStateSnapshot} state The current RouterStateSnapshot + * @returns AdminNotificationsSuggestionTargetsPageParams Emits the route parameters + */ + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): AdminNotificationsPublicationClaimPageParams { + return { + pageId: route.queryParams.pageId, + pageSize: parseInt(route.queryParams.pageSize, 10), + currentPage: parseInt(route.queryParams.page, 10) + }; + } +} diff --git a/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.html b/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.html new file mode 100644 index 0000000000..b04e7132f1 --- /dev/null +++ b/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.html @@ -0,0 +1 @@ + diff --git a/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.scss b/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.scss similarity index 100% rename from src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.scss rename to src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.scss diff --git a/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.spec.ts b/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.spec.ts new file mode 100644 index 0000000000..c5406023ce --- /dev/null +++ b/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.spec.ts @@ -0,0 +1,38 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AdminNotificationsPublicationClaimPageComponent } from './admin-notifications-publication-claim-page.component'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { TranslateModule } from '@ngx-translate/core'; + +describe('AdminNotificationsPublicationClaimPageComponent', () => { + let component: AdminNotificationsPublicationClaimPageComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + CommonModule, + TranslateModule.forRoot() + ], + declarations: [ + AdminNotificationsPublicationClaimPageComponent + ], + providers: [ + AdminNotificationsPublicationClaimPageComponent + ], + schemas: [NO_ERRORS_SCHEMA] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AdminNotificationsPublicationClaimPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.ts b/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.ts new file mode 100644 index 0000000000..2256a1bc36 --- /dev/null +++ b/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.ts @@ -0,0 +1,10 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'ds-admin-notifications-publication-claim-page', + templateUrl: './admin-notifications-publication-claim-page.component.html', + styleUrls: ['./admin-notifications-publication-claim-page.component.scss'] +}) +export class AdminNotificationsPublicationClaimPageComponent { + +} diff --git a/src/app/admin/admin-notifications/admin-notifications-routing-paths.ts b/src/app/admin/admin-notifications/admin-notifications-routing-paths.ts index 791b44af2b..9fcabedd64 100644 --- a/src/app/admin/admin-notifications/admin-notifications-routing-paths.ts +++ b/src/app/admin/admin-notifications/admin-notifications-routing-paths.ts @@ -1,5 +1,6 @@ export const QUALITY_ASSURANCE_EDIT_PATH = 'quality-assurance'; +export const PUBLICATION_CLAIMS_PATH = 'publication-claim'; export function getQualityAssuranceEditRoute() { return `/${QUALITY_ASSURANCE_EDIT_PATH}`; diff --git a/src/app/admin/admin-notifications/admin-notifications-routing.module.ts b/src/app/admin/admin-notifications/admin-notifications-routing.module.ts index 3fb9e223b4..761c819b1b 100644 --- a/src/app/admin/admin-notifications/admin-notifications-routing.module.ts +++ b/src/app/admin/admin-notifications/admin-notifications-routing.module.ts @@ -4,6 +4,9 @@ import { RouterModule } from '@angular/router'; import { AuthenticatedGuard } from '../../core/auth/authenticated.guard'; import { I18nBreadcrumbResolver } from '../../core/breadcrumbs/i18n-breadcrumb.resolver'; import { I18nBreadcrumbsService } from '../../core/breadcrumbs/i18n-breadcrumbs.service'; +import { PUBLICATION_CLAIMS_PATH } from './admin-notifications-routing-paths'; +import { AdminNotificationsPublicationClaimPageComponent } from './admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component'; +import { AdminNotificationsPublicationClaimPageResolver } from './admin-notifications-publication-claim-page/admin-notifications-publication-claim-page-resolver.service'; import { QUALITY_ASSURANCE_EDIT_PATH } from './admin-notifications-routing-paths'; import { AdminQualityAssuranceTopicsPageComponent } from './admin-quality-assurance-topics-page/admin-quality-assurance-topics-page.component'; import { AdminQualityAssuranceEventsPageComponent } from './admin-quality-assurance-events-page/admin-quality-assurance-events-page.component'; @@ -23,6 +26,21 @@ import { @NgModule({ imports: [ RouterModule.forChild([ + { + canActivate: [ AuthenticatedGuard ], + path: `${PUBLICATION_CLAIMS_PATH}`, + component: AdminNotificationsPublicationClaimPageComponent, + pathMatch: 'full', + resolve: { + breadcrumb: I18nBreadcrumbResolver, + suggestionTargetParams: AdminNotificationsPublicationClaimPageResolver + }, + data: { + title: 'admin.notifications.publicationclaim.page.title', + breadcrumbKey: 'admin.notifications.publicationclaim', + showBreadcrumbsFluid: false + } + }, { canActivate: [ AuthenticatedGuard ], path: `${QUALITY_ASSURANCE_EDIT_PATH}/:sourceId`, @@ -74,7 +92,9 @@ import { providers: [ I18nBreadcrumbResolver, I18nBreadcrumbsService, + AdminNotificationsPublicationClaimPageResolver, SourceDataResolver, + AdminQualityAssuranceSourcePageResolver, AdminQualityAssuranceTopicsPageResolver, AdminQualityAssuranceEventsPageResolver, AdminQualityAssuranceSourcePageResolver, diff --git a/src/app/admin/admin-notifications/admin-notifications.module.ts b/src/app/admin/admin-notifications/admin-notifications.module.ts index 84475a1623..d9efb4c288 100644 --- a/src/app/admin/admin-notifications/admin-notifications.module.ts +++ b/src/app/admin/admin-notifications/admin-notifications.module.ts @@ -3,10 +3,11 @@ import { NgModule } from '@angular/core'; import { CoreModule } from '../../core/core.module'; import { SharedModule } from '../../shared/shared.module'; import { AdminNotificationsRoutingModule } from './admin-notifications-routing.module'; +import { AdminNotificationsPublicationClaimPageComponent } from './admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component'; import { AdminQualityAssuranceTopicsPageComponent } from './admin-quality-assurance-topics-page/admin-quality-assurance-topics-page.component'; import { AdminQualityAssuranceEventsPageComponent } from './admin-quality-assurance-events-page/admin-quality-assurance-events-page.component'; import { AdminQualityAssuranceSourcePageComponent } from './admin-quality-assurance-source-page-component/admin-quality-assurance-source-page.component'; -import {NotificationsModule} from '../../notifications/notifications.module'; +import { NotificationsModule } from '../../notifications/notifications.module'; @NgModule({ imports: [ @@ -17,6 +18,7 @@ import {NotificationsModule} from '../../notifications/notifications.module'; NotificationsModule ], declarations: [ + AdminNotificationsPublicationClaimPageComponent, AdminQualityAssuranceTopicsPageComponent, AdminQualityAssuranceEventsPageComponent, AdminQualityAssuranceSourcePageComponent diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 6932275245..e94ec51215 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -1,5 +1,5 @@ import { NgModule } from '@angular/core'; -import { RouterModule, NoPreloading } from '@angular/router'; +import { NoPreloading, RouterModule } from '@angular/router'; import { AuthBlockingGuard } from './core/auth/auth-blocking.guard'; import { AuthenticatedGuard } from './core/auth/authenticated.guard'; @@ -35,9 +35,11 @@ import { ThemedPageInternalServerErrorComponent } from './page-internal-server-error/themed-page-internal-server-error.component'; import { ServerCheckGuard } from './core/server-check/server-check.guard'; +import { SUGGESTION_MODULE_PATH } from './suggestions-page/suggestions-page-routing-paths'; import { MenuResolver } from './menu.resolver'; import { ThemedPageErrorComponent } from './page-error/themed-page-error.component'; import { NOTIFICATIONS_MODULE_PATH } from './admin/admin-routing-paths'; +import { ForgotPasswordCheckGuard } from './core/rest-property/forgot-password-check-guard.guard'; @NgModule({ imports: [ @@ -92,7 +94,10 @@ import { NOTIFICATIONS_MODULE_PATH } from './admin/admin-routing-paths'; path: FORGOT_PASSWORD_PATH, loadChildren: () => import('./forgot-password/forgot-password.module') .then((m) => m.ForgotPasswordModule), - canActivate: [EndUserAgreementCurrentUserGuard] + canActivate: [ + ForgotPasswordCheckGuard, + EndUserAgreementCurrentUserGuard + ] }, { path: COMMUNITY_MODULE_PATH, @@ -206,6 +211,11 @@ import { NOTIFICATIONS_MODULE_PATH } from './admin/admin-routing-paths'; .then((m) => m.ProcessPageModule), canActivate: [AuthenticatedGuard, EndUserAgreementCurrentUserGuard] }, + { path: SUGGESTION_MODULE_PATH, + loadChildren: () => import('./suggestions-page/suggestions-page.module') + .then((m) => m.SuggestionsPageModule), + canActivate: [AuthenticatedGuard, EndUserAgreementCurrentUserGuard] + }, { path: INFO_MODULE_PATH, loadChildren: () => import('./info/info.module').then((m) => m.InfoModule) diff --git a/src/app/bitstream-page/edit-bitstream-page/edit-bitstream-page.component.scss b/src/app/bitstream-page/edit-bitstream-page/edit-bitstream-page.component.scss index 80adde4ecc..e69de29bb2 100644 --- a/src/app/bitstream-page/edit-bitstream-page/edit-bitstream-page.component.scss +++ b/src/app/bitstream-page/edit-bitstream-page/edit-bitstream-page.component.scss @@ -1,12 +0,0 @@ -:host { - ::ng-deep { - .switch { - position: absolute; - top: calc(var(--bs-spacer) * 2.5); - } - } -} -:host ::ng-deep ds-dynamic-form-control-container > div > label { - margin-top: 1.75rem; -} - diff --git a/src/app/bitstream-page/edit-bitstream-page/edit-bitstream-page.component.ts b/src/app/bitstream-page/edit-bitstream-page/edit-bitstream-page.component.ts index b77d2151a9..7aad6df2fb 100644 --- a/src/app/bitstream-page/edit-bitstream-page/edit-bitstream-page.component.ts +++ b/src/app/bitstream-page/edit-bitstream-page/edit-bitstream-page.component.ts @@ -2,14 +2,35 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnIni import { Bitstream } from '../../core/shared/bitstream.model'; import { ActivatedRoute, Router } from '@angular/router'; import { filter, map, switchMap, tap } from 'rxjs/operators'; -import { combineLatest, combineLatest as observableCombineLatest, Observable, of as observableOf, Subscription } from 'rxjs'; -import { DynamicFormControlModel, DynamicFormGroupModel, DynamicFormLayout, DynamicFormService, DynamicInputModel, DynamicSelectModel } from '@ng-dynamic-forms/core'; +import { + combineLatest, + combineLatest as observableCombineLatest, + Observable, + of as observableOf, + Subscription +} from 'rxjs'; +import { + DynamicFormControlModel, + DynamicFormGroupModel, + DynamicFormLayout, + DynamicFormService, + DynamicInputModel, + DynamicSelectModel +} from '@ng-dynamic-forms/core'; import { UntypedFormGroup } from '@angular/forms'; import { TranslateService } from '@ngx-translate/core'; -import { DynamicCustomSwitchModel } from '../../shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.model'; +import { + DynamicCustomSwitchModel +} from '../../shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.model'; import cloneDeep from 'lodash/cloneDeep'; import { BitstreamDataService } from '../../core/data/bitstream-data.service'; -import { getAllSucceededRemoteDataPayload, getFirstCompletedRemoteData, getFirstSucceededRemoteData, getFirstSucceededRemoteDataPayload, getRemoteDataPayload } from '../../core/shared/operators'; +import { + getAllSucceededRemoteDataPayload, + getFirstCompletedRemoteData, + getFirstSucceededRemoteData, + getFirstSucceededRemoteDataPayload, + getRemoteDataPayload +} from '../../core/shared/operators'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { BitstreamFormatDataService } from '../../core/data/bitstream-format-data.service'; import { BitstreamFormat } from '../../core/shared/bitstream-format.model'; @@ -245,7 +266,7 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { /** * All input models in a simple array for easier iterations */ - inputModels = [this.fileNameModel, this.primaryBitstreamModel, this.descriptionModel, this.selectedFormatModel, + inputModels = [this.primaryBitstreamModel, this.fileNameModel, this.descriptionModel, this.selectedFormatModel, this.newFormatModel]; /** @@ -256,8 +277,8 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { new DynamicFormGroupModel({ id: 'fileNamePrimaryContainer', group: [ - this.fileNameModel, - this.primaryBitstreamModel + this.primaryBitstreamModel, + this.fileNameModel ] }, { grid: { @@ -295,7 +316,10 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { }, primaryBitstream: { grid: { - host: 'col col-sm-4 d-inline-block switch border-0' + container: 'col-12' + }, + element: { + container: 'text-right' } }, description: { diff --git a/src/app/browse-by/browse-by-date-page/browse-by-date-page.component.spec.ts b/src/app/browse-by/browse-by-date/browse-by-date.component.spec.ts similarity index 88% rename from src/app/browse-by/browse-by-date-page/browse-by-date-page.component.spec.ts rename to src/app/browse-by/browse-by-date/browse-by-date.component.spec.ts index e41d3a45b2..c5ef9020f1 100644 --- a/src/app/browse-by/browse-by-date-page/browse-by-date-page.component.spec.ts +++ b/src/app/browse-by/browse-by-date/browse-by-date.component.spec.ts @@ -1,4 +1,4 @@ -import { BrowseByDatePageComponent } from './browse-by-date-page.component'; +import { BrowseByDateComponent } from './browse-by-date.component'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { CommonModule } from '@angular/common'; import { RouterTestingModule } from '@angular/router/testing'; @@ -15,7 +15,7 @@ import { ActivatedRouteStub } from '../../shared/testing/active-router.stub'; import { Community } from '../../core/shared/community.model'; import { Item } from '../../core/shared/item.model'; import { BrowseEntrySearchOptions } from '../../core/browse/browse-entry-search-options.model'; -import { toRemoteData } from '../browse-by-metadata-page/browse-by-metadata-page.component.spec'; +import { toRemoteData } from '../browse-by-metadata/browse-by-metadata.component.spec'; import { VarDirective } from '../../shared/utils/var.directive'; import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { PaginationService } from '../../core/pagination/pagination.service'; @@ -23,10 +23,11 @@ import { PaginationServiceStub } from '../../shared/testing/pagination-service.s import { APP_CONFIG } from '../../../config/app-config.interface'; import { environment } from '../../../environments/environment'; import { SortDirection } from '../../core/cache/models/sort-options.model'; +import { cold } from 'jasmine-marbles'; -describe('BrowseByDatePageComponent', () => { - let comp: BrowseByDatePageComponent; - let fixture: ComponentFixture; +describe('BrowseByDateComponent', () => { + let comp: BrowseByDateComponent; + let fixture: ComponentFixture; let route: ActivatedRoute; let paginationService; @@ -86,7 +87,7 @@ describe('BrowseByDatePageComponent', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule], - declarations: [BrowseByDatePageComponent, EnumKeysPipe, VarDirective], + declarations: [BrowseByDateComponent, EnumKeysPipe, VarDirective], providers: [ { provide: ActivatedRoute, useValue: activatedRouteStub }, { provide: BrowseService, useValue: mockBrowseService }, @@ -101,7 +102,7 @@ describe('BrowseByDatePageComponent', () => { })); beforeEach(() => { - fixture = TestBed.createComponent(BrowseByDatePageComponent); + fixture = TestBed.createComponent(BrowseByDateComponent); const browseService = fixture.debugElement.injector.get(BrowseService); spyOn(browseService, 'getFirstItemFor') // ok to expect the default browse as first param since we just need the mock items obtained via sort direction. @@ -112,9 +113,13 @@ describe('BrowseByDatePageComponent', () => { fixture.detectChanges(); }); - it('should initialize the list of items', () => { + it('should initialize the list of items', (done: DoneFn) => { + expect(comp.loading$).toBeObservable(cold('(a|)', { + a: false, + })); comp.items$.subscribe((result) => { expect(result.payload.page).toEqual([firstItem]); + done(); }); }); diff --git a/src/app/browse-by/browse-by-date-page/browse-by-date-page.component.ts b/src/app/browse-by/browse-by-date/browse-by-date.component.ts similarity index 72% rename from src/app/browse-by/browse-by-date-page/browse-by-date-page.component.ts rename to src/app/browse-by/browse-by-date/browse-by-date.component.ts index d0a75691a2..3485519929 100644 --- a/src/app/browse-by/browse-by-date-page/browse-by-date-page.component.ts +++ b/src/app/browse-by/browse-by-date/browse-by-date.component.ts @@ -1,10 +1,6 @@ import { ChangeDetectorRef, Component, Inject, OnInit } from '@angular/core'; -import { - BrowseByMetadataPageComponent, - browseParamsToOptions, - getBrowseSearchOptions -} from '../browse-by-metadata-page/browse-by-metadata-page.component'; -import { combineLatest as observableCombineLatest } from 'rxjs'; +import { BrowseByMetadataComponent, browseParamsToOptions, getBrowseSearchOptions } from '../browse-by-metadata/browse-by-metadata.component'; +import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { ActivatedRoute, Params, Router } from '@angular/router'; import { BrowseService } from '../../core/browse/browse.service'; @@ -23,9 +19,9 @@ import { rendersBrowseBy } from '../browse-by-switcher/browse-by-decorator'; import { BrowseByDataType } from '../browse-by-switcher/browse-by-data-type'; @Component({ - selector: 'ds-browse-by-date-page', - styleUrls: ['../browse-by-metadata-page/browse-by-metadata-page.component.scss'], - templateUrl: '../browse-by-metadata-page/browse-by-metadata-page.component.html' + selector: 'ds-browse-by-date', + styleUrls: ['../browse-by-metadata/browse-by-metadata.component.scss'], + templateUrl: '../browse-by-metadata/browse-by-metadata.component.html', }) /** * Component for browsing items by metadata definition of type 'date' @@ -33,21 +29,22 @@ import { BrowseByDataType } from '../browse-by-switcher/browse-by-data-type'; * An example would be 'dateissued' for 'dc.date.issued' */ @rendersBrowseBy(BrowseByDataType.Date) -export class BrowseByDatePageComponent extends BrowseByMetadataPageComponent implements OnInit { +export class BrowseByDateComponent extends BrowseByMetadataComponent implements OnInit { /** * The default metadata keys to use for determining the lower limit of the StartsWith dropdown options */ defaultMetadataKeys = ['dc.date.issued']; - public constructor(protected route: ActivatedRoute, - protected browseService: BrowseService, - protected dsoService: DSpaceObjectDataService, - protected router: Router, - protected paginationService: PaginationService, - protected cdRef: ChangeDetectorRef, - @Inject(APP_CONFIG) public appConfig: AppConfig, - public dsoNameService: DSONameService, + public constructor( + protected route: ActivatedRoute, + protected browseService: BrowseService, + protected dsoService: DSpaceObjectDataService, + protected paginationService: PaginationService, + protected router: Router, + @Inject(APP_CONFIG) public appConfig: AppConfig, + public dsoNameService: DSONameService, + protected cdRef: ChangeDetectorRef, ) { super(route, browseService, dsoService, paginationService, router, appConfig, dsoNameService); } @@ -60,19 +57,17 @@ export class BrowseByDatePageComponent extends BrowseByMetadataPageComponent imp this.currentPagination$ = this.paginationService.getCurrentPagination(this.paginationConfig.id, this.paginationConfig); this.currentSort$ = this.paginationService.getCurrentSort(this.paginationConfig.id, sortConfig); this.subs.push( - observableCombineLatest([this.route.params, this.route.queryParams, this.route.data, + observableCombineLatest([this.route.params, this.route.queryParams, this.scope$, this.route.data, this.currentPagination$, this.currentSort$]).pipe( - map(([routeParams, queryParams, data, currentPage, currentSort]) => { - return [Object.assign({}, routeParams, queryParams, data), currentPage, currentSort]; + map(([routeParams, queryParams, scope, data, currentPage, currentSort]) => { + return [Object.assign({}, routeParams, queryParams, data), scope, currentPage, currentSort]; }) - ).subscribe(([params, currentPage, currentSort]: [Params, PaginationComponentOptions, SortOptions]) => { + ).subscribe(([params, scope, currentPage, currentSort]: [Params, string, PaginationComponentOptions, SortOptions]) => { const metadataKeys = params.browseDefinition ? params.browseDefinition.metadataKeys : this.defaultMetadataKeys; this.browseId = params.id || this.defaultBrowseId; this.startsWith = +params.startsWith || params.startsWith; - const searchOptions = browseParamsToOptions(params, currentPage, currentSort, this.browseId, this.fetchThumbnails); + const searchOptions = browseParamsToOptions(params, scope, currentPage, currentSort, this.browseId, this.fetchThumbnails); this.updatePageWithItems(searchOptions, this.value, undefined); - this.updateParent(params.scope); - this.updateLogo(); this.updateStartsWithOptions(this.browseId, metadataKeys, params.scope); })); } @@ -88,12 +83,21 @@ export class BrowseByDatePageComponent extends BrowseByMetadataPageComponent imp * @param scope The scope under which to fetch the earliest item for */ updateStartsWithOptions(definition: string, metadataKeys: string[], scope?: string) { - const firstItemRD = this.browseService.getFirstItemFor(definition, scope, SortDirection.ASC); - const lastItemRD = this.browseService.getFirstItemFor(definition, scope, SortDirection.DESC); + const firstItemRD$: Observable> = this.browseService.getFirstItemFor(definition, scope, SortDirection.ASC); + const lastItemRD$: Observable> = this.browseService.getFirstItemFor(definition, scope, SortDirection.DESC); + this.loading$ = observableCombineLatest([ + firstItemRD$, + lastItemRD$, + ]).pipe( + map(([firstItemRD, lastItemRD]: [RemoteData, RemoteData]) => firstItemRD.isLoading || lastItemRD.isLoading) + ); this.subs.push( - observableCombineLatest([firstItemRD, lastItemRD]).subscribe(([firstItem, lastItem]) => { - let lowerLimit: number = this.getLimit(firstItem, metadataKeys, this.appConfig.browseBy.defaultLowerLimit); - let upperLimit: number = this.getLimit(lastItem, metadataKeys, new Date().getUTCFullYear()); + observableCombineLatest([ + firstItemRD$, + lastItemRD$, + ]).subscribe(([firstItemRD, lastItemRD]: [RemoteData, RemoteData]) => { + let lowerLimit: number = this.getLimit(firstItemRD, metadataKeys, this.appConfig.browseBy.defaultLowerLimit); + let upperLimit: number = this.getLimit(lastItemRD, metadataKeys, new Date().getUTCFullYear()); const options: number[] = []; const oneYearBreak: number = Math.floor((upperLimit - this.appConfig.browseBy.oneYearLimit) / 5) * 5; const fiveYearBreak: number = Math.floor((upperLimit - this.appConfig.browseBy.fiveYearLimit) / 10) * 10; diff --git a/src/app/browse-by/browse-by-guard.spec.ts b/src/app/browse-by/browse-by-guard.spec.ts index c7d3e1e0c0..c3840470c6 100644 --- a/src/app/browse-by/browse-by-guard.spec.ts +++ b/src/app/browse-by/browse-by-guard.spec.ts @@ -1,17 +1,13 @@ import { first } from 'rxjs/operators'; import { BrowseByGuard } from './browse-by-guard'; -import { of as observableOf } from 'rxjs'; import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils'; import { BrowseByDataType } from './browse-by-switcher/browse-by-data-type'; import { ValueListBrowseDefinition } from '../core/shared/value-list-browse-definition.model'; -import { DSONameServiceMock } from '../shared/mocks/dso-name.service.mock'; -import { DSONameService } from '../core/breadcrumbs/dso-name.service'; import { RouterStub } from '../shared/testing/router.stub'; describe('BrowseByGuard', () => { describe('canActivate', () => { let guard: BrowseByGuard; - let dsoService: any; let translateService: any; let browseDefinitionService: any; let router: any; @@ -25,10 +21,6 @@ describe('BrowseByGuard', () => { const browseDefinition = Object.assign(new ValueListBrowseDefinition(), { type: BrowseByDataType.Metadata, metadataKeys: ['dc.contributor'] }); beforeEach(() => { - dsoService = { - findById: (dsoId: string) => observableOf({ payload: { name: name }, hasSucceeded: true }) - }; - translateService = { instant: () => field }; @@ -39,7 +31,7 @@ describe('BrowseByGuard', () => { router = new RouterStub() as any; - guard = new BrowseByGuard(dsoService, translateService, browseDefinitionService, new DSONameServiceMock() as DSONameService, router); + guard = new BrowseByGuard(translateService, browseDefinitionService, router); }); it('should return true, and sets up the data correctly, with a scope and value', () => { @@ -48,6 +40,7 @@ describe('BrowseByGuard', () => { title: field, browseDefinition, }, + parent: null, params: { id, }, @@ -64,7 +57,7 @@ describe('BrowseByGuard', () => { title, id, browseDefinition, - collection: name, + scope, field, value: '"' + value + '"' }; @@ -97,7 +90,7 @@ describe('BrowseByGuard', () => { title, id, browseDefinition, - collection: name, + scope, field, value: '' }; @@ -108,12 +101,48 @@ describe('BrowseByGuard', () => { ); }); + it('should return true, and sets up the data correctly using the community/collection page id, with a scope and without value', () => { + const scopedNoValueRoute = { + data: { + title: field, + browseDefinition, + }, + parent: { + params: { + id: scope, + }, + }, + params: { + id, + }, + queryParams: { + }, + }; + + guard.canActivate(scopedNoValueRoute as any, undefined).pipe( + first(), + ).subscribe((canActivate) => { + const result = { + title, + id, + browseDefinition, + scope, + field, + value: '', + }; + expect(scopedNoValueRoute.data).toEqual(result); + expect(router.navigate).not.toHaveBeenCalled(); + expect(canActivate).toEqual(true); + }); + }); + it('should return true, and sets up the data correctly, without a scope and with a value', () => { const route = { data: { title: field, browseDefinition, }, + parent: null, params: { id, }, @@ -129,7 +158,7 @@ describe('BrowseByGuard', () => { title, id, browseDefinition, - collection: '', + scope: undefined, field, value: '"' + value + '"' }; @@ -147,6 +176,7 @@ describe('BrowseByGuard', () => { data: { title: field, }, + parent: null, params: { id, }, diff --git a/src/app/browse-by/browse-by-guard.ts b/src/app/browse-by/browse-by-guard.ts index dd87699a84..cca9a6a675 100644 --- a/src/app/browse-by/browse-by-guard.ts +++ b/src/app/browse-by/browse-by-guard.ts @@ -1,15 +1,12 @@ -import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot } from '@angular/router'; +import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, Data } from '@angular/router'; import { Injectable } from '@angular/core'; -import { DSpaceObjectDataService } from '../core/data/dspace-object-data.service'; import { hasNoValue, hasValue } from '../shared/empty.util'; import { map, switchMap } from 'rxjs/operators'; -import { getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload } from '../core/shared/operators'; +import { getFirstCompletedRemoteData } from '../core/shared/operators'; import { TranslateService } from '@ngx-translate/core'; import { Observable, of as observableOf } from 'rxjs'; import { BrowseDefinitionDataService } from '../core/browse/browse-definition-data.service'; import { BrowseDefinition } from '../core/shared/browse-definition.model'; -import { DSONameService } from '../core/breadcrumbs/dso-name.service'; -import { DSpaceObject } from '../core/shared/dspace-object.model'; import { RemoteData } from '../core/data/remote-data'; import { PAGE_NOT_FOUND_PATH } from '../app-routing-paths'; @@ -19,11 +16,10 @@ import { PAGE_NOT_FOUND_PATH } from '../app-routing-paths'; */ export class BrowseByGuard implements CanActivate { - constructor(protected dsoService: DSpaceObjectDataService, - protected translate: TranslateService, - protected browseDefinitionService: BrowseDefinitionDataService, - protected dsoNameService: DSONameService, - protected router: Router, + constructor( + protected translate: TranslateService, + protected browseDefinitionService: BrowseDefinitionDataService, + protected router: Router, ) { } @@ -39,25 +35,14 @@ export class BrowseByGuard implements CanActivate { } else { browseDefinition$ = observableOf(route.data.browseDefinition); } - const scope = route.queryParams.scope; + const scope = route.queryParams.scope ?? route.parent?.params.id; const value = route.queryParams.value; const metadataTranslated = this.translate.instant(`browse.metadata.${id}`); return browseDefinition$.pipe( switchMap((browseDefinition: BrowseDefinition | undefined) => { if (hasValue(browseDefinition)) { - if (hasValue(scope)) { - const dso$: Observable = this.dsoService.findById(scope).pipe(getFirstSucceededRemoteDataPayload()); - return dso$.pipe( - map((dso: DSpaceObject) => { - const name = this.dsoNameService.getName(dso); - route.data = this.createData(title, id, browseDefinition, name, metadataTranslated, value, route); - return true; - }) - ); - } else { - route.data = this.createData(title, id, browseDefinition, '', metadataTranslated, value, route); - return observableOf(true); - } + route.data = this.createData(title, id, browseDefinition, metadataTranslated, value, route, scope); + return observableOf(true); } else { void this.router.navigate([PAGE_NOT_FOUND_PATH]); return observableOf(false); @@ -66,14 +51,14 @@ export class BrowseByGuard implements CanActivate { ); } - private createData(title, id, browseDefinition, collection, field, value, route) { + private createData(title: string, id: string, browseDefinition: BrowseDefinition, field: string, value: string, route: ActivatedRouteSnapshot, scope: string): Data { return Object.assign({}, route.data, { title: title, id: id, browseDefinition: browseDefinition, - collection: collection, field: field, - value: hasValue(value) ? `"${value}"` : '' + value: hasValue(value) ? `"${value}"` : '', + scope: scope, }); } } diff --git a/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.html b/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.html deleted file mode 100644 index cfc2cbe305..0000000000 --- a/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.html +++ /dev/null @@ -1,67 +0,0 @@ -
- - -
- -
- - - - - - - - - - - - - - - -
- -
- - -
- -
- -
- - -
-
- - - - -
-
-
-
-
diff --git a/src/app/browse-by/browse-by-metadata/browse-by-metadata.component.html b/src/app/browse-by/browse-by-metadata/browse-by-metadata.component.html new file mode 100644 index 0000000000..52ef06206f --- /dev/null +++ b/src/app/browse-by/browse-by-metadata/browse-by-metadata.component.html @@ -0,0 +1,21 @@ +
+ +
diff --git a/src/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.scss b/src/app/browse-by/browse-by-metadata/browse-by-metadata.component.scss similarity index 100% rename from src/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.scss rename to src/app/browse-by/browse-by-metadata/browse-by-metadata.component.scss diff --git a/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.spec.ts b/src/app/browse-by/browse-by-metadata/browse-by-metadata.component.spec.ts similarity index 91% rename from src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.spec.ts rename to src/app/browse-by/browse-by-metadata/browse-by-metadata.component.spec.ts index a5beeb8a45..9fef2b1a35 100644 --- a/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.spec.ts +++ b/src/app/browse-by/browse-by-metadata/browse-by-metadata.component.spec.ts @@ -1,8 +1,8 @@ import { - BrowseByMetadataPageComponent, + BrowseByMetadataComponent, browseParamsToOptions, getBrowseSearchOptions -} from './browse-by-metadata-page.component'; +} from './browse-by-metadata.component'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { BrowseService } from '../../core/browse/browse.service'; import { CommonModule } from '@angular/common'; @@ -30,10 +30,11 @@ import { PaginationService } from '../../core/pagination/pagination.service'; import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; import { PaginationServiceStub } from '../../shared/testing/pagination-service.stub'; import { APP_CONFIG } from '../../../config/app-config.interface'; +import { cold } from 'jasmine-marbles'; -describe('BrowseByMetadataPageComponent', () => { - let comp: BrowseByMetadataPageComponent; - let fixture: ComponentFixture; +describe('BrowseByMetadataComponent', () => { + let comp: BrowseByMetadataComponent; + let fixture: ComponentFixture; let browseService: BrowseService; let route: ActivatedRoute; let paginationService; @@ -103,7 +104,7 @@ describe('BrowseByMetadataPageComponent', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule], - declarations: [BrowseByMetadataPageComponent, EnumKeysPipe, VarDirective], + declarations: [BrowseByMetadataComponent, EnumKeysPipe, VarDirective], providers: [ { provide: ActivatedRoute, useValue: activatedRouteStub }, { provide: BrowseService, useValue: mockBrowseService }, @@ -117,7 +118,7 @@ describe('BrowseByMetadataPageComponent', () => { })); beforeEach(() => { - fixture = TestBed.createComponent(BrowseByMetadataPageComponent); + fixture = TestBed.createComponent(BrowseByMetadataComponent); comp = fixture.componentInstance; fixture.detectChanges(); browseService = (comp as any).browseService; @@ -144,20 +145,18 @@ describe('BrowseByMetadataPageComponent', () => { route.params = observableOf(paramsWithValue); comp.ngOnInit(); - comp.updateParent('fake-scope'); - comp.updateLogo(); fixture.detectChanges(); }); - it('should fetch items', () => { + it('should fetch items', (done: DoneFn) => { + expect(comp.loading$).toBeObservable(cold('(a|)', { + a: false, + })); comp.items$.subscribe((result) => { expect(result.payload.page).toEqual(mockItems); + done(); }); }); - - it('should fetch the logo', () => { - expect(comp.logo$).toBeTruthy(); - }); }); describe('when calling browseParamsToOptions', () => { @@ -176,7 +175,7 @@ describe('BrowseByMetadataPageComponent', () => { field: 'fake-field', }; - result = browseParamsToOptions(paramsScope, paginationOptions, sortOptions, 'author', comp.fetchThumbnails); + result = browseParamsToOptions(paramsScope, 'fake-scope', paginationOptions, sortOptions, 'author', comp.fetchThumbnails); }); it('should return BrowseEntrySearchOptions with the correct properties', () => { diff --git a/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.ts b/src/app/browse-by/browse-by-metadata/browse-by-metadata.component.ts similarity index 77% rename from src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.ts rename to src/app/browse-by/browse-by-metadata/browse-by-metadata.component.ts index 8555fd3426..00cfedba88 100644 --- a/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.ts +++ b/src/app/browse-by/browse-by-metadata/browse-by-metadata.component.ts @@ -1,5 +1,5 @@ -import { combineLatest as observableCombineLatest, Observable, Subscription } from 'rxjs'; -import { Component, Inject, OnInit, OnDestroy, Input } from '@angular/core'; +import { BehaviorSubject, combineLatest as observableCombineLatest, Observable, Subscription, of as observableOf } from 'rxjs'; +import { Component, Inject, OnInit, OnDestroy, Input, OnChanges } from '@angular/core'; import { RemoteData } from '../../core/data/remote-data'; import { PaginatedList } from '../../core/data/paginated-list.model'; import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; @@ -12,14 +12,9 @@ import { Item } from '../../core/shared/item.model'; import { BrowseEntrySearchOptions } from '../../core/browse/browse-entry-search-options.model'; import { getFirstSucceededRemoteData } from '../../core/shared/operators'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; -import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { StartsWithType } from '../../shared/starts-with/starts-with-decorator'; import { PaginationService } from '../../core/pagination/pagination.service'; -import { filter, map, mergeMap } from 'rxjs/operators'; -import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { Bitstream } from '../../core/shared/bitstream.model'; -import { Collection } from '../../core/shared/collection.model'; -import { Community } from '../../core/shared/community.model'; +import { map } from 'rxjs/operators'; import { APP_CONFIG, AppConfig } from '../../../config/app-config.interface'; import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; import { rendersBrowseBy } from '../browse-by-switcher/browse-by-decorator'; @@ -29,9 +24,9 @@ import { Context } from '../../core/shared/context.model'; export const BBM_PAGINATION_ID = 'bbm'; @Component({ - selector: 'ds-browse-by-metadata-page', - styleUrls: ['./browse-by-metadata-page.component.scss'], - templateUrl: './browse-by-metadata-page.component.html' + selector: 'ds-browse-by-metadata', + styleUrls: ['./browse-by-metadata.component.scss'], + templateUrl: './browse-by-metadata.component.html', }) /** * Component for browsing (items) by metadata definition. @@ -40,7 +35,7 @@ export const BBM_PAGINATION_ID = 'bbm'; * 'dc.contributor.*' */ @rendersBrowseBy(BrowseByDataType.Metadata) -export class BrowseByMetadataPageComponent implements OnInit, OnDestroy { +export class BrowseByMetadataComponent implements OnInit, OnChanges, OnDestroy { /** * The optional context @@ -52,6 +47,18 @@ export class BrowseByMetadataPageComponent implements OnInit, OnDestroy { */ @Input() browseByType: BrowseByDataType; + /** + * The ID of the {@link Community} or {@link Collection} of the scope to display + */ + @Input() scope: string; + + /** + * Display the h1 title in the section + */ + @Input() displayTitle = true; + + scope$: BehaviorSubject = new BehaviorSubject(undefined); + /** * The list of browse-entries to display */ @@ -62,16 +69,6 @@ export class BrowseByMetadataPageComponent implements OnInit, OnDestroy { */ items$: Observable>>; - /** - * The current Community or Collection we're browsing metadata/items in - */ - parent$: Observable>; - - /** - * The logo of the current Community or Collection - */ - logo$: Observable>; - /** * The pagination config used to display the values */ @@ -112,7 +109,7 @@ export class BrowseByMetadataPageComponent implements OnInit, OnDestroy { * The list of StartsWith options * Should be defined after ngOnInit is called! */ - startsWithOptions; + startsWithOptions: (string | number)[]; /** * The value we're browsing items for @@ -136,6 +133,11 @@ export class BrowseByMetadataPageComponent implements OnInit, OnDestroy { */ fetchThumbnails: boolean; + /** + * Observable determining if the loading animation needs to be shown + */ + loading$ = observableOf(true); + public constructor(protected route: ActivatedRoute, protected browseService: BrowseService, protected dsoService: DSpaceObjectDataService, @@ -160,12 +162,12 @@ export class BrowseByMetadataPageComponent implements OnInit, OnDestroy { this.currentPagination$ = this.paginationService.getCurrentPagination(this.paginationConfig.id, this.paginationConfig); this.currentSort$ = this.paginationService.getCurrentSort(this.paginationConfig.id, sortConfig); this.subs.push( - observableCombineLatest([this.route.params, this.route.queryParams, this.currentPagination$, this.currentSort$]).pipe( - map(([routeParams, queryParams, currentPage, currentSort]) => { - return [Object.assign({}, routeParams, queryParams),currentPage,currentSort]; + observableCombineLatest([this.route.params, this.route.queryParams, this.scope$, this.currentPagination$, this.currentSort$]).pipe( + map(([routeParams, queryParams, scope, currentPage, currentSort]) => { + return [Object.assign({}, routeParams, queryParams), scope, currentPage, currentSort]; }) - ).subscribe(([params, currentPage, currentSort]: [Params, PaginationComponentOptions, SortOptions]) => { - this.browseId = params.id || this.defaultBrowseId; + ).subscribe(([params, scope, currentPage, currentSort]: [Params, string, PaginationComponentOptions, SortOptions]) => { + this.browseId = params.id || this.defaultBrowseId; this.authority = params.authority; if (typeof params.value === 'string'){ @@ -183,18 +185,19 @@ export class BrowseByMetadataPageComponent implements OnInit, OnDestroy { } if (isNotEmpty(this.value)) { - this.updatePageWithItems( - browseParamsToOptions(params, currentPage, currentSort, this.browseId, this.fetchThumbnails), this.value, this.authority); + this.updatePageWithItems(browseParamsToOptions(params, scope, currentPage, currentSort, this.browseId, this.fetchThumbnails), this.value, this.authority); } else { - this.updatePage(browseParamsToOptions(params, currentPage, currentSort, this.browseId, false)); + this.updatePage(browseParamsToOptions(params, scope, currentPage, currentSort, this.browseId, false)); } - this.updateParent(params.scope); - this.updateLogo(); })); this.updateStartsWithTextOptions(); } + ngOnChanges(): void { + this.scope$.next(this.scope); + } + /** * Update the StartsWith options with text values * It adds the value "0-9" as well as all letters from A to Z @@ -213,6 +216,9 @@ export class BrowseByMetadataPageComponent implements OnInit, OnDestroy { */ updatePage(searchOptions: BrowseEntrySearchOptions) { this.browseEntries$ = this.browseService.getBrowseEntriesFor(searchOptions); + this.loading$ = this.browseEntries$.pipe( + map((browseEntriesRD: RemoteData>) => browseEntriesRD.isLoading), + ); this.items$ = undefined; } @@ -227,37 +233,9 @@ export class BrowseByMetadataPageComponent implements OnInit, OnDestroy { */ updatePageWithItems(searchOptions: BrowseEntrySearchOptions, value: string, authority: string) { this.items$ = this.browseService.getBrowseItemsFor(value, authority, searchOptions); - } - - /** - * Update the parent Community or Collection using their scope - * @param scope The UUID of the Community or Collection to fetch - */ - updateParent(scope: string) { - if (hasValue(scope)) { - const linksToFollow = () => { - return [followLink('logo')]; - }; - this.parent$ = this.dsoService.findById(scope, - true, - true, - ...linksToFollow() as FollowLinkConfig[]).pipe( - getFirstSucceededRemoteData() - ); - } - } - - /** - * Update the parent Community or Collection logo - */ - updateLogo() { - if (hasValue(this.parent$)) { - this.logo$ = this.parent$.pipe( - map((rd: RemoteData) => rd.payload), - filter((collectionOrCommunity: Collection | Community) => hasValue(collectionOrCommunity.logo)), - mergeMap((collectionOrCommunity: Collection | Community) => collectionOrCommunity.logo) - ); - } + this.loading$ = this.items$.pipe( + map((itemsRD: RemoteData>) => itemsRD.isLoading), + ); } /** @@ -320,12 +298,14 @@ export function getBrowseSearchOptions(defaultBrowseId: string, /** * Function to transform query and url parameters into searchOptions used to fetch browse entries or items * @param params URL and query parameters + * @param scope The scope to show the results * @param paginationConfig Pagination configuration * @param sortConfig Sorting configuration * @param metadata Optional metadata definition to fetch browse entries/items for * @param fetchThumbnail Optional parameter for requesting thumbnail images */ export function browseParamsToOptions(params: any, + scope: string, paginationConfig: PaginationComponentOptions, sortConfig: SortOptions, metadata?: string, @@ -335,7 +315,7 @@ export function browseParamsToOptions(params: any, paginationConfig, sortConfig, params.startsWith, - params.scope, + scope, fetchThumbnail ); } diff --git a/src/app/browse-by/browse-by-page/browse-by-page.component.html b/src/app/browse-by/browse-by-page/browse-by-page.component.html index b7b109643b..6e9476e1e9 100644 --- a/src/app/browse-by/browse-by-page/browse-by-page.component.html +++ b/src/app/browse-by/browse-by-page/browse-by-page.component.html @@ -1,2 +1,4 @@ - - +
+ + +
diff --git a/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.ts b/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.ts index 0c200c3453..99b969417e 100644 --- a/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.ts +++ b/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.ts @@ -15,6 +15,10 @@ export class BrowseBySwitcherComponent extends AbstractComponentLoaderComponent< @Input() browseByType: BrowseByDataType; + @Input() displayTitle: boolean; + + @Input() scope: string; + protected inputNamesDependentForComponent: (keyof this & string)[] = [ 'context', 'browseByType', @@ -23,6 +27,8 @@ export class BrowseBySwitcherComponent extends AbstractComponentLoaderComponent< protected inputNames: (keyof this & string)[] = [ 'context', 'browseByType', + 'displayTitle', + 'scope', ]; public getComponent(): GenericConstructor { diff --git a/src/app/browse-by/browse-by-switcher/dynamic-component-loader.directive.ts b/src/app/browse-by/browse-by-switcher/dynamic-component-loader.directive.ts new file mode 100644 index 0000000000..8c77df1cdb --- /dev/null +++ b/src/app/browse-by/browse-by-switcher/dynamic-component-loader.directive.ts @@ -0,0 +1,16 @@ +import { Directive, ViewContainerRef } from '@angular/core'; + +/** + * Directive used as a hook to know where to inject the dynamic loaded component + */ +@Directive({ + selector: '[dsDynamicComponentLoader]' +}) +export class DynamicComponentLoaderDirective { + + constructor( + public viewContainerRef: ViewContainerRef, + ) { + } + +} diff --git a/src/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.html b/src/app/browse-by/browse-by-taxonomy/browse-by-taxonomy.component.html similarity index 70% rename from src/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.html rename to src/app/browse-by/browse-by-taxonomy/browse-by-taxonomy.component.html index c24ca93403..3dd9b6b25a 100644 --- a/src/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.html +++ b/src/app/browse-by/browse-by-taxonomy/browse-by-taxonomy.component.html @@ -1,5 +1,11 @@ -
-

{{ ('browse.taxonomy_' + vocabularyName + '.title') | translate }}

+
+

+ {{ ('browse.title') | translate:{ + field: 'browse.metadata.' + vocabularyName | translate, + startsWith: '', + value: '', + } }} +

{{ 'browse.taxonomy.button' | translate }} -
+
diff --git a/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.scss b/src/app/browse-by/browse-by-taxonomy/browse-by-taxonomy.component.scss similarity index 100% rename from src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.scss rename to src/app/browse-by/browse-by-taxonomy/browse-by-taxonomy.component.scss diff --git a/src/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.spec.ts b/src/app/browse-by/browse-by-taxonomy/browse-by-taxonomy.component.spec.ts similarity index 68% rename from src/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.spec.ts rename to src/app/browse-by/browse-by-taxonomy/browse-by-taxonomy.component.spec.ts index c724017b1f..ac8dbff598 100644 --- a/src/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.spec.ts +++ b/src/app/browse-by/browse-by-taxonomy/browse-by-taxonomy.component.spec.ts @@ -1,6 +1,5 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { BrowseByTaxonomyPageComponent } from './browse-by-taxonomy-page.component'; +import { BrowseByTaxonomyComponent } from './browse-by-taxonomy.component'; import { VocabularyEntryDetail } from '../../core/submission/vocabularies/models/vocabulary-entry-detail.model'; import { TranslateModule } from '@ngx-translate/core'; import { NO_ERRORS_SCHEMA } from '@angular/core'; @@ -10,9 +9,9 @@ import { createDataWithBrowseDefinition } from '../browse-by-switcher/browse-by- import { HierarchicalBrowseDefinition } from '../../core/shared/hierarchical-browse-definition.model'; import { ThemeService } from '../../shared/theme-support/theme.service'; -describe('BrowseByTaxonomyPageComponent', () => { - let component: BrowseByTaxonomyPageComponent; - let fixture: ComponentFixture; +describe('BrowseByTaxonomyComponent', () => { + let component: BrowseByTaxonomyComponent; + let fixture: ComponentFixture; let themeService: ThemeService; let detail1: VocabularyEntryDetail; let detail2: VocabularyEntryDetail; @@ -29,7 +28,9 @@ describe('BrowseByTaxonomyPageComponent', () => { await TestBed.configureTestingModule({ imports: [ TranslateModule.forRoot() ], - declarations: [ BrowseByTaxonomyPageComponent ], + declarations: [ + BrowseByTaxonomyComponent, + ], providers: [ { provide: ActivatedRoute, useValue: activatedRouteStub }, { provide: ThemeService, useValue: themeService }, @@ -40,8 +41,9 @@ describe('BrowseByTaxonomyPageComponent', () => { }); beforeEach(() => { - fixture = TestBed.createComponent(BrowseByTaxonomyPageComponent); + fixture = TestBed.createComponent(BrowseByTaxonomyComponent); component = fixture.componentInstance; + spyOn(component, 'updateQueryParams').and.callThrough(); fixture.detectChanges(); detail1 = new VocabularyEntryDetail(); detail2 = new VocabularyEntryDetail(); @@ -61,6 +63,7 @@ describe('BrowseByTaxonomyPageComponent', () => { expect(component.selectedItems).toContain(detail1); expect(component.selectedItems.length).toBe(1); expect(component.filterValues).toEqual(['HUMANITIES and RELIGION,equals'] ); + expect(component.updateQueryParams).toHaveBeenCalled(); }); it('should handle select event with multiple selected items', () => { @@ -70,6 +73,7 @@ describe('BrowseByTaxonomyPageComponent', () => { expect(component.selectedItems).toContain(detail1, detail2); expect(component.selectedItems.length).toBe(2); expect(component.filterValues).toEqual(['HUMANITIES and RELIGION,equals', 'TECHNOLOGY,equals'] ); + expect(component.updateQueryParams).toHaveBeenCalled(); }); it('should handle deselect event', () => { @@ -82,6 +86,33 @@ describe('BrowseByTaxonomyPageComponent', () => { expect(component.selectedItems).toContain(detail2); expect(component.selectedItems.length).toBe(1); expect(component.filterValues).toEqual(['TECHNOLOGY,equals'] ); + expect(component.updateQueryParams).toHaveBeenCalled(); + }); + + describe('updateQueryParams', () => { + beforeEach(() => { + component.facetType = 'subject'; + component.filterValues = ['HUMANITIES and RELIGION,equals', 'TECHNOLOGY,equals']; + }); + + it('should update the queryParams with the selected filterValues', () => { + component.updateQueryParams(); + + expect(component.queryParams).toEqual({ + 'f.subject': ['HUMANITIES and RELIGION,equals', 'TECHNOLOGY,equals'], + }); + }); + + it('should include the scope if present', () => { + component.scope = '67f849f1-2499-4872-8c61-9e2b47d71068'; + + component.updateQueryParams(); + + expect(component.queryParams).toEqual({ + 'f.subject': ['HUMANITIES and RELIGION,equals', 'TECHNOLOGY,equals'], + 'scope': '67f849f1-2499-4872-8c61-9e2b47d71068', + }); + }); }); afterEach(() => { diff --git a/src/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.ts b/src/app/browse-by/browse-by-taxonomy/browse-by-taxonomy.component.ts similarity index 69% rename from src/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.ts rename to src/app/browse-by/browse-by-taxonomy/browse-by-taxonomy.component.ts index fb2f28c8c5..d23a3dd15c 100644 --- a/src/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.ts +++ b/src/app/browse-by/browse-by-taxonomy/browse-by-taxonomy.component.ts @@ -1,25 +1,26 @@ -import { Component, OnInit, OnDestroy, Input } from '@angular/core'; +import { Component, OnInit, OnChanges, OnDestroy, Input } from '@angular/core'; import { VocabularyOptions } from '../../core/submission/vocabularies/models/vocabulary-options.model'; import { VocabularyEntryDetail } from '../../core/submission/vocabularies/models/vocabulary-entry-detail.model'; -import { ActivatedRoute } from '@angular/router'; -import { Observable, Subscription } from 'rxjs'; +import { ActivatedRoute, Params } from '@angular/router'; +import { BehaviorSubject, Observable, Subscription } from 'rxjs'; import { BrowseDefinition } from '../../core/shared/browse-definition.model'; import { rendersBrowseBy } from '../browse-by-switcher/browse-by-decorator'; import { map } from 'rxjs/operators'; import { HierarchicalBrowseDefinition } from '../../core/shared/hierarchical-browse-definition.model'; import { BrowseByDataType } from '../browse-by-switcher/browse-by-data-type'; import { Context } from '../../core/shared/context.model'; +import { hasValue } from '../../shared/empty.util'; @Component({ - selector: 'ds-browse-by-taxonomy-page', - templateUrl: './browse-by-taxonomy-page.component.html', - styleUrls: ['./browse-by-taxonomy-page.component.scss'] + selector: 'ds-browse-by-taxonomy', + templateUrl: './browse-by-taxonomy.component.html', + styleUrls: ['./browse-by-taxonomy.component.scss'], }) /** * Component for browsing items by metadata in a hierarchical controlled vocabulary */ @rendersBrowseBy(BrowseByDataType.Hierarchy) -export class BrowseByTaxonomyPageComponent implements OnInit, OnDestroy { +export class BrowseByTaxonomyComponent implements OnInit, OnChanges, OnDestroy { /** * The optional context @@ -31,6 +32,18 @@ export class BrowseByTaxonomyPageComponent implements OnInit, OnDestroy { */ @Input() browseByType: BrowseByDataType; + /** + * The ID of the {@link Community} or {@link Collection} of the scope to display + */ + @Input() scope: string; + + /** + * Display the h1 title in the section + */ + @Input() displayTitle = true; + + scope$: BehaviorSubject = new BehaviorSubject(undefined); + /** * The {@link VocabularyOptions} object */ @@ -59,7 +72,7 @@ export class BrowseByTaxonomyPageComponent implements OnInit, OnDestroy { /** * The parameters used in the URL */ - queryParams: any; + queryParams: Params; /** * Resolved browse-by definition @@ -87,6 +100,13 @@ export class BrowseByTaxonomyPageComponent implements OnInit, OnDestroy { this.vocabularyName = browseDefinition.vocabulary; this.vocabularyOptions = { name: this.vocabularyName, closed: true }; })); + this.subs.push(this.scope$.subscribe(() => { + this.updateQueryParams(); + })); + } + + ngOnChanges(): void { + this.scope$.next(this.scope); } /** @@ -96,9 +116,9 @@ export class BrowseByTaxonomyPageComponent implements OnInit, OnDestroy { * @param detail VocabularyEntryDetail to be added */ onSelect(detail: VocabularyEntryDetail): void { - this.selectedItems.push(detail); - this.filterValues = this.selectedItems - .map((item: VocabularyEntryDetail) => `${item.value},equals`); + this.selectedItems.push(detail); + this.filterValues = this.selectedItems + .map((item: VocabularyEntryDetail) => `${item.value},equals`); this.updateQueryParams(); } @@ -108,18 +128,25 @@ export class BrowseByTaxonomyPageComponent implements OnInit, OnDestroy { * @param detail VocabularyEntryDetail to be removed */ onDeselect(detail: VocabularyEntryDetail): void { - this.selectedItems = this.selectedItems.filter((entry: VocabularyEntryDetail) => { return entry.id !== detail.id; }); - this.filterValues = this.filterValues.filter((value: string) => { return value !== `${detail.value},equals`; }); + this.selectedItems = this.selectedItems.filter((entry: VocabularyEntryDetail) => { + return entry.id !== detail.id; + }); + this.filterValues = this.filterValues.filter((value: string) => { + return value !== `${detail.value},equals`; + }); this.updateQueryParams(); } /** * Updates queryParams based on the current facetType and filterValues. */ - private updateQueryParams(): void { + updateQueryParams(): void { this.queryParams = { ['f.' + this.facetType]: this.filterValues }; + if (hasValue(this.scope)) { + this.queryParams.scope = this.scope; + } } ngOnDestroy(): void { diff --git a/src/app/browse-by/browse-by-title-page/browse-by-title-page.component.ts b/src/app/browse-by/browse-by-title-page/browse-by-title-page.component.ts deleted file mode 100644 index 1e18429fa0..0000000000 --- a/src/app/browse-by/browse-by-title-page/browse-by-title-page.component.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { combineLatest as observableCombineLatest } from 'rxjs'; -import { Component, Inject, OnInit } from '@angular/core'; -import { ActivatedRoute, Params, Router } from '@angular/router'; -import { - BrowseByMetadataPageComponent, - browseParamsToOptions, getBrowseSearchOptions -} from '../browse-by-metadata-page/browse-by-metadata-page.component'; -import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; -import { BrowseService } from '../../core/browse/browse.service'; -import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; -import { PaginationService } from '../../core/pagination/pagination.service'; -import { map } from 'rxjs/operators'; -import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; -import { AppConfig, APP_CONFIG } from '../../../config/app-config.interface'; -import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; -import { rendersBrowseBy } from '../browse-by-switcher/browse-by-decorator'; -import { BrowseByDataType } from '../browse-by-switcher/browse-by-data-type'; - -@Component({ - selector: 'ds-browse-by-title-page', - styleUrls: ['../browse-by-metadata-page/browse-by-metadata-page.component.scss'], - templateUrl: '../browse-by-metadata-page/browse-by-metadata-page.component.html' -}) -/** - * Component for browsing items by title (dc.title) - */ -@rendersBrowseBy(BrowseByDataType.Title) -export class BrowseByTitlePageComponent extends BrowseByMetadataPageComponent implements OnInit { - - public constructor(protected route: ActivatedRoute, - protected browseService: BrowseService, - protected dsoService: DSpaceObjectDataService, - protected paginationService: PaginationService, - protected router: Router, - @Inject(APP_CONFIG) public appConfig: AppConfig, - public dsoNameService: DSONameService, - ) { - super(route, browseService, dsoService, paginationService, router, appConfig, dsoNameService); - } - - ngOnInit(): void { - const sortConfig = new SortOptions('dc.title', SortDirection.ASC); - // include the thumbnail configuration in browse search options - this.updatePage(getBrowseSearchOptions(this.defaultBrowseId, this.paginationConfig, sortConfig, this.fetchThumbnails)); - this.currentPagination$ = this.paginationService.getCurrentPagination(this.paginationConfig.id, this.paginationConfig); - this.currentSort$ = this.paginationService.getCurrentSort(this.paginationConfig.id, sortConfig); - this.subs.push( - observableCombineLatest([this.route.params, this.route.queryParams, this.currentPagination$, this.currentSort$]).pipe( - map(([routeParams, queryParams, currentPage, currentSort]) => { - return [Object.assign({}, routeParams, queryParams),currentPage,currentSort]; - }) - ).subscribe(([params, currentPage, currentSort]: [Params, PaginationComponentOptions, SortOptions]) => { - this.startsWith = +params.startsWith || params.startsWith; - this.browseId = params.id || this.defaultBrowseId; - this.updatePageWithItems(browseParamsToOptions(params, currentPage, currentSort, this.browseId, this.fetchThumbnails), undefined, undefined); - this.updateParent(params.scope); - this.updateLogo(); - })); - this.updateStartsWithTextOptions(); - } - -} diff --git a/src/app/browse-by/browse-by-title-page/browse-by-title-page.component.spec.ts b/src/app/browse-by/browse-by-title/browse-by-title.component.spec.ts similarity index 87% rename from src/app/browse-by/browse-by-title-page/browse-by-title-page.component.spec.ts rename to src/app/browse-by/browse-by-title/browse-by-title.component.spec.ts index e32c0ac430..54394087ec 100644 --- a/src/app/browse-by/browse-by-title-page/browse-by-title-page.component.spec.ts +++ b/src/app/browse-by/browse-by-title/browse-by-title.component.spec.ts @@ -9,8 +9,8 @@ import { TranslateModule } from '@ngx-translate/core'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { EnumKeysPipe } from '../../shared/utils/enum-keys-pipe'; import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { toRemoteData } from '../browse-by-metadata-page/browse-by-metadata-page.component.spec'; -import { BrowseByTitlePageComponent } from './browse-by-title-page.component'; +import { toRemoteData } from '../browse-by-metadata/browse-by-metadata.component.spec'; +import { BrowseByTitleComponent } from './browse-by-title.component'; import { ItemDataService } from '../../core/data/item-data.service'; import { Community } from '../../core/shared/community.model'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; @@ -24,9 +24,9 @@ import { APP_CONFIG } from '../../../config/app-config.interface'; import { environment } from '../../../environments/environment'; -describe('BrowseByTitlePageComponent', () => { - let comp: BrowseByTitlePageComponent; - let fixture: ComponentFixture; +describe('BrowseByTitleComponent', () => { + let comp: BrowseByTitleComponent; + let fixture: ComponentFixture; let itemDataService: ItemDataService; let route: ActivatedRoute; @@ -71,7 +71,7 @@ describe('BrowseByTitlePageComponent', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule], - declarations: [BrowseByTitlePageComponent, EnumKeysPipe, VarDirective], + declarations: [BrowseByTitleComponent, EnumKeysPipe, VarDirective], providers: [ { provide: ActivatedRoute, useValue: activatedRouteStub }, { provide: BrowseService, useValue: mockBrowseService }, @@ -85,7 +85,7 @@ describe('BrowseByTitlePageComponent', () => { })); beforeEach(() => { - fixture = TestBed.createComponent(BrowseByTitlePageComponent); + fixture = TestBed.createComponent(BrowseByTitleComponent); comp = fixture.componentInstance; fixture.detectChanges(); itemDataService = (comp as any).itemDataService; diff --git a/src/app/browse-by/browse-by-title/browse-by-title.component.ts b/src/app/browse-by/browse-by-title/browse-by-title.component.ts new file mode 100644 index 0000000000..7b603af48b --- /dev/null +++ b/src/app/browse-by/browse-by-title/browse-by-title.component.ts @@ -0,0 +1,44 @@ +import { combineLatest as observableCombineLatest } from 'rxjs'; +import { Component, OnInit } from '@angular/core'; +import { Params } from '@angular/router'; +import { + BrowseByMetadataComponent, + browseParamsToOptions, getBrowseSearchOptions +} from '../browse-by-metadata/browse-by-metadata.component'; +import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; +import { map } from 'rxjs/operators'; +import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; +import { rendersBrowseBy } from '../browse-by-switcher/browse-by-decorator'; +import { BrowseByDataType } from '../browse-by-switcher/browse-by-data-type'; + +@Component({ + selector: 'ds-browse-by-title', + styleUrls: ['../browse-by-metadata/browse-by-metadata.component.scss'], + templateUrl: '../browse-by-metadata/browse-by-metadata.component.html' +}) +/** + * Component for browsing items by title (dc.title) + */ +@rendersBrowseBy(BrowseByDataType.Title) +export class BrowseByTitleComponent extends BrowseByMetadataComponent implements OnInit { + + ngOnInit(): void { + const sortConfig = new SortOptions('dc.title', SortDirection.ASC); + // include the thumbnail configuration in browse search options + this.updatePage(getBrowseSearchOptions(this.defaultBrowseId, this.paginationConfig, sortConfig, this.fetchThumbnails)); + this.currentPagination$ = this.paginationService.getCurrentPagination(this.paginationConfig.id, this.paginationConfig); + this.currentSort$ = this.paginationService.getCurrentSort(this.paginationConfig.id, sortConfig); + this.subs.push( + observableCombineLatest([this.route.params, this.route.queryParams, this.scope$, this.currentPagination$, this.currentSort$]).pipe( + map(([routeParams, queryParams, scope, currentPage, currentSort]) => { + return [Object.assign({}, routeParams, queryParams), scope, currentPage, currentSort]; + }) + ).subscribe(([params, scope, currentPage, currentSort]: [Params, string, PaginationComponentOptions, SortOptions]) => { + this.startsWith = +params.startsWith || params.startsWith; + this.browseId = params.id || this.defaultBrowseId; + this.updatePageWithItems(browseParamsToOptions(params, scope, currentPage, currentSort, this.browseId, this.fetchThumbnails), undefined, undefined); + })); + this.updateStartsWithTextOptions(); + } + +} diff --git a/src/app/browse-by/browse-by.module.ts b/src/app/browse-by/browse-by.module.ts index 5772063742..b1263595b3 100644 --- a/src/app/browse-by/browse-by.module.ts +++ b/src/app/browse-by/browse-by.module.ts @@ -1,11 +1,10 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { BrowseByTitlePageComponent } from './browse-by-title-page/browse-by-title-page.component'; -import { BrowseByMetadataPageComponent } from './browse-by-metadata-page/browse-by-metadata-page.component'; -import { BrowseByDatePageComponent } from './browse-by-date-page/browse-by-date-page.component'; +import { BrowseByTitleComponent } from './browse-by-title/browse-by-title.component'; +import { BrowseByMetadataComponent } from './browse-by-metadata/browse-by-metadata.component'; +import { BrowseByDateComponent } from './browse-by-date/browse-by-date.component'; import { BrowseBySwitcherComponent } from './browse-by-switcher/browse-by-switcher.component'; -import { BrowseByTaxonomyPageComponent } from './browse-by-taxonomy-page/browse-by-taxonomy-page.component'; -import { ComcolModule } from '../shared/comcol/comcol.module'; +import { BrowseByTaxonomyComponent } from './browse-by-taxonomy/browse-by-taxonomy.component'; import { SharedBrowseByModule } from '../shared/browse-by/shared-browse-by.module'; import { DsoPageModule } from '../shared/dso-page/dso-page.module'; import { FormModule } from '../shared/form/form.module'; @@ -17,17 +16,16 @@ const DECLARATIONS = [ const ENTRY_COMPONENTS = [ // put only entry components that use custom decorator - BrowseByTitlePageComponent, - BrowseByMetadataPageComponent, - BrowseByDatePageComponent, - BrowseByTaxonomyPageComponent, + BrowseByTitleComponent, + BrowseByMetadataComponent, + BrowseByDateComponent, + BrowseByTaxonomyComponent, ]; @NgModule({ imports: [ SharedBrowseByModule, CommonModule, - ComcolModule, DsoPageModule, FormModule, SharedModule, diff --git a/src/app/collection-page/collection-page-routing.module.ts b/src/app/collection-page/collection-page-routing.module.ts index 9dc25b778e..5ddef6ca68 100644 --- a/src/app/collection-page/collection-page-routing.module.ts +++ b/src/app/collection-page/collection-page-routing.module.ts @@ -22,6 +22,10 @@ import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model'; import { ThemedCollectionPageComponent } from './themed-collection-page.component'; import { MenuItemType } from '../shared/menu/menu-item-type.model'; import { DSOEditMenuResolver } from '../shared/dso-page/dso-edit-menu.resolver'; +import { ComcolBrowseByComponent } from '../shared/comcol/sections/comcol-browse-by/comcol-browse-by.component'; +import { BrowseByGuard } from '../browse-by/browse-by-guard'; +import { BrowseByI18nBreadcrumbResolver } from '../browse-by/browse-by-i18n-breadcrumb.resolver'; +import { CollectionRecentlyAddedComponent } from './sections/recently-added/collection-recently-added.component'; @NgModule({ imports: [ @@ -65,7 +69,23 @@ import { DSOEditMenuResolver } from '../shared/dso-page/dso-edit-menu.resolver'; { path: '', component: ThemedCollectionPageComponent, - pathMatch: 'full', + children: [ + { + path: '', + pathMatch: 'full', + component: CollectionRecentlyAddedComponent, + }, + { + path: 'browse/:id', + pathMatch: 'full', + component: ComcolBrowseByComponent, + canActivate: [BrowseByGuard], + resolve: { + breadcrumb: BrowseByI18nBreadcrumbResolver, + }, + data: { breadcrumbKey: 'browse.metadata' }, + }, + ], } ], data: { diff --git a/src/app/collection-page/collection-page.component.html b/src/app/collection-page/collection-page.component.html index 9a5414952f..21cc94af68 100644 --- a/src/app/collection-page/collection-page.component.html +++ b/src/app/collection-page/collection-page.component.html @@ -1,78 +1,61 @@
-
-
-
- -
-
- - - - - - +
+
+
+ +
+
+ + + + + + - - - - - - - - - -
- -
-
- - - + + + + + + + + + +
+ +
+
+ + + - -
-

{{'collection.page.browse.recent.head' | translate}}

- - -
- - - -
-
-
+ + +
+ [content]="collection.copyrightText" + [hasInnerHtml]="true">
- - + +
diff --git a/src/app/collection-page/collection-page.component.ts b/src/app/collection-page/collection-page.component.ts index 16704cef52..6b3cbbe64e 100644 --- a/src/app/collection-page/collection-page.component.ts +++ b/src/app/collection-page/collection-page.component.ts @@ -1,36 +1,21 @@ -import { ChangeDetectionStrategy, Component, OnInit, Inject } from '@angular/core'; +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; -import { BehaviorSubject, combineLatest as observableCombineLatest, Observable, Subject } from 'rxjs'; -import { filter, map, mergeMap, startWith, switchMap, take } from 'rxjs/operators'; -import { PaginatedSearchOptions } from '../shared/search/models/paginated-search-options.model'; -import { SearchService } from '../core/shared/search/search.service'; -import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model'; -import { CollectionDataService } from '../core/data/collection-data.service'; -import { PaginatedList } from '../core/data/paginated-list.model'; +import { Observable } from 'rxjs'; +import { filter, map, mergeMap, take } from 'rxjs/operators'; +import { SortOptions } from '../core/cache/models/sort-options.model'; import { RemoteData } from '../core/data/remote-data'; import { Bitstream } from '../core/shared/bitstream.model'; - import { Collection } from '../core/shared/collection.model'; -import { DSpaceObjectType } from '../core/shared/dspace-object-type.model'; -import { Item } from '../core/shared/item.model'; -import { - getAllSucceededRemoteDataPayload, - getFirstSucceededRemoteData, - toDSpaceObjectListRD -} from '../core/shared/operators'; - +import { getAllSucceededRemoteDataPayload } from '../core/shared/operators'; import { fadeIn, fadeInOut } from '../shared/animations/fade'; import { hasValue, isNotEmpty } from '../shared/empty.util'; import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model'; import { AuthService } from '../core/auth/auth.service'; -import { PaginationService } from '../core/pagination/pagination.service'; import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service'; import { FeatureID } from '../core/data/feature-authorization/feature-id'; import { getCollectionPageRoute } from './collection-page-routing-paths'; import { redirectOn4xx } from '../core/shared/authorized.operators'; -import { BROWSE_LINKS_TO_FOLLOW } from '../core/browse/browse.service'; import { DSONameService } from '../core/breadcrumbs/dso-name.service'; -import { APP_CONFIG, AppConfig } from '../../../src/config/app-config.interface'; @Component({ selector: 'ds-collection-page', @@ -44,14 +29,9 @@ import { APP_CONFIG, AppConfig } from '../../../src/config/app-config.interface' }) export class CollectionPageComponent implements OnInit { collectionRD$: Observable>; - itemRD$: Observable>>; logoRD$: Observable>; paginationConfig: PaginationComponentOptions; sortConfig: SortOptions; - private paginationChanges$: Subject<{ - paginationConfig: PaginationComponentOptions, - sortConfig: SortOptions - }>; /** * Whether the current user is a Community admin @@ -64,23 +44,12 @@ export class CollectionPageComponent implements OnInit { collectionPageRoute$: Observable; constructor( - private collectionDataService: CollectionDataService, - private searchService: SearchService, - private route: ActivatedRoute, - private router: Router, - private authService: AuthService, - private paginationService: PaginationService, - private authorizationDataService: AuthorizationDataService, + protected route: ActivatedRoute, + protected router: Router, + protected authService: AuthService, + protected authorizationDataService: AuthorizationDataService, public dsoNameService: DSONameService, - @Inject(APP_CONFIG) public appConfig: AppConfig, ) { - this.paginationConfig = Object.assign(new PaginationComponentOptions(), { - id: 'cp', - currentPage: 1, - pageSize: this.appConfig.browseBy.pageSize, - }); - - this.sortConfig = new SortOptions('dc.date.accessioned', SortDirection.DESC); } ngOnInit(): void { @@ -96,33 +65,6 @@ export class CollectionPageComponent implements OnInit { ); this.isCollectionAdmin$ = this.authorizationDataService.isAuthorized(FeatureID.IsCollectionAdmin); - this.paginationChanges$ = new BehaviorSubject({ - paginationConfig: this.paginationConfig, - sortConfig: this.sortConfig - }); - - const currentPagination$ = this.paginationService.getCurrentPagination(this.paginationConfig.id, this.paginationConfig); - const currentSort$ = this.paginationService.getCurrentSort(this.paginationConfig.id, this.sortConfig); - - this.itemRD$ = observableCombineLatest([currentPagination$, currentSort$]).pipe( - switchMap(([currentPagination, currentSort]) => this.collectionRD$.pipe( - getFirstSucceededRemoteData(), - map((rd) => rd.payload.id), - switchMap((id: string) => { - return this.searchService.search( - new PaginatedSearchOptions({ - scope: id, - pagination: currentPagination, - sort: currentSort, - dsoTypes: [DSpaceObjectType.ITEM] - }), null, true, true, ...BROWSE_LINKS_TO_FOLLOW) - .pipe(toDSpaceObjectListRD()) as Observable>>; - }), - startWith(undefined) // Make sure switching pages shows loading component - ) - ) - ); - this.collectionPageRoute$ = this.collectionRD$.pipe( getAllSucceededRemoteDataPayload(), map((collection) => getCollectionPageRoute(collection.id)) @@ -133,9 +75,5 @@ export class CollectionPageComponent implements OnInit { return isNotEmpty(object); } - ngOnDestroy(): void { - this.paginationService.clearPagination(this.paginationConfig.id); - } - } diff --git a/src/app/collection-page/collection-page.module.ts b/src/app/collection-page/collection-page.module.ts index 6bcefed2b7..8782be0a45 100644 --- a/src/app/collection-page/collection-page.module.ts +++ b/src/app/collection-page/collection-page.module.ts @@ -18,6 +18,19 @@ import { ThemedCollectionPageComponent } from './themed-collection-page.componen import { ComcolModule } from '../shared/comcol/comcol.module'; import { DsoSharedModule } from '../dso-shared/dso-shared.module'; import { DsoPageModule } from '../shared/dso-page/dso-page.module'; +import { BrowseByPageModule } from '../browse-by/browse-by-page.module'; +import { CollectionRecentlyAddedComponent } from './sections/recently-added/collection-recently-added.component'; + +const DECLARATIONS = [ + CollectionPageComponent, + ThemedCollectionPageComponent, + CreateCollectionPageComponent, + DeleteCollectionPageComponent, + EditItemTemplatePageComponent, + ThemedEditItemTemplatePageComponent, + CollectionItemMapperComponent, + CollectionRecentlyAddedComponent, +]; @NgModule({ imports: [ @@ -30,15 +43,10 @@ import { DsoPageModule } from '../shared/dso-page/dso-page.module'; ComcolModule, DsoSharedModule, DsoPageModule, + BrowseByPageModule, ], declarations: [ - CollectionPageComponent, - ThemedCollectionPageComponent, - CreateCollectionPageComponent, - DeleteCollectionPageComponent, - EditItemTemplatePageComponent, - ThemedEditItemTemplatePageComponent, - CollectionItemMapperComponent + ...DECLARATIONS, ], providers: [ SearchService, diff --git a/src/app/collection-page/sections/recently-added/collection-recently-added.component.html b/src/app/collection-page/sections/recently-added/collection-recently-added.component.html new file mode 100644 index 0000000000..002b8cceda --- /dev/null +++ b/src/app/collection-page/sections/recently-added/collection-recently-added.component.html @@ -0,0 +1,18 @@ + +
+

{{'collection.page.browse.recent.head' | translate}}

+ + +
+ + + +
diff --git a/src/app/community-page/sub-community-list/community-page-sub-community-list.component.scss b/src/app/collection-page/sections/recently-added/collection-recently-added.component.scss similarity index 100% rename from src/app/community-page/sub-community-list/community-page-sub-community-list.component.scss rename to src/app/collection-page/sections/recently-added/collection-recently-added.component.scss diff --git a/src/app/collection-page/sections/recently-added/collection-recently-added.component.spec.ts b/src/app/collection-page/sections/recently-added/collection-recently-added.component.spec.ts new file mode 100644 index 0000000000..4acc24e3f5 --- /dev/null +++ b/src/app/collection-page/sections/recently-added/collection-recently-added.component.spec.ts @@ -0,0 +1,53 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { CollectionRecentlyAddedComponent } from './collection-recently-added.component'; +import { APP_CONFIG } from '../../../../config/app-config.interface'; +import { environment } from '../../../../environments/environment.test'; +import { ActivatedRoute } from '@angular/router'; +import { ActivatedRouteStub } from '../../../shared/testing/active-router.stub'; +import { PaginationService } from '../../../core/pagination/pagination.service'; +import { PaginationServiceStub } from '../../../shared/testing/pagination-service.stub'; +import { SearchServiceStub } from '../../../shared/testing/search-service.stub'; +import { SearchService } from '../../../core/shared/search/search.service'; +import { VarDirective } from '../../../shared/utils/var.directive'; +import { TranslateModule } from '@ngx-translate/core'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; + +describe('CollectionRecentlyAddedComponent', () => { + let component: CollectionRecentlyAddedComponent; + let fixture: ComponentFixture; + + let activatedRoute: ActivatedRouteStub; + let paginationService: PaginationServiceStub; + let searchService: SearchServiceStub; + + beforeEach(async () => { + activatedRoute = new ActivatedRouteStub(); + paginationService = new PaginationServiceStub(); + searchService = new SearchServiceStub(); + + await TestBed.configureTestingModule({ + declarations: [ + CollectionRecentlyAddedComponent, + VarDirective, + ], + imports: [ + TranslateModule.forRoot(), + ], + providers: [ + { provide: ActivatedRoute, useValue: activatedRoute }, + { provide: APP_CONFIG, useValue: environment }, + { provide: PaginationService, useValue: paginationService }, + { provide: SearchService, useValue: SearchServiceStub }, + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(CollectionRecentlyAddedComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/collection-page/sections/recently-added/collection-recently-added.component.ts b/src/app/collection-page/sections/recently-added/collection-recently-added.component.ts new file mode 100644 index 0000000000..65af77a63b --- /dev/null +++ b/src/app/collection-page/sections/recently-added/collection-recently-added.component.ts @@ -0,0 +1,82 @@ +import { Component, OnInit, Inject, OnDestroy } from '@angular/core'; +import { Observable, combineLatest as observableCombineLatest } from 'rxjs'; +import { RemoteData } from '../../../core/data/remote-data'; +import { PaginatedList } from '../../../core/data/paginated-list.model'; +import { Item } from '../../../core/shared/item.model'; +import { switchMap, map, startWith, take } from 'rxjs/operators'; +import { getFirstSucceededRemoteData, toDSpaceObjectListRD } from '../../../core/shared/operators'; +import { PaginatedSearchOptions } from '../../../shared/search/models/paginated-search-options.model'; +import { DSpaceObjectType } from '../../../core/shared/dspace-object-type.model'; +import { BROWSE_LINKS_TO_FOLLOW } from '../../../core/browse/browse.service'; +import { PaginationService } from '../../../core/pagination/pagination.service'; +import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; +import { SortOptions, SortDirection } from '../../../core/cache/models/sort-options.model'; +import { APP_CONFIG, AppConfig } from '../../../../config/app-config.interface'; +import { SearchService } from '../../../core/shared/search/search.service'; +import { Collection } from '../../../core/shared/collection.model'; +import { ActivatedRoute, Data } from '@angular/router'; +import { fadeIn } from '../../../shared/animations/fade'; + +@Component({ + selector: 'ds-collection-recently-added', + templateUrl: './collection-recently-added.component.html', + styleUrls: ['./collection-recently-added.component.scss'], + animations: [fadeIn], +}) +export class CollectionRecentlyAddedComponent implements OnInit, OnDestroy { + + paginationConfig: PaginationComponentOptions; + + sortConfig: SortOptions; + + collectionRD$: Observable>; + + itemRD$: Observable>>; + + constructor( + @Inject(APP_CONFIG) protected appConfig: AppConfig, + protected paginationService: PaginationService, + protected route: ActivatedRoute, + protected searchService: SearchService, + ) { + this.paginationConfig = Object.assign(new PaginationComponentOptions(), { + id: 'cp', + currentPage: 1, + pageSize: this.appConfig.browseBy.pageSize, + }); + + this.sortConfig = new SortOptions('dc.date.accessioned', SortDirection.DESC); + } + + ngOnInit(): void { + this.collectionRD$ = this.route.data.pipe( + map((data: Data) => data.dso as RemoteData), + take(1), + ); + + this.itemRD$ = observableCombineLatest([ + this.paginationService.getCurrentPagination(this.paginationConfig.id, this.paginationConfig), + this.paginationService.getCurrentSort(this.paginationConfig.id, this.sortConfig), + ]).pipe( + switchMap(([currentPagination, currentSort]: [PaginationComponentOptions, SortOptions]) => this.collectionRD$.pipe( + getFirstSucceededRemoteData(), + map((rd: RemoteData) => rd.payload.id), + switchMap((id: string) => this.searchService.search( + new PaginatedSearchOptions({ + scope: id, + pagination: currentPagination, + sort: currentSort, + dsoTypes: [DSpaceObjectType.ITEM] + }), null, true, true, ...BROWSE_LINKS_TO_FOLLOW).pipe( + toDSpaceObjectListRD() + ) as Observable>>), + startWith(undefined), // Make sure switching pages shows loading component + )), + ); + } + + ngOnDestroy(): void { + this.paginationService.clearPagination(this.paginationConfig.id); + } + +} diff --git a/src/app/community-page/community-page-routing.module.ts b/src/app/community-page/community-page-routing.module.ts index c37f8832f8..5ca544bb54 100644 --- a/src/app/community-page/community-page-routing.module.ts +++ b/src/app/community-page/community-page-routing.module.ts @@ -15,6 +15,10 @@ import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model'; import { ThemedCommunityPageComponent } from './themed-community-page.component'; import { MenuItemType } from '../shared/menu/menu-item-type.model'; import { DSOEditMenuResolver } from '../shared/dso-page/dso-edit-menu.resolver'; +import { SubComColSectionComponent } from './sections/sub-com-col-section/sub-com-col-section.component'; +import { BrowseByI18nBreadcrumbResolver } from '../browse-by/browse-by-i18n-breadcrumb.resolver'; +import { BrowseByGuard } from '../browse-by/browse-by-guard'; +import { ComcolBrowseByComponent } from '../shared/comcol/sections/comcol-browse-by/comcol-browse-by.component'; @NgModule({ imports: [ @@ -48,7 +52,23 @@ import { DSOEditMenuResolver } from '../shared/dso-page/dso-edit-menu.resolver'; { path: '', component: ThemedCommunityPageComponent, - pathMatch: 'full', + children: [ + { + path: '', + pathMatch: 'full', + component: SubComColSectionComponent, + }, + { + path: 'browse/:id', + pathMatch: 'full', + component: ComcolBrowseByComponent, + canActivate: [BrowseByGuard], + resolve: { + breadcrumb: BrowseByI18nBreadcrumbResolver, + }, + data: { breadcrumbKey: 'browse.metadata' }, + }, + ], } ], data: { diff --git a/src/app/community-page/community-page.component.html b/src/app/community-page/community-page.component.html index 194fb747d0..b3e577af7d 100644 --- a/src/app/community-page/community-page.component.html +++ b/src/app/community-page/community-page.component.html @@ -17,7 +17,7 @@ + [title]="'community.page.news'"> @@ -28,10 +28,9 @@ - - + -
+
diff --git a/src/app/community-page/community-page.component.ts b/src/app/community-page/community-page.component.ts index a5bbff3cee..206aa54cb0 100644 --- a/src/app/community-page/community-page.component.ts +++ b/src/app/community-page/community-page.component.ts @@ -1,16 +1,10 @@ import { mergeMap, filter, map } from 'rxjs/operators'; import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; - import { Observable } from 'rxjs'; -import { CommunityDataService } from '../core/data/community-data.service'; import { RemoteData } from '../core/data/remote-data'; import { Bitstream } from '../core/shared/bitstream.model'; - import { Community } from '../core/shared/community.model'; - -import { MetadataService } from '../core/metadata/metadata.service'; - import { fadeInOut } from '../shared/animations/fade'; import { hasValue } from '../shared/empty.util'; import { getAllSucceededRemoteDataPayload} from '../core/shared/operators'; @@ -53,8 +47,6 @@ export class CommunityPageComponent implements OnInit { communityPageRoute$: Observable; constructor( - private communityDataService: CommunityDataService, - private metadata: MetadataService, private route: ActivatedRoute, private router: Router, private authService: AuthService, diff --git a/src/app/community-page/community-page.module.ts b/src/app/community-page/community-page.module.ts index 45ffb2a786..5ebd7c3057 100644 --- a/src/app/community-page/community-page.module.ts +++ b/src/app/community-page/community-page.module.ts @@ -4,9 +4,9 @@ import { CommonModule } from '@angular/common'; import { SharedModule } from '../shared/shared.module'; import { CommunityPageComponent } from './community-page.component'; -import { CommunityPageSubCollectionListComponent } from './sub-collection-list/community-page-sub-collection-list.component'; +import { CommunityPageSubCollectionListComponent } from './sections/sub-com-col-section/sub-collection-list/community-page-sub-collection-list.component'; import { CommunityPageRoutingModule } from './community-page-routing.module'; -import { CommunityPageSubCommunityListComponent } from './sub-community-list/community-page-sub-community-list.component'; +import { CommunityPageSubCommunityListComponent } from './sections/sub-com-col-section/sub-community-list/community-page-sub-community-list.component'; import { CreateCommunityPageComponent } from './create-community-page/create-community-page.component'; import { DeleteCommunityPageComponent } from './delete-community-page/delete-community-page.component'; import { StatisticsModule } from '../statistics/statistics.module'; @@ -15,20 +15,25 @@ import { ThemedCommunityPageComponent } from './themed-community-page.component' import { ComcolModule } from '../shared/comcol/comcol.module'; import { ThemedCommunityPageSubCommunityListComponent -} from './sub-community-list/themed-community-page-sub-community-list.component'; +} from './sections/sub-com-col-section/sub-community-list/themed-community-page-sub-community-list.component'; import { ThemedCollectionPageSubCollectionListComponent -} from './sub-collection-list/themed-community-page-sub-collection-list.component'; +} from './sections/sub-com-col-section/sub-collection-list/themed-community-page-sub-collection-list.component'; import { DsoPageModule } from '../shared/dso-page/dso-page.module'; +import { SubComColSectionComponent } from './sections/sub-com-col-section/sub-com-col-section.component'; +import { BrowseByPageModule } from '../browse-by/browse-by-page.module'; -const DECLARATIONS = [CommunityPageComponent, +const DECLARATIONS = [ + CommunityPageComponent, ThemedCommunityPageComponent, ThemedCommunityPageSubCommunityListComponent, CommunityPageSubCollectionListComponent, ThemedCollectionPageSubCollectionListComponent, CommunityPageSubCommunityListComponent, CreateCommunityPageComponent, - DeleteCommunityPageComponent]; + DeleteCommunityPageComponent, + SubComColSectionComponent, +]; @NgModule({ imports: [ @@ -39,6 +44,7 @@ const DECLARATIONS = [CommunityPageComponent, CommunityFormModule, ComcolModule, DsoPageModule, + BrowseByPageModule, ], declarations: [ ...DECLARATIONS diff --git a/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.html b/src/app/community-page/sections/sub-com-col-section/sub-collection-list/community-page-sub-collection-list.component.html similarity index 90% rename from src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.html rename to src/app/community-page/sections/sub-com-col-section/sub-collection-list/community-page-sub-collection-list.component.html index 69f16ee3ac..b5fbf1a01d 100644 --- a/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.html +++ b/src/app/community-page/sections/sub-com-col-section/sub-collection-list/community-page-sub-collection-list.component.html @@ -1,6 +1,6 @@
-

{{'community.sub-collection-list.head' | translate}}

+

{{'community.sub-collection-list.head' | translate}}

{ let comp: CommunityPageSubCollectionListComponent; diff --git a/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.ts b/src/app/community-page/sections/sub-com-col-section/sub-collection-list/community-page-sub-collection-list.component.ts similarity index 79% rename from src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.ts rename to src/app/community-page/sections/sub-com-col-section/sub-collection-list/community-page-sub-collection-list.component.ts index ed14096ce0..92e689b127 100644 --- a/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.ts +++ b/src/app/community-page/sections/sub-com-col-section/sub-collection-list/community-page-sub-collection-list.component.ts @@ -1,19 +1,17 @@ import { Component, Input, OnDestroy, OnInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; - import { BehaviorSubject, combineLatest as observableCombineLatest, Subscription } from 'rxjs'; - -import { RemoteData } from '../../core/data/remote-data'; -import { Collection } from '../../core/shared/collection.model'; -import { Community } from '../../core/shared/community.model'; -import { fadeIn } from '../../shared/animations/fade'; -import { PaginatedList } from '../../core/data/paginated-list.model'; -import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; -import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; -import { CollectionDataService } from '../../core/data/collection-data.service'; -import { PaginationService } from '../../core/pagination/pagination.service'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { Collection } from '../../../../core/shared/collection.model'; +import { Community } from '../../../../core/shared/community.model'; +import { fadeIn } from '../../../../shared/animations/fade'; +import { PaginatedList } from '../../../../core/data/paginated-list.model'; +import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model'; +import { SortDirection, SortOptions } from '../../../../core/cache/models/sort-options.model'; +import { CollectionDataService } from '../../../../core/data/collection-data.service'; +import { PaginationService } from '../../../../core/pagination/pagination.service'; import { switchMap } from 'rxjs/operators'; -import { hasValue } from '../../shared/empty.util'; +import { hasValue } from '../../../../shared/empty.util'; @Component({ selector: 'ds-community-page-sub-collection-list', diff --git a/src/app/community-page/sub-collection-list/themed-community-page-sub-collection-list.component.ts b/src/app/community-page/sections/sub-com-col-section/sub-collection-list/themed-community-page-sub-collection-list.component.ts similarity index 68% rename from src/app/community-page/sub-collection-list/themed-community-page-sub-collection-list.component.ts rename to src/app/community-page/sections/sub-com-col-section/sub-collection-list/themed-community-page-sub-collection-list.component.ts index f1f49f204c..ebbec33e8e 100644 --- a/src/app/community-page/sub-collection-list/themed-community-page-sub-collection-list.component.ts +++ b/src/app/community-page/sections/sub-com-col-section/sub-collection-list/themed-community-page-sub-collection-list.component.ts @@ -1,12 +1,12 @@ -import { ThemedComponent } from '../../shared/theme-support/themed.component'; +import { ThemedComponent } from '../../../../shared/theme-support/themed.component'; import { CommunityPageSubCollectionListComponent } from './community-page-sub-collection-list.component'; import { Component, Input } from '@angular/core'; -import { Community } from '../../core/shared/community.model'; +import { Community } from '../../../../core/shared/community.model'; @Component({ selector: 'ds-themed-community-page-sub-collection-list', styleUrls: [], - templateUrl: '../../shared/theme-support/themed.component.html', + templateUrl: '../../../../shared/theme-support/themed.component.html', }) export class ThemedCollectionPageSubCollectionListComponent extends ThemedComponent { @Input() community: Community; @@ -18,7 +18,7 @@ export class ThemedCollectionPageSubCollectionListComponent extends ThemedCompon } protected importThemedComponent(themeName: string): Promise { - return import(`../../../themes/${themeName}/app/community-page/sub-collection-list/community-page-sub-collection-list.component`); + return import(`../../../../../themes/${themeName}/app/community-page/sections/sub-com-col-section/sub-collection-list/community-page-sub-collection-list.component`); } protected importUnthemedComponent(): Promise { diff --git a/src/app/community-page/sections/sub-com-col-section/sub-com-col-section.component.html b/src/app/community-page/sections/sub-com-col-section/sub-com-col-section.component.html new file mode 100644 index 0000000000..515e08ffdf --- /dev/null +++ b/src/app/community-page/sections/sub-com-col-section/sub-com-col-section.component.html @@ -0,0 +1,8 @@ + + + + + + diff --git a/src/themes/custom/app/browse-by/browse-by-date-page/browse-by-date-page.component.html b/src/app/community-page/sections/sub-com-col-section/sub-com-col-section.component.scss similarity index 100% rename from src/themes/custom/app/browse-by/browse-by-date-page/browse-by-date-page.component.html rename to src/app/community-page/sections/sub-com-col-section/sub-com-col-section.component.scss diff --git a/src/app/community-page/sections/sub-com-col-section/sub-com-col-section.component.spec.ts b/src/app/community-page/sections/sub-com-col-section/sub-com-col-section.component.spec.ts new file mode 100644 index 0000000000..804299d3d9 --- /dev/null +++ b/src/app/community-page/sections/sub-com-col-section/sub-com-col-section.component.spec.ts @@ -0,0 +1,32 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { SubComColSectionComponent } from './sub-com-col-section.component'; +import { ActivatedRoute } from '@angular/router'; +import { ActivatedRouteStub } from '../../../shared/testing/active-router.stub'; + +describe('SubComColSectionComponent', () => { + let component: SubComColSectionComponent; + let fixture: ComponentFixture; + + let activatedRoute: ActivatedRouteStub; + + beforeEach(async () => { + activatedRoute = new ActivatedRouteStub(); + + await TestBed.configureTestingModule({ + declarations: [ + SubComColSectionComponent, + ], + providers: [ + { provide: ActivatedRoute, useValue: activatedRoute }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(SubComColSectionComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/community-page/sections/sub-com-col-section/sub-com-col-section.component.ts b/src/app/community-page/sections/sub-com-col-section/sub-com-col-section.component.ts new file mode 100644 index 0000000000..ff30e51607 --- /dev/null +++ b/src/app/community-page/sections/sub-com-col-section/sub-com-col-section.component.ts @@ -0,0 +1,28 @@ +import { Component, OnInit } from '@angular/core'; +import { Observable } from 'rxjs'; +import { RemoteData } from '../../../core/data/remote-data'; +import { Community } from '../../../core/shared/community.model'; +import { ActivatedRoute, Data } from '@angular/router'; +import { map } from 'rxjs/operators'; + +@Component({ + selector: 'ds-sub-com-col-section', + templateUrl: './sub-com-col-section.component.html', + styleUrls: ['./sub-com-col-section.component.scss'], +}) +export class SubComColSectionComponent implements OnInit { + + community$: Observable; + + constructor( + private route: ActivatedRoute, + ) { + } + + ngOnInit(): void { + this.community$ = this.route.data.pipe( + map((data: Data) => (data.dso as RemoteData).payload), + ); + } + +} diff --git a/src/app/community-page/sub-community-list/community-page-sub-community-list.component.html b/src/app/community-page/sections/sub-com-col-section/sub-community-list/community-page-sub-community-list.component.html similarity index 90% rename from src/app/community-page/sub-community-list/community-page-sub-community-list.component.html rename to src/app/community-page/sections/sub-com-col-section/sub-community-list/community-page-sub-community-list.component.html index be2788a9f4..0834d08ba5 100644 --- a/src/app/community-page/sub-community-list/community-page-sub-community-list.component.html +++ b/src/app/community-page/sections/sub-com-col-section/sub-community-list/community-page-sub-community-list.component.html @@ -1,6 +1,6 @@
-

{{'community.sub-community-list.head' | translate}}

+

{{'community.sub-community-list.head' | translate}}

{ let comp: CommunityPageSubCommunityListComponent; diff --git a/src/app/community-page/sub-community-list/community-page-sub-community-list.component.ts b/src/app/community-page/sections/sub-com-col-section/sub-community-list/community-page-sub-community-list.component.ts similarity index 81% rename from src/app/community-page/sub-community-list/community-page-sub-community-list.component.ts rename to src/app/community-page/sections/sub-com-col-section/sub-community-list/community-page-sub-community-list.component.ts index 08c9509edb..3108be8a60 100644 --- a/src/app/community-page/sub-community-list/community-page-sub-community-list.component.ts +++ b/src/app/community-page/sections/sub-com-col-section/sub-community-list/community-page-sub-community-list.component.ts @@ -3,16 +3,16 @@ import { ActivatedRoute } from '@angular/router'; import { BehaviorSubject, combineLatest as observableCombineLatest, Subscription } from 'rxjs'; -import { RemoteData } from '../../core/data/remote-data'; -import { Community } from '../../core/shared/community.model'; -import { fadeIn } from '../../shared/animations/fade'; -import { PaginatedList } from '../../core/data/paginated-list.model'; -import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; -import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; -import { CommunityDataService } from '../../core/data/community-data.service'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { Community } from '../../../../core/shared/community.model'; +import { fadeIn } from '../../../../shared/animations/fade'; +import { PaginatedList } from '../../../../core/data/paginated-list.model'; +import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model'; +import { SortDirection, SortOptions } from '../../../../core/cache/models/sort-options.model'; +import { CommunityDataService } from '../../../../core/data/community-data.service'; import { switchMap } from 'rxjs/operators'; -import { PaginationService } from '../../core/pagination/pagination.service'; -import { hasValue } from '../../shared/empty.util'; +import { PaginationService } from '../../../../core/pagination/pagination.service'; +import { hasValue } from '../../../../shared/empty.util'; @Component({ selector: 'ds-community-page-sub-community-list', diff --git a/src/app/community-page/sub-community-list/themed-community-page-sub-community-list.component.ts b/src/app/community-page/sections/sub-com-col-section/sub-community-list/themed-community-page-sub-community-list.component.ts similarity index 68% rename from src/app/community-page/sub-community-list/themed-community-page-sub-community-list.component.ts rename to src/app/community-page/sections/sub-com-col-section/sub-community-list/themed-community-page-sub-community-list.component.ts index 852c53186e..9c500cac10 100644 --- a/src/app/community-page/sub-community-list/themed-community-page-sub-community-list.component.ts +++ b/src/app/community-page/sections/sub-com-col-section/sub-community-list/themed-community-page-sub-community-list.component.ts @@ -1,12 +1,12 @@ -import { ThemedComponent } from '../../shared/theme-support/themed.component'; +import { ThemedComponent } from '../../../../shared/theme-support/themed.component'; import { CommunityPageSubCommunityListComponent } from './community-page-sub-community-list.component'; import { Component, Input } from '@angular/core'; -import { Community } from '../../core/shared/community.model'; +import { Community } from '../../../../core/shared/community.model'; @Component({ selector: 'ds-themed-community-page-sub-community-list', styleUrls: [], - templateUrl: '../../shared/theme-support/themed.component.html', + templateUrl: '../../../../shared/theme-support/themed.component.html', }) export class ThemedCommunityPageSubCommunityListComponent extends ThemedComponent { @@ -19,7 +19,7 @@ export class ThemedCommunityPageSubCommunityListComponent extends ThemedComponen } protected importThemedComponent(themeName: string): Promise { - return import(`../../../themes/${themeName}/app/community-page/sub-community-list/community-page-sub-community-list.component`); + return import(`../../../../../themes/${themeName}/app/community-page/sections/sub-com-col-section/sub-community-list/community-page-sub-community-list.component`); } protected importUnthemedComponent(): Promise { diff --git a/src/app/core/breadcrumbs/publication-claim-breadcrumb.resolver.spec.ts b/src/app/core/breadcrumbs/publication-claim-breadcrumb.resolver.spec.ts new file mode 100644 index 0000000000..b6f4142469 --- /dev/null +++ b/src/app/core/breadcrumbs/publication-claim-breadcrumb.resolver.spec.ts @@ -0,0 +1,31 @@ +import { PublicationClaimBreadcrumbResolver } from './publication-claim-breadcrumb.resolver'; + +describe('PublicationClaimBreadcrumbResolver', () => { + describe('resolve', () => { + let resolver: PublicationClaimBreadcrumbResolver; + let publicationClaimBreadcrumbService: any; + const fullPath = '/test/publication-claim/openaire:6bee076d-4f2a-4555-a475-04a267769b2a'; + const expectedKey = '6bee076d-4f2a-4555-a475-04a267769b2a'; + const expectedId = 'openaire:6bee076d-4f2a-4555-a475-04a267769b2a'; + let route; + + beforeEach(() => { + route = { + paramMap: { + get: function (param) { + return this[param]; + }, + targetId: expectedId, + } + }; + publicationClaimBreadcrumbService = {}; + resolver = new PublicationClaimBreadcrumbResolver(publicationClaimBreadcrumbService); + }); + + it('should resolve the breadcrumb config', () => { + const resolvedConfig = resolver.resolve(route as any, {url: fullPath } as any); + const expectedConfig = { provider: publicationClaimBreadcrumbService, key: expectedKey }; + expect(resolvedConfig).toEqual(expectedConfig); + }); + }); +}); diff --git a/src/app/core/breadcrumbs/publication-claim-breadcrumb.resolver.ts b/src/app/core/breadcrumbs/publication-claim-breadcrumb.resolver.ts new file mode 100644 index 0000000000..713500d6a7 --- /dev/null +++ b/src/app/core/breadcrumbs/publication-claim-breadcrumb.resolver.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; +import {BreadcrumbConfig} from '../../breadcrumbs/breadcrumb/breadcrumb-config.model'; +import { PublicationClaimBreadcrumbService } from './publication-claim-breadcrumb.service'; + +@Injectable({ + providedIn: 'root' +}) +export class PublicationClaimBreadcrumbResolver implements Resolve> { + constructor(protected breadcrumbService: PublicationClaimBreadcrumbService) { + } + + /** + * Method that resolve Publication Claim item into a breadcrumb + * The parameter are retrieved by the url since part of the Publication Claim route config + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + * @param {RouterStateSnapshot} state The current RouterStateSnapshot + * @returns BreadcrumbConfig object + */ + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): BreadcrumbConfig { + const targetId = route.paramMap.get('targetId').split(':')[1]; + return { provider: this.breadcrumbService, key: targetId }; + } +} diff --git a/src/app/core/breadcrumbs/publication-claim-breadcrumb.service.spec.ts b/src/app/core/breadcrumbs/publication-claim-breadcrumb.service.spec.ts new file mode 100644 index 0000000000..11062210bb --- /dev/null +++ b/src/app/core/breadcrumbs/publication-claim-breadcrumb.service.spec.ts @@ -0,0 +1,51 @@ +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; +import { getTestScheduler } from 'jasmine-marbles'; +import { PublicationClaimBreadcrumbService } from './publication-claim-breadcrumb.service'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { of } from 'rxjs'; + +describe('PublicationClaimBreadcrumbService', () => { + let service: PublicationClaimBreadcrumbService; + let dsoNameService: any = { + getName: (str) => str + }; + let translateService: any = { + instant: (str) => str, + }; + + let dataService: any = { + findById: (str) => createSuccessfulRemoteDataObject$(str), + }; + + let authorizationService: any = { + isAuthorized: (str) => of(true), + }; + + let exampleKey; + + const ADMIN_PUBLICATION_CLAIMS_PATH = 'admin/notifications/publication-claim'; + const ADMIN_PUBLICATION_CLAIMS_BREADCRUMB_KEY = 'admin.notifications.publicationclaim.page.title'; + + function init() { + exampleKey = 'suggestion.suggestionFor.breadcrumb'; + } + + beforeEach(waitForAsync(() => { + init(); + TestBed.configureTestingModule({}).compileComponents(); + })); + + beforeEach(() => { + service = new PublicationClaimBreadcrumbService(dataService,dsoNameService,translateService, authorizationService); + }); + + describe('getBreadcrumbs', () => { + it('should return a breadcrumb based on a string', () => { + const breadcrumbs = service.getBreadcrumbs(exampleKey); + getTestScheduler().expectObservable(breadcrumbs).toBe('(a|)', { a: [new Breadcrumb(ADMIN_PUBLICATION_CLAIMS_BREADCRUMB_KEY, ADMIN_PUBLICATION_CLAIMS_PATH), + new Breadcrumb(exampleKey, undefined)] + }); + }); + }); +}); diff --git a/src/app/core/breadcrumbs/publication-claim-breadcrumb.service.ts b/src/app/core/breadcrumbs/publication-claim-breadcrumb.service.ts new file mode 100644 index 0000000000..1a87fd7de6 --- /dev/null +++ b/src/app/core/breadcrumbs/publication-claim-breadcrumb.service.ts @@ -0,0 +1,46 @@ +import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; +import { BreadcrumbsProviderService } from './breadcrumbsProviderService'; +import { combineLatest, Observable } from 'rxjs'; +import { Injectable } from '@angular/core'; +import { ItemDataService } from '../data/item-data.service'; +import { getFirstCompletedRemoteData } from '../shared/operators'; +import { map } from 'rxjs/operators'; +import { DSONameService } from './dso-name.service'; +import { TranslateService } from '@ngx-translate/core'; +import { AuthorizationDataService } from '../data/feature-authorization/authorization-data.service'; +import { FeatureID } from '../data/feature-authorization/feature-id'; + + + +/** + * Service to calculate Publication claims breadcrumbs + */ +@Injectable({ + providedIn: 'root' +}) +export class PublicationClaimBreadcrumbService implements BreadcrumbsProviderService { + private ADMIN_PUBLICATION_CLAIMS_PATH = 'admin/notifications/publication-claim'; + private ADMIN_PUBLICATION_CLAIMS_BREADCRUMB_KEY = 'admin.notifications.publicationclaim.page.title'; + + constructor(private dataService: ItemDataService, + private dsoNameService: DSONameService, + private tranlsateService: TranslateService, + protected authorizationService: AuthorizationDataService) { + } + + + /** + * Method to calculate the breadcrumbs + * @param key The key used to resolve the breadcrumb + */ + getBreadcrumbs(key: string): Observable { + return combineLatest([this.dataService.findById(key).pipe(getFirstCompletedRemoteData()),this.authorizationService.isAuthorized(FeatureID.AdministratorOf)]).pipe( + map(([item, isAdmin]) => { + const itemName = this.dsoNameService.getName(item.payload); + return isAdmin ? [new Breadcrumb(this.tranlsateService.instant(this.ADMIN_PUBLICATION_CLAIMS_BREADCRUMB_KEY), this.ADMIN_PUBLICATION_CLAIMS_PATH), + new Breadcrumb(this.tranlsateService.instant('suggestion.suggestionFor.breadcrumb', {name: itemName}), undefined)] : + [new Breadcrumb(this.tranlsateService.instant('suggestion.suggestionFor.breadcrumb', {name: itemName}), undefined)]; + }) + ); + } +} diff --git a/src/app/core/cache/builders/build-decorators.spec.ts b/src/app/core/cache/builders/build-decorators.spec.ts index 150a07f006..e4baaa4a5a 100644 --- a/src/app/core/cache/builders/build-decorators.spec.ts +++ b/src/app/core/cache/builders/build-decorators.spec.ts @@ -1,7 +1,7 @@ import { HALLink } from '../../shared/hal-link.model'; import { HALResource } from '../../shared/hal-resource.model'; import { ResourceType } from '../../shared/resource-type'; -import { getLinkDefinition, link } from './build-decorators'; +import { dataService, getDataServiceFor, getLinkDefinition, link } from './build-decorators'; class TestHALResource implements HALResource { _links: { @@ -46,5 +46,17 @@ describe('build decorators', () => { expect(result).toBeUndefined(); }); }); + + describe(`set data service`, () => { + it(`should throw error`, () => { + expect(dataService(null)).toThrow(); + }); + + it(`should set properly data service for type`, () => { + const target = new TestHALResource(); + dataService(testType)(target); + expect(getDataServiceFor(testType)).toEqual(target); + }); + }); }); }); diff --git a/src/app/core/cache/builders/build-decorators.ts b/src/app/core/cache/builders/build-decorators.ts index 9e5ebaed85..8da1861ecf 100644 --- a/src/app/core/cache/builders/build-decorators.ts +++ b/src/app/core/cache/builders/build-decorators.ts @@ -3,13 +3,20 @@ import { hasNoValue, hasValue } from '../../../shared/empty.util'; import { GenericConstructor } from '../../shared/generic-constructor'; import { HALResource } from '../../shared/hal-resource.model'; import { ResourceType } from '../../shared/resource-type'; -import { getResourceTypeValueFor } from '../object-cache.reducer'; +import { + getResourceTypeValueFor +} from '../object-cache.reducer'; import { InjectionToken } from '@angular/core'; +import { CacheableObject } from '../cacheable-object.model'; import { TypedObject } from '../typed-object.model'; +export const DATA_SERVICE_FACTORY = new InjectionToken<(resourceType: ResourceType) => GenericConstructor>('getDataServiceFor', { + providedIn: 'root', + factory: () => getDataServiceFor +}); export const LINK_DEFINITION_FACTORY = new InjectionToken<(source: GenericConstructor, linkName: keyof T['_links']) => LinkDefinition>('getLinkDefinition', { providedIn: 'root', - factory: () => getLinkDefinition, + factory: () => getLinkDefinition }); export const LINK_DEFINITION_MAP_FACTORY = new InjectionToken<(source: GenericConstructor) => Map>>('getLinkDefinitions', { providedIn: 'root', @@ -20,6 +27,7 @@ const resolvedLinkKey = Symbol('resolvedLink'); const resolvedLinkMap = new Map(); const typeMap = new Map(); +const dataServiceMap = new Map(); const linkMap = new Map(); /** @@ -38,6 +46,39 @@ export function getClassForType(type: string | ResourceType) { return typeMap.get(getResourceTypeValueFor(type)); } +/** + * A class decorator to indicate that this class is a dataservice + * for a given resource type. + * + * "dataservice" in this context means that it has findByHref and + * findAllByHref methods. + * + * @param resourceType the resource type the class is a dataservice for + */ +export function dataService(resourceType: ResourceType): any { + return (target: any) => { + if (hasNoValue(resourceType)) { + throw new Error(`Invalid @dataService annotation on ${target}, resourceType needs to be defined`); + } + const existingDataservice = dataServiceMap.get(resourceType.value); + + if (hasValue(existingDataservice)) { + throw new Error(`Multiple dataservices for ${resourceType.value}: ${existingDataservice} and ${target}`); + } + + dataServiceMap.set(resourceType.value, target); + }; +} + +/** + * Return the dataservice matching the given resource type + * + * @param resourceType the resource type you want the matching dataservice for + */ +export function getDataServiceFor(resourceType: ResourceType) { + return dataServiceMap.get(resourceType.value); +} + /** * A class to represent the data that can be set by the @link decorator */ @@ -65,7 +106,7 @@ export const link = ( resourceType: ResourceType, isList = false, linkName?: keyof T['_links'], - ) => { +) => { return (target: T, propertyName: string) => { let targetMap = linkMap.get(target.constructor); diff --git a/src/app/core/cache/builders/remote-data-build.service.ts b/src/app/core/cache/builders/remote-data-build.service.ts index 075bf3ca0c..5b5e362406 100644 --- a/src/app/core/cache/builders/remote-data-build.service.ts +++ b/src/app/core/cache/builders/remote-data-build.service.ts @@ -161,6 +161,7 @@ export class RemoteDataBuildService { } else { // in case the elements of the paginated list were already filled in, because they're UnCacheableObjects paginatedList.page = paginatedList.page + .filter((obj: any) => obj != null) .map((obj: any) => this.plainObjectToInstance(obj)) .map((obj: any) => this.linkService.resolveLinks(obj, ...pageLink.linksToFollow) diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 28b79cd1cb..119b993faf 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -186,6 +186,8 @@ import { ValueListBrowseDefinition } from './shared/value-list-browse-definition import { NonHierarchicalBrowseDefinition } from './shared/non-hierarchical-browse-definition'; import { BulkAccessConditionOptions } from './config/models/bulk-access-condition-options.model'; import { CorrectionTypeDataService } from './submission/correctiontype-data.service'; +import { SuggestionTarget } from './suggestion-notifications/models/suggestion-target.model'; +import { SuggestionSource } from './suggestion-notifications/models/suggestion-source.model'; /** * When not in production, endpoint responses can be mocked for testing purposes @@ -388,7 +390,9 @@ export const models = IdentifierData, Subscription, ItemRequest, - BulkAccessConditionOptions + BulkAccessConditionOptions, + SuggestionTarget, + SuggestionSource ]; @NgModule({ diff --git a/src/app/core/data/base/base-data.service.ts b/src/app/core/data/base/base-data.service.ts index c7cd5b0a70..84b1686024 100644 --- a/src/app/core/data/base/base-data.service.ts +++ b/src/app/core/data/base/base-data.service.ts @@ -29,11 +29,11 @@ export const EMBED_SEPARATOR = '%2F'; /** * Common functionality for data services. * Specific functionality that not all services would need - * is implemented in "DataService feature" classes (e.g. {@link CreateData} + * is implemented in "UpdateDataServiceImpl feature" classes (e.g. {@link CreateData} * - * All DataService (or DataService feature) classes must + * All UpdateDataServiceImpl (or UpdateDataServiceImpl feature) classes must * - extend this class (or {@link IdentifiableDataService}) - * - implement any DataService features it requires in order to forward calls to it + * - implement any UpdateDataServiceImpl features it requires in order to forward calls to it * * ``` * export class SomeDataService extends BaseDataService implements CreateData, SearchData { @@ -385,7 +385,7 @@ export class BaseDataService implements HALDataServic /** * Return the links to traverse from the root of the api to the - * endpoint this DataService represents + * endpoint this UpdateDataServiceImpl represents * * e.g. if the api root links to 'foo', and the endpoint at 'foo' * links to 'bar' the linkPath for the BarDataService would be diff --git a/src/app/core/data/base/create-data.ts b/src/app/core/data/base/create-data.ts index 3ffcd9adf2..d2e009f669 100644 --- a/src/app/core/data/base/create-data.ts +++ b/src/app/core/data/base/create-data.ts @@ -37,7 +37,7 @@ export interface CreateData { } /** - * A DataService feature to create objects. + * A UpdateDataServiceImpl feature to create objects. * * Concrete data services can use this feature by implementing {@link CreateData} * and delegating its method to an inner instance of this class. diff --git a/src/app/core/data/base/find-all-data.ts b/src/app/core/data/base/find-all-data.ts index 57884e537e..bc0c1fb613 100644 --- a/src/app/core/data/base/find-all-data.ts +++ b/src/app/core/data/base/find-all-data.ts @@ -42,7 +42,7 @@ export interface FindAllData { } /** - * A DataService feature to list all objects. + * A UpdateDataServiceImpl feature to list all objects. * * Concrete data services can use this feature by implementing {@link FindAllData} * and delegating its method to an inner instance of this class. diff --git a/src/app/core/data/base/patch-data.ts b/src/app/core/data/base/patch-data.ts index e30c394a34..1f93671458 100644 --- a/src/app/core/data/base/patch-data.ts +++ b/src/app/core/data/base/patch-data.ts @@ -54,7 +54,7 @@ export interface PatchData { } /** - * A DataService feature to patch and update objects. + * A UpdateDataServiceImpl feature to patch and update objects. * * Concrete data services can use this feature by implementing {@link PatchData} * and delegating its method to an inner instance of this class. diff --git a/src/app/core/data/base/put-data.ts b/src/app/core/data/base/put-data.ts index bd2a8d2929..66ae73405e 100644 --- a/src/app/core/data/base/put-data.ts +++ b/src/app/core/data/base/put-data.ts @@ -31,7 +31,7 @@ export interface PutData { } /** - * A DataService feature to send PUT requests. + * A UpdateDataServiceImpl feature to send PUT requests. * * Concrete data services can use this feature by implementing {@link PutData} * and delegating its method to an inner instance of this class. diff --git a/src/app/core/data/base/search-data.ts b/src/app/core/data/base/search-data.ts index 536d6d6e25..ff0b492945 100644 --- a/src/app/core/data/base/search-data.ts +++ b/src/app/core/data/base/search-data.ts @@ -51,7 +51,7 @@ export interface SearchData { } /** - * A DataService feature to search for objects. + * A UpdateDataServiceImpl feature to search for objects. * * Concrete data services can use this feature by implementing {@link SearchData} * and delegating its method to an inner instance of this class. diff --git a/src/app/core/data/bitstream-data.service.spec.ts b/src/app/core/data/bitstream-data.service.spec.ts index 89178f8dd2..ccdff75fdb 100644 --- a/src/app/core/data/bitstream-data.service.spec.ts +++ b/src/app/core/data/bitstream-data.service.spec.ts @@ -21,6 +21,11 @@ import { NotificationsService } from '../../shared/notifications/notifications.s import objectContaining = jasmine.objectContaining; import { RemoteData } from './remote-data'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { BundleDataService } from './bundle-data.service'; +import { ItemMock } from 'src/app/shared/mocks/item.mock'; +import { createFailedRemoteDataObject, createSuccessfulRemoteDataObject } from 'src/app/shared/remote-data.utils'; +import { Bundle } from '../shared/bundle.model'; +import { cold } from 'jasmine-marbles'; describe('BitstreamDataService', () => { let service: BitstreamDataService; @@ -29,6 +34,7 @@ describe('BitstreamDataService', () => { let halService: HALEndpointService; let bitstreamFormatService: BitstreamFormatDataService; let rdbService: RemoteDataBuildService; + let bundleDataService: BundleDataService; const bitstreamFormatHref = 'rest-api/bitstreamformats'; const bitstream1 = Object.assign(new Bitstream(), { @@ -62,6 +68,7 @@ describe('BitstreamDataService', () => { bitstreamFormatService = jasmine.createSpyObj('bistreamFormatService', { getBrowseEndpoint: observableOf(bitstreamFormatHref) }); + rdbService = getMockRemoteDataBuildService(); TestBed.configureTestingModule({ @@ -76,6 +83,7 @@ describe('BitstreamDataService', () => { ], }); service = TestBed.inject(BitstreamDataService); + bundleDataService = TestBed.inject(BundleDataService); }); describe('composition', () => { @@ -118,6 +126,32 @@ describe('BitstreamDataService', () => { expect(service.invalidateByHref).toHaveBeenCalledWith('fake-bitstream1-self'); }); + describe('findPrimaryBitstreamByItemAndName', () => { + it('should return primary bitstream', () => { + const exprected$ = cold('(a|)', { a: bitstream1} ); + const bundle = Object.assign(new Bundle(), { + primaryBitstream: observableOf(createSuccessfulRemoteDataObject(bitstream1)), + }); + spyOn(bundleDataService, 'findByItemAndName').and.returnValue(observableOf(createSuccessfulRemoteDataObject(bundle))); + expect(service.findPrimaryBitstreamByItemAndName(ItemMock, 'ORIGINAL')).toBeObservable(exprected$); + }); + + it('should return null if primary bitstream has not be succeeded ', () => { + const exprected$ = cold('(a|)', { a: null} ); + const bundle = Object.assign(new Bundle(), { + primaryBitstream: observableOf(createFailedRemoteDataObject()), + }); + spyOn(bundleDataService, 'findByItemAndName').and.returnValue(observableOf(createSuccessfulRemoteDataObject(bundle))); + expect(service.findPrimaryBitstreamByItemAndName(ItemMock, 'ORIGINAL')).toBeObservable(exprected$); + }); + + it('should return EMPTY if nothing where found', () => { + const exprected$ = cold('(|)', {} ); + spyOn(bundleDataService, 'findByItemAndName').and.returnValue(observableOf(createFailedRemoteDataObject())); + expect(service.findPrimaryBitstreamByItemAndName(ItemMock, 'ORIGINAL')).toBeObservable(exprected$); + }); + }); + it('should be able to delete multiple bitstreams', () => { service.removeMultiple([bitstream1, bitstream2]); diff --git a/src/app/core/data/bitstream-data.service.ts b/src/app/core/data/bitstream-data.service.ts index bb4ec28166..97949ffa25 100644 --- a/src/app/core/data/bitstream-data.service.ts +++ b/src/app/core/data/bitstream-data.service.ts @@ -1,9 +1,9 @@ import { HttpHeaders } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; +import { combineLatest as observableCombineLatest, Observable, EMPTY } from 'rxjs'; import { find, map, switchMap, take } from 'rxjs/operators'; import { hasValue } from '../../shared/empty.util'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { FollowLinkConfig, followLink } from '../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { Bitstream } from '../shared/bitstream.model'; @@ -34,6 +34,7 @@ import { NoContent } from '../shared/NoContent.model'; import { IdentifiableDataService } from './base/identifiable-data.service'; import { dataService } from './base/data-service.decorator'; import { Operation, RemoveOperation } from 'fast-json-patch'; +import { getFirstCompletedRemoteData } from '../shared/operators'; /** * A service to retrieve {@link Bitstream}s from the REST API @@ -201,6 +202,37 @@ export class BitstreamDataService extends IdentifiableDataService imp return this.searchData.getSearchByHref(searchMethod, options, ...linksToFollow); } + + /** + * + * Make a request to get primary bitstream + * in all current use cases, and having it simplifies this method + * + * @param item the {@link Item} the {@link Bundle} is a part of + * @param bundleName the name of the {@link Bundle} we want to find + * {@link Bitstream}s for + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @return {Observable} + * Return an observable that constains primary bitstream information or null + */ + public findPrimaryBitstreamByItemAndName(item: Item, bundleName: string, useCachedVersionIfAvailable = true, reRequestOnStale = true): Observable { + return this.bundleService.findByItemAndName(item, bundleName, useCachedVersionIfAvailable, reRequestOnStale, followLink('primaryBitstream')).pipe( + getFirstCompletedRemoteData(), + switchMap((rd: RemoteData) => { + if (!rd.hasSucceeded) { + return EMPTY; + } + return rd.payload.primaryBitstream.pipe( + getFirstCompletedRemoteData(), + map((rdb: RemoteData) => rdb.hasSucceeded ? rdb.payload : null) + ); + }) + ); + } + /** * Make a new FindListRequest with given search method * diff --git a/src/app/core/data/feature-authorization/feature-id.ts b/src/app/core/data/feature-authorization/feature-id.ts index e2943f1762..b5f0c60dc6 100644 --- a/src/app/core/data/feature-authorization/feature-id.ts +++ b/src/app/core/data/feature-authorization/feature-id.ts @@ -34,5 +34,6 @@ export enum FeatureID { CanEditItem = 'canEditItem', CanRegisterDOI = 'canRegisterDOI', CanSubscribe = 'canSubscribeDso', - CanSeeQA = 'canSeeQA' + EPersonForgotPassword = 'epersonForgotPassword', + CanSeeQA = 'canSeeQA', } diff --git a/src/app/core/data/href-only-data.service.ts b/src/app/core/data/href-only-data.service.ts index 0a765de101..8393d71460 100644 --- a/src/app/core/data/href-only-data.service.ts +++ b/src/app/core/data/href-only-data.service.ts @@ -17,8 +17,8 @@ import { HALDataService } from './base/hal-data-service.interface'; import { dataService } from './base/data-service.decorator'; /** - * A DataService with only findByHref methods. Its purpose is to be used for resources that don't - * need to be retrieved by ID, or have any way to update them, but require a DataService in order + * A UpdateDataServiceImpl with only findByHref methods. Its purpose is to be used for resources that don't + * need to be retrieved by ID, or have any way to update them, but require a UpdateDataServiceImpl in order * for their links to be resolved by the LinkService. * * an @dataService annotation can be added for any number of these resource types diff --git a/src/app/core/data/update-data.service.spec.ts b/src/app/core/data/update-data.service.spec.ts new file mode 100644 index 0000000000..426fa87eb6 --- /dev/null +++ b/src/app/core/data/update-data.service.spec.ts @@ -0,0 +1,144 @@ +import { of as observableOf } from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { RequestService } from './request.service'; +import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { HrefOnlyDataService } from './href-only-data.service'; +import { getMockHrefOnlyDataService } from '../../shared/mocks/href-only-data.service.mock'; +import { RestResponse } from '../cache/response.models'; +import { cold, getTestScheduler, hot } from 'jasmine-marbles'; +import { Item } from '../shared/item.model'; +import { Version } from '../shared/version.model'; +import { VersionHistory } from '../shared/version-history.model'; +import { RequestEntry } from './request-entry.model'; +import { testPatchDataImplementation } from './base/patch-data.spec'; +import { UpdateDataServiceImpl } from './update-data.service'; +import { testSearchDataImplementation } from './base/search-data.spec'; +import { testDeleteDataImplementation } from './base/delete-data.spec'; +import { testCreateDataImplementation } from './base/create-data.spec'; +import { testFindAllDataImplementation } from './base/find-all-data.spec'; +import { testPutDataImplementation } from './base/put-data.spec'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; + +describe('VersionDataService test', () => { + let scheduler: TestScheduler; + let service: UpdateDataServiceImpl; + let requestService: RequestService; + let rdbService: RemoteDataBuildService; + let objectCache: ObjectCacheService; + let halService: HALEndpointService; + let hrefOnlyDataService: HrefOnlyDataService; + let responseCacheEntry: RequestEntry; + + const notificationsService = {} as NotificationsService; + + const item = Object.assign(new Item(), { + id: '1234-1234', + uuid: '1234-1234', + bundles: observableOf({}), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'dc.type': [ + { + language: null, + value: 'Article' + } + ], + 'dc.contributor.author': [ + { + language: 'en_US', + value: 'Smith, Donald' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '2015-06-26' + } + ] + } + }); + + const versionHistory = Object.assign(new VersionHistory(), { + id: '1', + draftVersion: true, + }); + + const mockVersion: Version = Object.assign(new Version(), { + item: createSuccessfulRemoteDataObject$(item), + versionhistory: createSuccessfulRemoteDataObject$(versionHistory), + version: 1, + }); + const mockVersionRD = createSuccessfulRemoteDataObject(mockVersion); + + const endpointURL = `https://rest.api/rest/api/versioning/versions`; + const requestUUID = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a'; + + objectCache = {} as ObjectCacheService; + const comparatorEntry = {} as any; + function initTestService() { + hrefOnlyDataService = getMockHrefOnlyDataService(); + return new UpdateDataServiceImpl( + 'testLinkPath', + requestService, + rdbService, + objectCache, + halService, + notificationsService, + comparatorEntry, + 10 * 1000 + ); + } + + beforeEach(() => { + + scheduler = getTestScheduler(); + + halService = jasmine.createSpyObj('halService', { + getEndpoint: cold('a', { a: endpointURL }) + }); + responseCacheEntry = new RequestEntry(); + responseCacheEntry.request = { href: 'https://rest.api/' } as any; + responseCacheEntry.response = new RestResponse(true, 200, 'Success'); + + requestService = jasmine.createSpyObj('requestService', { + generateRequestId: requestUUID, + send: true, + removeByHrefSubstring: {}, + getByHref: observableOf(responseCacheEntry), + getByUUID: observableOf(responseCacheEntry), + }); + rdbService = jasmine.createSpyObj('rdbService', { + buildSingle: hot('(a|)', { + a: mockVersionRD + }) + }); + + service = initTestService(); + + spyOn((service as any), 'findById').and.callThrough(); + }); + + afterEach(() => { + service = null; + }); + + describe('composition', () => { + const initService = () => new UpdateDataServiceImpl(null, null, null, null, null, null, null, null); + + testPatchDataImplementation(initService); + testSearchDataImplementation(initService); + testDeleteDataImplementation(initService); + testCreateDataImplementation(initService); + testFindAllDataImplementation(initService); + testPutDataImplementation(initService); + }); + +}); diff --git a/src/app/core/data/update-data.service.ts b/src/app/core/data/update-data.service.ts index 9f707a82da..715b2ee413 100644 --- a/src/app/core/data/update-data.service.ts +++ b/src/app/core/data/update-data.service.ts @@ -1,13 +1,317 @@ -import { Observable } from 'rxjs'; -import { RemoteData } from './remote-data'; -import { RestRequestMethod } from './rest-request-method'; import { Operation } from 'fast-json-patch'; +import { AsyncSubject, from as observableFrom, Observable } from 'rxjs'; +import { + find, + map, + mergeMap, + switchMap, + take, + toArray +} from 'rxjs/operators'; +import { hasValue } from '../../shared/empty.util'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { RequestParam } from '../cache/models/request-param.model'; +import { ObjectCacheEntry } from '../cache/object-cache.reducer'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { ChangeAnalyzer } from './change-analyzer'; +import { PaginatedList } from './paginated-list.model'; +import { RemoteData } from './remote-data'; +import { + DeleteByIDRequest, + PostRequest +} from './request.models'; +import { RequestService } from './request.service'; +import { RestRequestMethod } from './rest-request-method'; +import { NoContent } from '../shared/NoContent.model'; +import { CacheableObject } from '../cache/cacheable-object.model'; +import { FindListOptions } from './find-list-options.model'; +import { FindAllData, FindAllDataImpl } from './base/find-all-data'; +import { SearchData, SearchDataImpl } from './base/search-data'; +import { CreateData, CreateDataImpl } from './base/create-data'; +import { PatchData, PatchDataImpl } from './base/patch-data'; +import { IdentifiableDataService } from './base/identifiable-data.service'; +import { PutData, PutDataImpl } from './base/put-data'; +import { DeleteData, DeleteDataImpl } from './base/delete-data'; + /** - * Represents a data service to update a given object + * Interface to list the methods used by the injected service in components */ export interface UpdateDataService { patch(dso: T, operations: Operation[]): Observable>; update(object: T): Observable>; - commitUpdates(method?: RestRequestMethod); + commitUpdates(method?: RestRequestMethod): void; +} + + +/** + * Specific functionalities that not all services would need. + * Goal of the class is to update remote objects, handling custom methods that don't belong to BaseDataService + * The class implements also the following common interfaces + * + * findAllData: FindAllData; + * searchData: SearchData; + * createData: CreateData; + * patchData: PatchData; + * putData: PutData; + * deleteData: DeleteData; + * + * Custom methods are: + * + * deleteOnRelated - delete all related objects to the given one + * postOnRelated - post all the related objects to the given one + * invalidate - invalidate the DSpaceObject making all requests as stale + * invalidateByHref - invalidate the href making all requests as stale + */ + +export class UpdateDataServiceImpl extends IdentifiableDataService implements FindAllData, SearchData, CreateData, PatchData, PutData, DeleteData { + private findAllData: FindAllDataImpl; + private searchData: SearchDataImpl; + private createData: CreateDataImpl; + private patchData: PatchDataImpl; + private putData: PutDataImpl; + private deleteData: DeleteDataImpl; + + + constructor( + protected linkPath: string, + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected comparator: ChangeAnalyzer, + protected responseMsToLive: number, + ) { + super(linkPath, requestService, rdbService, objectCache, halService, responseMsToLive); + this.findAllData = new FindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + this.createData = new CreateDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService ,this.responseMsToLive); + this.patchData = new PatchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, comparator ,this.responseMsToLive, this.constructIdEndpoint); + this.putData = new PutDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + this.deleteData = new DeleteDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService ,this.responseMsToLive, this.constructIdEndpoint); + } + + + /** + * Create the HREF with given options object + * + * @param options The [[FindListOptions]] object + * @param linkPath The link path for the object + * @return {Observable} + * Return an observable that emits created HREF + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + public getFindAllHref(options: FindListOptions = {}, linkPath?: string, ...linksToFollow: FollowLinkConfig[]): Observable { + return this.findAllData.getFindAllHref(options, linkPath, ...linksToFollow); + } + + /** + * Create the HREF for a specific object's search method with given options object + * + * @param searchMethod The search method for the object + * @param options The [[FindListOptions]] object + * @return {Observable} + * Return an observable that emits created HREF + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + public getSearchByHref(searchMethod: string, options: FindListOptions = {}, ...linksToFollow: FollowLinkConfig[]): Observable { + return this.searchData.getSearchByHref(searchMethod, options, ...linksToFollow); + } + + /** + * Returns {@link RemoteData} of all object with a list of {@link FollowLinkConfig}, to indicate which embedded + * info should be added to the objects + * + * @param options Find list options object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + * @return {Observable>>} + * Return an observable that emits object list + */ + findAll(options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * Make a new FindListRequest with given search method + * + * @param searchMethod The search method for the object + * @param options The [[FindListOptions]] object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + * @return {Observable>} + * Return an observable that emits response from the server + */ + searchBy(searchMethod: string, options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * Send a patch request for a specified object + * @param {T} object The object to send a patch request for + * @param {Operation[]} operations The patch operations to be performed + */ + patch(object: T, operations: Operation[]): Observable> { + return this.patchData.patch(object, operations); + } + + createPatchFromCache(object: T): Observable { + return this.patchData.createPatchFromCache(object); + } + + /** + * Send a PUT request for the specified object + * + * @param object The object to send a put request for. + */ + put(object: T): Observable> { + return this.putData.put(object); + } + + /** + * Add a new patch to the object cache + * The patch is derived from the differences between the given object and its version in the object cache + * @param {DSpaceObject} object The given object + */ + update(object: T): Observable> { + return this.patchData.update(object); + } + + /** + * Create a new DSpaceObject on the server, and store the response + * in the object cache + * + * @param {CacheableObject} object + * The object to create + * @param {RequestParam[]} params + * Array with additional params to combine with query string + */ + create(object: T, ...params: RequestParam[]): Observable> { + return this.createData.create(object, ...params); + } + + /** + <<<<<<< HEAD + * Perform a post on an endpoint related item with ID. Ex.: endpoint//related?item= + * @param itemId The item id + * @param relatedItemId The related item Id + * @param body The optional POST body + * @return the RestResponse as an Observable + */ + public postOnRelated(itemId: string, relatedItemId: string, body?: any) { + const requestId = this.requestService.generateRequestId(); + const hrefObs = this.getIDHrefObs(itemId); + + hrefObs.pipe( + take(1) + ).subscribe((href: string) => { + const request = new PostRequest(requestId, href + '/related?item=' + relatedItemId, body); + if (hasValue(this.responseMsToLive)) { + request.responseMsToLive = this.responseMsToLive; + } + this.requestService.send(request); + }); + + return this.rdbService.buildFromRequestUUID(requestId); + } + + /** + * Perform a delete on an endpoint related item. Ex.: endpoint//related + * @param itemId The item id + * @return the RestResponse as an Observable + */ + public deleteOnRelated(itemId: string): Observable> { + const requestId = this.requestService.generateRequestId(); + const hrefObs = this.getIDHrefObs(itemId); + + hrefObs.pipe( + find((href: string) => hasValue(href)), + map((href: string) => { + const request = new DeleteByIDRequest(requestId, href + '/related', itemId); + if (hasValue(this.responseMsToLive)) { + request.responseMsToLive = this.responseMsToLive; + } + this.requestService.send(request); + }) + ).subscribe(); + + return this.rdbService.buildFromRequestUUID(requestId); + } + + /* + * Invalidate an existing DSpaceObject by marking all requests it is included in as stale + * @param objectId The id of the object to be invalidated + * @return An Observable that will emit `true` once all requests are stale + */ + invalidate(objectId: string): Observable { + return this.getIDHrefObs(objectId).pipe( + switchMap((href: string) => this.invalidateByHref(href)) + ); + } + + /** + * Invalidate an existing DSpaceObject by marking all requests it is included in as stale + * @param href The self link of the object to be invalidated + * @return An Observable that will emit `true` once all requests are stale + */ + invalidateByHref(href: string): Observable { + const done$ = new AsyncSubject(); + + this.objectCache.getByHref(href).pipe( + switchMap((oce: ObjectCacheEntry) => observableFrom(oce.requestUUIDs).pipe( + mergeMap((requestUUID: string) => this.requestService.setStaleByUUID(requestUUID)), + toArray(), + )), + ).subscribe(() => { + done$.next(true); + done$.complete(); + }); + + return done$; + } + + /** + * Delete an existing DSpace Object on the server + * @param objectId The id of the object to be removed + * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual + * metadata should be saved as real metadata + * @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode, + * errorMessage, timeCompleted, etc + */ + delete(objectId: string, copyVirtualMetadata?: string[]): Observable> { + return this.deleteData.delete(objectId, copyVirtualMetadata); + } + + /** + * Delete an existing DSpace Object on the server + * @param href The self link of the object to be removed + * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual + * metadata should be saved as real metadata + * @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode, + * errorMessage, timeCompleted, etc + * Only emits once all request related to the DSO has been invalidated. + */ + deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable> { + return this.deleteData.deleteByHref(href, copyVirtualMetadata); + } + + /** + * Commit current object changes to the server + * @param method The RestRequestMethod for which de server sync buffer should be committed + */ + commitUpdates(method?: RestRequestMethod) { + this.patchData.commitUpdates(method); + } } diff --git a/src/app/core/data/version-data.service.spec.ts b/src/app/core/data/version-data.service.spec.ts index dd3f9eec94..0579b998b8 100644 --- a/src/app/core/data/version-data.service.spec.ts +++ b/src/app/core/data/version-data.service.spec.ts @@ -128,7 +128,7 @@ describe('VersionDataService test', () => { }); describe('getHistoryFromVersion', () => { - it('should proxy the call to DataService.findByHref', () => { + it('should proxy the call to UpdateDataServiceImpl.findByHref', () => { scheduler.schedule(() => service.getHistoryFromVersion(mockVersion, true, true)); scheduler.flush(); diff --git a/src/app/core/eperson/eperson-data.service.spec.ts b/src/app/core/eperson/eperson-data.service.spec.ts index c1bc3563a3..cbddf1e6c3 100644 --- a/src/app/core/eperson/eperson-data.service.spec.ts +++ b/src/app/core/eperson/eperson-data.service.spec.ts @@ -315,7 +315,7 @@ describe('EPersonDataService', () => { service.deleteEPerson(EPersonMock).subscribe(); }); - it('should call DataService.delete with the EPerson\'s UUID', () => { + it('should call UpdateDataServiceImpl.delete with the EPerson\'s UUID', () => { expect(service.delete).toHaveBeenCalledWith(EPersonMock.id); }); }); diff --git a/src/app/core/rest-property/forgot-password-check-guard.guard.ts b/src/app/core/rest-property/forgot-password-check-guard.guard.ts new file mode 100644 index 0000000000..438a532c7b --- /dev/null +++ b/src/app/core/rest-property/forgot-password-check-guard.guard.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; +import { Observable, of } from 'rxjs'; +import { AuthorizationDataService } from '../data/feature-authorization/authorization-data.service'; +import { FeatureID } from '../data/feature-authorization/feature-id'; +import { + SingleFeatureAuthorizationGuard +} from '../data/feature-authorization/feature-authorization-guard/single-feature-authorization.guard'; +import { AuthService } from '../auth/auth.service'; + +@Injectable({ + providedIn: 'root' +}) +/** + * Guard that checks if the forgot-password feature is enabled + */ +export class ForgotPasswordCheckGuard extends SingleFeatureAuthorizationGuard { + + constructor( + protected readonly authorizationService: AuthorizationDataService, + protected readonly router: Router, + protected readonly authService: AuthService + ) { + super(authorizationService, router, authService); + } + + getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + return of(FeatureID.EPersonForgotPassword); + } + +} diff --git a/src/app/core/shared/search/search-filter.service.ts b/src/app/core/shared/search/search-filter.service.ts index 80ba200d38..bf232cb141 100644 --- a/src/app/core/shared/search/search-filter.service.ts +++ b/src/app/core/shared/search/search-filter.service.ts @@ -27,6 +27,7 @@ const filterStateSelector = (state: SearchFiltersState) => state.searchFilter; export const FILTER_CONFIG: InjectionToken = new InjectionToken('filterConfig'); export const IN_PLACE_SEARCH: InjectionToken = new InjectionToken('inPlaceSearch'); export const REFRESH_FILTER: InjectionToken> = new InjectionToken('refreshFilters'); +export const SCOPE: InjectionToken = new InjectionToken('scope'); /** * Service that performs all actions that have to do with search filters and facets diff --git a/src/app/core/submission/models/workspaceitem-section-upload.model.ts b/src/app/core/submission/models/workspaceitem-section-upload.model.ts index f98e0584eb..d992567df4 100644 --- a/src/app/core/submission/models/workspaceitem-section-upload.model.ts +++ b/src/app/core/submission/models/workspaceitem-section-upload.model.ts @@ -4,7 +4,10 @@ import { WorkspaceitemSectionUploadFileObject } from './workspaceitem-section-up * An interface to represent submission's upload section data. */ export interface WorkspaceitemSectionUploadObject { - + /** + * Primary bitstream flag + */ + primary: string | null; /** * A list of [[WorkspaceitemSectionUploadFileObject]] */ diff --git a/src/app/core/submission/workflowitem-data.service.spec.ts b/src/app/core/submission/workflowitem-data.service.spec.ts index 3f6ec54fda..64ffbe5718 100644 --- a/src/app/core/submission/workflowitem-data.service.spec.ts +++ b/src/app/core/submission/workflowitem-data.service.spec.ts @@ -126,7 +126,7 @@ describe('WorkflowItemDataService test', () => { }); describe('findByItem', () => { - it('should proxy the call to DataService.findByHref', () => { + it('should proxy the call to UpdateDataServiceImpl.findByHref', () => { scheduler.schedule(() => service.findByItem('1234-1234', true, true, pageInfo)); scheduler.flush(); diff --git a/src/app/core/submission/workspaceitem-data.service.spec.ts b/src/app/core/submission/workspaceitem-data.service.spec.ts index e766a6a039..25a849baa2 100644 --- a/src/app/core/submission/workspaceitem-data.service.spec.ts +++ b/src/app/core/submission/workspaceitem-data.service.spec.ts @@ -1,4 +1,4 @@ -import { HttpClient } from '@angular/common/http'; +import { HttpClient, HttpHeaders } from '@angular/common/http'; import { of as observableOf } from 'rxjs'; import { TestScheduler } from 'rxjs/testing'; @@ -8,7 +8,7 @@ import { ObjectCacheService } from '../cache/object-cache.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { RequestService } from '../data/request.service'; import { PageInfo } from '../shared/page-info.model'; -import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; +import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { HrefOnlyDataService } from '../data/href-only-data.service'; import { getMockHrefOnlyDataService } from '../../shared/mocks/href-only-data.service.mock'; import { WorkspaceitemDataService } from './workspaceitem-data.service'; @@ -21,6 +21,11 @@ import { RequestEntry } from '../data/request-entry.model'; import { CoreState } from '../core-state.model'; import { testSearchDataImplementation } from '../data/base/search-data.spec'; import { testDeleteDataImplementation } from '../data/base/delete-data.spec'; +import { SearchData } from '../data/base/search-data'; +import { DeleteData } from '../data/base/delete-data'; +import { RequestParam } from '../cache/models/request-param.model'; +import { PostRequest } from '../data/request.models'; +import { HttpOptions } from '../dspace-rest/dspace-rest.service'; describe('WorkspaceitemDataService test', () => { let scheduler: TestScheduler; @@ -68,15 +73,12 @@ describe('WorkspaceitemDataService test', () => { const wsiRD = createSuccessfulRemoteDataObject(wsi); const endpointURL = `https://rest.api/rest/api/submission/workspaceitems`; - const searchRequestURL = `https://rest.api/rest/api/submission/workspaceitems/search/item?uuid=1234-1234`; - const searchRequestURL$ = observableOf(searchRequestURL); const requestUUID = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a'; objectCache = {} as ObjectCacheService; const notificationsService = {} as NotificationsService; const http = {} as HttpClient; - const comparator = {} as any; const comparatorEntry = {} as any; const store = {} as Store; const pageInfo = new PageInfo(); @@ -84,18 +86,23 @@ describe('WorkspaceitemDataService test', () => { function initTestService() { hrefOnlyDataService = getMockHrefOnlyDataService(); return new WorkspaceitemDataService( + comparatorEntry, + halService, + http, + notificationsService, requestService, rdbService, objectCache, - halService, - notificationsService, + store ); } describe('composition', () => { - const initService = () => new WorkspaceitemDataService(null, null, null, null, null); - testSearchDataImplementation(initService); - testDeleteDataImplementation(initService); + const initSearchService = () => new WorkspaceitemDataService(null, null, null, null, null, null, null, null) as unknown as SearchData; + const initDeleteService = () => new WorkspaceitemDataService(null, null, null, null, null, null, null, null) as unknown as DeleteData; + + testSearchDataImplementation(initSearchService); + testDeleteDataImplementation(initDeleteService); }); describe('', () => { @@ -104,7 +111,7 @@ describe('WorkspaceitemDataService test', () => { scheduler = getTestScheduler(); halService = jasmine.createSpyObj('halService', { - getEndpoint: cold('a', { a: endpointURL }) + getEndpoint: observableOf(endpointURL) }); responseCacheEntry = new RequestEntry(); responseCacheEntry.request = { href: 'https://rest.api/' } as any; @@ -120,13 +127,13 @@ describe('WorkspaceitemDataService test', () => { rdbService = jasmine.createSpyObj('rdbService', { buildSingle: hot('a|', { a: wsiRD - }) + }), + buildFromRequestUUID: createSuccessfulRemoteDataObject$({}) }); service = initTestService(); spyOn((service as any), 'findByHref').and.callThrough(); - spyOn((service as any), 'getSearchByHref').and.returnValue(searchRequestURL$); }); afterEach(() => { @@ -134,11 +141,11 @@ describe('WorkspaceitemDataService test', () => { }); describe('findByItem', () => { - it('should proxy the call to DataService.findByHref', () => { + it('should proxy the call to UpdateDataServiceImpl.findByHref', () => { scheduler.schedule(() => service.findByItem('1234-1234', true, true, pageInfo)); scheduler.flush(); - - expect((service as any).findByHref).toHaveBeenCalledWith(searchRequestURL$, true, true); + const searchUrl = service.getIDHref('item', [new RequestParam('uuid', encodeURIComponent('1234-1234'))]); + expect((service as any).findByHref).toHaveBeenCalledWith(searchUrl, true, true); }); it('should return a RemoteData for the search', () => { @@ -150,6 +157,19 @@ describe('WorkspaceitemDataService test', () => { }); }); - }); + describe('importExternalSourceEntry', () => { + it('should send a POST request containing the provided item request', (done) => { + const options: HttpOptions = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Content-Type', 'text/uri-list'); + options.headers = headers; + + service.importExternalSourceEntry('externalHref', 'testId').subscribe(() => { + expect(requestService.send).toHaveBeenCalledWith(new PostRequest(requestUUID, `${endpointURL}?owningCollection=testId`, 'externalHref', options)); + done(); + }); + }); + }); + }); }); diff --git a/src/app/core/submission/workspaceitem-data.service.ts b/src/app/core/submission/workspaceitem-data.service.ts index f285fb6eca..181530a29b 100644 --- a/src/app/core/submission/workspaceitem-data.service.ts +++ b/src/app/core/submission/workspaceitem-data.service.ts @@ -1,46 +1,58 @@ import { Injectable } from '@angular/core'; +import {HttpClient, HttpHeaders} from '@angular/common/http'; + +import { Store } from '@ngrx/store'; +import { dataService } from '../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RequestService } from '../data/request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { ObjectCacheService } from '../cache/object-cache.service'; +import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; import { WorkspaceItem } from './models/workspaceitem.model'; import { Observable } from 'rxjs'; import { RemoteData } from '../data/remote-data'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { RequestParam } from '../cache/models/request-param.model'; +import { CoreState } from '../core-state.model'; import { FindListOptions } from '../data/find-list-options.model'; +import {HttpOptions} from '../dspace-rest/dspace-rest.service'; +import {find, map} from 'rxjs/operators'; +import {PostRequest} from '../data/request.models'; +import {hasValue} from '../../shared/empty.util'; import { IdentifiableDataService } from '../data/base/identifiable-data.service'; +import { NoContent } from '../shared/NoContent.model'; +import { DeleteData, DeleteDataImpl } from '../data/base/delete-data'; import { SearchData, SearchDataImpl } from '../data/base/search-data'; import { PaginatedList } from '../data/paginated-list.model'; -import { DeleteData, DeleteDataImpl } from '../data/base/delete-data'; -import { NoContent } from '../shared/NoContent.model'; -import { dataService } from '../data/base/data-service.decorator'; /** * A service that provides methods to make REST requests with workspaceitems endpoint. */ @Injectable() @dataService(WorkspaceItem.type) -export class WorkspaceitemDataService extends IdentifiableDataService implements SearchData, DeleteData { +export class WorkspaceitemDataService extends IdentifiableDataService implements DeleteData, SearchData{ + protected linkPath = 'workspaceitems'; protected searchByItemLinkPath = 'item'; - - private searchData: SearchDataImpl; - private deleteData: DeleteDataImpl; + private deleteData: DeleteData; + private searchData: SearchData; constructor( + protected comparator: DSOChangeAnalyzer, + protected halService: HALEndpointService, + protected http: HttpClient, + protected notificationsService: NotificationsService, protected requestService: RequestService, protected rdbService: RemoteDataBuildService, protected objectCache: ObjectCacheService, - protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - ) { + protected store: Store) { super('workspaceitems', requestService, rdbService, objectCache, halService); - - this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); this.deleteData = new DeleteDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive, this.constructIdEndpoint); + this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + } + public delete(objectId: string, copyVirtualMetadata?: string[]): Observable> { + return this.deleteData.delete(objectId, copyVirtualMetadata); } - /** * Return the WorkspaceItem object found through the UUID of an item * @@ -55,21 +67,46 @@ export class WorkspaceitemDataService extends IdentifiableDataService[]): Observable> { const findListOptions = new FindListOptions(); findListOptions.searchParams = [new RequestParam('uuid', encodeURIComponent(uuid))]; - const href$ = this.getSearchByHref(this.searchByItemLinkPath, findListOptions, ...linksToFollow); + const href$ = this.getIDHref(this.searchByItemLinkPath, findListOptions, ...linksToFollow); return this.findByHref(href$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); } /** - * Create the HREF for a specific object's search method with given options object - * - * @param searchMethod The search method for the object - * @param options The [[FindListOptions]] object - * @return {Observable} - * Return an observable that emits created HREF - * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + * Import an external source entry into a collection + * @param externalSourceEntryHref + * @param collectionId */ - public getSearchByHref(searchMethod: string, options?: FindListOptions, ...linksToFollow): Observable { - return this.searchData.getSearchByHref(searchMethod, options, ...linksToFollow); + public importExternalSourceEntry(externalSourceEntryHref: string, collectionId: string): Observable> { + const options: HttpOptions = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Content-Type', 'text/uri-list'); + options.headers = headers; + + const requestId = this.requestService.generateRequestId(); + const href$ = this.halService.getEndpoint(this.linkPath).pipe(map((href) => `${href}?owningCollection=${collectionId}`)); + + href$.pipe( + find((href: string) => hasValue(href)), + map((href: string) => { + const request = new PostRequest(requestId, href, externalSourceEntryHref, options); + this.requestService.send(request); + }) + ).subscribe(); + + return this.rdbService.buildFromRequestUUID(requestId); + } + + /** + * Delete an existing object on the server + * @param href The self link of the object to be removed + * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual + * metadata should be saved as real metadata + * @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode, + * errorMessage, timeCompleted, etc + * Only emits once all request related to the DSO has been invalidated. + */ + deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable> { + return this.deleteData.deleteByHref(href, copyVirtualMetadata); } /** @@ -86,33 +123,7 @@ export class WorkspaceitemDataService extends IdentifiableDataService>} * Return an observable that emits response from the server */ - public searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { + searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); } - - /** - * Delete an existing object on the server - * @param objectId The id of the object to be removed - * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual - * metadata should be saved as real metadata - * @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode, - * errorMessage, timeCompleted, etc - */ - public delete(objectId: string, copyVirtualMetadata?: string[]): Observable> { - return this.deleteData.delete(objectId, copyVirtualMetadata); - } - - /** - * Delete an existing object on the server - * @param href The self link of the object to be removed - * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual - * metadata should be saved as real metadata - * @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode, - * errorMessage, timeCompleted, etc - * Only emits once all request related to the DSO has been invalidated. - */ - public deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable> { - return this.deleteData.deleteByHref(href, copyVirtualMetadata); - } - } diff --git a/src/app/core/suggestion-notifications/models/suggestion-objects.resource-type.ts b/src/app/core/suggestion-notifications/models/suggestion-objects.resource-type.ts new file mode 100644 index 0000000000..8f83d86376 --- /dev/null +++ b/src/app/core/suggestion-notifications/models/suggestion-objects.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from '../../shared/resource-type'; + +/** + * The resource type for the Suggestion object + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const SUGGESTION = new ResourceType('suggestion'); diff --git a/src/app/core/suggestion-notifications/models/suggestion-source-object.resource-type.ts b/src/app/core/suggestion-notifications/models/suggestion-source-object.resource-type.ts new file mode 100644 index 0000000000..e319ed5109 --- /dev/null +++ b/src/app/core/suggestion-notifications/models/suggestion-source-object.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from '../../shared/resource-type'; + +/** + * The resource type for the Suggestion Source object + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const SUGGESTION_SOURCE = new ResourceType('suggestionsource'); diff --git a/src/app/core/suggestion-notifications/models/suggestion-source.model.ts b/src/app/core/suggestion-notifications/models/suggestion-source.model.ts new file mode 100644 index 0000000000..12d9d7e9d8 --- /dev/null +++ b/src/app/core/suggestion-notifications/models/suggestion-source.model.ts @@ -0,0 +1,47 @@ +import { autoserialize, deserialize } from 'cerialize'; + +import { SUGGESTION_SOURCE } from './suggestion-source-object.resource-type'; +import { excludeFromEquals } from '../../utilities/equals.decorators'; +import { ResourceType } from '../../shared/resource-type'; +import { HALLink } from '../../shared/hal-link.model'; +import { typedObject } from '../../cache/builders/build-decorators'; +import {CacheableObject} from '../../cache/cacheable-object.model'; + +/** + * The interface representing the Suggestion Source model + */ +@typedObject +export class SuggestionSource implements CacheableObject { + /** + * A string representing the kind of object, e.g. community, item, … + */ + static type = SUGGESTION_SOURCE; + + /** + * The Suggestion Target id + */ + @autoserialize + id: string; + + /** + * The total number of suggestions provided by Suggestion Target for + */ + @autoserialize + total: number; + + /** + * The type of this ConfigObject + */ + @excludeFromEquals + @autoserialize + type: ResourceType; + + /** + * The links to all related resources returned by the rest api. + */ + @deserialize + _links: { + self: HALLink, + suggestiontargets: HALLink + }; +} diff --git a/src/app/core/suggestion-notifications/models/suggestion-target-object.resource-type.ts b/src/app/core/suggestion-notifications/models/suggestion-target-object.resource-type.ts new file mode 100644 index 0000000000..81b1b5c261 --- /dev/null +++ b/src/app/core/suggestion-notifications/models/suggestion-target-object.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from '../../shared/resource-type'; + +/** + * The resource type for the Suggestion Target object + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const SUGGESTION_TARGET = new ResourceType('suggestiontarget'); diff --git a/src/app/core/suggestion-notifications/models/suggestion-target.model.ts b/src/app/core/suggestion-notifications/models/suggestion-target.model.ts new file mode 100644 index 0000000000..99d9a8628a --- /dev/null +++ b/src/app/core/suggestion-notifications/models/suggestion-target.model.ts @@ -0,0 +1,61 @@ +import { autoserialize, deserialize } from 'cerialize'; + + +import { CacheableObject } from '../../cache/cacheable-object.model'; +import { SUGGESTION_TARGET } from './suggestion-target-object.resource-type'; +import { excludeFromEquals } from '../../utilities/equals.decorators'; +import { ResourceType } from '../../shared/resource-type'; +import { HALLink } from '../../shared/hal-link.model'; +import { typedObject } from '../../cache/builders/build-decorators'; + +/** + * The interface representing the Suggestion Target model + */ +@typedObject +export class SuggestionTarget implements CacheableObject { + /** + * A string representing the kind of object, e.g. community, item, … + */ + static type = SUGGESTION_TARGET; + + /** + * The Suggestion Target id + */ + @autoserialize + id: string; + + /** + * The Suggestion Target name to display + */ + @autoserialize + display: string; + + /** + * The Suggestion Target source to display + */ + @autoserialize + source: string; + + /** + * The total number of suggestions provided by Suggestion Target for + */ + @autoserialize + total: number; + + /** + * The type of this ConfigObject + */ + @excludeFromEquals + @autoserialize + type: ResourceType; + + /** + * The links to all related resources returned by the rest api. + */ + @deserialize + _links: { + self: HALLink, + suggestions: HALLink, + target: HALLink + }; +} diff --git a/src/app/core/suggestion-notifications/models/suggestion.model.ts b/src/app/core/suggestion-notifications/models/suggestion.model.ts new file mode 100644 index 0000000000..ad58b1cfe5 --- /dev/null +++ b/src/app/core/suggestion-notifications/models/suggestion.model.ts @@ -0,0 +1,88 @@ +import { autoserialize, autoserializeAs, deserialize } from 'cerialize'; + +import { SUGGESTION } from './suggestion-objects.resource-type'; +import { excludeFromEquals } from '../../utilities/equals.decorators'; +import { ResourceType } from '../../shared/resource-type'; +import { HALLink } from '../../shared/hal-link.model'; +import { typedObject } from '../../cache/builders/build-decorators'; +import { MetadataMap, MetadataMapSerializer } from '../../shared/metadata.models'; +import {CacheableObject} from '../../cache/cacheable-object.model'; + +/** + * The interface representing Suggestion Evidences such as scores (authorScore, datescore) + */ +export interface SuggestionEvidences { + [sectionId: string]: { + score: string; + notes: string + }; +} +/** + * The interface representing the Suggestion Source model + */ +@typedObject +export class Suggestion implements CacheableObject { + /** + * A string representing the kind of object, e.g. community, item, … + */ + static type = SUGGESTION; + + /** + * The Suggestion id + */ + @autoserialize + id: string; + + /** + * The Suggestion name to display + */ + @autoserialize + display: string; + + /** + * The Suggestion source to display + */ + @autoserialize + source: string; + + /** + * The Suggestion external source uri + */ + @autoserialize + externalSourceUri: string; + + /** + * The Total Score of the suggestion + */ + @autoserialize + score: string; + + /** + * The total number of suggestions provided by Suggestion Target for + */ + @autoserialize + evidences: SuggestionEvidences; + + /** + * All metadata of this suggestion object + */ + @excludeFromEquals + @autoserializeAs(MetadataMapSerializer) + metadata: MetadataMap; + + /** + * The type of this ConfigObject + */ + @excludeFromEquals + @autoserialize + type: ResourceType; + + /** + * The links to all related resources returned by the rest api. + */ + @deserialize + _links: { + self: HALLink, + target: HALLink + }; +} diff --git a/src/app/core/suggestion-notifications/source/suggestion-source-data.service.ts b/src/app/core/suggestion-notifications/source/suggestion-source-data.service.ts new file mode 100644 index 0000000000..f00a84c95b --- /dev/null +++ b/src/app/core/suggestion-notifications/source/suggestion-source-data.service.ts @@ -0,0 +1,96 @@ +import { Injectable } from '@angular/core'; +import { dataService } from '../../data/base/data-service.decorator'; +import { SUGGESTION_SOURCE } from '../models/suggestion-source-object.resource-type'; +import { IdentifiableDataService } from '../../data/base/identifiable-data.service'; +import { SuggestionSource } from '../models/suggestion-source.model'; +import { FindAllData, FindAllDataImpl } from '../../data/base/find-all-data'; +import { Store } from '@ngrx/store'; +import { RequestService } from '../../data/request.service'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { CoreState } from '../../core-state.model'; +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { HttpClient } from '@angular/common/http'; +import { FindListOptions } from '../../data/find-list-options.model'; +import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; +import { PaginatedList } from '../../data/paginated-list.model'; +import { RemoteData } from '../../data/remote-data'; +import { Observable } from 'rxjs/internal/Observable'; +import { DefaultChangeAnalyzer } from '../../data/default-change-analyzer.service'; + +/** + * Service that retrieves Suggestion Source data + */ +@Injectable() +@dataService(SUGGESTION_SOURCE) +export class SuggestionSourceDataService extends IdentifiableDataService { + + protected linkPath = 'suggestionsources'; + + private findAllData: FindAllData; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DefaultChangeAnalyzer) { + super('suggestionsources', requestService, rdbService, objectCache, halService); + this.findAllData = new FindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + } + /** + * Return the list of Suggestion source. + * + * @param options Find list options object. + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved. + * + * @return Observable>> + * The list of Quality Assurance source. + */ + public getSources(options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * Return a single Suggestoin source. + * + * @param id The Quality Assurance source id + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved. + * + * @return Observable> The Quality Assurance source. + */ + public getSource(id: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { + return this.findById(id, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + + /** + * Returns {@link RemoteData} of all object with a list of {@link FollowLinkConfig}, to indicate which embedded + * info should be added to the objects + * + * @param options Find list options object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + * @return {Observable>>} + * Return an observable that emits object list + */ + findAll(options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } +} diff --git a/src/app/core/suggestion-notifications/source/suggestions-source-data.service.spec.ts b/src/app/core/suggestion-notifications/source/suggestions-source-data.service.spec.ts new file mode 100644 index 0000000000..28f34b863d --- /dev/null +++ b/src/app/core/suggestion-notifications/source/suggestions-source-data.service.spec.ts @@ -0,0 +1,115 @@ +import { TestScheduler } from 'rxjs/testing'; +import { RequestService } from '../../data/request.service'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { RequestEntry } from '../../data/request-entry.model'; +import { cold, getTestScheduler } from 'jasmine-marbles'; +import { RestResponse } from '../../cache/response.models'; +import { of as observableOf } from 'rxjs'; +import { Store } from '@ngrx/store'; +import { CoreState } from '../../core-state.model'; +import { HttpClient } from '@angular/common/http'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { DefaultChangeAnalyzer } from '../../data/default-change-analyzer.service'; +import { testFindAllDataImplementation } from '../../data/base/find-all-data.spec'; +import { FindAllData } from '../../data/base/find-all-data'; +import { GetRequest } from '../../data/request.models'; +import { + createSuccessfulRemoteDataObject$ +} from '../../../shared/remote-data.utils'; +import { RemoteData } from '../../data/remote-data'; +import { RequestEntryState } from '../../data/request-entry-state.model'; +import { SuggestionSourceDataService } from './suggestion-source-data.service'; +import { SuggestionSource } from '../models/suggestion-source.model'; + +describe('SuggestionSourceDataService test', () => { + let scheduler: TestScheduler; + let service: SuggestionSourceDataService; + let requestService: RequestService; + let rdbService: RemoteDataBuildService; + let objectCache: ObjectCacheService; + let halService: HALEndpointService; + let notificationsService: NotificationsService; + let http: HttpClient; + let comparator: DefaultChangeAnalyzer; + let responseCacheEntry: RequestEntry; + + const store = {} as Store; + const endpointURL = `https://rest.api/rest/api/suggestionsources`; + const requestUUID = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a'; + + const remoteDataMocks = { + Success: new RemoteData(null, null, null, RequestEntryState.Success, null, null, 200), + }; + + function initTestService() { + return new SuggestionSourceDataService( + requestService, + rdbService, + store, + objectCache, + halService, + notificationsService, + http, + comparator + ); + } + + beforeEach(() => { + scheduler = getTestScheduler(); + + objectCache = {} as ObjectCacheService; + http = {} as HttpClient; + notificationsService = {} as NotificationsService; + comparator = {} as DefaultChangeAnalyzer; + responseCacheEntry = new RequestEntry(); + responseCacheEntry.request = { href: 'https://rest.api/' } as any; + responseCacheEntry.response = new RestResponse(true, 200, 'Success'); + + requestService = jasmine.createSpyObj('requestService', { + generateRequestId: requestUUID, + send: true, + removeByHrefSubstring: {}, + getByHref: observableOf(responseCacheEntry), + getByUUID: observableOf(responseCacheEntry), + }); + + halService = jasmine.createSpyObj('halService', { + getEndpoint: observableOf(endpointURL) + }); + + rdbService = jasmine.createSpyObj('rdbService', { + buildSingle: createSuccessfulRemoteDataObject$({}, 500), + buildList: cold('a', { a: remoteDataMocks.Success }) + }); + + + service = initTestService(); + }); + + describe('composition', () => { + const initFindAllService = () => new SuggestionSourceDataService(null, null, null, null, null, null, null, null) as unknown as FindAllData; + testFindAllDataImplementation(initFindAllService); + }); + + describe('getSources', () => { + it('should send a new GetRequest', () => { + const expected = new GetRequest(requestService.generateRequestId(), `${endpointURL}`); + scheduler.schedule(() => service.getSources().subscribe()); + scheduler.flush(); + + expect(requestService.send).toHaveBeenCalledWith(expected, true); + }); + }); + + describe('getSource', () => { + it('should send a new GetRequest', () => { + const expected = new GetRequest(requestService.generateRequestId(), `${endpointURL}/testId`); + scheduler.schedule(() => service.getSource('testId').subscribe()); + scheduler.flush(); + + expect(requestService.send).toHaveBeenCalledWith(expected, true); + }); + }); +}); diff --git a/src/app/core/suggestion-notifications/suggestion-data.service.spec.ts b/src/app/core/suggestion-notifications/suggestion-data.service.spec.ts new file mode 100644 index 0000000000..c0bc97ea12 --- /dev/null +++ b/src/app/core/suggestion-notifications/suggestion-data.service.spec.ts @@ -0,0 +1,173 @@ +import { TestScheduler } from 'rxjs/testing'; +import { SuggestionDataServiceImpl, SuggestionsDataService } from './suggestions-data.service'; +import { RequestService } from '../data/request.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { HttpClient } from '@angular/common/http'; +import { DefaultChangeAnalyzer } from '../data/default-change-analyzer.service'; +import { Suggestion } from './models/suggestion.model'; +import { cold, getTestScheduler } from 'jasmine-marbles'; +import { RequestEntry } from '../data/request-entry.model'; +import { RestResponse } from '../cache/response.models'; +import { of as observableOf } from 'rxjs'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { RemoteData } from '../data/remote-data'; +import { RequestEntryState } from '../data/request-entry-state.model'; +import { SuggestionSource } from './models/suggestion-source.model'; +import { SuggestionTarget } from './models/suggestion-target.model'; +import { SuggestionSourceDataService } from './source/suggestion-source-data.service'; +import { SuggestionTargetDataService } from './target/suggestion-target-data.service'; +import { RequestParam } from '../cache/models/request-param.model'; + +describe('SuggestionDataService test', () => { + let scheduler: TestScheduler; + let service: SuggestionsDataService; + let requestService: RequestService; + let rdbService: RemoteDataBuildService; + let objectCache: ObjectCacheService; + let halService: HALEndpointService; + let notificationsService: NotificationsService; + let http: HttpClient; + let comparatorSuggestion: DefaultChangeAnalyzer; + let comparatorSuggestionSource: DefaultChangeAnalyzer; + let comparatorSuggestionTarget: DefaultChangeAnalyzer; + let suggestionSourcesDataService: SuggestionSourceDataService; + let suggestionTargetsDataService: SuggestionTargetDataService; + let suggestionsDataService: SuggestionDataServiceImpl; + let responseCacheEntry: RequestEntry; + + + const testSource = 'test-source'; + const testUserId = '1234-4321'; + const endpointURL = `https://rest.api/rest/api/`; + const requestUUID = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a'; + const remoteDataMocks = { + Success: new RemoteData(null, null, null, RequestEntryState.Success, null, null, 200), + }; + + function initTestService() { + return new SuggestionsDataService( + requestService, + rdbService, + objectCache, + halService, + notificationsService, + http, + comparatorSuggestion, + comparatorSuggestionSource, + comparatorSuggestionTarget + ); + } + + beforeEach(() => { + scheduler = getTestScheduler(); + + objectCache = {} as ObjectCacheService; + http = {} as HttpClient; + notificationsService = {} as NotificationsService; + comparatorSuggestion = {} as DefaultChangeAnalyzer; + comparatorSuggestionTarget = {} as DefaultChangeAnalyzer; + comparatorSuggestionSource = {} as DefaultChangeAnalyzer; + responseCacheEntry = new RequestEntry(); + responseCacheEntry.request = { href: 'https://rest.api/' } as any; + responseCacheEntry.response = new RestResponse(true, 200, 'Success'); + + requestService = jasmine.createSpyObj('requestService', { + generateRequestId: requestUUID, + send: true, + removeByHrefSubstring: {}, + getByHref: observableOf(responseCacheEntry), + getByUUID: observableOf(responseCacheEntry), + setStaleByHrefSubstring: observableOf(true) + }); + + halService = jasmine.createSpyObj('halService', { + getEndpoint: observableOf(endpointURL) + }); + + rdbService = jasmine.createSpyObj('rdbService', { + buildSingle: createSuccessfulRemoteDataObject$({}, 500), + buildList: cold('a', { a: remoteDataMocks.Success }) + }); + + + suggestionSourcesDataService = jasmine.createSpyObj('suggestionSourcesDataService', { + getSources: observableOf(null), + }); + + suggestionTargetsDataService = jasmine.createSpyObj('suggestionTargetsDataService', { + getTargets: observableOf(null), + getTargetsByUser: observableOf(null), + findById: observableOf(null), + }); + + suggestionsDataService = jasmine.createSpyObj('suggestionsDataService', { + searchBy: observableOf(null), + delete: observableOf(null), + }); + + + service = initTestService(); + /* eslint-disable-next-line @typescript-eslint/dot-notation */ + service['suggestionSourcesDataService'] = suggestionSourcesDataService; + /* eslint-disable-next-line @typescript-eslint/dot-notation */ + service['suggestionTargetsDataService'] = suggestionTargetsDataService; + /* eslint-disable-next-line @typescript-eslint/dot-notation */ + service['suggestionsDataService'] = suggestionsDataService; + }); + + describe('Suggestion targets service', () => { + it('should call suggestionSourcesDataService.getTargets', () => { + const options = { + searchParams: [new RequestParam('source', testSource)] + }; + service.getTargets(testSource); + expect(suggestionTargetsDataService.getTargets).toHaveBeenCalledWith('findBySource', options); + }); + + it('should call suggestionSourcesDataService.getTargetsByUser', () => { + const options = { + searchParams: [new RequestParam('target', testUserId)] + }; + service.getTargetsByUser(testUserId); + expect(suggestionTargetsDataService.getTargetsByUser).toHaveBeenCalledWith(testUserId, options); + }); + + it('should call suggestionSourcesDataService.getTargetById', () => { + service.getTargetById('1'); + expect(suggestionTargetsDataService.findById).toHaveBeenCalledWith('1'); + }); + }); + + + describe('Suggestion sources service', () => { + it('should call suggestionSourcesDataService.getSources', () => { + service.getSources(); + expect(suggestionSourcesDataService.getSources).toHaveBeenCalled(); + }); + }); + + describe('Suggestion service', () => { + it('should call suggestionsDataService.searchBy', () => { + const options = { + searchParams: [new RequestParam('target', testUserId), new RequestParam('source', testSource)] + }; + service.getSuggestionsByTargetAndSource(testUserId, testSource); + expect(suggestionsDataService.searchBy).toHaveBeenCalledWith('findByTargetAndSource', options, false, true); + }); + + it('should call suggestionsDataService.delete', () => { + service.deleteSuggestion('1'); + expect(suggestionsDataService.delete).toHaveBeenCalledWith('1'); + }); + }); + + describe('Request service', () => { + it('should call requestService.setStaleByHrefSubstring', () => { + service.clearSuggestionRequests(); + expect(requestService.setStaleByHrefSubstring).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/app/core/suggestion-notifications/suggestions-data.service.ts b/src/app/core/suggestion-notifications/suggestions-data.service.ts new file mode 100644 index 0000000000..17b1482578 --- /dev/null +++ b/src/app/core/suggestion-notifications/suggestions-data.service.ts @@ -0,0 +1,229 @@ +/* eslint-disable max-classes-per-file */ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Store } from '@ngrx/store'; + +import { Observable } from 'rxjs'; + +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { dataService } from '../cache/builders/build-decorators'; +import { RequestService } from '../data/request.service'; +import { UpdateDataServiceImpl } from '../data/update-data.service'; +import { ChangeAnalyzer } from '../data/change-analyzer'; +import { DefaultChangeAnalyzer } from '../data/default-change-analyzer.service'; +import { RemoteData } from '../data/remote-data'; +import { SUGGESTION } from './models/suggestion-objects.resource-type'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { PaginatedList } from '../data/paginated-list.model'; +import { SuggestionSource } from './models/suggestion-source.model'; +import { SuggestionTarget } from './models/suggestion-target.model'; +import { Suggestion } from './models/suggestion.model'; +import { RequestParam } from '../cache/models/request-param.model'; +import { NoContent } from '../shared/NoContent.model'; +import {CoreState} from '../core-state.model'; +import {FindListOptions} from '../data/find-list-options.model'; +import { SuggestionSourceDataService } from './source/suggestion-source-data.service'; +import { SuggestionTargetDataService } from './target/suggestion-target-data.service'; + +/* tslint:disable:max-classes-per-file */ + +/** + * A private UpdateDataServiceImpl implementation to delegate specific methods to. + */ +export class SuggestionDataServiceImpl extends UpdateDataServiceImpl { + + /** + * Initialize service variables + * @param {RequestService} requestService + * @param {RemoteDataBuildService} rdbService + * @param {Store} store + * @param {ObjectCacheService} objectCache + * @param {HALEndpointService} halService + * @param {NotificationsService} notificationsService + * @param {HttpClient} http + * @param {ChangeAnalyzer} comparator + * @param responseMsToLive + */ + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: ChangeAnalyzer, + protected responseMsToLive: number, + ) { + super('suggestions', requestService, rdbService, objectCache, halService, notificationsService, comparator ,responseMsToLive); + } +} + +/** + * The service handling all Suggestion Target REST requests. + */ +@Injectable() +@dataService(SUGGESTION) +export class SuggestionsDataService { + protected searchFindBySourceMethod = 'findBySource'; + protected searchFindByTargetAndSourceMethod = 'findByTargetAndSource'; + + /** + * A private UpdateDataServiceImpl implementation to delegate specific methods to. + */ + private suggestionsDataService: SuggestionDataServiceImpl; + + /** + * A private UpdateDataServiceImpl implementation to delegate specific methods to. + */ + private suggestionSourcesDataService: SuggestionSourceDataService; + + /** + * A private UpdateDataServiceImpl implementation to delegate specific methods to. + */ + private suggestionTargetsDataService: SuggestionTargetDataService; + + private responseMsToLive = 10 * 1000; + + /** + * Initialize service variables + * @param {RequestService} requestService + * @param {RemoteDataBuildService} rdbService + * @param {ObjectCacheService} objectCache + * @param {HALEndpointService} halService + * @param {NotificationsService} notificationsService + * @param {HttpClient} http + * @param {DefaultChangeAnalyzer} comparatorSuggestions + * @param {DefaultChangeAnalyzer} comparatorSources + * @param {DefaultChangeAnalyzer} comparatorTargets + */ + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparatorSuggestions: DefaultChangeAnalyzer, + protected comparatorSources: DefaultChangeAnalyzer, + protected comparatorTargets: DefaultChangeAnalyzer, + ) { + this.suggestionsDataService = new SuggestionDataServiceImpl(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparatorSuggestions, this.responseMsToLive); + this.suggestionSourcesDataService = new SuggestionSourceDataService(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparatorSources); + this.suggestionTargetsDataService = new SuggestionTargetDataService(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparatorTargets); + } + + /** + * Return the list of Suggestion Sources + * + * @param options + * Find list options object. + * @return Observable>> + * The list of Suggestion Sources. + */ + public getSources(options: FindListOptions = {}): Observable>> { + return this.suggestionSourcesDataService.getSources(options); + } + + /** + * Return the list of Suggestion Target for a given source + * + * @param source + * The source for which to find targets. + * @param options + * Find list options object. + * @param linksToFollow + * List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved. + * @return Observable>> + * The list of Suggestion Target. + */ + public getTargets( + source: string, + options: FindListOptions = {}, + ...linksToFollow: FollowLinkConfig[] + ): Observable>> { + options.searchParams = [new RequestParam('source', source)]; + + return this.suggestionTargetsDataService.getTargets(this.searchFindBySourceMethod, options, ...linksToFollow); + } + + /** + * Return the list of Suggestion Target for a given user + * + * @param userId + * The user Id for which to find targets. + * @param options + * Find list options object. + * @param linksToFollow + * List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved. + * @return Observable>> + * The list of Suggestion Target. + */ + public getTargetsByUser( + userId: string, + options: FindListOptions = {}, + ...linksToFollow: FollowLinkConfig[] + ): Observable>> { + options.searchParams = [new RequestParam('target', userId)]; + return this.suggestionTargetsDataService.getTargetsByUser(userId, options, ...linksToFollow); + } + + /** + * Return a Suggestion Target for a given id + * + * @param targetId + * The target id to retrieve. + * + * @return Observable> + * The list of Suggestion Target. + */ + public getTargetById(targetId: string): Observable> { + return this.suggestionTargetsDataService.findById(targetId); + } + + /** + * Used to delete Suggestion + * @suggestionId + */ + public deleteSuggestion(suggestionId: string): Observable> { + return this.suggestionsDataService.delete(suggestionId); + } + + /** + * Return the list of Suggestion for a given target and source + * + * @param target + * The target for which to find suggestions. + * @param source + * The source for which to find suggestions. + * @param options + * Find list options object. + * @param linksToFollow + * List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved. + * @return Observable>> + * The list of Suggestion. + */ + public getSuggestionsByTargetAndSource( + target: string, + source: string, + options: FindListOptions = {}, + ...linksToFollow: FollowLinkConfig[] + ): Observable>> { + options.searchParams = [ + new RequestParam('target', target), + new RequestParam('source', source) + ]; + + return this.suggestionsDataService.searchBy(this.searchFindByTargetAndSourceMethod, options, false, true, ...linksToFollow); + } + + /** + * Clear findByTargetAndSource suggestions requests from cache + */ + public clearSuggestionRequests() { + this.requestService.setStaleByHrefSubstring(this.searchFindByTargetAndSourceMethod); + } +} diff --git a/src/app/core/suggestion-notifications/target/suggestion-target-data.service.ts b/src/app/core/suggestion-notifications/target/suggestion-target-data.service.ts new file mode 100644 index 0000000000..a2f1507b10 --- /dev/null +++ b/src/app/core/suggestion-notifications/target/suggestion-target-data.service.ts @@ -0,0 +1,141 @@ +import { Injectable } from '@angular/core'; +import { dataService } from '../../data/base/data-service.decorator'; + +import { IdentifiableDataService } from '../../data/base/identifiable-data.service'; +import { SuggestionTarget } from '../models/suggestion-target.model'; +import { FindAllData, FindAllDataImpl } from '../../data/base/find-all-data'; +import { Store } from '@ngrx/store'; +import { RequestService } from '../../data/request.service'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { CoreState } from '../../core-state.model'; +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { HttpClient } from '@angular/common/http'; +import { FindListOptions } from '../../data/find-list-options.model'; +import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; +import { PaginatedList } from '../../data/paginated-list.model'; +import { RemoteData } from '../../data/remote-data'; +import { Observable } from 'rxjs/internal/Observable'; +import { RequestParam } from '../../cache/models/request-param.model'; +import { SearchData, SearchDataImpl } from '../../data/base/search-data'; +import { DefaultChangeAnalyzer } from '../../data/default-change-analyzer.service'; +import { SUGGESTION_TARGET } from '../models/suggestion-target-object.resource-type'; + +@Injectable() +@dataService(SUGGESTION_TARGET) +export class SuggestionTargetDataService extends IdentifiableDataService { + + protected linkPath = 'suggestiontargets'; + private findAllData: FindAllData; + private searchData: SearchData; + protected searchFindBySourceMethod = 'findBySource'; + protected searchFindByTargetMethod = 'findByTarget'; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DefaultChangeAnalyzer) { + super('suggestiontargets', requestService, rdbService, objectCache, halService); + this.findAllData = new FindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + } + /** + * Return the list of Suggestion Target for a given source + * + * @param source + * The source for which to find targets. + * @param options + * Find list options object. + * @param linksToFollow + * List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved. + * @return Observable>> + * The list of Suggestion Target. + */ + public getTargets( + source: string, + options: FindListOptions = {}, + ...linksToFollow: FollowLinkConfig[] + ): Observable>> { + options.searchParams = [new RequestParam('source', source)]; + + return this.searchBy(this.searchFindBySourceMethod, options, true, true, ...linksToFollow); + } + + /** + * Return the list of Suggestion Target for a given user + * + * @param userId + * The user Id for which to find targets. + * @param options + * Find list options object. + * @param linksToFollow + * List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved. + * @return Observable>> + * The list of Suggestion Target. + */ + public getTargetsByUser( + userId: string, + options: FindListOptions = {}, + ...linksToFollow: FollowLinkConfig[] + ): Observable>> { + options.searchParams = [new RequestParam('target', userId)]; + + return this.searchBy(this.searchFindByTargetMethod, options, true, true, ...linksToFollow); + } + /** + * Return a Suggestion Target for a given id + * + * @param targetId + * The target id to retrieve. + * + * @return Observable> + * The list of Suggestion Target. + */ + public getTargetById(targetId: string): Observable> { + return this.findById(targetId); + } + + /** + * Make a new FindListRequest with given search method + * + * @param searchMethod The search method for the object + * @param options The [[FindListOptions]] object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + * @return {Observable>} + * Return an observable that emits response from the server + */ + public searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + + /** + * Returns {@link RemoteData} of all object with a list of {@link FollowLinkConfig}, to indicate which embedded + * info should be added to the objects + * + * @param options Find list options object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + * @return {Observable>>} + * Return an observable that emits object list + */ + findAll(options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + +} diff --git a/src/app/core/suggestion-notifications/target/suggestions-target-data.service.spec.ts b/src/app/core/suggestion-notifications/target/suggestions-target-data.service.spec.ts new file mode 100644 index 0000000000..9207603a5a --- /dev/null +++ b/src/app/core/suggestion-notifications/target/suggestions-target-data.service.spec.ts @@ -0,0 +1,138 @@ +import { TestScheduler } from 'rxjs/testing'; +import { RequestService } from '../../data/request.service'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { RequestEntry } from '../../data/request-entry.model'; +import { cold, getTestScheduler } from 'jasmine-marbles'; +import { RestResponse } from '../../cache/response.models'; +import { of as observableOf } from 'rxjs'; +import { Store } from '@ngrx/store'; +import { CoreState } from '../../core-state.model'; +import { HttpClient } from '@angular/common/http'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { SearchData } from '../../data/base/search-data'; +import { testSearchDataImplementation } from '../../data/base/search-data.spec'; +import { SuggestionTargetDataService } from './suggestion-target-data.service'; +import { DefaultChangeAnalyzer } from '../../data/default-change-analyzer.service'; +import { SuggestionTarget } from '../models/suggestion-target.model'; +import { testFindAllDataImplementation } from '../../data/base/find-all-data.spec'; +import { FindAllData } from '../../data/base/find-all-data'; +import { GetRequest } from '../../data/request.models'; +import { + createSuccessfulRemoteDataObject$ +} from '../../../shared/remote-data.utils'; +import { RequestParam } from '../../cache/models/request-param.model'; +import { RemoteData } from '../../data/remote-data'; +import { RequestEntryState } from '../../data/request-entry-state.model'; + +describe('SuggestionTargetDataService test', () => { + let scheduler: TestScheduler; + let service: SuggestionTargetDataService; + let requestService: RequestService; + let rdbService: RemoteDataBuildService; + let objectCache: ObjectCacheService; + let halService: HALEndpointService; + let notificationsService: NotificationsService; + let http: HttpClient; + let comparator: DefaultChangeAnalyzer; + let responseCacheEntry: RequestEntry; + + const store = {} as Store; + const endpointURL = `https://rest.api/rest/api/suggestiontargets`; + const requestUUID = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a'; + + const remoteDataMocks = { + Success: new RemoteData(null, null, null, RequestEntryState.Success, null, null, 200), + }; + + function initTestService() { + return new SuggestionTargetDataService( + requestService, + rdbService, + store, + objectCache, + halService, + notificationsService, + http, + comparator + ); + } + + beforeEach(() => { + scheduler = getTestScheduler(); + + objectCache = {} as ObjectCacheService; + http = {} as HttpClient; + notificationsService = {} as NotificationsService; + comparator = {} as DefaultChangeAnalyzer; + responseCacheEntry = new RequestEntry(); + responseCacheEntry.request = { href: 'https://rest.api/' } as any; + responseCacheEntry.response = new RestResponse(true, 200, 'Success'); + + requestService = jasmine.createSpyObj('requestService', { + generateRequestId: requestUUID, + send: true, + removeByHrefSubstring: {}, + getByHref: observableOf(responseCacheEntry), + getByUUID: observableOf(responseCacheEntry), + }); + + halService = jasmine.createSpyObj('halService', { + getEndpoint: observableOf(endpointURL) + }); + + rdbService = jasmine.createSpyObj('rdbService', { + buildSingle: createSuccessfulRemoteDataObject$({}, 500), + buildList: cold('a', { a: remoteDataMocks.Success }) + }); + + + service = initTestService(); + }); + + describe('composition', () => { + const initSearchService = () => new SuggestionTargetDataService(null, null, null, null, null, null, null, null) as unknown as SearchData; + const initFindAllService = () => new SuggestionTargetDataService(null, null, null, null, null, null, null, null) as unknown as FindAllData; + testSearchDataImplementation(initSearchService); + testFindAllDataImplementation(initFindAllService); + }); + + describe('getTargetById', () => { + it('should send a new GetRequest', () => { + const expected = new GetRequest(requestService.generateRequestId(), endpointURL + '/testId'); + scheduler.schedule(() => service.getTargetById('testId').subscribe()); + scheduler.flush(); + + expect(requestService.send).toHaveBeenCalledWith(expected, true); + }); + }); + + describe('getTargetsByUser', () => { + it('should send a new GetRequest', () => { + const options = { + searchParams: [new RequestParam('target', 'testId')] + }; + const searchFindByTargetMethod = 'findByTarget'; + const expected = new GetRequest(requestService.generateRequestId(), `${endpointURL}/search/${searchFindByTargetMethod}?target=testId`); + scheduler.schedule(() => service.getTargetsByUser('testId', options).subscribe()); + scheduler.flush(); + + expect(requestService.send).toHaveBeenCalledWith(expected, true); + }); + }); + + describe('getTargets', () => { + it('should send a new GetRequest', () => { + const options = { + searchParams: [new RequestParam('source', 'testId')] + }; + const searchFindBySourceMethod = 'findBySource'; + const expected = new GetRequest(requestService.generateRequestId(), `${endpointURL}/search/${searchFindBySourceMethod}?source=testId`); + scheduler.schedule(() => service.getTargets('testId', options).subscribe()); + scheduler.flush(); + + expect(requestService.send).toHaveBeenCalledWith(expected, true); + }); + }); +}); diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.ts index d44817be84..60b1dffa45 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.ts +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.ts @@ -12,7 +12,6 @@ import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject'; import { getFirstCompletedRemoteData, } from '../../core/shared/operators'; -import { UpdateDataService } from '../../core/data/update-data.service'; import { ResourceType } from '../../core/shared/resource-type'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { TranslateService } from '@ngx-translate/core'; @@ -22,6 +21,7 @@ import { ArrayMoveChangeAnalyzer } from '../../core/data/array-move-change-analy import { DATA_SERVICE_FACTORY } from '../../core/data/base/data-service.decorator'; import { GenericConstructor } from '../../core/shared/generic-constructor'; import { HALDataService } from '../../core/data/base/hal-data-service.interface'; +import { UpdateDataService } from '../../core/data/update-data.service'; @Component({ selector: 'ds-dso-edit-metadata', diff --git a/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.html b/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.html index c79d19e267..52df841d3b 100644 --- a/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.html +++ b/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.html @@ -32,6 +32,18 @@
+ +
+ diff --git a/src/app/home-page/home-page.module.ts b/src/app/home-page/home-page.module.ts index 1681abd805..7b656abd73 100644 --- a/src/app/home-page/home-page.module.ts +++ b/src/app/home-page/home-page.module.ts @@ -13,6 +13,7 @@ import { RecentItemListComponent } from './recent-item-list/recent-item-list.com import { JournalEntitiesModule } from '../entity-groups/journal-entities/journal-entities.module'; import { ResearchEntitiesModule } from '../entity-groups/research-entities/research-entities.module'; import { ThemedTopLevelCommunityListComponent } from './top-level-community-list/themed-top-level-community-list.component'; +import { NotificationsModule } from '../notifications/notifications.module'; const DECLARATIONS = [ HomePageComponent, @@ -31,7 +32,8 @@ const DECLARATIONS = [ JournalEntitiesModule.withEntryComponents(), ResearchEntitiesModule.withEntryComponents(), HomePageRoutingModule, - StatisticsModule.forRoot() + StatisticsModule.forRoot(), + NotificationsModule ], declarations: [ ...DECLARATIONS, diff --git a/src/app/item-page/field-components/metadata-values/metadata-values.component.html b/src/app/item-page/field-components/metadata-values/metadata-values.component.html index a598815d4c..857e82e3fc 100644 --- a/src/app/item-page/field-components/metadata-values/metadata-values.component.html +++ b/src/app/item-page/field-components/metadata-values/metadata-values.component.html @@ -3,8 +3,8 @@ - + @@ -23,6 +23,17 @@ + + + + + {{value}} diff --git a/src/app/item-page/field-components/metadata-values/metadata-values.component.ts b/src/app/item-page/field-components/metadata-values/metadata-values.component.ts index cbbae9006d..29c20c6e2a 100644 --- a/src/app/item-page/field-components/metadata-values/metadata-values.component.ts +++ b/src/app/item-page/field-components/metadata-values/metadata-values.component.ts @@ -4,6 +4,7 @@ import { APP_CONFIG, AppConfig } from '../../../../config/app-config.interface'; import { BrowseDefinition } from '../../../core/shared/browse-definition.model'; import { hasValue } from '../../../shared/empty.util'; import { VALUE_LIST_BROWSE_DEFINITION } from '../../../core/shared/value-list-browse-definition.resource-type'; +import { ImageField } from '../../simple/field-components/specific-field/item-page-field.component'; /** * This component renders the configured 'values' into the ds-metadata-field-wrapper component. @@ -55,6 +56,11 @@ export class MetadataValuesComponent implements OnChanges { @Input() browseDefinition?: BrowseDefinition; + /** + * Optional {@code ImageField} reference that represents an image to be displayed inline. + */ + @Input() img?: ImageField; + ngOnChanges(changes: SimpleChanges): void { this.renderMarkdown = !!this.appConfig.markdown.enabled && this.enableMarkdown; } diff --git a/src/app/item-page/item-shared.module.ts b/src/app/item-page/item-shared.module.ts index 9a7477e6ae..49f575cbb3 100644 --- a/src/app/item-page/item-shared.module.ts +++ b/src/app/item-page/item-shared.module.ts @@ -1,17 +1,31 @@ -import { RelatedEntitiesSearchComponent } from './simple/related-entities/related-entities-search/related-entities-search.component'; +import { + RelatedEntitiesSearchComponent +} from './simple/related-entities/related-entities-search/related-entities-search.component'; import { NgModule } from '@angular/core'; -import { CommonModule } from '@angular/common'; +import { CommonModule, NgOptimizedImage } from '@angular/common'; import { SearchModule } from '../shared/search/search.module'; import { SharedModule } from '../shared/shared.module'; import { TranslateModule } from '@ngx-translate/core'; import { DYNAMIC_FORM_CONTROL_MAP_FN } from '@ng-dynamic-forms/core'; -import { dsDynamicFormControlMapFn } from '../shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component'; -import { TabbedRelatedEntitiesSearchComponent } from './simple/related-entities/tabbed-related-entities-search/tabbed-related-entities-search.component'; -import { ItemVersionsDeleteModalComponent } from './versions/item-versions-delete-modal/item-versions-delete-modal.component'; -import { ItemVersionsSummaryModalComponent } from './versions/item-versions-summary-modal/item-versions-summary-modal.component'; +import { + dsDynamicFormControlMapFn +} from '../shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component'; +import { + TabbedRelatedEntitiesSearchComponent +} from './simple/related-entities/tabbed-related-entities-search/tabbed-related-entities-search.component'; +import { + ItemVersionsDeleteModalComponent +} from './versions/item-versions-delete-modal/item-versions-delete-modal.component'; +import { + ItemVersionsSummaryModalComponent +} from './versions/item-versions-summary-modal/item-versions-summary-modal.component'; import { MetadataValuesComponent } from './field-components/metadata-values/metadata-values.component'; -import { GenericItemPageFieldComponent } from './simple/field-components/specific-field/generic/generic-item-page-field.component'; -import { MetadataRepresentationListComponent } from './simple/metadata-representation-list/metadata-representation-list.component'; +import { + GenericItemPageFieldComponent +} from './simple/field-components/specific-field/generic/generic-item-page-field.component'; +import { + MetadataRepresentationListComponent +} from './simple/metadata-representation-list/metadata-representation-list.component'; import { RelatedItemsComponent } from './simple/related-items/related-items-component'; import { ThemedMetadataRepresentationListComponent @@ -19,6 +33,7 @@ import { import { ItemWithdrawnReinstateModalComponent } from '../shared/correction-suggestion/withdrawn-reinstate-modal.component'; +import { ItemPageImgFieldComponent } from './simple/field-components/specific-field/img/item-page-img-field.component'; const ENTRY_COMPONENTS = [ ItemVersionsDeleteModalComponent, @@ -36,6 +51,7 @@ const COMPONENTS = [ MetadataRepresentationListComponent, ThemedMetadataRepresentationListComponent, RelatedItemsComponent, + ItemPageImgFieldComponent, ]; @NgModule({ @@ -46,7 +62,8 @@ const COMPONENTS = [ CommonModule, SearchModule, SharedModule, - TranslateModule + TranslateModule, + NgOptimizedImage ], exports: [ ...COMPONENTS diff --git a/src/app/item-page/simple/field-components/file-section/file-section.component.html b/src/app/item-page/simple/field-components/file-section/file-section.component.html index cd708510e8..eee29603a5 100644 --- a/src/app/item-page/simple/field-components/file-section/file-section.component.html +++ b/src/app/item-page/simple/field-components/file-section/file-section.component.html @@ -2,7 +2,10 @@
- {{ dsoNameService.getName(file) }} + + {{ 'item.page.bitstreams.primary' | translate }} + {{ dsoNameService.getName(file) }} + ({{(file?.sizeBytes) | dsFileSize }}) diff --git a/src/app/item-page/simple/field-components/file-section/file-section.component.spec.ts b/src/app/item-page/simple/field-components/file-section/file-section.component.spec.ts index 8acf405b55..4a825e50c9 100644 --- a/src/app/item-page/simple/field-components/file-section/file-section.component.spec.ts +++ b/src/app/item-page/simple/field-components/file-section/file-section.component.spec.ts @@ -25,7 +25,8 @@ describe('FileSectionComponent', () => { let fixture: ComponentFixture; const bitstreamDataService = jasmine.createSpyObj('bitstreamDataService', { - findAllByItemAndBundleName: createSuccessfulRemoteDataObject$(createPaginatedList([])) + findAllByItemAndBundleName: createSuccessfulRemoteDataObject$(createPaginatedList([])), + findPrimaryBitstreamByItemAndName: observableOf(null) }); const mockBitstream: Bitstream = Object.assign(new Bitstream(), @@ -81,6 +82,20 @@ describe('FileSectionComponent', () => { fixture.detectChanges(); })); + it('should set the id of primary bitstream', () => { + comp.primaryBitsreamId = undefined; + bitstreamDataService.findPrimaryBitstreamByItemAndName.and.returnValue(observableOf(mockBitstream)); + comp.ngOnInit(); + expect(comp.primaryBitsreamId).toBe(mockBitstream.id); + }); + + it('should not set the id of primary bitstream', () => { + comp.primaryBitsreamId = undefined; + bitstreamDataService.findPrimaryBitstreamByItemAndName.and.returnValue(observableOf(null)); + comp.ngOnInit(); + expect(comp.primaryBitsreamId).toBeUndefined(); + }); + describe('when the bitstreams are loading', () => { beforeEach(() => { comp.bitstreams$.next([mockBitstream]); diff --git a/src/app/item-page/simple/field-components/file-section/file-section.component.ts b/src/app/item-page/simple/field-components/file-section/file-section.component.ts index 3c41731c5f..76f33de906 100644 --- a/src/app/item-page/simple/field-components/file-section/file-section.component.ts +++ b/src/app/item-page/simple/field-components/file-section/file-section.component.ts @@ -39,6 +39,8 @@ export class FileSectionComponent implements OnInit { pageSize: number; + primaryBitsreamId: string; + constructor( protected bitstreamDataService: BitstreamDataService, protected notificationsService: NotificationsService, @@ -50,9 +52,19 @@ export class FileSectionComponent implements OnInit { } ngOnInit(): void { + this.getPrimaryBitstreamId(); this.getNextPage(); } + private getPrimaryBitstreamId() { + this.bitstreamDataService.findPrimaryBitstreamByItemAndName(this.item, 'ORIGINAL', true, true).subscribe((primaryBitstream: Bitstream | null) => { + if (!primaryBitstream) { + return; + } + this.primaryBitsreamId = primaryBitstream?.id; + }); + } + /** * This method will retrieve the next page of Bitstreams from the external BitstreamDataService call. * It'll retrieve the currentPage from the class variables and it'll add the next page of bitstreams with the diff --git a/src/app/item-page/simple/field-components/specific-field/img/item-page-img-field.component.spec.ts b/src/app/item-page/simple/field-components/specific-field/img/item-page-img-field.component.spec.ts new file mode 100644 index 0000000000..b96daa47ad --- /dev/null +++ b/src/app/item-page/simple/field-components/specific-field/img/item-page-img-field.component.spec.ts @@ -0,0 +1,85 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ItemPageImgFieldComponent } from './item-page-img-field.component'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { TranslateLoaderMock } from '../../../../../shared/testing/translate-loader.mock'; +import { APP_CONFIG } from '../../../../../../config/app-config.interface'; +import { environment } from '../../../../../../environments/environment'; +import { BrowseDefinitionDataService } from '../../../../../core/browse/browse-definition-data.service'; +import { BrowseDefinitionDataServiceStub } from '../../../../../shared/testing/browse-definition-data-service.stub'; +import { GenericItemPageFieldComponent } from '../generic/generic-item-page-field.component'; +import { MetadataValuesComponent } from '../../../../field-components/metadata-values/metadata-values.component'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { mockItemWithMetadataFieldsAndValue } from '../item-page-field.component.spec'; +import { By } from '@angular/platform-browser'; +import { ImageField } from '../item-page-field.component'; + +let component: ItemPageImgFieldComponent; +let fixture: ComponentFixture; + +const mockField = 'organization.identifier.ror'; +const mockValue = 'http://ror.org/awesome-identifier'; +const mockLabel = 'ROR label'; +const mockUrlRegex = '(.*)ror.org'; +const mockImg = { + URI: './assets/images/ror-icon.svg', + alt: 'item.page.image.alt.ROR', + heightVar: '--ds-item-page-img-field-ror-inline-height' +} as ImageField; + +describe('ItemPageImgFieldComponent', () => { + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + })], + providers: [ + { provide: APP_CONFIG, useValue: environment }, + { provide: BrowseDefinitionDataService, useValue: BrowseDefinitionDataServiceStub } + ], + declarations: [ItemPageImgFieldComponent, GenericItemPageFieldComponent, MetadataValuesComponent], + schemas: [NO_ERRORS_SCHEMA] + }) + .overrideComponent(GenericItemPageFieldComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }) + .compileComponents(); + + fixture = TestBed.createComponent(ItemPageImgFieldComponent); + component = fixture.componentInstance; + component.item = mockItemWithMetadataFieldsAndValue([mockField], mockValue); + component.fields = [mockField]; + component.label = mockLabel; + component.urlRegex = mockUrlRegex; + component.img = mockImg; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should display display img tag', () => { + const image = fixture.debugElement.query(By.css('img.link-logo')); + expect(image).not.toBeNull(); + }); + + it('should have right attributes', () => { + const image = fixture.debugElement.query(By.css('img.link-logo')); + expect(image.attributes.src).toEqual(mockImg.URI); + expect(image.attributes.alt).toEqual(mockImg.alt); + + const imageEl = image.nativeElement; + expect(imageEl.style.height).toContain(mockImg.heightVar); + }); + + it('should have the right value', () => { + const imageAnchor = fixture.debugElement.query(By.css('a.link-anchor')); + const anchorEl = imageAnchor.nativeElement; + expect(anchorEl.innerHTML).toContain(mockValue); + }); +}); diff --git a/src/app/item-page/simple/field-components/specific-field/img/item-page-img-field.component.ts b/src/app/item-page/simple/field-components/specific-field/img/item-page-img-field.component.ts new file mode 100644 index 0000000000..d442323b53 --- /dev/null +++ b/src/app/item-page/simple/field-components/specific-field/img/item-page-img-field.component.ts @@ -0,0 +1,46 @@ +import { Component, Input } from '@angular/core'; +import { ImageField, ItemPageFieldComponent } from '../item-page-field.component'; +import { Item } from '../../../../../core/shared/item.model'; + +@Component({ + selector: 'ds-item-page-img-field', + templateUrl: '../item-page-field.component.html' +}) +/** + * Component that renders an inline image for a given field. + * This component uses a given {@code ImageField} configuration to correctly render the img. + */ +export class ItemPageImgFieldComponent extends ItemPageFieldComponent { + + /** + * The item to display metadata for + */ + @Input() item: Item; + + /** + * Separator string between multiple values of the metadata fields defined + * @type {string} + */ + @Input() separator: string; + + /** + * Fields (schema.element.qualifier) used to render their values. + */ + @Input() fields: string[]; + + /** + * Label i18n key for the rendered metadata + */ + @Input() label: string; + + /** + * Image Configuration + */ + @Input() img: ImageField; + + /** + * Whether any valid HTTP(S) URL should be rendered as a link + */ + @Input() urlRegex?: string; + +} diff --git a/src/app/item-page/simple/field-components/specific-field/item-page-field.component.html b/src/app/item-page/simple/field-components/specific-field/item-page-field.component.html index 91d40b0ad7..f45d4657a4 100644 --- a/src/app/item-page/simple/field-components/specific-field/item-page-field.component.html +++ b/src/app/item-page/simple/field-components/specific-field/item-page-field.component.html @@ -6,5 +6,6 @@ [enableMarkdown]="enableMarkdown" [urlRegex]="urlRegex" [browseDefinition]="browseDefinition|async" + [img]="img" >
diff --git a/src/app/item-page/simple/field-components/specific-field/item-page-field.component.ts b/src/app/item-page/simple/field-components/specific-field/item-page-field.component.ts index fc526dabcc..57f49e3647 100644 --- a/src/app/item-page/simple/field-components/specific-field/item-page-field.component.ts +++ b/src/app/item-page/simple/field-components/specific-field/item-page-field.component.ts @@ -6,6 +6,25 @@ import { BrowseDefinition } from '../../../../core/shared/browse-definition.mode import { BrowseDefinitionDataService } from '../../../../core/browse/browse-definition-data.service'; import { getRemoteDataPayload } from '../../../../core/shared/operators'; +/** + * Interface that encapsulate Image configuration for this component. + */ +export interface ImageField { + /** + * URI that is used to retrieve the image. + */ + URI: string; + /** + * i18n Key that represents the alt text to display + */ + alt: string; + /** + * CSS variable that contains the height of the inline image. + */ + heightVar: string; +} + + /** * This component can be used to represent metadata on a simple item page. * It expects one input parameter of type Item to which the metadata belongs. @@ -51,6 +70,11 @@ export class ItemPageFieldComponent { */ urlRegex?: string; + /** + * Image Configuration + */ + img: ImageField; + /** * Return browse definition that matches any field used in this component if it is configured as a browse * link in dspace.cfg (webui.browse.link.) diff --git a/src/app/menu.resolver.ts b/src/app/menu.resolver.ts index 318667267b..94b56ba99d 100644 --- a/src/app/menu.resolver.ts +++ b/src/app/menu.resolver.ts @@ -47,6 +47,7 @@ import { import { ExportBatchSelectorComponent } from './shared/dso-selector/modal-wrappers/export-batch-selector/export-batch-selector.component'; +import { PUBLICATION_CLAIMS_PATH } from './admin/admin-notifications/admin-notifications-routing-paths'; /** * Creates all of the app's menus @@ -559,6 +560,17 @@ export class MenuResolver implements Resolve { link: '/notifications/quality-assurance' } as LinkMenuItemModel, }, + { + id: 'notifications_publication-claim', + parentID: 'notifications', + active: false, + visible: authorized, + model: { + type: MenuItemType.LINK, + text: 'menu.section.notifications_publication-claim', + link: '/admin/notifications/' + PUBLICATION_CLAIMS_PATH + } as LinkMenuItemModel, + }, /* Admin Search */ { id: 'admin_search', diff --git a/src/app/my-dspace-page/my-dspace-page.component.html b/src/app/my-dspace-page/my-dspace-page.component.html index ed3c58a286..70bcf1b7bc 100644 --- a/src/app/my-dspace-page/my-dspace-page.component.html +++ b/src/app/my-dspace-page/my-dspace-page.component.html @@ -1,6 +1,7 @@
+
= { qaTopic: qualityAssuranceTopicsReducer, - qaSource: qualityAssuranceSourceReducer + qaSource: qualityAssuranceSourceReducer, + suggestionTarget: SuggestionTargetsReducer }; export const suggestionNotificationsSelector = createFeatureSelector('suggestionNotifications'); diff --git a/src/app/profile-page/profile-page.component.html b/src/app/profile-page/profile-page.component.html index 331c360054..b5703c47a3 100644 --- a/src/app/profile-page/profile-page.component.html +++ b/src/app/profile-page/profile-page.component.html @@ -8,6 +8,7 @@
+
diff --git a/src/app/profile-page/profile-page.module.ts b/src/app/profile-page/profile-page.module.ts index 0e2902de33..126cf7bd8a 100644 --- a/src/app/profile-page/profile-page.module.ts +++ b/src/app/profile-page/profile-page.module.ts @@ -12,7 +12,7 @@ import { ThemedProfilePageComponent } from './themed-profile-page.component'; import { FormModule } from '../shared/form/form.module'; import { UiSwitchModule } from 'ngx-ui-switch'; import { ProfileClaimItemModalComponent } from './profile-claim-item-modal/profile-claim-item-modal.component'; - +import { NotificationsModule } from '../notifications/notifications.module'; @NgModule({ imports: [ @@ -20,7 +20,8 @@ import { ProfileClaimItemModalComponent } from './profile-claim-item-modal/profi CommonModule, SharedModule, FormModule, - UiSwitchModule + UiSwitchModule, + NotificationsModule ], exports: [ ProfilePageComponent, diff --git a/src/app/shared/browse-by/browse-by.component.html b/src/app/shared/browse-by/browse-by.component.html index 37799e805f..7d7e726659 100644 --- a/src/app/shared/browse-by/browse-by.component.html +++ b/src/app/shared/browse-by/browse-by.component.html @@ -1,6 +1,7 @@ -

{{title | translate}}

- +

{{title | translate}}

+ +
diff --git a/src/app/shared/browse-by/browse-by.component.ts b/src/app/shared/browse-by/browse-by.component.ts index 0dc20033f8..465d1e6ceb 100644 --- a/src/app/shared/browse-by/browse-by.component.ts +++ b/src/app/shared/browse-by/browse-by.component.ts @@ -1,4 +1,4 @@ -import { Component, EventEmitter, Injector, Input, OnDestroy, OnInit, Output } from '@angular/core'; +import { Component, EventEmitter, Injector, Input, OnDestroy, OnInit, Output, OnChanges } from '@angular/core'; import { RemoteData } from '../../core/data/remote-data'; import { PaginatedList } from '../../core/data/paginated-list.model'; import { PaginationComponentOptions } from '../pagination/pagination-component-options.model'; @@ -6,7 +6,7 @@ import { SortDirection, SortOptions } from '../../core/cache/models/sort-options import { fadeIn, fadeInOut } from '../animations/fade'; import { BehaviorSubject, combineLatest as observableCombineLatest, Observable, Subscription } from 'rxjs'; import { ListableObject } from '../object-collection/shared/listable-object.model'; -import { getStartsWithComponent, StartsWithType } from '../starts-with/starts-with-decorator'; +import { StartsWithType } from '../starts-with/starts-with-decorator'; import { PaginationService } from '../../core/pagination/pagination.service'; import { ViewMode } from '../../core/shared/view-mode.model'; import { RouteService } from '../../core/services/route.service'; @@ -26,7 +26,7 @@ import { TranslateService } from '@ngx-translate/core'; /** * Component to display a browse-by page for any ListableObject */ -export class BrowseByComponent implements OnInit, OnDestroy { +export class BrowseByComponent implements OnInit, OnChanges, OnDestroy { /** * ViewMode that should be passed to {@link ListableObjectComponentLoaderComponent}. @@ -39,9 +39,10 @@ export class BrowseByComponent implements OnInit, OnDestroy { @Input() title: string; /** - * The parent name + * Whether the title should be displayed */ - @Input() parentname: string; + @Input() displayTitle = true; + /** * The list of objects to display */ @@ -66,7 +67,7 @@ export class BrowseByComponent implements OnInit, OnDestroy { /** * The list of options to render for the StartsWith component */ - @Input() startsWithOptions = []; + @Input() startsWithOptions: (string | number)[] = []; /** * Whether or not the pagination should be rendered as simple previous and next buttons instead of the normal pagination @@ -98,16 +99,6 @@ export class BrowseByComponent implements OnInit, OnDestroy { */ @Output() sortDirectionChange = new EventEmitter(); - /** - * An object injector used to inject the startsWithOptions to the switchable StartsWith component - */ - objectInjector: Injector; - - /** - * Declare SortDirection enumeration to use it in the template - */ - public sortDirections = SortDirection; - /** * Observable that tracks if the back button should be displayed based on the path parameters */ @@ -141,7 +132,7 @@ export class BrowseByComponent implements OnInit, OnDestroy { */ back = () => { const page = +this.previousPage$.value > 1 ? +this.previousPage$.value : 1; - this.paginationService.updateRoute(this.paginationConfig.id, {page: page}, {[this.paginationConfig.id + '.return']: null, value: null, startsWith: null}); + this.paginationService.updateRoute(this.paginationConfig.id, { page: page }, { [this.paginationConfig.id + '.return']: null, value: null, startsWith: null }); }; /** @@ -158,44 +149,19 @@ export class BrowseByComponent implements OnInit, OnDestroy { this.next.emit(true); } - /** - * Change the page size - * @param size - */ - doPageSizeChange(size) { - this.paginationService.updateRoute(this.paginationConfig.id,{pageSize: size}); - } - - /** - * Change the sort direction - * @param direction - */ - doSortDirectionChange(direction) { - this.paginationService.updateRoute(this.paginationConfig.id,{sortDirection: direction}); - } - - /** - * Get the switchable StartsWith component dependant on the type - */ - getStartsWithComponent() { - return getStartsWithComponent(this.type); - } - ngOnInit(): void { - this.objectInjector = Injector.create({ - providers: [ - { provide: 'startsWithOptions', useFactory: () => (this.startsWithOptions), deps:[] }, - { provide: 'paginationId', useFactory: () => (this.paginationConfig?.id), deps:[] } - ], - parent: this.injector - }); - const startsWith$ = this.routeService.getQueryParameterValue('startsWith'); const value$ = this.routeService.getQueryParameterValue('value'); this.shouldDisplayResetButton$ = observableCombineLatest([startsWith$, value$]).pipe( map(([startsWith, value]) => hasValue(startsWith) || hasValue(value)) ); + } + + ngOnChanges(): void { + if (this.sub) { + this.sub.unsubscribe(); + } this.sub = this.routeService.getQueryParameterValue(this.paginationConfig.id + '.return').subscribe(this.previousPage$); } diff --git a/src/app/shared/browse-by/themed-browse-by.component.ts b/src/app/shared/browse-by/themed-browse-by.component.ts index eaa17ebf16..780a860004 100644 --- a/src/app/shared/browse-by/themed-browse-by.component.ts +++ b/src/app/shared/browse-by/themed-browse-by.component.ts @@ -21,7 +21,7 @@ export class ThemedBrowseByComponent extends ThemedComponent @Input() title: string; - @Input() parentname: string; + @Input() displayTitle: boolean; @Input() objects$: Observable>>; @@ -31,7 +31,7 @@ export class ThemedBrowseByComponent extends ThemedComponent @Input() type: StartsWithType; - @Input() startsWithOptions: number[]; + @Input() startsWithOptions: (string | number)[]; @Input() showPaginator: boolean; @@ -47,7 +47,7 @@ export class ThemedBrowseByComponent extends ThemedComponent protected inAndOutputNames: (keyof BrowseByComponent & keyof this)[] = [ 'title', - 'parentname', + 'displayTitle', 'objects$', 'paginationConfig', 'sortConfig', diff --git a/src/app/shared/comcol/comcol-page-browse-by/comcol-page-browse-by.component.html b/src/app/shared/comcol/comcol-page-browse-by/comcol-page-browse-by.component.html index 504d9f4bcd..b0d72f4a72 100644 --- a/src/app/shared/comcol/comcol-page-browse-by/comcol-page-browse-by.component.html +++ b/src/app/shared/comcol/comcol-page-browse-by/comcol-page-browse-by.component.html @@ -1,13 +1,17 @@ -

{{'browse.comcol.head' | translate}}

-