diff --git a/config/config.example.yml b/config/config.example.yml index 69a9ffd320..4fbc98fea2 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. @@ -136,7 +136,7 @@ submission: # NOTE: example of configuration # # NOTE: metadata name # - name: dc.author - # # NOTE: fontawesome (v5.x) icon classes and bootstrap utility classes can be used + # # NOTE: fontawesome (v6.x) icon classes and bootstrap utility classes can be used # style: fas fa-user - name: dc.author style: fas fa-user @@ -147,18 +147,40 @@ submission: confidence: # NOTE: example of configuration # # NOTE: confidence value - # - name: dc.author - # # NOTE: fontawesome (v5.x) icon classes and bootstrap utility classes can be used - # style: fa-user + # - value: 600 + # # NOTE: fontawesome (v6.x) icon classes and bootstrap utility classes can be used + # style: text-success + # icon: fa-circle-check + # # NOTE: the class configured in property style is used by default, the icon property could be used in component + # configured to use a 'icon mode' display (mainly in edit-item page) - value: 600 style: text-success + icon: fa-circle-check - value: 500 style: text-info + icon: fa-gear - value: 400 style: text-warning + icon: fa-circle-question + - value: 300 + style: text-muted + icon: fa-thumbs-down + - value: 200 + style: text-muted + icon: fa-circle-exclamation + - value: 100 + style: text-muted + icon: fa-circle-stop + - value: 0 + style: text-muted + icon: fa-ban + - value: -1 + style: text-muted + icon: fa-circle-xmark # default configuration - value: default style: text-muted + icon: fa-circle-xmark # Default Language in which the UI will be rendered if the user's browser language is not an active language defaultLanguage: en @@ -272,6 +294,8 @@ homePage: # No. of communities to list per page on the home page # This will always round to the nearest number from the list of page sizes. e.g. if you set it to 7 it'll use 10 pageSize: 5 + # Enable or disable the Discover filters on the homepage + showDiscoverFilters: false # Item Config item: @@ -285,8 +309,17 @@ item: # settings menu. See pageSizeOptions in 'pagination-component-options.model.ts'. pageSize: 5 +# Community Page Config +community: + # Search tab config + searchSection: + showSidebar: true + # Collection Page Config collection: + # Search tab config + searchSection: + showSidebar: true edit: undoTimeout: 10000 # 10 seconds @@ -382,7 +415,12 @@ 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/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/collection-page/sections/recently-added/collection-recently-added.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/collection-page/sections/recently-added/collection-recently-added.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 2820a9a2c7..f92a96d242 100644 --- a/src/app/admin/admin-notifications/admin-notifications-routing-paths.ts +++ b/src/app/admin/admin-notifications/admin-notifications-routing-paths.ts @@ -2,6 +2,7 @@ import { URLCombiner } from '../../core/url-combiner/url-combiner'; import { getNotificationsModuleRoute } from '../admin-routing-paths'; export const QUALITY_ASSURANCE_EDIT_PATH = 'quality-assurance'; +export const PUBLICATION_CLAIMS_PATH = 'publication-claim'; export function getQualityAssuranceRoute(id: string) { return new URLCombiner(getNotificationsModuleRoute(), QUALITY_ASSURANCE_EDIT_PATH, id).toString(); 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 63d555d7b7..07a98aa080 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'; @@ -20,6 +23,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`, @@ -71,7 +89,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 1a0dc423b9..a14000aef4 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -38,6 +38,7 @@ 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 { ForgotPasswordCheckGuard } from './core/rest-property/forgot-password-check-guard.guard'; @@ -206,6 +207,11 @@ import { ForgotPasswordCheckGuard } from './core/rest-property/forgot-password-c .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/collection-page/collection-page-routing.module.ts b/src/app/collection-page/collection-page-routing.module.ts index 5ddef6ca68..ed0c77c2f8 100644 --- a/src/app/collection-page/collection-page-routing.module.ts +++ b/src/app/collection-page/collection-page-routing.module.ts @@ -25,7 +25,7 @@ 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'; +import { ComcolSearchSectionComponent } from '../shared/comcol/sections/comcol-search-section/comcol-search-section.component'; @NgModule({ imports: [ @@ -73,7 +73,7 @@ import { CollectionRecentlyAddedComponent } from './sections/recently-added/coll { path: '', pathMatch: 'full', - component: CollectionRecentlyAddedComponent, + component: ComcolSearchSectionComponent, }, { path: 'browse/:id', diff --git a/src/app/collection-page/collection-page.component.html b/src/app/collection-page/collection-page.component.html index 21cc94af68..d5da37c12f 100644 --- a/src/app/collection-page/collection-page.component.html +++ b/src/app/collection-page/collection-page.component.html @@ -58,4 +58,4 @@ - + \ No newline at end of file diff --git a/src/app/collection-page/collection-page.module.ts b/src/app/collection-page/collection-page.module.ts index 8782be0a45..5db85d48e9 100644 --- a/src/app/collection-page/collection-page.module.ts +++ b/src/app/collection-page/collection-page.module.ts @@ -19,7 +19,6 @@ 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, @@ -29,7 +28,6 @@ const DECLARATIONS = [ EditItemTemplatePageComponent, ThemedEditItemTemplatePageComponent, CollectionItemMapperComponent, - CollectionRecentlyAddedComponent, ]; @NgModule({ diff --git a/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.spec.ts b/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.spec.ts index 3eb83ebe8a..f717943e8e 100644 --- a/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.spec.ts +++ b/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.spec.ts @@ -88,7 +88,7 @@ describe('CollectionSourceControlsComponent', () => { invoke: createSuccessfulRemoteDataObject$(process), }); processDataService = jasmine.createSpyObj('processDataService', { - findById: createSuccessfulRemoteDataObject$(process), + autoRefreshUntilCompletion: createSuccessfulRemoteDataObject$(process), }); bitstreamService = jasmine.createSpyObj('bitstreamService', { findByHref: createSuccessfulRemoteDataObject$(bitstream), @@ -137,7 +137,7 @@ describe('CollectionSourceControlsComponent', () => { {name: '-i', value: new ContentSourceSetSerializer().Serialize(contentSource.oaiSetId)}, ], []); - expect(processDataService.findById).toHaveBeenCalledWith(process.processId, false); + expect(processDataService.autoRefreshUntilCompletion).toHaveBeenCalledWith(process.processId); expect(bitstreamService.findByHref).toHaveBeenCalledWith(process._links.output.href); expect(notificationsService.info).toHaveBeenCalledWith(jasmine.anything() as any, 'Script text'); }); @@ -151,7 +151,7 @@ describe('CollectionSourceControlsComponent', () => { {name: '-r', value: null}, {name: '-c', value: collection.uuid}, ], []); - expect(processDataService.findById).toHaveBeenCalledWith(process.processId, false); + expect(processDataService.autoRefreshUntilCompletion).toHaveBeenCalledWith(process.processId); expect(notificationsService.success).toHaveBeenCalled(); }); }); @@ -164,7 +164,7 @@ describe('CollectionSourceControlsComponent', () => { {name: '-o', value: null}, {name: '-c', value: collection.uuid}, ], []); - expect(processDataService.findById).toHaveBeenCalledWith(process.processId, false); + expect(processDataService.autoRefreshUntilCompletion).toHaveBeenCalledWith(process.processId); expect(notificationsService.success).toHaveBeenCalled(); }); }); diff --git a/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.ts b/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.ts index 7113c25e9f..1e6e3dd511 100644 --- a/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.ts +++ b/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.ts @@ -1,15 +1,14 @@ -import { Component, Input, OnDestroy } from '@angular/core'; +import { Component, Input, OnDestroy, OnInit } from '@angular/core'; import { ScriptDataService } from '../../../../core/data/processes/script-data.service'; import { ContentSource } from '../../../../core/shared/content-source.model'; import { ProcessDataService } from '../../../../core/data/processes/process-data.service'; import { - getAllCompletedRemoteData, getAllSucceededRemoteDataPayload, getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload } from '../../../../core/shared/operators'; import { filter, map, switchMap, tap } from 'rxjs/operators'; -import { hasValue, hasValueOperator } from '../../../../shared/empty.util'; +import { hasValue } from '../../../../shared/empty.util'; import { ProcessStatus } from '../../../../process-page/processes/process-status.model'; import { BehaviorSubject, Observable, Subscription } from 'rxjs'; import { RequestService } from '../../../../core/data/request.service'; @@ -30,7 +29,7 @@ import { ContentSourceSetSerializer } from '../../../../core/shared/content-sour styleUrls: ['./collection-source-controls.component.scss'], templateUrl: './collection-source-controls.component.html', }) -export class CollectionSourceControlsComponent implements OnDestroy { +export class CollectionSourceControlsComponent implements OnInit, OnDestroy { /** * Should the controls be enabled. @@ -49,6 +48,7 @@ export class CollectionSourceControlsComponent implements OnDestroy { contentSource$: Observable; private subs: Subscription[] = []; + private autoRefreshIDs: string[] = []; testConfigRunning$ = new BehaviorSubject(false); importRunning$ = new BehaviorSubject(false); @@ -95,36 +95,28 @@ export class CollectionSourceControlsComponent implements OnDestroy { }), // filter out responses that aren't successful since the pinging of the process only needs to happen when the invocation was successful. filter((rd) => rd.hasSucceeded && hasValue(rd.payload)), - switchMap((rd) => this.processDataService.findById(rd.payload.processId, false)), - getAllCompletedRemoteData(), - filter((rd) => !rd.isStale && (rd.hasSucceeded || rd.hasFailed)), - map((rd) => rd.payload), - hasValueOperator(), + switchMap((rd) => { + this.autoRefreshIDs.push(rd.payload.processId); + return this.processDataService.autoRefreshUntilCompletion(rd.payload.processId); + }), + map((rd) => rd.payload) ).subscribe((process: Process) => { - if (process.processStatus.toString() !== ProcessStatus[ProcessStatus.COMPLETED].toString() && - process.processStatus.toString() !== ProcessStatus[ProcessStatus.FAILED].toString()) { - // Ping the current process state every 5s - setTimeout(() => { - this.requestService.setStaleByHrefSubstring(process._links.self.href); - }, 5000); - } - if (process.processStatus.toString() === ProcessStatus[ProcessStatus.FAILED].toString()) { - this.notificationsService.error(this.translateService.get('collection.source.controls.test.failed')); - this.testConfigRunning$.next(false); - } - if (process.processStatus.toString() === ProcessStatus[ProcessStatus.COMPLETED].toString()) { - this.bitstreamService.findByHref(process._links.output.href).pipe(getFirstSucceededRemoteDataPayload()).subscribe((bitstream) => { - this.httpClient.get(bitstream._links.content.href, {responseType: 'text'}).subscribe((data: any) => { - const output = data.replaceAll(new RegExp('.*\\@(.*)', 'g'), '$1') - .replaceAll('The script has started', '') - .replaceAll('The script has completed', ''); - this.notificationsService.info(this.translateService.get('collection.source.controls.test.completed'), output); - }); - }); - this.testConfigRunning$.next(false); - } + if (process.processStatus.toString() === ProcessStatus[ProcessStatus.FAILED].toString()) { + this.notificationsService.error(this.translateService.get('collection.source.controls.test.failed')); + this.testConfigRunning$.next(false); } - )); + if (process.processStatus.toString() === ProcessStatus[ProcessStatus.COMPLETED].toString()) { + this.bitstreamService.findByHref(process._links.output.href).pipe(getFirstSucceededRemoteDataPayload()).subscribe((bitstream) => { + this.httpClient.get(bitstream._links.content.href, {responseType: 'text'}).subscribe((data: any) => { + const output = data.replaceAll(new RegExp('.*\\@(.*)', 'g'), '$1') + .replaceAll('The script has started', '') + .replaceAll('The script has completed', ''); + this.notificationsService.info(this.translateService.get('collection.source.controls.test.completed'), output); + }); + }); + this.testConfigRunning$.next(false); + } + })); } /** @@ -147,31 +139,22 @@ export class CollectionSourceControlsComponent implements OnDestroy { } }), filter((rd) => rd.hasSucceeded && hasValue(rd.payload)), - switchMap((rd) => this.processDataService.findById(rd.payload.processId, false)), - getAllCompletedRemoteData(), - filter((rd) => !rd.isStale && (rd.hasSucceeded || rd.hasFailed)), - map((rd) => rd.payload), - hasValueOperator(), + switchMap((rd) => { + this.autoRefreshIDs.push(rd.payload.processId); + return this.processDataService.autoRefreshUntilCompletion(rd.payload.processId); + }), + map((rd) => rd.payload) ).subscribe((process) => { - if (process.processStatus.toString() !== ProcessStatus[ProcessStatus.COMPLETED].toString() && - process.processStatus.toString() !== ProcessStatus[ProcessStatus.FAILED].toString()) { - // Ping the current process state every 5s - setTimeout(() => { - this.requestService.setStaleByHrefSubstring(process._links.self.href); - this.requestService.setStaleByHrefSubstring(this.collection._links.self.href); - }, 5000); - } - if (process.processStatus.toString() === ProcessStatus[ProcessStatus.FAILED].toString()) { - this.notificationsService.error(this.translateService.get('collection.source.controls.import.failed')); - this.importRunning$.next(false); - } - if (process.processStatus.toString() === ProcessStatus[ProcessStatus.COMPLETED].toString()) { - this.notificationsService.success(this.translateService.get('collection.source.controls.import.completed')); - this.requestService.setStaleByHrefSubstring(this.collection._links.self.href); - this.importRunning$.next(false); - } + if (process.processStatus.toString() === ProcessStatus[ProcessStatus.FAILED].toString()) { + this.notificationsService.error(this.translateService.get('collection.source.controls.import.failed')); + this.importRunning$.next(false); } - )); + if (process.processStatus.toString() === ProcessStatus[ProcessStatus.COMPLETED].toString()) { + this.notificationsService.success(this.translateService.get('collection.source.controls.import.completed')); + this.requestService.setStaleByHrefSubstring(this.collection._links.self.href); + this.importRunning$.next(false); + } + })); } /** @@ -194,31 +177,22 @@ export class CollectionSourceControlsComponent implements OnDestroy { } }), filter((rd) => rd.hasSucceeded && hasValue(rd.payload)), - switchMap((rd) => this.processDataService.findById(rd.payload.processId, false)), - getAllCompletedRemoteData(), - filter((rd) => !rd.isStale && (rd.hasSucceeded || rd.hasFailed)), - map((rd) => rd.payload), - hasValueOperator(), + switchMap((rd) => { + this.autoRefreshIDs.push(rd.payload.processId); + return this.processDataService.autoRefreshUntilCompletion(rd.payload.processId); + }), + map((rd) => rd.payload) ).subscribe((process) => { - if (process.processStatus.toString() !== ProcessStatus[ProcessStatus.COMPLETED].toString() && - process.processStatus.toString() !== ProcessStatus[ProcessStatus.FAILED].toString()) { - // Ping the current process state every 5s - setTimeout(() => { - this.requestService.setStaleByHrefSubstring(process._links.self.href); - this.requestService.setStaleByHrefSubstring(this.collection._links.self.href); - }, 5000); - } - if (process.processStatus.toString() === ProcessStatus[ProcessStatus.FAILED].toString()) { - this.notificationsService.error(this.translateService.get('collection.source.controls.reset.failed')); - this.reImportRunning$.next(false); - } - if (process.processStatus.toString() === ProcessStatus[ProcessStatus.COMPLETED].toString()) { - this.notificationsService.success(this.translateService.get('collection.source.controls.reset.completed')); - this.requestService.setStaleByHrefSubstring(this.collection._links.self.href); - this.reImportRunning$.next(false); - } + if (process.processStatus.toString() === ProcessStatus[ProcessStatus.FAILED].toString()) { + this.notificationsService.error(this.translateService.get('collection.source.controls.reset.failed')); + this.reImportRunning$.next(false); } - )); + if (process.processStatus.toString() === ProcessStatus[ProcessStatus.COMPLETED].toString()) { + this.notificationsService.success(this.translateService.get('collection.source.controls.reset.completed')); + this.requestService.setStaleByHrefSubstring(this.collection._links.self.href); + this.reImportRunning$.next(false); + } + })); } ngOnDestroy(): void { @@ -227,5 +201,9 @@ export class CollectionSourceControlsComponent implements OnDestroy { sub.unsubscribe(); } }); + + this.autoRefreshIDs.forEach((id) => { + this.processDataService.stopAutoRefreshing(id); + }); } } 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 deleted file mode 100644 index 002b8cceda..0000000000 --- a/src/app/collection-page/sections/recently-added/collection-recently-added.component.html +++ /dev/null @@ -1,18 +0,0 @@ - -
-

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

- - -
- - - -
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 deleted file mode 100644 index 4acc24e3f5..0000000000 --- a/src/app/collection-page/sections/recently-added/collection-recently-added.component.spec.ts +++ /dev/null @@ -1,53 +0,0 @@ -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 deleted file mode 100644 index 65af77a63b..0000000000 --- a/src/app/collection-page/sections/recently-added/collection-recently-added.component.ts +++ /dev/null @@ -1,82 +0,0 @@ -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 5ca544bb54..f38e670546 100644 --- a/src/app/community-page/community-page-routing.module.ts +++ b/src/app/community-page/community-page-routing.module.ts @@ -19,6 +19,8 @@ import { SubComColSectionComponent } from './sections/sub-com-col-section/sub-co 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'; +import { ComcolSearchSectionComponent } from '../shared/comcol/sections/comcol-search-section/comcol-search-section.component'; +import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver'; @NgModule({ imports: [ @@ -56,7 +58,16 @@ import { ComcolBrowseByComponent } from '../shared/comcol/sections/comcol-browse { path: '', pathMatch: 'full', + component: ComcolSearchSectionComponent, + }, + { + path: 'subcoms-cols', + pathMatch: 'full', component: SubComColSectionComponent, + resolve: { + breadcrumb: I18nBreadcrumbResolver, + }, + data: { breadcrumbKey: 'community.subcoms-cols' }, }, { path: 'browse/:id', diff --git a/src/app/community-page/community-page.component.ts b/src/app/community-page/community-page.component.ts index 206aa54cb0..148df02509 100644 --- a/src/app/community-page/community-page.component.ts +++ b/src/app/community-page/community-page.component.ts @@ -7,7 +7,7 @@ import { Bitstream } from '../core/shared/bitstream.model'; import { Community } from '../core/shared/community.model'; import { fadeInOut } from '../shared/animations/fade'; import { hasValue } from '../shared/empty.util'; -import { getAllSucceededRemoteDataPayload} from '../core/shared/operators'; +import { getAllSucceededRemoteDataPayload } from '../core/shared/operators'; import { AuthService } from '../core/auth/auth.service'; import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service'; import { FeatureID } from '../core/data/feature-authorization/feature-id'; 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 index 804299d3d9..cb3c41aa97 100644 --- 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 @@ -11,6 +11,7 @@ describe('SubComColSectionComponent', () => { beforeEach(async () => { activatedRoute = new ActivatedRouteStub(); + activatedRoute.parent = new ActivatedRouteStub(); await TestBed.configureTestingModule({ declarations: [ 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 index ff30e51607..a72674adec 100644 --- 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 @@ -20,7 +20,7 @@ export class SubComColSectionComponent implements OnInit { } ngOnInit(): void { - this.community$ = this.route.data.pipe( + this.community$ = this.route.parent.data.pipe( map((data: Data) => (data.dso as RemoteData).payload), ); } 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..cd63ff6436 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) @@ -272,12 +273,13 @@ export class RemoteDataBuildService { return isStale(r2.state) ? r1 : r2; } }), - distinctUntilKeyChanged('lastUpdated') ); const payload$ = this.buildPayload(requestEntry$, href$, ...linksToFollow); - return this.toRemoteDataObservable(requestEntry$, payload$); + return this.toRemoteDataObservable(requestEntry$, payload$).pipe( + distinctUntilKeyChanged('lastUpdated'), + ); } /** diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index b3abf5f877..f151f10f66 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -185,6 +185,8 @@ import { FlatBrowseDefinition } from './shared/flat-browse-definition.model'; import { ValueListBrowseDefinition } from './shared/value-list-browse-definition.model'; import { NonHierarchicalBrowseDefinition } from './shared/non-hierarchical-browse-definition'; import { BulkAccessConditionOptions } from './config/models/bulk-access-condition-options.model'; +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 @@ -386,7 +388,9 @@ export const models = IdentifierData, Subscription, ItemRequest, - BulkAccessConditionOptions + BulkAccessConditionOptions, + SuggestionTarget, + SuggestionSource ]; @NgModule({ diff --git a/src/app/core/data/base/base-data.service.spec.ts b/src/app/core/data/base/base-data.service.spec.ts index 75662a691f..3366209179 100644 --- a/src/app/core/data/base/base-data.service.spec.ts +++ b/src/app/core/data/base/base-data.service.spec.ts @@ -21,6 +21,10 @@ import { RequestEntryState } from '../request-entry-state.model'; import { fakeAsync, tick } from '@angular/core/testing'; import { BaseDataService } from './base-data.service'; import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { ObjectCacheServiceStub } from '../../../shared/testing/object-cache-service.stub'; +import { ObjectCacheEntry } from '../../cache/object-cache.reducer'; +import { HALLink } from '../../shared/hal-link.model'; +import { createPaginatedList } from '../../../shared/testing/utils.test'; const endpoint = 'https://rest.api/core'; @@ -46,34 +50,18 @@ describe('BaseDataService', () => { let requestService; let halService; let rdbService; - let objectCache; + let objectCache: ObjectCacheServiceStub; let selfLink; let linksToFollow; let testScheduler; - let remoteDataMocks; + let remoteDataMocks: { [responseType: string]: RemoteData }; + let remoteDataPageMocks: { [responseType: string]: RemoteData }; function initTestService(): TestService { requestService = getMockRequestService(); halService = new HALEndpointServiceStub('url') as any; rdbService = getMockRemoteDataBuildService(); - objectCache = { - - addPatch: () => { - /* empty */ - }, - getObjectBySelfLink: () => { - /* empty */ - }, - getByHref: () => { - /* empty */ - }, - addDependency: () => { - /* empty */ - }, - removeDependents: () => { - /* empty */ - }, - } as any; + objectCache = new ObjectCacheServiceStub(); selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7'; linksToFollow = [ followLink('a'), @@ -88,7 +76,27 @@ describe('BaseDataService', () => { const timeStamp = new Date().getTime(); const msToLive = 15 * 60 * 1000; - const payload = { foo: 'bar' }; + const payload = { + foo: 'bar', + followLink1: {}, + followLink2: {}, + _links: { + self: Object.assign(new HALLink(), { + href: 'self-test-link', + }), + followLink1: Object.assign(new HALLink(), { + href: 'follow-link-1', + }), + followLink2: [ + Object.assign(new HALLink(), { + href: 'follow-link-2-1', + }), + Object.assign(new HALLink(), { + href: 'follow-link-2-2', + }), + ], + } + }; const statusCodeSuccess = 200; const statusCodeError = 404; const errorMessage = 'not found'; @@ -101,11 +109,20 @@ describe('BaseDataService', () => { Error: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Error, errorMessage, undefined, statusCodeError), ErrorStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.ErrorStale, errorMessage, undefined, statusCodeError), }; + remoteDataPageMocks = { + RequestPending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.RequestPending, undefined, undefined, undefined), + ResponsePending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.ResponsePending, undefined, undefined, undefined), + ResponsePendingStale: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.ResponsePendingStale, undefined, undefined, undefined), + Success: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Success, undefined, createPaginatedList([payload]), statusCodeSuccess), + SuccessStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.SuccessStale, undefined, createPaginatedList([payload]), statusCodeSuccess), + Error: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Error, errorMessage, undefined, statusCodeError), + ErrorStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.ErrorStale, errorMessage, undefined, statusCodeError), + }; return new TestService( requestService, rdbService, - objectCache, + objectCache as ObjectCacheService, halService, ); } @@ -380,6 +397,27 @@ describe('BaseDataService', () => { }); + it('should link all the followLinks of a cached object by calling addDependency', () => { + spyOn(objectCache, 'addDependency').and.callThrough(); + testScheduler.run(({ cold, expectObservable, flush }) => { + spyOn(rdbService, 'buildSingle').and.returnValue(cold('a-b-c-d', { + a: remoteDataMocks.Success, + b: remoteDataMocks.RequestPending, + c: remoteDataMocks.ResponsePending, + d: remoteDataMocks.Success, + })); + const expected = '--b-c-d'; + const values = { + b: remoteDataMocks.RequestPending, + c: remoteDataMocks.ResponsePending, + d: remoteDataMocks.Success, + }; + + expectObservable(service.findByHref(selfLink, false, false, ...linksToFollow)).toBe(expected, values); + flush(); + expect(objectCache.addDependency).toHaveBeenCalledTimes(3); + }); + }); }); describe(`findListByHref`, () => { @@ -392,8 +430,8 @@ describe('BaseDataService', () => { it(`should call buildHrefFromFindOptions with href and linksToFollow`, () => { testScheduler.run(({ cold }) => { spyOn(service, 'buildHrefFromFindOptions').and.returnValue(selfLink); - spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataMocks.Success })); - spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataMocks.Success })); + spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataPageMocks.Success })); + spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataPageMocks.Success })); service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow); expect(service.buildHrefFromFindOptions).toHaveBeenCalledWith(selfLink, findListOptions, [], ...linksToFollow); @@ -403,8 +441,8 @@ describe('BaseDataService', () => { it(`should call createAndSendGetRequest with the result from buildHrefFromFindOptions and useCachedVersionIfAvailable`, () => { testScheduler.run(({ cold, expectObservable }) => { spyOn(service, 'buildHrefFromFindOptions').and.returnValue('bingo!'); - spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataMocks.Success })); - spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataMocks.Success })); + spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataPageMocks.Success })); + spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataPageMocks.Success })); service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow); expect((service as any).createAndSendGetRequest).toHaveBeenCalledWith(jasmine.anything(), true); @@ -419,8 +457,8 @@ describe('BaseDataService', () => { it(`should call rdbService.buildList with the result from buildHrefFromFindOptions and linksToFollow`, () => { testScheduler.run(({ cold, expectObservable }) => { spyOn(service, 'buildHrefFromFindOptions').and.returnValue('bingo!'); - spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataMocks.Success })); - spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataMocks.Success })); + spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataPageMocks.Success })); + spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataPageMocks.Success })); service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow); expect(rdbService.buildList).toHaveBeenCalledWith(jasmine.anything() as any, ...linksToFollow); @@ -431,12 +469,12 @@ describe('BaseDataService', () => { it(`should call reRequestStaleRemoteData with reRequestOnStale and the exact same findListByHref call as a callback`, () => { testScheduler.run(({ cold, expectObservable }) => { spyOn(service, 'buildHrefFromFindOptions').and.returnValue('bingo!'); - spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataMocks.SuccessStale })); - spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataMocks.SuccessStale })); + spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataPageMocks.SuccessStale })); + spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataPageMocks.SuccessStale })); service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow); expect((service as any).reRequestStaleRemoteData.calls.argsFor(0)[0]).toBeTrue(); - spyOn(service, 'findListByHref').and.returnValue(cold('a', { a: remoteDataMocks.SuccessStale })); + spyOn(service, 'findListByHref').and.returnValue(cold('a', { a: remoteDataPageMocks.SuccessStale })); // prove that the spy we just added hasn't been called yet expect(service.findListByHref).not.toHaveBeenCalled(); // call the callback passed to reRequestStaleRemoteData @@ -451,7 +489,7 @@ describe('BaseDataService', () => { it(`should return a the output from reRequestStaleRemoteData`, () => { testScheduler.run(({ cold, expectObservable }) => { spyOn(service, 'buildHrefFromFindOptions').and.returnValue(selfLink); - spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataMocks.Success })); + spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataPageMocks.Success })); spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: 'bingo!' })); const expected = 'a'; const values = { @@ -471,19 +509,19 @@ describe('BaseDataService', () => { it(`should emit a cached completed RemoteData immediately, and keep emitting if it gets rerequested`, () => { testScheduler.run(({ cold, expectObservable }) => { spyOn(rdbService, 'buildList').and.returnValue(cold('a-b-c-d-e', { - a: remoteDataMocks.Success, - b: remoteDataMocks.RequestPending, - c: remoteDataMocks.ResponsePending, - d: remoteDataMocks.Success, - e: remoteDataMocks.SuccessStale, + a: remoteDataPageMocks.Success, + b: remoteDataPageMocks.RequestPending, + c: remoteDataPageMocks.ResponsePending, + d: remoteDataPageMocks.Success, + e: remoteDataPageMocks.SuccessStale, })); const expected = 'a-b-c-d-e'; const values = { - a: remoteDataMocks.Success, - b: remoteDataMocks.RequestPending, - c: remoteDataMocks.ResponsePending, - d: remoteDataMocks.Success, - e: remoteDataMocks.SuccessStale, + a: remoteDataPageMocks.Success, + b: remoteDataPageMocks.RequestPending, + c: remoteDataPageMocks.ResponsePending, + d: remoteDataPageMocks.Success, + e: remoteDataPageMocks.SuccessStale, }; expectObservable(service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow)).toBe(expected, values); @@ -493,20 +531,20 @@ describe('BaseDataService', () => { it(`should not emit a cached stale RemoteData, but only start emitting after the state first changes to RequestPending`, () => { testScheduler.run(({ cold, expectObservable }) => { spyOn(rdbService, 'buildList').and.returnValue(cold('a-b-c-d-e-f-g', { - a: remoteDataMocks.ResponsePendingStale, - b: remoteDataMocks.SuccessStale, - c: remoteDataMocks.ErrorStale, - d: remoteDataMocks.RequestPending, - e: remoteDataMocks.ResponsePending, - f: remoteDataMocks.Success, - g: remoteDataMocks.SuccessStale, + a: remoteDataPageMocks.ResponsePendingStale, + b: remoteDataPageMocks.SuccessStale, + c: remoteDataPageMocks.ErrorStale, + d: remoteDataPageMocks.RequestPending, + e: remoteDataPageMocks.ResponsePending, + f: remoteDataPageMocks.Success, + g: remoteDataPageMocks.SuccessStale, })); const expected = '------d-e-f-g'; const values = { - d: remoteDataMocks.RequestPending, - e: remoteDataMocks.ResponsePending, - f: remoteDataMocks.Success, - g: remoteDataMocks.SuccessStale, + d: remoteDataPageMocks.RequestPending, + e: remoteDataPageMocks.ResponsePending, + f: remoteDataPageMocks.Success, + g: remoteDataPageMocks.SuccessStale, }; expectObservable(service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow)).toBe(expected, values); @@ -525,18 +563,18 @@ describe('BaseDataService', () => { it(`should not emit a cached completed RemoteData, but only start emitting after the state first changes to RequestPending`, () => { testScheduler.run(({ cold, expectObservable }) => { spyOn(rdbService, 'buildList').and.returnValue(cold('a-b-c-d-e', { - a: remoteDataMocks.Success, - b: remoteDataMocks.RequestPending, - c: remoteDataMocks.ResponsePending, - d: remoteDataMocks.Success, - e: remoteDataMocks.SuccessStale, + a: remoteDataPageMocks.Success, + b: remoteDataPageMocks.RequestPending, + c: remoteDataPageMocks.ResponsePending, + d: remoteDataPageMocks.Success, + e: remoteDataPageMocks.SuccessStale, })); const expected = '--b-c-d-e'; const values = { - b: remoteDataMocks.RequestPending, - c: remoteDataMocks.ResponsePending, - d: remoteDataMocks.Success, - e: remoteDataMocks.SuccessStale, + b: remoteDataPageMocks.RequestPending, + c: remoteDataPageMocks.ResponsePending, + d: remoteDataPageMocks.Success, + e: remoteDataPageMocks.SuccessStale, }; expectObservable(service.findListByHref(selfLink, findListOptions, false, true, ...linksToFollow)).toBe(expected, values); @@ -546,20 +584,20 @@ describe('BaseDataService', () => { it(`should not emit a cached stale RemoteData, but only start emitting after the state first changes to RequestPending`, () => { testScheduler.run(({ cold, expectObservable }) => { spyOn(rdbService, 'buildList').and.returnValue(cold('a-b-c-d-e-f-g', { - a: remoteDataMocks.ResponsePendingStale, - b: remoteDataMocks.SuccessStale, - c: remoteDataMocks.ErrorStale, - d: remoteDataMocks.RequestPending, - e: remoteDataMocks.ResponsePending, - f: remoteDataMocks.Success, - g: remoteDataMocks.SuccessStale, + a: remoteDataPageMocks.ResponsePendingStale, + b: remoteDataPageMocks.SuccessStale, + c: remoteDataPageMocks.ErrorStale, + d: remoteDataPageMocks.RequestPending, + e: remoteDataPageMocks.ResponsePending, + f: remoteDataPageMocks.Success, + g: remoteDataPageMocks.SuccessStale, })); const expected = '------d-e-f-g'; const values = { - d: remoteDataMocks.RequestPending, - e: remoteDataMocks.ResponsePending, - f: remoteDataMocks.Success, - g: remoteDataMocks.SuccessStale, + d: remoteDataPageMocks.RequestPending, + e: remoteDataPageMocks.ResponsePending, + f: remoteDataPageMocks.Success, + g: remoteDataPageMocks.SuccessStale, }; @@ -567,6 +605,27 @@ describe('BaseDataService', () => { }); }); + it('should link all the followLinks of the cached objects by calling addDependency', () => { + spyOn(objectCache, 'addDependency').and.callThrough(); + testScheduler.run(({ cold, expectObservable, flush }) => { + spyOn(rdbService, 'buildList').and.returnValue(cold('a-b-c-d', { + a: remoteDataPageMocks.SuccessStale, + b: remoteDataPageMocks.RequestPending, + c: remoteDataPageMocks.ResponsePending, + d: remoteDataPageMocks.Success, + })); + const expected = '--b-c-d'; + const values = { + b: remoteDataPageMocks.RequestPending, + c: remoteDataPageMocks.ResponsePending, + d: remoteDataPageMocks.Success, + }; + + expectObservable(service.findListByHref(selfLink, findListOptions, false, false, ...linksToFollow)).toBe(expected, values); + flush(); + expect(objectCache.addDependency).toHaveBeenCalledTimes(3); + }); + }); }); }); @@ -577,7 +636,7 @@ describe('BaseDataService', () => { getByHrefSpy = spyOn(objectCache, 'getByHref').and.returnValue(observableOf({ requestUUIDs: ['request1', 'request2', 'request3'], dependentRequestUUIDs: ['request4', 'request5'] - })); + } as ObjectCacheEntry)); }); diff --git a/src/app/core/data/base/base-data.service.ts b/src/app/core/data/base/base-data.service.ts index c7cd5b0a70..5694cd7791 100644 --- a/src/app/core/data/base/base-data.service.ts +++ b/src/app/core/data/base/base-data.service.ts @@ -24,16 +24,17 @@ import { ObjectCacheEntry } from '../../cache/object-cache.reducer'; import { ObjectCacheService } from '../../cache/object-cache.service'; import { HALDataService } from './hal-data-service.interface'; import { getFirstCompletedRemoteData } from '../../shared/operators'; +import { HALLink } from '../../shared/hal-link.model'; 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 { @@ -268,7 +269,7 @@ export class BaseDataService implements HALDataServic this.createAndSendGetRequest(requestHref$, useCachedVersionIfAvailable); - return this.rdbService.buildSingle(requestHref$, ...linksToFollow).pipe( + const response$: Observable> = this.rdbService.buildSingle(requestHref$, ...linksToFollow).pipe( // This skip ensures that if a stale object is present in the cache when you do a // call it isn't immediately returned, but we wait until the remote data for the new request // is created. If useCachedVersionIfAvailable is false it also ensures you don't get a @@ -277,6 +278,25 @@ export class BaseDataService implements HALDataServic this.reRequestStaleRemoteData(reRequestOnStale, () => this.findByHref(href$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow)), ); + return response$.pipe( + // Ensure all followLinks from the cached object are automatically invalidated when invalidating the cached object + tap((remoteDataObject: RemoteData) => { + if (hasValue(remoteDataObject?.payload?._links)) { + for (const followLinkName of Object.keys(remoteDataObject.payload._links)) { + // only add the followLinks if they are embedded + if (hasValue(remoteDataObject.payload[followLinkName]) && followLinkName !== 'self') { + // followLink can be either an individual HALLink or a HALLink[] + const followLinksList: HALLink[] = [].concat(remoteDataObject.payload._links[followLinkName]); + for (const individualFollowLink of followLinksList) { + if (hasValue(individualFollowLink?.href)) { + this.addDependency(response$, individualFollowLink.href); + } + } + } + } + } + }), + ); } /** @@ -302,7 +322,7 @@ export class BaseDataService implements HALDataServic this.createAndSendGetRequest(requestHref$, useCachedVersionIfAvailable); - return this.rdbService.buildList(requestHref$, ...linksToFollow).pipe( + const response$: Observable>> = this.rdbService.buildList(requestHref$, ...linksToFollow).pipe( // This skip ensures that if a stale object is present in the cache when you do a // call it isn't immediately returned, but we wait until the remote data for the new request // is created. If useCachedVersionIfAvailable is false it also ensures you don't get a @@ -311,6 +331,29 @@ export class BaseDataService implements HALDataServic this.reRequestStaleRemoteData(reRequestOnStale, () => this.findListByHref(href$, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow)), ); + return response$.pipe( + // Ensure all followLinks from the cached object are automatically invalidated when invalidating the cached object + tap((remoteDataObject: RemoteData>) => { + if (hasValue(remoteDataObject?.payload?.page)) { + for (const object of remoteDataObject.payload.page) { + if (hasValue(object?._links)) { + for (const followLinkName of Object.keys(object._links)) { + // only add the followLinks if they are embedded + if (hasValue(object[followLinkName]) && followLinkName !== 'self') { + // followLink can be either an individual HALLink or a HALLink[] + const followLinksList: HALLink[] = [].concat(object._links[followLinkName]); + for (const individualFollowLink of followLinksList) { + if (hasValue(individualFollowLink?.href)) { + this.addDependency(response$, individualFollowLink.href); + } + } + } + } + } + } + } + }), + ); } /** @@ -385,7 +428,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/collection-data.service.spec.ts b/src/app/core/data/collection-data.service.spec.ts index 65f8b3ab2c..c1a7ac64c2 100644 --- a/src/app/core/data/collection-data.service.spec.ts +++ b/src/app/core/data/collection-data.service.spec.ts @@ -24,6 +24,7 @@ import { testFindAllDataImplementation } from './base/find-all-data.spec'; import { testSearchDataImplementation } from './base/search-data.spec'; import { testPatchDataImplementation } from './base/patch-data.spec'; import { testDeleteDataImplementation } from './base/delete-data.spec'; +import { ObjectCacheServiceStub } from '../../shared/testing/object-cache-service.stub'; const url = 'fake-url'; const collectionId = 'fake-collection-id'; @@ -35,7 +36,7 @@ describe('CollectionDataService', () => { let translate: TranslateService; let notificationsService: any; let rdbService: RemoteDataBuildService; - let objectCache: ObjectCacheService; + let objectCache: ObjectCacheServiceStub; let halService: any; const mockCollection1: Collection = Object.assign(new Collection(), { @@ -205,14 +206,12 @@ describe('CollectionDataService', () => { buildFromRequestUUID: buildResponse$, buildSingle: buildResponse$ }); - objectCache = jasmine.createSpyObj('objectCache', { - remove: jasmine.createSpy('remove') - }); + objectCache = new ObjectCacheServiceStub(); halService = new HALEndpointServiceStub(url); notificationsService = new NotificationsServiceStub(); translate = getMockTranslateService(); - service = new CollectionDataService(requestService, rdbService, objectCache, halService, null, notificationsService, null, null, translate); + service = new CollectionDataService(requestService, rdbService, objectCache as ObjectCacheService, halService, null, notificationsService, null, null, translate); } }); 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/processes/process-data.service.spec.ts b/src/app/core/data/processes/process-data.service.spec.ts index 88e5bd5791..99cd317cdb 100644 --- a/src/app/core/data/processes/process-data.service.spec.ts +++ b/src/app/core/data/processes/process-data.service.spec.ts @@ -7,13 +7,187 @@ */ import { testFindAllDataImplementation } from '../base/find-all-data.spec'; -import { ProcessDataService } from './process-data.service'; +import { ProcessDataService, TIMER_FACTORY } from './process-data.service'; import { testDeleteDataImplementation } from '../base/delete-data.spec'; +import { waitForAsync, TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { RequestService } from '../request.service'; +import { RemoteData } from '../remote-data'; +import { RequestEntryState } from '../request-entry-state.model'; +import { Process } from '../../../process-page/processes/process.model'; +import { ProcessStatus } from '../../../process-page/processes/process-status.model'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { ReducerManager } from '@ngrx/store'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { DSOChangeAnalyzer } from '../dso-change-analyzer.service'; +import { BitstreamFormatDataService } from '../bitstream-format-data.service'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { TestScheduler } from 'rxjs/testing'; +import { testSearchDataImplementation } from '../base/search-data.spec'; +import { PaginatedList } from '../paginated-list.model'; +import { FindListOptions } from '../find-list-options.model'; +import { of } from 'rxjs'; +import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; describe('ProcessDataService', () => { + let testScheduler; + + const mockTimer = (fn: () => {}, interval: number) => { + fn(); + return 555; + }; + describe('composition', () => { - const initService = () => new ProcessDataService(null, null, null, null, null, null); + const initService = () => new ProcessDataService(null, null, null, null, null, null, null, null); testFindAllDataImplementation(initService); testDeleteDataImplementation(initService); + testSearchDataImplementation(initService); + }); + + let requestService = getMockRequestService(); + let processDataService; + let remoteDataBuildService; + + describe('autoRefreshUntilCompletion', () => { + beforeEach(waitForAsync(() => { + testScheduler = new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected); + }); + TestBed.configureTestingModule({ + imports: [], + providers: [ + ProcessDataService, + { provide: RequestService, useValue: null }, + { provide: RemoteDataBuildService, useValue: null }, + { provide: ObjectCacheService, useValue: null }, + { provide: ReducerManager, useValue: null }, + { provide: HALEndpointService, useValue: null }, + { provide: DSOChangeAnalyzer, useValue: null }, + { provide: BitstreamFormatDataService, useValue: null }, + { provide: NotificationsService, useValue: null }, + { provide: TIMER_FACTORY, useValue: mockTimer }, + ] + }); + + processDataService = TestBed.inject(ProcessDataService); + spyOn(processDataService, 'invalidateByHref'); + })); + + it('should not do any polling when the process is already completed', () => { + testScheduler.run(({ cold, expectObservable }) => { + let completedProcess = new Process(); + completedProcess.processStatus = ProcessStatus.COMPLETED; + + const completedProcessRD = new RemoteData(0, 0, 0, RequestEntryState.Success, null, completedProcess); + + spyOn(processDataService, 'findById').and.returnValue( + cold('c', { + 'c': completedProcessRD + }) + ); + + let process$ = processDataService.autoRefreshUntilCompletion('instantly'); + expectObservable(process$).toBe('c', { + c: completedProcessRD + }); + }); + + expect(processDataService.findById).toHaveBeenCalledTimes(1); + expect(processDataService.invalidateByHref).not.toHaveBeenCalled(); + }); + + it('should poll until a process completes', () => { + testScheduler.run(({ cold, expectObservable }) => { + const runningProcess = Object.assign(new Process(), { + _links: { + self: { + href: 'https://rest.api/processes/123' + } + } + }); + runningProcess.processStatus = ProcessStatus.RUNNING; + const completedProcess = new Process(); + completedProcess.processStatus = ProcessStatus.COMPLETED; + const runningProcessRD = new RemoteData(0, 0, 0, RequestEntryState.Success, null, runningProcess); + const completedProcessRD = new RemoteData(0, 0, 0, RequestEntryState.Success, null, completedProcess); + + spyOn(processDataService, 'findById').and.returnValue( + cold('r 150ms c', { + 'r': runningProcessRD, + 'c': completedProcessRD + }) + ); + + let process$ = processDataService.autoRefreshUntilCompletion('foo', 100); + expectObservable(process$).toBe('r 150ms c', { + 'r': runningProcessRD, + 'c': completedProcessRD + }); + }); + + expect(processDataService.findById).toHaveBeenCalledTimes(1); + expect(processDataService.invalidateByHref).toHaveBeenCalledTimes(1); + }); + }); + + describe('autoRefreshingSearchBy', () => { + beforeEach(waitForAsync(() => { + + TestBed.configureTestingModule({ + imports: [], + providers: [ + ProcessDataService, + { provide: RequestService, useValue: requestService }, + { provide: RemoteDataBuildService, useValue: null }, + { provide: ObjectCacheService, useValue: null }, + { provide: ReducerManager, useValue: null }, + { provide: HALEndpointService, useValue: null }, + { provide: DSOChangeAnalyzer, useValue: null }, + { provide: BitstreamFormatDataService, useValue: null }, + { provide: NotificationsService, useValue: null }, + { provide: TIMER_FACTORY, useValue: mockTimer }, + ] + }); + + processDataService = TestBed.inject(ProcessDataService); + })); + + it('should refresh after the specified interval', fakeAsync(() => { + const runningProcess = Object.assign(new Process(), { + _links: { + self: { + href: 'https://rest.api/processes/123' + } + } + }); + runningProcess.processStatus = ProcessStatus.RUNNING; + + const runningProcessPagination: PaginatedList = Object.assign(new PaginatedList(), { + page: [runningProcess], + _links: { + self: { + href: 'https://rest.api/processesList/456' + } + } + }); + + const runningProcessRD = new RemoteData(0, 0, 0, RequestEntryState.Success, null, runningProcessPagination); + + spyOn(processDataService, 'searchBy').and.returnValue( + of(runningProcessRD) + ); + + expect(processDataService.searchBy).toHaveBeenCalledTimes(0); + expect(requestService.setStaleByHrefSubstring).toHaveBeenCalledTimes(0); + + let sub = processDataService.autoRefreshingSearchBy('id', 'byProperty', new FindListOptions(), 200).subscribe(); + expect(processDataService.searchBy).toHaveBeenCalledTimes(1); + + tick(250); + + expect(requestService.setStaleByHrefSubstring).toHaveBeenCalledTimes(1); + + sub.unsubscribe(); + })); }); }); diff --git a/src/app/core/data/processes/process-data.service.ts b/src/app/core/data/processes/process-data.service.ts index 3bf34eb650..080a4a4c09 100644 --- a/src/app/core/data/processes/process-data.service.ts +++ b/src/app/core/data/processes/process-data.service.ts @@ -1,12 +1,12 @@ -import { Injectable } from '@angular/core'; +import { Injectable, NgZone, Inject, InjectionToken } from '@angular/core'; import { RequestService } from '../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 { Process } from '../../../process-page/processes/process.model'; import { PROCESS } from '../../../process-page/processes/process.resource-type'; -import { Observable } from 'rxjs'; -import { switchMap } from 'rxjs/operators'; +import { Observable, Subscription } from 'rxjs'; +import { switchMap, filter, distinctUntilChanged, find } from 'rxjs/operators'; import { PaginatedList } from '../paginated-list.model'; import { Bitstream } from '../../shared/bitstream.model'; import { RemoteData } from '../remote-data'; @@ -19,12 +19,29 @@ import { dataService } from '../base/data-service.decorator'; import { DeleteData, DeleteDataImpl } from '../base/delete-data'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { NoContent } from '../../shared/NoContent.model'; +import { getAllCompletedRemoteData } from '../../shared/operators'; +import { ProcessStatus } from 'src/app/process-page/processes/process-status.model'; +import { hasValue } from '../../../shared/empty.util'; +import { SearchData, SearchDataImpl } from '../base/search-data'; + +/** + * Create an InjectionToken for the default JS setTimeout function, purely so we can mock it during + * testing. (fakeAsync isn't working for this case) + */ +export const TIMER_FACTORY = new InjectionToken<(callback: (...args: any[]) => void, ms?: number, ...args: any[]) => NodeJS.Timeout>('timer', { + providedIn: 'root', + factory: () => setTimeout +}); @Injectable() @dataService(PROCESS) -export class ProcessDataService extends IdentifiableDataService implements FindAllData, DeleteData { +export class ProcessDataService extends IdentifiableDataService implements FindAllData, DeleteData, SearchData { + private findAllData: FindAllData; private deleteData: DeleteData; + private searchData: SearchData; + protected activelyBeingPolled: Map = new Map(); + protected subs: Map = new Map(); constructor( protected requestService: RequestService, @@ -33,11 +50,30 @@ export class ProcessDataService extends IdentifiableDataService impleme protected halService: HALEndpointService, protected bitstreamDataService: BitstreamDataService, protected notificationsService: NotificationsService, + protected zone: NgZone, + @Inject(TIMER_FACTORY) protected timer: (callback: (...args: any[]) => void, ms?: number, ...args: any[]) => NodeJS.Timeout ) { super('processes', requestService, rdbService, objectCache, halService); this.findAllData = new FindAllDataImpl(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); + } + + /** + * Return true if the given process has the given status + * @protected + */ + protected static statusIs(process: Process, status: ProcessStatus): boolean { + return hasValue(process) && process.processStatus === status; + } + + /** + * Return true if the given process has the status COMPLETED or FAILED + */ + public static hasCompletedOrFailed(process: Process): boolean { + return ProcessDataService.statusIs(process, ProcessStatus.COMPLETED) || + ProcessDataService.statusIs(process, ProcessStatus.FAILED); } /** @@ -77,6 +113,71 @@ export class ProcessDataService extends IdentifiableDataService impleme return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); } + /** + * @param searchMethod The search method for the Process + * @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 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 automatically be resolved. + * @return {Observable>>} + * Return an observable that emits a paginated list of processes + */ + searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * @param id The id for this auto-refreshing search. Used to stop + * auto-refreshing afterwards, and ensure we're not + * auto-refreshing the same thing multiple times. + * @param searchMethod The search method for the Process + * @param options The FindListOptions object + * @param pollingIntervalInMs The interval by which the search will be repeated + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should automatically be resolved. + * @return {Observable>>} + * Return an observable that emits a paginated list of processes every interval + */ + autoRefreshingSearchBy(id: string, searchMethod: string, options?: FindListOptions, pollingIntervalInMs: number = 5000, ...linksToFollow: FollowLinkConfig[]): Observable>> { + + const result$ = this.searchBy(searchMethod, options, true, true, ...linksToFollow).pipe( + getAllCompletedRemoteData() + ); + + const sub = result$.pipe( + filter(() => + !this.activelyBeingPolled.has(id) + ) + ).subscribe((processListRd: RemoteData>) => { + this.clearCurrentTimeout(id); + const nextTimeout = this.timer(() => { + this.activelyBeingPolled.delete(id); + this.requestService.setStaleByHrefSubstring(processListRd.payload._links.self.href); + }, pollingIntervalInMs); + + this.activelyBeingPolled.set(id, nextTimeout); + }); + + this.subs.set(id, sub); + + return result$; + } + + /** + * Stop auto-refreshing the request with the given id + * @param id the id of the request to stop automatically refreshing + */ + stopAutoRefreshing(id: string) { + this.clearCurrentTimeout(id); + if (hasValue(this.subs.get(id))) { + this.subs.get(id).unsubscribe(); + this.subs.delete(id); + } + } + /** * Delete an existing object on the server * @param objectId The id of the object to be removed @@ -101,4 +202,74 @@ export class ProcessDataService extends IdentifiableDataService impleme public deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable> { return this.deleteData.deleteByHref(href, copyVirtualMetadata); } + + /** + * Clear the timeout for the given id, if that timeout exists + * @protected + */ + protected clearCurrentTimeout(id: string): void { + const timeout = this.activelyBeingPolled.get(id); + if (hasValue(timeout)) { + clearTimeout(timeout); + } + this.activelyBeingPolled.delete(id); + } + + /** + * Poll the process with the given ID, using the given interval, until that process either + * completes successfully or fails + * + * Return an Observable for the Process. Note that this will also emit while the + * process is still running. It will only emit again when the process (not the RemoteData!) changes + * status. That makes it more convenient to retrieve that process for a component: you can replace + * a findByID call with this method, rather than having to do a separate findById, and then call + * this method + * + * @param processId The ID of the {@link Process} to poll + * @param pollingIntervalInMs The interval for how often the request needs to be polled + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be + * automatically resolved + */ + public autoRefreshUntilCompletion(processId: string, pollingIntervalInMs = 5000, ...linksToFollow: FollowLinkConfig[]): Observable> { + const process$: Observable> = this.findById(processId, true, true, ...linksToFollow) + .pipe( + getAllCompletedRemoteData(), + ); + + // Create a subscription that marks the data as stale if the process hasn't been completed and + // the polling interval time has been exceeded. + const sub = process$.pipe( + filter((processRD: RemoteData) => + !ProcessDataService.hasCompletedOrFailed(processRD.payload) && + !this.activelyBeingPolled.has(processId) + ) + ).subscribe((processRD: RemoteData) => { + this.clearCurrentTimeout(processId); + if (processRD.hasSucceeded) { + const nextTimeout = this.timer(() => { + this.activelyBeingPolled.delete(processId); + this.invalidateByHref(processRD.payload._links.self.href); + }, pollingIntervalInMs); + + this.activelyBeingPolled.set(processId, nextTimeout); + } + }); + + this.subs.set(processId, sub); + + // When the process completes create a one off subscription (the `find` completes the + // observable) that unsubscribes the previous one, removes the processId from the list of + // processes being polled and clears any running timeouts + process$.pipe( + find((processRD: RemoteData) => ProcessDataService.hasCompletedOrFailed(processRD.payload)) + ).subscribe(() => { + this.stopAutoRefreshing(processId); + }); + + return process$.pipe( + distinctUntilChanged((previous: RemoteData, current: RemoteData) => + previous.payload?.processStatus === current.payload?.processStatus, + ) + ); + } } diff --git a/src/app/core/data/relationship-data.service.spec.ts b/src/app/core/data/relationship-data.service.spec.ts index 4432d5213a..8ce67d19e0 100644 --- a/src/app/core/data/relationship-data.service.spec.ts +++ b/src/app/core/data/relationship-data.service.spec.ts @@ -23,6 +23,7 @@ import { FindListOptions } from './find-list-options.model'; import { testSearchDataImplementation } from './base/search-data.spec'; import { MetadataValue } from '../shared/metadata.models'; import { MetadataRepresentationType } from '../shared/metadata-representation/metadata-representation.model'; +import { ObjectCacheServiceStub } from '../../shared/testing/object-cache-service.stub'; describe('RelationshipDataService', () => { let service: RelationshipDataService; @@ -114,14 +115,7 @@ describe('RelationshipDataService', () => { 'href': buildList$, 'https://rest.api/core/publication/relationships': relationships$ }); - const objectCache = Object.assign({ - /* eslint-disable no-empty,@typescript-eslint/no-empty-function */ - remove: () => { - }, - hasBySelfLinkObservable: () => observableOf(false), - hasByHref$: () => observableOf(false) - /* eslint-enable no-empty, @typescript-eslint/no-empty-function */ - }) as ObjectCacheService; + const objectCache = new ObjectCacheServiceStub(); const itemService = jasmine.createSpyObj('itemService', { findById: (uuid) => createSuccessfulRemoteDataObject(relatedItems.find((relatedItem) => relatedItem.id === uuid)), @@ -133,7 +127,7 @@ describe('RelationshipDataService', () => { requestService, rdbService, halService, - objectCache, + objectCache as ObjectCacheService, itemService, null, jasmine.createSpy('paginatedRelationsToItems').and.returnValue((v) => v), diff --git a/src/app/core/data/relationship-type-data.service.spec.ts b/src/app/core/data/relationship-type-data.service.spec.ts index 6a788446d8..ecd84f8288 100644 --- a/src/app/core/data/relationship-type-data.service.spec.ts +++ b/src/app/core/data/relationship-type-data.service.spec.ts @@ -10,6 +10,7 @@ import { RequestService } from './request.service'; import { createPaginatedList } from '../../shared/testing/utils.test'; import { hasValueOperator } from '../../shared/empty.util'; import { ObjectCacheService } from '../cache/object-cache.service'; +import { ObjectCacheServiceStub } from '../../shared/testing/object-cache-service.stub'; describe('RelationshipTypeDataService', () => { let service: RelationshipTypeDataService; @@ -28,7 +29,7 @@ describe('RelationshipTypeDataService', () => { let buildList; let rdbService; - let objectCache; + let objectCache: ObjectCacheServiceStub; function init() { restEndpointURL = 'https://rest.api/relationshiptypes'; @@ -60,21 +61,14 @@ describe('RelationshipTypeDataService', () => { buildList = createSuccessfulRemoteDataObject(createPaginatedList([relationshipType1, relationshipType2])); rdbService = getMockRemoteDataBuildService(undefined, observableOf(buildList)); - objectCache = Object.assign({ - /* eslint-disable no-empty,@typescript-eslint/no-empty-function */ - remove: () => { - }, - hasBySelfLinkObservable: () => observableOf(false) - /* eslint-enable no-empty, @typescript-eslint/no-empty-function */ - }) as ObjectCacheService; - + objectCache = new ObjectCacheServiceStub(); } function initTestService() { return new RelationshipTypeDataService( requestService, rdbService, - objectCache, + objectCache as ObjectCacheService, halService, ); } 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/notifications/qa/events/quality-assurance-event-data.service.spec.ts b/src/app/core/notifications/qa/events/quality-assurance-event-data.service.spec.ts index 50d0e43a99..6ab60ef2de 100644 --- a/src/app/core/notifications/qa/events/quality-assurance-event-data.service.spec.ts +++ b/src/app/core/notifications/qa/events/quality-assurance-event-data.service.spec.ts @@ -22,6 +22,7 @@ import { import { ReplaceOperation } from 'fast-json-patch'; import { RequestEntry } from '../../../data/request-entry.model'; import { FindListOptions } from '../../../data/find-list-options.model'; +import { ObjectCacheServiceStub } from '../../../../shared/testing/object-cache-service.stub'; describe('QualityAssuranceEventDataService', () => { let scheduler: TestScheduler; @@ -32,7 +33,7 @@ describe('QualityAssuranceEventDataService', () => { let responseCacheEntryC: RequestEntry; let requestService: RequestService; let rdbService: RemoteDataBuildService; - let objectCache: ObjectCacheService; + let objectCache: ObjectCacheServiceStub; let halService: HALEndpointService; let notificationsService: NotificationsService; let http: HttpClient; @@ -91,7 +92,7 @@ describe('QualityAssuranceEventDataService', () => { buildFromRequestUUIDAndAwait: jasmine.createSpy('buildFromRequestUUIDAndAwait') }); - objectCache = {} as ObjectCacheService; + objectCache = new ObjectCacheServiceStub(); halService = jasmine.createSpyObj('halService', { getEndpoint: cold('a|', { a: endpointURL }) }); @@ -103,7 +104,7 @@ describe('QualityAssuranceEventDataService', () => { service = new QualityAssuranceEventDataService( requestService, rdbService, - objectCache, + objectCache as ObjectCacheService, halService, notificationsService, comparator diff --git a/src/app/core/notifications/qa/source/quality-assurance-source-data.service.spec.ts b/src/app/core/notifications/qa/source/quality-assurance-source-data.service.spec.ts index 50d9251bb8..105303d1f9 100644 --- a/src/app/core/notifications/qa/source/quality-assurance-source-data.service.spec.ts +++ b/src/app/core/notifications/qa/source/quality-assurance-source-data.service.spec.ts @@ -19,6 +19,7 @@ import { } from '../../../../shared/mocks/notifications.mock'; import { RequestEntry } from '../../../data/request-entry.model'; import { QualityAssuranceSourceDataService } from './quality-assurance-source-data.service'; +import { ObjectCacheServiceStub } from '../../../../shared/testing/object-cache-service.stub'; describe('QualityAssuranceSourceDataService', () => { let scheduler: TestScheduler; @@ -26,7 +27,7 @@ describe('QualityAssuranceSourceDataService', () => { let responseCacheEntry: RequestEntry; let requestService: RequestService; let rdbService: RemoteDataBuildService; - let objectCache: ObjectCacheService; + let objectCache: ObjectCacheServiceStub; let halService: HALEndpointService; let notificationsService: NotificationsService; let http: HttpClient; @@ -63,7 +64,7 @@ describe('QualityAssuranceSourceDataService', () => { }), }); - objectCache = {} as ObjectCacheService; + objectCache = new ObjectCacheServiceStub(); halService = jasmine.createSpyObj('halService', { getEndpoint: cold('a|', { a: endpointURL }) }); @@ -75,7 +76,7 @@ describe('QualityAssuranceSourceDataService', () => { service = new QualityAssuranceSourceDataService( requestService, rdbService, - objectCache, + objectCache as ObjectCacheService, halService, notificationsService ); diff --git a/src/app/core/notifications/qa/topics/quality-assurance-topic-data.service.spec.ts b/src/app/core/notifications/qa/topics/quality-assurance-topic-data.service.spec.ts index 638ee3fa62..360e6b1ccd 100644 --- a/src/app/core/notifications/qa/topics/quality-assurance-topic-data.service.spec.ts +++ b/src/app/core/notifications/qa/topics/quality-assurance-topic-data.service.spec.ts @@ -19,6 +19,7 @@ import { qualityAssuranceTopicObjectMorePid } from '../../../../shared/mocks/notifications.mock'; import { RequestEntry } from '../../../data/request-entry.model'; +import { ObjectCacheServiceStub } from '../../../../shared/testing/object-cache-service.stub'; describe('QualityAssuranceTopicDataService', () => { let scheduler: TestScheduler; @@ -26,7 +27,7 @@ describe('QualityAssuranceTopicDataService', () => { let responseCacheEntry: RequestEntry; let requestService: RequestService; let rdbService: RemoteDataBuildService; - let objectCache: ObjectCacheService; + let objectCache: ObjectCacheServiceStub; let halService: HALEndpointService; let notificationsService: NotificationsService; let http: HttpClient; @@ -63,7 +64,7 @@ describe('QualityAssuranceTopicDataService', () => { }), }); - objectCache = {} as ObjectCacheService; + objectCache = new ObjectCacheServiceStub(); halService = jasmine.createSpyObj('halService', { getEndpoint: cold('a|', { a: endpointURL }) }); @@ -75,7 +76,7 @@ describe('QualityAssuranceTopicDataService', () => { service = new QualityAssuranceTopicDataService( requestService, rdbService, - objectCache, + objectCache as ObjectCacheService, halService, notificationsService ); diff --git a/src/app/core/resource-policy/resource-policy-data.service.spec.ts b/src/app/core/resource-policy/resource-policy-data.service.spec.ts index 7cfcaabb5d..e4c54d862c 100644 --- a/src/app/core/resource-policy/resource-policy-data.service.spec.ts +++ b/src/app/core/resource-policy/resource-policy-data.service.spec.ts @@ -20,13 +20,14 @@ import { FindListOptions } from '../data/find-list-options.model'; import { EPersonDataService } from '../eperson/eperson-data.service'; import { GroupDataService } from '../eperson/group-data.service'; import { RestRequestMethod } from '../data/rest-request-method'; +import { ObjectCacheServiceStub } from '../../shared/testing/object-cache-service.stub'; describe('ResourcePolicyService', () => { let scheduler: TestScheduler; let service: ResourcePolicyDataService; let requestService: RequestService; let rdbService: RemoteDataBuildService; - let objectCache: ObjectCacheService; + let objectCache: ObjectCacheServiceStub; let halService: HALEndpointService; let responseCacheEntry: RequestEntry; let ePersonService: EPersonDataService; @@ -139,14 +140,14 @@ describe('ResourcePolicyService', () => { a: 'https://rest.api/rest/api/eperson/groups/' + groupUUID }), }); - objectCache = {} as ObjectCacheService; + objectCache = new ObjectCacheServiceStub(); const notificationsService = {} as NotificationsService; const comparator = {} as any; service = new ResourcePolicyDataService( requestService, rdbService, - objectCache, + objectCache as ObjectCacheService, halService, notificationsService, comparator, 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/vocabularies/vocabulary.data.service.spec.ts b/src/app/core/submission/vocabularies/vocabulary.data.service.spec.ts index 4b35871418..eecf86a211 100644 --- a/src/app/core/submission/vocabularies/vocabulary.data.service.spec.ts +++ b/src/app/core/submission/vocabularies/vocabulary.data.service.spec.ts @@ -7,8 +7,16 @@ */ import { VocabularyDataService } from './vocabulary.data.service'; import { testFindAllDataImplementation } from '../../data/base/find-all-data.spec'; +import { FindListOptions } from '../../data/find-list-options.model'; +import { RequestParam } from '../../cache/models/request-param.model'; +import { createSuccessfulRemoteDataObject$ } from 'src/app/shared/remote-data.utils'; describe('VocabularyDataService', () => { + let service: VocabularyDataService; + service = initTestService(); + let restEndpointURL = 'https://rest.api/server/api/submission/vocabularies'; + let vocabularyByMetadataAndCollectionEndpoint = `${restEndpointURL}/search/byMetadataAndCollection?metadata=dc.contributor.author&collection=1234-1234`; + function initTestService() { return new VocabularyDataService(null, null, null, null); } @@ -17,4 +25,18 @@ describe('VocabularyDataService', () => { const initService = () => new VocabularyDataService(null, null, null, null); testFindAllDataImplementation(initService); }); + + describe('getVocabularyByMetadataAndCollection', () => { + it('search vocabulary by metadata and collection calls expected methods', () => { + spyOn((service as any).searchData, 'getSearchByHref').and.returnValue(vocabularyByMetadataAndCollectionEndpoint); + spyOn(service, 'findByHref').and.returnValue(createSuccessfulRemoteDataObject$(null)); + service.getVocabularyByMetadataAndCollection('dc.contributor.author', '1234-1234'); + const options = Object.assign(new FindListOptions(), { + searchParams: [Object.assign(new RequestParam('metadata', encodeURIComponent('dc.contributor.author'))), + Object.assign(new RequestParam('collection', encodeURIComponent('1234-1234')))] + }); + expect((service as any).searchData.getSearchByHref).toHaveBeenCalledWith('byMetadataAndCollection', options); + expect(service.findByHref).toHaveBeenCalledWith(vocabularyByMetadataAndCollectionEndpoint, true, true); + }); + }); }); diff --git a/src/app/core/submission/vocabularies/vocabulary.data.service.ts b/src/app/core/submission/vocabularies/vocabulary.data.service.ts index a67b67ced7..9215990dec 100644 --- a/src/app/core/submission/vocabularies/vocabulary.data.service.ts +++ b/src/app/core/submission/vocabularies/vocabulary.data.service.ts @@ -20,6 +20,8 @@ import { PaginatedList } from '../../data/paginated-list.model'; import { Injectable } from '@angular/core'; import { VOCABULARY } from './models/vocabularies.resource-type'; import { dataService } from '../../data/base/data-service.decorator'; +import { SearchDataImpl } from '../../data/base/search-data'; +import { RequestParam } from '../../cache/models/request-param.model'; /** * Data service to retrieve vocabularies from the REST server. @@ -27,7 +29,10 @@ import { dataService } from '../../data/base/data-service.decorator'; @Injectable() @dataService(VOCABULARY) export class VocabularyDataService extends IdentifiableDataService implements FindAllData { + protected searchByMetadataAndCollectionPath = 'byMetadataAndCollection'; + private findAllData: FindAllData; + private searchData: SearchDataImpl; constructor( protected requestService: RequestService, @@ -38,6 +43,7 @@ export class VocabularyDataService extends IdentifiableDataService i super('vocabularies', 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); } /** @@ -57,4 +63,23 @@ export class VocabularyDataService extends IdentifiableDataService i public findAll(options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); } + + /** + * Return the controlled vocabulary configured for the specified metadata and collection if any (/submission/vocabularies/search/{@link searchByMetadataAndCollectionPath}?metadata=<>&collection=<>) + * @param metadataField metadata field to search + * @param collectionUUID collection UUID where is configured the vocabulary + * @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 + */ + public getVocabularyByMetadataAndCollection(metadataField: string, collectionUUID: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { + const findListOptions = new FindListOptions(); + findListOptions.searchParams = [new RequestParam('metadata', encodeURIComponent(metadataField)), + new RequestParam('collection', encodeURIComponent(collectionUUID))]; + const href$ = this.searchData.getSearchByHref(this.searchByMetadataAndCollectionPath, findListOptions, ...linksToFollow); + return this.findByHref(href$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } } diff --git a/src/app/core/submission/vocabularies/vocabulary.service.spec.ts b/src/app/core/submission/vocabularies/vocabulary.service.spec.ts index faa5823520..38824b3fac 100644 --- a/src/app/core/submission/vocabularies/vocabulary.service.spec.ts +++ b/src/app/core/submission/vocabularies/vocabulary.service.spec.ts @@ -25,6 +25,7 @@ import { createPaginatedList } from '../../../shared/testing/utils.test'; import { RequestEntry } from '../../data/request-entry.model'; import { VocabularyDataService } from './vocabulary.data.service'; import { VocabularyEntryDetailsDataService } from './vocabulary-entry-details.data.service'; +import { ObjectCacheServiceStub } from '../../../shared/testing/object-cache-service.stub'; describe('VocabularyService', () => { let scheduler: TestScheduler; @@ -205,6 +206,7 @@ describe('VocabularyService', () => { function initTestService() { hrefOnlyDataService = getMockHrefOnlyDataService(); + objectCache = new ObjectCacheServiceStub() as ObjectCacheService; return new VocabularyService( requestService, @@ -253,7 +255,9 @@ describe('VocabularyService', () => { spyOn((service as any).vocabularyDataService, 'findById').and.callThrough(); spyOn((service as any).vocabularyDataService, 'findAll').and.callThrough(); spyOn((service as any).vocabularyDataService, 'findByHref').and.callThrough(); + spyOn((service as any).vocabularyDataService, 'getVocabularyByMetadataAndCollection').and.callThrough(); spyOn((service as any).vocabularyDataService.findAllData, 'getFindAllHref').and.returnValue(observableOf(entriesRequestURL)); + spyOn((service as any).vocabularyDataService.searchData, 'getSearchByHref').and.returnValue(observableOf(searchRequestURL)); }); afterEach(() => { @@ -310,6 +314,23 @@ describe('VocabularyService', () => { expect(result).toBeObservable(expected); }); }); + + describe('getVocabularyByMetadataAndCollection', () => { + it('should proxy the call to vocabularyDataService.getVocabularyByMetadataAndCollection', () => { + scheduler.schedule(() => service.getVocabularyByMetadataAndCollection(metadata, collectionUUID)); + scheduler.flush(); + + expect((service as any).vocabularyDataService.getVocabularyByMetadataAndCollection).toHaveBeenCalledWith(metadata, collectionUUID, true, true); + }); + + it('should return a RemoteData for the object with the given metadata and collection', () => { + const result = service.getVocabularyByMetadataAndCollection(metadata, collectionUUID); + const expected = cold('a|', { + a: vocabularyRD + }); + expect(result).toBeObservable(expected); + }); + }); }); describe('vocabulary entries', () => { diff --git a/src/app/core/submission/vocabularies/vocabulary.service.ts b/src/app/core/submission/vocabularies/vocabulary.service.ts index 1ff5b30ee0..2dd2cc3792 100644 --- a/src/app/core/submission/vocabularies/vocabulary.service.ts +++ b/src/app/core/submission/vocabularies/vocabulary.service.ts @@ -87,6 +87,23 @@ export class VocabularyService { return this.vocabularyDataService.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); } + /** + * Return the controlled vocabulary configured for the specified metadata and collection if any + * @param metadataField metadata field to search + * @param collectionUUID collection UUID where is configured the vocabulary + * @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 vocabulary object + */ + getVocabularyByMetadataAndCollection(metadataField: string, collectionUUID: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { + return this.vocabularyDataService.getVocabularyByMetadataAndCollection(metadataField, collectionUUID, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + /** * Return the {@link VocabularyEntry} list for a given {@link Vocabulary} * 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/core/supervision-order/supervision-order-data.service.spec.ts b/src/app/core/supervision-order/supervision-order-data.service.spec.ts index b12817fa1a..b25d440fa2 100644 --- a/src/app/core/supervision-order/supervision-order-data.service.spec.ts +++ b/src/app/core/supervision-order/supervision-order-data.service.spec.ts @@ -17,13 +17,14 @@ import { RestResponse } from '../cache/response.models'; import { RequestEntry } from '../data/request-entry.model'; import { FindListOptions } from '../data/find-list-options.model'; import { GroupDataService } from '../eperson/group-data.service'; +import { ObjectCacheServiceStub } from '../../shared/testing/object-cache-service.stub'; describe('SupervisionOrderService', () => { let scheduler: TestScheduler; let service: SupervisionOrderDataService; let requestService: RequestService; let rdbService: RemoteDataBuildService; - let objectCache: ObjectCacheService; + let objectCache: ObjectCacheServiceStub; let halService: HALEndpointService; let responseCacheEntry: RequestEntry; let groupService: GroupDataService; @@ -127,14 +128,14 @@ describe('SupervisionOrderService', () => { a: 'https://rest.api/rest/api/group/groups/' + groupUUID }), }); - objectCache = {} as ObjectCacheService; + objectCache = new ObjectCacheServiceStub(); const notificationsService = {} as NotificationsService; const comparator = {} as any; service = new SupervisionOrderDataService( requestService, rdbService, - objectCache, + objectCache as ObjectCacheService, halService, notificationsService, comparator, diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.html b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.html index 9f74216d54..aee9fb980c 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.html +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component.html @@ -3,6 +3,7 @@ -
+
{{ mdValue.newValue.value }}
- + + + + +
+ + + {{ dsoType + '.edit.metadata.authority.label' | translate }} {{ mdValue.newValue.authority }} + +
+
+
+ + + + +
+
{{ mdRepresentationName$ | async }} @@ -45,14 +92,14 @@ [disabled]="isVirtual || (!mdValue.change && mdValue.reordered) || (!mdValue.change && !mdValue.editing) || (saving$ | async)" (click)="undo.emit()"> +
-
diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.spec.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.spec.ts index 459095ea67..5d7d04b690 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.spec.ts +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.spec.ts @@ -11,6 +11,23 @@ import { ItemMetadataRepresentation } from '../../../core/shared/metadata-repres import { MetadataValue, VIRTUAL_METADATA_PREFIX } from '../../../core/shared/metadata.models'; import { DsoEditMetadataChangeType, DsoEditMetadataValue } from '../dso-edit-metadata-form'; import { By } from '@angular/platform-browser'; +import { ItemDataService } from '../../../core/data/item-data.service'; +import { Item } from '../../../core/shared/item.model'; +import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { Collection } from '../../../core/shared/collection.model'; +import { DSpaceObject } from '../../../core/shared/dspace-object.model'; +import { Vocabulary } from 'src/app/core/submission/vocabularies/models/vocabulary.model'; +import { VocabularyServiceStub } from 'src/app/shared/testing/vocabulary-service.stub'; +import { VocabularyService } from 'src/app/core/submission/vocabularies/vocabulary.service'; +import { ConfidenceType } from 'src/app/core/shared/confidence-type'; +import { DynamicOneboxModel } from 'src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.model'; +import { Observable } from 'rxjs'; +import { DynamicScrollableDropdownModel } from 'src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.model'; +import { RegistryService } from 'src/app/core/registry/registry.service'; +import { NotificationsService } from 'src/app/shared/notifications/notifications.service'; +import { createPaginatedList } from 'src/app/shared/testing/utils.test'; +import { MetadataField } from 'src/app/core/metadata/metadata-field.model'; +import { MetadataSchema } from 'src/app/core/metadata/metadata-schema.model'; const EDIT_BTN = 'edit'; const CONFIRM_BTN = 'confirm'; @@ -24,17 +41,111 @@ describe('DsoEditMetadataValueComponent', () => { let relationshipService: RelationshipDataService; let dsoNameService: DSONameService; + let vocabularyServiceStub: any; + let itemService: ItemDataService; + let registryService: RegistryService; + let notificationsService: NotificationsService; let editMetadataValue: DsoEditMetadataValue; let metadataValue: MetadataValue; + let dso: DSpaceObject; + + const collection = Object.assign(new Collection(), { + uuid: 'fake-uuid' + }); + + const item = Object.assign(new Item(), { + _links: { + self: { href: 'fake-item-url/item' } + }, + id: 'item', + uuid: 'item', + owningCollection: createSuccessfulRemoteDataObject$(collection) + }); + + const mockVocabularyScrollable: Vocabulary = { + id: 'scrollable', + name: 'scrollable', + scrollable: true, + hierarchical: false, + preloadLevel: 0, + type: 'vocabulary', + _links: { + self: { + href: 'self' + }, + entries: { + href: 'entries' + } + } + }; + + const mockVocabularyHierarchical: Vocabulary = { + id: 'hierarchical', + name: 'hierarchical', + scrollable: false, + hierarchical: true, + preloadLevel: 2, + type: 'vocabulary', + _links: { + self: { + href: 'self' + }, + entries: { + href: 'entries' + } + } + }; + + const mockVocabularySuggester: Vocabulary = { + id: 'suggester', + name: 'suggester', + scrollable: false, + hierarchical: false, + preloadLevel: 0, + type: 'vocabulary', + _links: { + self: { + href: 'self' + }, + entries: { + href: 'entries' + } + } + }; + + let metadataSchema: MetadataSchema; + let metadataFields: MetadataField[]; function initServices(): void { + metadataSchema = Object.assign(new MetadataSchema(), { + id: 0, + prefix: 'metadata', + namespace: 'http://example.com/', + }); + metadataFields = [ + Object.assign(new MetadataField(), { + id: 0, + element: 'regular', + qualifier: null, + schema: createSuccessfulRemoteDataObject$(metadataSchema), + }), + ]; + relationshipService = jasmine.createSpyObj('relationshipService', { resolveMetadataRepresentation: of(new ItemMetadataRepresentation(metadataValue)), }); dsoNameService = jasmine.createSpyObj('dsoNameService', { getName: 'Related Name', }); + itemService = jasmine.createSpyObj('itemService', { + findByHref: createSuccessfulRemoteDataObject$(item) + }); + vocabularyServiceStub = new VocabularyServiceStub(); + registryService = jasmine.createSpyObj('registryService', { + queryMetadataFields: createSuccessfulRemoteDataObject$(createPaginatedList(metadataFields)), + }); + notificationsService = jasmine.createSpyObj('notificationsService', ['error', 'success']); } beforeEach(waitForAsync(() => { @@ -45,6 +156,11 @@ describe('DsoEditMetadataValueComponent', () => { authority: undefined, }); editMetadataValue = new DsoEditMetadataValue(metadataValue); + dso = Object.assign(new DSpaceObject(), { + _links: { + self: { href: 'fake-dso-url/dso' } + }, + }); initServices(); @@ -54,6 +170,10 @@ describe('DsoEditMetadataValueComponent', () => { providers: [ { provide: RelationshipDataService, useValue: relationshipService }, { provide: DSONameService, useValue: dsoNameService }, + { provide: VocabularyService, useValue: vocabularyServiceStub }, + { provide: ItemDataService, useValue: itemService }, + { provide: RegistryService, useValue: registryService }, + { provide: NotificationsService, useValue: notificationsService }, ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); @@ -63,6 +183,7 @@ describe('DsoEditMetadataValueComponent', () => { fixture = TestBed.createComponent(DsoEditMetadataValueComponent); component = fixture.componentInstance; component.mdValue = editMetadataValue; + component.dso = dso; component.saving$ = of(false); fixture.detectChanges(); }); @@ -144,6 +265,222 @@ describe('DsoEditMetadataValueComponent', () => { assertButton(DRAG_BTN, true, false); }); + describe('when the metadata field not uses a vocabulary and is editing', () => { + beforeEach(waitForAsync(() => { + spyOn(vocabularyServiceStub, 'getVocabularyByMetadataAndCollection').and.returnValue(createSuccessfulRemoteDataObject$(null, 204)); + metadataValue = Object.assign(new MetadataValue(), { + value: 'Regular value', + language: 'en', + place: 0, + authority: null, + }); + editMetadataValue = new DsoEditMetadataValue(metadataValue); + editMetadataValue.editing = true; + component.mdValue = editMetadataValue; + component.mdField = 'metadata.regular'; + component.ngOnInit(); + fixture.detectChanges(); + })); + + it('should render a textarea', () => { + expect(vocabularyServiceStub.getVocabularyByMetadataAndCollection).toHaveBeenCalled(); + expect(fixture.debugElement.query(By.css('textarea'))).toBeTruthy(); + }); + }); + + describe('when the metadata field uses a scrollable vocabulary and is editing', () => { + beforeEach(waitForAsync(() => { + spyOn(vocabularyServiceStub, 'getVocabularyByMetadataAndCollection').and.returnValue(createSuccessfulRemoteDataObject$(mockVocabularyScrollable)); + metadataValue = Object.assign(new MetadataValue(), { + value: 'Authority Controlled value', + language: 'en', + place: 0, + authority: null, + }); + editMetadataValue = new DsoEditMetadataValue(metadataValue); + editMetadataValue.editing = true; + component.mdValue = editMetadataValue; + component.mdField = 'metadata.scrollable'; + component.ngOnInit(); + fixture.detectChanges(); + })); + + it('should render the DsDynamicScrollableDropdownComponent', () => { + expect(vocabularyServiceStub.getVocabularyByMetadataAndCollection).toHaveBeenCalled(); + expect(fixture.debugElement.query(By.css('ds-dynamic-scrollable-dropdown'))).toBeTruthy(); + }); + + it('getModel should return a DynamicScrollableDropdownModel', () => { + const result = component.getModel(); + + expect(result instanceof Observable).toBe(true); + + result.subscribe((model) => { + expect(model instanceof DynamicScrollableDropdownModel).toBe(true); + expect(model.vocabularyOptions.name).toBe(mockVocabularyScrollable.name); + }); + }); + }); + + describe('when the metadata field uses a hierarchical vocabulary and is editing', () => { + beforeEach(waitForAsync(() => { + spyOn(vocabularyServiceStub, 'getVocabularyByMetadataAndCollection').and.returnValue(createSuccessfulRemoteDataObject$(mockVocabularyHierarchical)); + metadataValue = Object.assign(new MetadataValue(), { + value: 'Authority Controlled value', + language: 'en', + place: 0, + authority: null, + }); + editMetadataValue = new DsoEditMetadataValue(metadataValue); + editMetadataValue.editing = true; + component.mdValue = editMetadataValue; + component.mdField = 'metadata.hierarchical'; + component.ngOnInit(); + fixture.detectChanges(); + })); + + it('should render the DsDynamicOneboxComponent', () => { + expect(vocabularyServiceStub.getVocabularyByMetadataAndCollection).toHaveBeenCalled(); + expect(fixture.debugElement.query(By.css('ds-dynamic-onebox'))).toBeTruthy(); + }); + + it('getModel should return a DynamicOneboxModel', () => { + const result = component.getModel(); + + expect(result instanceof Observable).toBe(true); + + result.subscribe((model) => { + expect(model instanceof DynamicOneboxModel).toBe(true); + expect(model.vocabularyOptions.name).toBe(mockVocabularyHierarchical.name); + }); + }); + }); + + describe('when the metadata field uses a suggester vocabulary and is editing', () => { + beforeEach(waitForAsync(() => { + spyOn(vocabularyServiceStub, 'getVocabularyByMetadataAndCollection').and.returnValue(createSuccessfulRemoteDataObject$(mockVocabularySuggester)); + spyOn(component.confirm, 'emit'); + metadataValue = Object.assign(new MetadataValue(), { + value: 'Authority Controlled value', + language: 'en', + place: 0, + authority: 'authority-key', + confidence: ConfidenceType.CF_UNCERTAIN + }); + editMetadataValue = new DsoEditMetadataValue(metadataValue); + editMetadataValue.editing = true; + component.mdValue = editMetadataValue; + component.mdField = 'metadata.suggester'; + component.ngOnInit(); + fixture.detectChanges(); + })); + + it('should render the DsDynamicOneboxComponent', () => { + expect(vocabularyServiceStub.getVocabularyByMetadataAndCollection).toHaveBeenCalled(); + expect(fixture.debugElement.query(By.css('ds-dynamic-onebox'))).toBeTruthy(); + }); + + it('getModel should return a DynamicOneboxModel', () => { + const result = component.getModel(); + + expect(result instanceof Observable).toBe(true); + + result.subscribe((model) => { + expect(model instanceof DynamicOneboxModel).toBe(true); + expect(model.vocabularyOptions.name).toBe(mockVocabularySuggester.name); + }); + }); + + describe('authority key edition', () => { + + it('should update confidence to CF_NOVALUE when authority is cleared', () => { + component.mdValue.newValue.authority = ''; + + component.onChangeAuthorityKey(); + + expect(component.mdValue.newValue.confidence).toBe(ConfidenceType.CF_NOVALUE); + expect(component.confirm.emit).toHaveBeenCalledWith(false); + }); + + it('should update confidence to CF_ACCEPTED when authority key is edited', () => { + component.mdValue.newValue.authority = 'newAuthority'; + component.mdValue.originalValue.authority = 'oldAuthority'; + + component.onChangeAuthorityKey(); + + expect(component.mdValue.newValue.confidence).toBe(ConfidenceType.CF_ACCEPTED); + expect(component.confirm.emit).toHaveBeenCalledWith(false); + }); + + it('should not update confidence when authority key remains the same', () => { + component.mdValue.newValue.authority = 'sameAuthority'; + component.mdValue.originalValue.authority = 'sameAuthority'; + + component.onChangeAuthorityKey(); + + expect(component.mdValue.newValue.confidence).toBe(ConfidenceType.CF_UNCERTAIN); + expect(component.confirm.emit).not.toHaveBeenCalled(); + }); + + it('should call onChangeEditingAuthorityStatus with true when clicking the lock button', () => { + spyOn(component, 'onChangeEditingAuthorityStatus'); + const lockButton = fixture.nativeElement.querySelector('#metadata-confirm-btn'); + + lockButton.click(); + + expect(component.onChangeEditingAuthorityStatus).toHaveBeenCalledWith(true); + }); + + it('should disable the input when editingAuthority is false', () => { + component.editingAuthority = false; + + fixture.detectChanges(); + + const inputElement = fixture.nativeElement.querySelector('input'); + expect(inputElement.disabled).toBe(true); + }); + + it('should enable the input when editingAuthority is true', () => { + component.editingAuthority = true; + + fixture.detectChanges(); + + const inputElement = fixture.nativeElement.querySelector('input'); + expect(inputElement.disabled).toBe(false); + }); + + it('should update mdValue.newValue properties when authority is present', () => { + const event = { + value: 'Some value', + authority: 'Some authority', + }; + + component.onChangeAuthorityField(event); + + expect(component.mdValue.newValue.value).toBe(event.value); + expect(component.mdValue.newValue.authority).toBe(event.authority); + expect(component.mdValue.newValue.confidence).toBe(ConfidenceType.CF_ACCEPTED); + expect(component.confirm.emit).toHaveBeenCalledWith(false); + }); + + it('should update mdValue.newValue properties when authority is not present', () => { + const event = { + value: 'Some value', + authority: null, + }; + + component.onChangeAuthorityField(event); + + expect(component.mdValue.newValue.value).toBe(event.value); + expect(component.mdValue.newValue.authority).toBeNull(); + expect(component.mdValue.newValue.confidence).toBe(ConfidenceType.CF_UNSET); + expect(component.confirm.emit).toHaveBeenCalledWith(false); + }); + + }); + + }); + function assertButton(name: string, exists: boolean, disabled: boolean = false): void { describe(`${name} button`, () => { let btn: DebugElement; diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.ts index 3fdcd381ab..29429ab3a0 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.ts +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.ts @@ -1,4 +1,4 @@ -import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { ChangeDetectorRef, Component, EventEmitter, Input, OnChanges, OnInit, Output, SimpleChanges } from '@angular/core'; import { DsoEditMetadataChangeType, DsoEditMetadataValue } from '../dso-edit-metadata-form'; import { Observable } from 'rxjs/internal/Observable'; import { @@ -8,10 +8,28 @@ import { import { RelationshipDataService } from '../../../core/data/relationship-data.service'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; import { ItemMetadataRepresentation } from '../../../core/shared/metadata-representation/item/item-metadata-representation.model'; -import { map } from 'rxjs/operators'; +import { map, switchMap, take } from 'rxjs/operators'; import { getItemPageRoute } from '../../../item-page/item-page-routing-paths'; import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; import { EMPTY } from 'rxjs/internal/observable/empty'; +import { VocabularyService } from 'src/app/core/submission/vocabularies/vocabulary.service'; +import { Vocabulary } from '../../../core/submission/vocabularies/models/vocabulary.model'; +import { UntypedFormControl, UntypedFormGroup } from '@angular/forms'; +import { VocabularyOptions } from '../../../core/submission/vocabularies/models/vocabulary-options.model'; +import { ConfidenceType } from '../../../core/shared/confidence-type'; +import { getFirstCompletedRemoteData, getFirstSucceededRemoteData, getFirstSucceededRemoteDataPayload, getRemoteDataPayload, metadataFieldsToString } from '../../../core/shared/operators'; +import { DsDynamicOneboxModelConfig, DynamicOneboxModel } from '../../../shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.model'; +import { DynamicScrollableDropdownModel, DynamicScrollableDropdownModelConfig } from '../../../shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.model'; +import { ItemDataService } from '../../../core/data/item-data.service'; +import { followLink } from '../../../shared/utils/follow-link-config.model'; +import { Item } from '../../../core/shared/item.model'; +import { Collection } from '../../../core/shared/collection.model'; +import { FormFieldMetadataValueObject } from '../../../shared/form/builder/models/form-field-metadata-value.model'; +import { isNotEmpty } from '../../../shared/empty.util'; +import { of as observableOf } from 'rxjs'; +import { RegistryService } from 'src/app/core/registry/registry.service'; +import { TranslateService } from '@ngx-translate/core'; +import { NotificationsService } from 'src/app/shared/notifications/notifications.service'; @Component({ selector: 'ds-dso-edit-metadata-value', @@ -21,7 +39,7 @@ import { EMPTY } from 'rxjs/internal/observable/empty'; /** * Component displaying a single editable row for a metadata value */ -export class DsoEditMetadataValueComponent implements OnInit { +export class DsoEditMetadataValueComponent implements OnInit, OnChanges { /** * The parent {@link DSpaceObject} to display a metadata form for * Also used to determine metadata-representations in case of virtual metadata @@ -51,6 +69,11 @@ export class DsoEditMetadataValueComponent implements OnInit { */ @Input() isOnlyValue = false; + /** + * MetadataField to edit + */ + @Input() mdField?: string; + /** * Emits when the user clicked edit */ @@ -82,6 +105,12 @@ export class DsoEditMetadataValueComponent implements OnInit { */ public DsoEditMetadataChangeTypeEnum = DsoEditMetadataChangeType; + /** + * The ConfidenceType enumeration for access in the component's template + * @type {ConfidenceType} + */ + public ConfidenceTypeEnum = ConfidenceType; + /** * The item this metadata value represents in case it's virtual (if any, otherwise null) */ @@ -97,12 +126,48 @@ export class DsoEditMetadataValueComponent implements OnInit { */ mdRepresentationName$: Observable; + /** + * Whether or not the authority field is currently being edited + */ + public editingAuthority = false; + + /** + * Field group used by authority field + * @type {UntypedFormGroup} + */ + group = new UntypedFormGroup({ authorityField : new UntypedFormControl()}); + + /** + * Observable property of the model to use for editinf authorities values + */ + private model$: Observable; + + /** + * Observable with information about the authority vocabulary used + */ + private vocabulary$: Observable; + + /** + * Observables with information about the authority vocabulary type used + */ + private isAuthorityControlled$: Observable; + private isHierarchicalVocabulary$: Observable; + private isScrollableVocabulary$: Observable; + private isSuggesterVocabulary$: Observable; + constructor(protected relationshipService: RelationshipDataService, - protected dsoNameService: DSONameService) { + protected dsoNameService: DSONameService, + protected vocabularyService: VocabularyService, + protected itemService: ItemDataService, + protected cdr: ChangeDetectorRef, + protected registryService: RegistryService, + protected notificationsService: NotificationsService, + protected translate: TranslateService) { } ngOnInit(): void { this.initVirtualProperties(); + this.initAuthorityProperties(); } /** @@ -123,4 +188,223 @@ export class DsoEditMetadataValueComponent implements OnInit { map((mdRepresentation: ItemMetadataRepresentation) => mdRepresentation ? this.dsoNameService.getName(mdRepresentation) : null), ); } + + /** + * Initialise potential properties of a authority controlled metadata field + */ + initAuthorityProperties(): void { + + if (isNotEmpty(this.mdField)) { + + const owningCollection$: Observable = this.itemService.findByHref(this.dso._links.self.href, true, true, followLink('owningCollection')) + .pipe( + getFirstSucceededRemoteData(), + getRemoteDataPayload(), + switchMap((item: Item) => item.owningCollection), + getFirstSucceededRemoteData(), + getRemoteDataPayload() + ); + + this.vocabulary$ = owningCollection$.pipe( + switchMap((c: Collection) => this.vocabularyService + .getVocabularyByMetadataAndCollection(this.mdField, c.uuid) + .pipe( + getFirstSucceededRemoteDataPayload() + )) + ); + } else { + this.vocabulary$ = observableOf(undefined); + } + + this.isAuthorityControlled$ = this.vocabulary$.pipe( + map((result: Vocabulary) => isNotEmpty(result)) + ); + + this.isHierarchicalVocabulary$ = this.vocabulary$.pipe( + map((result: Vocabulary) => isNotEmpty(result) && result.hierarchical) + ); + + this.isScrollableVocabulary$ = this.vocabulary$.pipe( + map((result: Vocabulary) => isNotEmpty(result) && result.scrollable) + ); + + this.isSuggesterVocabulary$ = this.vocabulary$.pipe( + map((result: Vocabulary) => isNotEmpty(result) && !result.hierarchical && !result.scrollable) + ); + + this.model$ = this.vocabulary$.pipe( + map((vocabulary: Vocabulary) => { + let formFieldValue; + if (isNotEmpty(this.mdValue.newValue.value)) { + formFieldValue = new FormFieldMetadataValueObject(); + formFieldValue.value = this.mdValue.newValue.value; + formFieldValue.display = this.mdValue.newValue.value; + if (this.mdValue.newValue.authority) { + formFieldValue.authority = this.mdValue.newValue.authority; + formFieldValue.confidence = this.mdValue.newValue.confidence; + } + } else { + formFieldValue = this.mdValue.newValue.value; + } + + let vocabularyOptions = vocabulary ? { + closed: false, + name: vocabulary.name + } as VocabularyOptions : null; + + if (!vocabulary.scrollable) { + let model: DsDynamicOneboxModelConfig = { + id: 'authorityField', + label: `${this.dsoType}.edit.metadata.edit.value`, + vocabularyOptions: vocabularyOptions, + metadataFields: [this.mdField], + value: formFieldValue, + repeatable: false, + submissionId: 'edit-metadata', + hasSelectableMetadata: false, + }; + return new DynamicOneboxModel(model); + } else { + let model: DynamicScrollableDropdownModelConfig = { + id: 'authorityField', + label: `${this.dsoType}.edit.metadata.edit.value`, + placeholder: `${this.dsoType}.edit.metadata.edit.value`, + vocabularyOptions: vocabularyOptions, + metadataFields: [this.mdField], + value: formFieldValue, + repeatable: false, + submissionId: 'edit-metadata', + hasSelectableMetadata: false, + maxOptions: 10 + }; + return new DynamicScrollableDropdownModel(model); + } + })); + } + + /** + * Change callback for the component. Check if the mdField has changed to retrieve whether it is metadata + * that uses a controlled vocabulary and update the related properties + * + * @param {SimpleChanges} changes + */ + ngOnChanges(changes: SimpleChanges): void { + if (isNotEmpty(changes.mdField) && !changes.mdField.firstChange) { + if (isNotEmpty(changes.mdField.currentValue) ) { + if (isNotEmpty(changes.mdField.previousValue) && + changes.mdField.previousValue !== changes.mdField.currentValue) { + // Clear authority value in case it has been assigned with the previous metadataField used + this.mdValue.newValue.authority = null; + this.mdValue.newValue.confidence = ConfidenceType.CF_UNSET; + } + + // Only ask if the current mdField have a period character to reduce request + if (changes.mdField.currentValue.includes('.')) { + this.validateMetadataField().subscribe((isValid: boolean) => { + if (isValid) { + this.initAuthorityProperties(); + this.cdr.detectChanges(); + } + }); + } + } + } + } + + /** + * Validate the metadata field to check if it exists on the server and return an observable boolean for success/error + */ + validateMetadataField(): Observable { + return this.registryService.queryMetadataFields(this.mdField, null, true, false, followLink('schema')).pipe( + getFirstCompletedRemoteData(), + switchMap((rd) => { + if (rd.hasSucceeded) { + return observableOf(rd).pipe( + metadataFieldsToString(), + take(1), + map((fields: string[]) => fields.indexOf(this.mdField) > -1) + ); + } else { + this.notificationsService.error(this.translate.instant(`${this.dsoType}.edit.metadata.metadatafield.error`), rd.errorMessage); + return [false]; + } + }), + ); + } + + /** + * Checks if this field use a authority vocabulary + */ + isAuthorityControlled(): Observable { + return this.isAuthorityControlled$; + } + + /** + * Checks if configured vocabulary is Hierarchical or not + */ + isHierarchicalVocabulary(): Observable { + return this.isHierarchicalVocabulary$; + } + + /** + * Checks if configured vocabulary is Scrollable or not + */ + isScrollableVocabulary(): Observable { + return this.isScrollableVocabulary$; + } + + /** + * Checks if configured vocabulary is Suggester or not + * (a vocabulary not Scrollable and not Hierarchical that uses an autocomplete field) + */ + isSuggesterVocabulary(): Observable { + return this.isSuggesterVocabulary$; + } + + /** + * Process the change of authority field value updating the authority key and confidence as necessary + */ + onChangeAuthorityField(event): void { + this.mdValue.newValue.value = event.value; + if (event.authority) { + this.mdValue.newValue.authority = event.authority; + this.mdValue.newValue.confidence = ConfidenceType.CF_ACCEPTED; + } else { + this.mdValue.newValue.authority = null; + this.mdValue.newValue.confidence = ConfidenceType.CF_UNSET; + } + this.confirm.emit(false); + } + + /** + * Returns an observable with the {@link DynamicOneboxModel} or {@link DynamicScrollableDropdownModel} model used + * for the authority field + */ + getModel(): Observable { + return this.model$; + } + + /** + * Change the status of the editingAuthority property + * @param status + */ + onChangeEditingAuthorityStatus(status: boolean) { + this.editingAuthority = status; + } + + /** + * Processes the change in authority value, updating the confidence as necessary. + * If the authority key is cleared, the confidence is set to {@link ConfidenceType.CF_NOVALUE}. + * If the authority key is edited and differs from the original, the confidence is set to {@link ConfidenceType.CF_ACCEPTED}. + */ + onChangeAuthorityKey() { + if (this.mdValue.newValue.authority === '') { + this.mdValue.newValue.confidence = ConfidenceType.CF_NOVALUE; + this.confirm.emit(false); + } else if (this.mdValue.newValue.authority !== this.mdValue.originalValue.authority) { + this.mdValue.newValue.confidence = ConfidenceType.CF_ACCEPTED; + this.confirm.emit(false); + } + } + } diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.html b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.html index 8fb676a724..d6c72abdb9 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.html +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.html @@ -40,6 +40,7 @@ [dsoType]="dsoType" [saving$]="savingOrLoadingFieldValidation$" [isOnlyValue]="true" + [mdField]="newMdField" (confirm)="confirmNewValue($event)" (remove)="form.newValue = undefined" (undo)="form.newValue = undefined"> 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/dso-shared/dso-shared.module.ts b/src/app/dso-shared/dso-shared.module.ts index 7d44d6a920..47a94c3de8 100644 --- a/src/app/dso-shared/dso-shared.module.ts +++ b/src/app/dso-shared/dso-shared.module.ts @@ -7,10 +7,12 @@ import { DsoEditMetadataValueComponent } from './dso-edit-metadata/dso-edit-meta import { DsoEditMetadataHeadersComponent } from './dso-edit-metadata/dso-edit-metadata-headers/dso-edit-metadata-headers.component'; import { DsoEditMetadataValueHeadersComponent } from './dso-edit-metadata/dso-edit-metadata-value-headers/dso-edit-metadata-value-headers.component'; import { ThemedDsoEditMetadataComponent } from './dso-edit-metadata/themed-dso-edit-metadata.component'; +import { FormModule } from '../shared/form/form.module'; @NgModule({ imports: [ SharedModule, + FormModule ], declarations: [ DsoEditMetadataComponent, 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.component.ts b/src/app/home-page/home-page.component.ts index c151cbbb16..9adc478b90 100644 --- a/src/app/home-page/home-page.component.ts +++ b/src/app/home-page/home-page.component.ts @@ -1,9 +1,10 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, Inject, OnInit } from '@angular/core'; import { map } from 'rxjs/operators'; import { ActivatedRoute } from '@angular/router'; import { Observable } from 'rxjs'; import { Site } from '../core/shared/site.model'; import { environment } from '../../environments/environment'; +import { APP_CONFIG, AppConfig } from 'src/config/app-config.interface'; @Component({ selector: 'ds-home-page', styleUrls: ['./home-page.component.scss'], @@ -14,6 +15,7 @@ export class HomePageComponent implements OnInit { site$: Observable; recentSubmissionspageSize: number; constructor( + @Inject(APP_CONFIG) protected appConfig: AppConfig, private route: ActivatedRoute, ) { this.recentSubmissionspageSize = environment.homePage.recentSubmissions.pageSize; diff --git a/src/app/home-page/home-page.module.ts b/src/app/home-page/home-page.module.ts index 1681abd805..e3debef5ca 100644 --- a/src/app/home-page/home-page.module.ts +++ b/src/app/home-page/home-page.module.ts @@ -3,7 +3,6 @@ import { NgModule } from '@angular/core'; import { SharedModule } from '../shared/shared.module'; import { HomeNewsComponent } from './home-news/home-news.component'; import { HomePageRoutingModule } from './home-page-routing.module'; - import { HomePageComponent } from './home-page.component'; import { TopLevelCommunityListComponent } from './top-level-community-list/top-level-community-list.component'; import { StatisticsModule } from '../statistics/statistics.module'; @@ -13,6 +12,8 @@ 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 { SearchModule } from '../shared/search/search.module'; +import { NotificationsModule } from '../notifications/notifications.module'; const DECLARATIONS = [ HomePageComponent, @@ -28,10 +29,12 @@ const DECLARATIONS = [ imports: [ CommonModule, SharedModule.withEntryComponents(), + SearchModule, 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 9c2bbba619..8b7243acde 100644 --- a/src/app/item-page/item-shared.module.ts +++ b/src/app/item-page/item-shared.module.ts @@ -1,21 +1,36 @@ -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 } from './simple/metadata-representation-list/themed-metadata-representation-list.component'; +import { ItemPageImgFieldComponent } from './simple/field-components/specific-field/img/item-page-img-field.component'; const ENTRY_COMPONENTS = [ ItemVersionsDeleteModalComponent, @@ -32,6 +47,7 @@ const COMPONENTS = [ MetadataRepresentationListComponent, ThemedMetadataRepresentationListComponent, RelatedItemsComponent, + ItemPageImgFieldComponent, ]; @NgModule({ @@ -42,7 +58,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 a9a247007f..fc6eb00195 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: '/admin/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 ea5784170f..c5e49b0cec 100644 --- a/src/app/my-dspace-page/my-dspace-page.component.html +++ b/src/app/my-dspace-page/my-dspace-page.component.html @@ -1,5 +1,6 @@
+
= { qaTopic: qualityAssuranceTopicsReducer, - qaSource: qualityAssuranceSourceReducer + qaSource: qualityAssuranceSourceReducer, + suggestionTarget: SuggestionTargetsReducer }; export const suggestionNotificationsSelector = createFeatureSelector('suggestionNotifications'); diff --git a/src/app/process-page/detail/process-detail.component.html b/src/app/process-page/detail/process-detail.component.html index 4daee064df..1e9b4d054f 100644 --- a/src/app/process-page/detail/process-detail.component.html +++ b/src/app/process-page/detail/process-detail.component.html @@ -5,8 +5,8 @@ {{ 'process.detail.title' | translate:{ id: process?.processId, name: process?.scriptName } }}
-
- Refreshing in {{ seconds }}s +
+ {{ 'process.detail.refreshing' | translate }}
diff --git a/src/app/process-page/detail/process-detail.component.spec.ts b/src/app/process-page/detail/process-detail.component.spec.ts index 9a0d89a882..9ba5d6e94d 100644 --- a/src/app/process-page/detail/process-detail.component.spec.ts +++ b/src/app/process-page/detail/process-detail.component.spec.ts @@ -27,7 +27,6 @@ import { ProcessDataService } from '../../core/data/processes/process-data.servi import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; import { createFailedRemoteDataObject$, - createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { createPaginatedList } from '../../shared/testing/utils.test'; @@ -35,7 +34,10 @@ import { NotificationsServiceStub } from '../../shared/testing/notifications-ser import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { getProcessListRoute } from '../process-page-routing.paths'; -import {ProcessStatus} from '../processes/process-status.model'; +import { PaginatedList } from '../../core/data/paginated-list.model'; +import { RouterTestingModule } from '@angular/router/testing'; +import { RouterStub } from '../../shared/testing/router.stub'; +import { ActivatedRouteStub } from '../../shared/testing/active-router.stub'; describe('ProcessDetailComponent', () => { let component: ProcessDetailComponent; @@ -45,44 +47,18 @@ describe('ProcessDetailComponent', () => { let nameService: DSONameService; let bitstreamDataService: BitstreamDataService; let httpClient: HttpClient; - let route: ActivatedRoute; + let route: ActivatedRouteStub; + let router: RouterStub; + let modalService; + let notificationsService: NotificationsServiceStub; let process: Process; let fileName: string; let files: Bitstream[]; - let processOutput; - - let modalService; - let notificationsService; - - let router; + let processOutput: string; function init() { - processOutput = 'Process Started'; - process = Object.assign(new Process(), { - processId: 1, - scriptName: 'script-name', - processStatus: 'COMPLETED', - parameters: [ - { - name: '-f', - value: 'file.xml' - }, - { - name: '-i', - value: 'identifier' - } - ], - _links: { - self: { - href: 'https://rest.api/processes/1' - }, - output: { - href: 'https://rest.api/processes/1/output' - } - } - }); fileName = 'fake-file-name'; files = [ Object.assign(new Bitstream(), { @@ -100,6 +76,33 @@ describe('ProcessDetailComponent', () => { } }) ]; + processOutput = 'Process Started'; + process = Object.assign(new Process(), { + processId: 1, + scriptName: 'script-name', + processStatus: 'COMPLETED', + parameters: [ + { + name: '-f', + value: 'file.xml' + }, + { + name: '-i', + value: 'identifier' + } + ], + files: createSuccessfulRemoteDataObject$(Object.assign(new PaginatedList(), { + page: files, + })), + _links: { + self: { + href: 'https://rest.api/processes/1' + }, + output: { + href: 'https://rest.api/processes/1/output' + } + }, + }); const logBitstream = Object.assign(new Bitstream(), { id: 'output.log', _links: { @@ -110,6 +113,7 @@ describe('ProcessDetailComponent', () => { getFiles: createSuccessfulRemoteDataObject$(createPaginatedList(files)), delete: createSuccessfulRemoteDataObject$(null), findById: createSuccessfulRemoteDataObject$(process), + autoRefreshUntilCompletion: createSuccessfulRemoteDataObject$(process) }); bitstreamDataService = jasmine.createSpyObj('bitstreamDataService', { findByHref: createSuccessfulRemoteDataObject$(logBitstream) @@ -127,28 +131,22 @@ describe('ProcessDetailComponent', () => { notificationsService = new NotificationsServiceStub(); - router = jasmine.createSpyObj('router', { - navigateByUrl:{} - }); + router = new RouterStub(); - route = jasmine.createSpyObj('route', { - data: observableOf({ process: createSuccessfulRemoteDataObject(process) }), - snapshot: { - params: { id: process.processId } - } + route = new ActivatedRouteStub({ + id: process.processId, + }, { + process: createSuccessfulRemoteDataObject$(process), }); } beforeEach(waitForAsync(() => { init(); - TestBed.configureTestingModule({ + void TestBed.configureTestingModule({ declarations: [ProcessDetailComponent, ProcessDetailFieldComponent, VarDirective, FileSizePipe], - imports: [TranslateModule.forRoot()], + imports: [TranslateModule.forRoot(), RouterTestingModule], providers: [ - { - provide: ActivatedRoute, - useValue: { data: observableOf({ process: createSuccessfulRemoteDataObject(process) }), snapshot: { params: { id: 1 } } }, - }, + { provide: ActivatedRoute, useValue: route }, { provide: ProcessDataService, useValue: processService }, { provide: BitstreamDataService, useValue: bitstreamDataService }, { provide: DSONameService, useValue: nameService }, @@ -253,6 +251,8 @@ describe('ProcessDetailComponent', () => { describe('deleteProcess', () => { it('should delete the process and navigate back to the overview page on success', () => { spyOn(component, 'closeModal'); + spyOn(router, 'navigateByUrl').and.callThrough(); + component.deleteProcess(process); expect(processService.delete).toHaveBeenCalledWith(process.processId); @@ -263,6 +263,7 @@ describe('ProcessDetailComponent', () => { it('should delete the process and not navigate on error', () => { (processService.delete as jasmine.Spy).and.returnValue(createFailedRemoteDataObject$()); spyOn(component, 'closeModal'); + spyOn(router, 'navigateByUrl').and.callThrough(); component.deleteProcess(process); @@ -272,98 +273,4 @@ describe('ProcessDetailComponent', () => { expect(router.navigateByUrl).not.toHaveBeenCalled(); }); }); - - describe('refresh counter', () => { - const queryRefreshCounter = () => fixture.debugElement.query(By.css('.refresh-counter')); - - describe('if process is completed', () => { - beforeEach(() => { - process.processStatus = ProcessStatus.COMPLETED; - route.data = observableOf({process: createSuccessfulRemoteDataObject(process)}); - }); - - it('should not show', () => { - spyOn(component, 'startRefreshTimer'); - - const refreshCounter = queryRefreshCounter(); - expect(refreshCounter).toBeNull(); - - expect(component.startRefreshTimer).not.toHaveBeenCalled(); - }); - }); - - describe('if process is not finished', () => { - beforeEach(() => { - process.processStatus = ProcessStatus.RUNNING; - route.data = observableOf({process: createSuccessfulRemoteDataObject(process)}); - fixture.detectChanges(); - component.stopRefreshTimer(); - }); - - it('should call startRefreshTimer', () => { - spyOn(component, 'startRefreshTimer'); - - component.ngOnInit(); - fixture.detectChanges(); // subscribe to process observable with async pipe - - expect(component.startRefreshTimer).toHaveBeenCalled(); - }); - - it('should call refresh method every 5 seconds, until process is completed', fakeAsync(() => { - spyOn(component, 'refresh').and.callThrough(); - spyOn(component, 'stopRefreshTimer').and.callThrough(); - - // start off with a running process in order for the refresh counter starts counting up - process.processStatus = ProcessStatus.RUNNING; - // set findbyId to return a completed process - (processService.findById as jasmine.Spy).and.returnValue(observableOf(createSuccessfulRemoteDataObject(process))); - - component.ngOnInit(); - fixture.detectChanges(); // subscribe to process observable with async pipe - - expect(component.refresh).not.toHaveBeenCalled(); - - expect(component.refreshCounter$.value).toBe(0); - - tick(1001); // 1 second + 1 ms by the setTimeout - expect(component.refreshCounter$.value).toBe(5); // 5 - 0 - - tick(2001); // 2 seconds + 1 ms by the setTimeout - expect(component.refreshCounter$.value).toBe(3); // 5 - 2 - - tick(2001); // 2 seconds + 1 ms by the setTimeout - expect(component.refreshCounter$.value).toBe(1); // 3 - 2 - - tick(1001); // 1 second + 1 ms by the setTimeout - expect(component.refreshCounter$.value).toBe(0); // 1 - 1 - - // set the process to completed right before the counter checks the process - process.processStatus = ProcessStatus.COMPLETED; - (processService.findById as jasmine.Spy).and.returnValue(observableOf(createSuccessfulRemoteDataObject(process))); - - tick(1000); // 1 second - - expect(component.refresh).toHaveBeenCalledTimes(1); - expect(component.stopRefreshTimer).toHaveBeenCalled(); - - expect(component.refreshCounter$.value).toBe(0); - - tick(1001); // 1 second + 1 ms by the setTimeout - // startRefreshTimer not called again - expect(component.refreshCounter$.value).toBe(0); - - discardPeriodicTasks(); // discard any periodic tasks that have not yet executed - })); - - it('should show if refreshCounter is different from 0', () => { - component.refreshCounter$.next(1); - fixture.detectChanges(); - - const refreshCounter = queryRefreshCounter(); - expect(refreshCounter).not.toBeNull(); - }); - - }); - - }); }); diff --git a/src/app/process-page/detail/process-detail.component.ts b/src/app/process-page/detail/process-detail.component.ts index be0b6ad0f6..793721dbd8 100644 --- a/src/app/process-page/detail/process-detail.component.ts +++ b/src/app/process-page/detail/process-detail.component.ts @@ -1,8 +1,8 @@ import { HttpClient } from '@angular/common/http'; -import { Component, Inject, NgZone, OnDestroy, OnInit, PLATFORM_ID } from '@angular/core'; +import { Component, Inject, NgZone, OnInit, PLATFORM_ID, OnDestroy } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; -import { BehaviorSubject, interval, Observable, shareReplay, Subscription } from 'rxjs'; -import { finalize, map, switchMap, take, tap } from 'rxjs/operators'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { finalize, map, switchMap, take, tap, find, startWith, filter } from 'rxjs/operators'; import { AuthService } from '../../core/auth/auth.service'; import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; import { BitstreamDataService } from '../../core/data/bitstream-data.service'; @@ -14,7 +14,7 @@ import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { getFirstCompletedRemoteData, getFirstSucceededRemoteData, - getFirstSucceededRemoteDataPayload + getFirstSucceededRemoteDataPayload, getAllSucceededRemoteDataPayload } from '../../core/shared/operators'; import { URLCombiner } from '../../core/url-combiner/url-combiner'; import { AlertType } from '../../shared/alert/alert-type'; @@ -26,8 +26,8 @@ import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; import { getProcessListRoute } from '../process-page-routing.paths'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { TranslateService } from '@ngx-translate/core'; -import { followLink } from '../../shared/utils/follow-link-config.model'; import { isPlatformBrowser } from '@angular/common'; +import { PROCESS_PAGE_FOLLOW_LINKS } from '../process-page.resolver'; @Component({ selector: 'ds-process-detail', @@ -78,15 +78,17 @@ export class ProcessDetailComponent implements OnInit, OnDestroy { */ dateFormat = 'yyyy-MM-dd HH:mm:ss ZZZZ'; - refreshCounter$ = new BehaviorSubject(0); + isRefreshing$: Observable; + + isDeleting: boolean; + + protected autoRefreshingID: string; /** * Reference to NgbModal */ protected modalRef: NgbModalRef; - private refreshTimerSub?: Subscription; - constructor( @Inject(PLATFORM_ID) protected platformId: object, protected route: ActivatedRoute, @@ -108,69 +110,36 @@ export class ProcessDetailComponent implements OnInit, OnDestroy { */ ngOnInit(): void { this.processRD$ = this.route.data.pipe( - map((data) => { + switchMap((data) => { if (isPlatformBrowser(this.platformId)) { - if (!this.isProcessFinished(data.process.payload)) { - this.startRefreshTimer(); - } - } - - return data.process as RemoteData; - }), - redirectOn4xx(this.router, this.authService), - shareReplay(1) - ); - - this.filesRD$ = this.processRD$.pipe( - getFirstSucceededRemoteDataPayload(), - switchMap((process: Process) => this.processService.getFiles(process.processId)) - ); - } - - refresh() { - this.processRD$ = this.processService.findById( - this.route.snapshot.params.id, - false, - true, - followLink('script') - ).pipe( - getFirstSucceededRemoteData(), - redirectOn4xx(this.router, this.authService), - tap((processRemoteData: RemoteData) => { - if (!this.isProcessFinished(processRemoteData.payload)) { - this.startRefreshTimer(); - } - }), - shareReplay(1) - ); - - this.filesRD$ = this.processRD$.pipe( - getFirstSucceededRemoteDataPayload(), - switchMap((process: Process) => this.processService.getFiles(process.processId)) - ); - } - - startRefreshTimer() { - this.refreshCounter$.next(0); - - this.refreshTimerSub = interval(1000).subscribe( - value => { - if (value > 5) { - setTimeout(() => { - this.refresh(); - this.stopRefreshTimer(); - this.refreshCounter$.next(0); - }, 1); + this.autoRefreshingID = this.route.snapshot.params.id; + return this.processService.autoRefreshUntilCompletion(this.autoRefreshingID, 5000, ...PROCESS_PAGE_FOLLOW_LINKS); } else { - this.refreshCounter$.next(5 - value); + return [data.process as RemoteData]; } - }); + }), + filter(() => !this.isDeleting), + redirectOn4xx(this.router, this.authService), + ); + + this.isRefreshing$ = this.processRD$.pipe( + find((processRD: RemoteData) => ProcessDataService.hasCompletedOrFailed(processRD.payload)), + map(() => false), + startWith(true) + ); + + this.filesRD$ = this.processRD$.pipe( + getAllSucceededRemoteDataPayload(), + switchMap((process: Process) => process.files), + ); } - stopRefreshTimer() { - if (hasValue(this.refreshTimerSub)) { - this.refreshTimerSub.unsubscribe(); - this.refreshTimerSub = undefined; + /** + * Make sure the autoRefreshUntilCompletion is cleaned up properly + */ + ngOnDestroy() { + if (hasValue(this.autoRefreshingID)) { + this.processService.stopAutoRefreshing(this.autoRefreshingID); } } @@ -249,15 +218,17 @@ export class ProcessDetailComponent implements OnInit, OnDestroy { * @param process */ deleteProcess(process: Process) { + this.isDeleting = true; this.processService.delete(process.processId).pipe( getFirstCompletedRemoteData() ).subscribe((rd) => { if (rd.hasSucceeded) { this.notificationsService.success(this.translateService.get('process.detail.delete.success')); this.closeModal(); - this.router.navigateByUrl(getProcessListRoute()); + void this.router.navigateByUrl(getProcessListRoute()); } else { this.notificationsService.error(this.translateService.get('process.detail.delete.error')); + this.isDeleting = false; } }); } @@ -276,8 +247,4 @@ export class ProcessDetailComponent implements OnInit, OnDestroy { closeModal() { this.modalRef.close(); } - - ngOnDestroy(): void { - this.stopRefreshTimer(); - } } diff --git a/src/app/process-page/form/process-form.component.ts b/src/app/process-page/form/process-form.component.ts index 70eb3160a8..1c5d6b9516 100644 --- a/src/app/process-page/form/process-form.component.ts +++ b/src/app/process-page/form/process-form.component.ts @@ -7,8 +7,7 @@ import { ControlContainer, NgForm } from '@angular/forms'; import { ScriptParameter } from '../scripts/script-parameter.model'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { TranslateService } from '@ngx-translate/core'; -import { RequestService } from '../../core/data/request.service'; -import { Router } from '@angular/router'; +import { Router, NavigationExtras } from '@angular/router'; import { getFirstCompletedRemoteData } from '../../core/shared/operators'; import { RemoteData } from '../../core/data/remote-data'; import { getProcessListRoute } from '../process-page-routing.paths'; @@ -57,7 +56,6 @@ export class ProcessFormComponent implements OnInit { private scriptService: ScriptDataService, private notificationsService: NotificationsService, private translationService: TranslateService, - private requestService: RequestService, private router: Router) { } @@ -91,7 +89,7 @@ export class ProcessFormComponent implements OnInit { const title = this.translationService.get('process.new.notification.success.title'); const content = this.translationService.get('process.new.notification.success.content'); this.notificationsService.success(title, content); - this.sendBack(); + this.sendBack(rd.payload); } else { const title = this.translationService.get('process.new.notification.error.title'); const content = this.translationService.get('process.new.notification.error.content'); @@ -143,11 +141,17 @@ export class ProcessFormComponent implements OnInit { return this.missingParameters.length > 0; } - private sendBack() { - this.requestService.removeByHrefSubstring('/processes'); - /* should subscribe on the previous method to know the action is finished and then navigate, - will fix this when the removeByHrefSubstring changes are merged */ - this.router.navigateByUrl(getProcessListRoute()); + /** + * Redirect the user to the processes overview page with the new process' ID, + * so it can be highlighted in the overview table. + * @param newProcess The newly created process + * @private + */ + private sendBack(newProcess: Process) { + const extras: NavigationExtras = { + queryParams: { new_process_id: newProcess.processId }, + }; + void this.router.navigate([getProcessListRoute()], extras); } } diff --git a/src/app/process-page/overview/process-overview.component.html b/src/app/process-page/overview/process-overview.component.html index 3f0e1e841f..8217cd8994 100644 --- a/src/app/process-page/overview/process-overview.component.html +++ b/src/app/process-page/overview/process-overview.component.html @@ -2,60 +2,46 @@

{{'process.overview.title' | translate}}

-
+ + +
+ + + + +
+ + +
+ + +
- + class="fas fa-plus pr-2">{{'process.overview.new' | translate}}
- -
- - - - - - - - - - - - - - - - - - - - - - - -
{{'process.overview.table.id' | translate}}{{'process.overview.table.name' | translate}}{{'process.overview.table.user' | translate}}{{'process.overview.table.start' | translate}}{{'process.overview.table.finish' | translate}}{{'process.overview.table.status' | translate}}{{'process.overview.table.actions' | translate}}
{{process.processId}}{{process.scriptName}}{{ePersonName}}{{process.startTime | date:dateFormat:'UTC'}}{{process.endTime | date:dateFormat:'UTC'}}{{process.processStatus}} - -
-
-
-
+ @@ -90,4 +76,3 @@ - diff --git a/src/app/process-page/overview/process-overview.component.spec.ts b/src/app/process-page/overview/process-overview.component.spec.ts index 94071c0e59..39f50bb1a9 100644 --- a/src/app/process-page/overview/process-overview.component.spec.ts +++ b/src/app/process-page/overview/process-overview.component.spec.ts @@ -3,86 +3,27 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { VarDirective } from '../../shared/utils/var.directive'; import { TranslateModule } from '@ngx-translate/core'; import { RouterTestingModule } from '@angular/router/testing'; -import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { NO_ERRORS_SCHEMA, TemplateRef } from '@angular/core'; import { ProcessDataService } from '../../core/data/processes/process-data.service'; -import { Process } from '../processes/process.model'; -import { EPersonDataService } from '../../core/eperson/eperson-data.service'; -import { EPerson } from '../../core/eperson/models/eperson.model'; import { By } from '@angular/platform-browser'; -import { ProcessStatus } from '../processes/process-status.model'; -import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; -import { createPaginatedList } from '../../shared/testing/utils.test'; -import { PaginationService } from '../../core/pagination/pagination.service'; -import { PaginationServiceStub } from '../../shared/testing/pagination-service.stub'; -import { DatePipe } from '@angular/common'; import { BehaviorSubject } from 'rxjs'; import { ProcessBulkDeleteService } from './process-bulk-delete.service'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { ProcessOverviewService } from './process-overview.service'; describe('ProcessOverviewComponent', () => { let component: ProcessOverviewComponent; let fixture: ComponentFixture; let processService: ProcessDataService; - let ePersonService: EPersonDataService; - let paginationService; - - let processes: Process[]; - let ePerson: EPerson; let processBulkDeleteService; let modalService; - const pipe = new DatePipe('en-US'); - function init() { - processes = [ - Object.assign(new Process(), { - processId: 1, - scriptName: 'script-name', - startTime: '2020-03-19 00:30:00', - endTime: '2020-03-19 23:30:00', - processStatus: ProcessStatus.COMPLETED - }), - Object.assign(new Process(), { - processId: 2, - scriptName: 'script-name', - startTime: '2020-03-20 00:30:00', - endTime: '2020-03-20 23:30:00', - processStatus: ProcessStatus.FAILED - }), - Object.assign(new Process(), { - processId: 3, - scriptName: 'another-script-name', - startTime: '2020-03-21 00:30:00', - endTime: '2020-03-21 23:30:00', - processStatus: ProcessStatus.RUNNING - }) - ]; - ePerson = Object.assign(new EPerson(), { - metadata: { - 'eperson.firstname': [ - { - value: 'John', - language: null - } - ], - 'eperson.lastname': [ - { - value: 'Doe', - language: null - } - ] - } + processService = jasmine.createSpyObj('processOverviewService', { + timeStarted: '2024-02-05 16:43:32', }); - processService = jasmine.createSpyObj('processService', { - findAll: createSuccessfulRemoteDataObject$(createPaginatedList(processes)) - }); - ePersonService = jasmine.createSpyObj('ePersonService', { - findById: createSuccessfulRemoteDataObject$(ePerson) - }); - - paginationService = new PaginationServiceStub(); processBulkDeleteService = jasmine.createSpyObj('processBulkDeleteService', { clearAllProcesses: {}, @@ -96,11 +37,7 @@ describe('ProcessOverviewComponent', () => { }); (processBulkDeleteService.isToBeDeleted as jasmine.Spy).and.callFake((id) => { - if (id === 2) { - return true; - } else { - return false; - } + return id === 2; }); modalService = jasmine.createSpyObj('modalService', { @@ -114,9 +51,7 @@ describe('ProcessOverviewComponent', () => { declarations: [ProcessOverviewComponent, VarDirective], imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], providers: [ - { provide: ProcessDataService, useValue: processService }, - { provide: EPersonDataService, useValue: ePersonService }, - { provide: PaginationService, useValue: paginationService }, + { provide: ProcessOverviewService, useValue: processService }, { provide: ProcessBulkDeleteService, useValue: processBulkDeleteService }, { provide: NgbModal, useValue: modalService }, ], @@ -130,73 +65,6 @@ describe('ProcessOverviewComponent', () => { fixture.detectChanges(); }); - describe('table structure', () => { - let rowElements; - - beforeEach(() => { - rowElements = fixture.debugElement.queryAll(By.css('tbody tr')); - }); - - it(`should contain 3 rows`, () => { - expect(rowElements.length).toEqual(3); - }); - - it('should display the process IDs in the first column', () => { - rowElements.forEach((rowElement, index) => { - const el = rowElement.query(By.css('td:nth-child(1)')).nativeElement; - expect(el.textContent).toContain(processes[index].processId); - }); - }); - - it('should display the script names in the second column', () => { - rowElements.forEach((rowElement, index) => { - const el = rowElement.query(By.css('td:nth-child(2)')).nativeElement; - expect(el.textContent).toContain(processes[index].scriptName); - }); - }); - - it('should display the eperson\'s name in the third column', () => { - rowElements.forEach((rowElement, index) => { - const el = rowElement.query(By.css('td:nth-child(3)')).nativeElement; - expect(el.textContent).toContain(ePerson.name); - }); - }); - - it('should display the start time in the fourth column', () => { - rowElements.forEach((rowElement, index) => { - const el = rowElement.query(By.css('td:nth-child(4)')).nativeElement; - expect(el.textContent).toContain(pipe.transform(processes[index].startTime, component.dateFormat, 'UTC')); - }); - }); - - it('should display the end time in the fifth column', () => { - rowElements.forEach((rowElement, index) => { - const el = rowElement.query(By.css('td:nth-child(5)')).nativeElement; - expect(el.textContent).toContain(pipe.transform(processes[index].endTime, component.dateFormat, 'UTC')); - }); - }); - - it('should display the status in the sixth column', () => { - rowElements.forEach((rowElement, index) => { - const el = rowElement.query(By.css('td:nth-child(6)')).nativeElement; - expect(el.textContent).toContain(processes[index].processStatus); - }); - }); - it('should display a delete button in the seventh column', () => { - rowElements.forEach((rowElement, index) => { - const el = rowElement.query(By.css('td:nth-child(7)')); - expect(el.nativeElement.innerHTML).toContain('fas fa-trash'); - - el.query(By.css('button')).triggerEventHandler('click', null); - expect(processBulkDeleteService.toggleDelete).toHaveBeenCalledWith(processes[index].processId); - }); - }); - it('should indicate a row that has been selected for deletion', () => { - const deleteRow = fixture.debugElement.query(By.css('.table-danger')); - expect(deleteRow.nativeElement.innerHTML).toContain('/processes/' + processes[1].processId); - }); - }); - describe('overview buttons', () => { it('should show a button to clear selected processes when there are selected processes', () => { const clearButton = fixture.debugElement.query(By.css('.btn-primary')); @@ -232,7 +100,7 @@ describe('ProcessOverviewComponent', () => { describe('openDeleteModal', () => { it('should open the modal', () => { - component.openDeleteModal({}); + component.openDeleteModal({} as TemplateRef); expect(modalService.open).toHaveBeenCalledWith({}); }); }); @@ -240,13 +108,11 @@ describe('ProcessOverviewComponent', () => { describe('deleteSelected', () => { it('should call the deleteSelectedProcesses method on the processBulkDeleteService and close the modal when processing is done', () => { spyOn(component, 'closeModal'); - spyOn(component, 'setProcesses'); component.deleteSelected(); expect(processBulkDeleteService.deleteSelectedProcesses).toHaveBeenCalled(); expect(component.closeModal).toHaveBeenCalled(); - expect(component.setProcesses).toHaveBeenCalled(); }); }); }); diff --git a/src/app/process-page/overview/process-overview.component.ts b/src/app/process-page/overview/process-overview.component.ts index 7fa3b12dac..3f8c2b4bfb 100644 --- a/src/app/process-page/overview/process-overview.component.ts +++ b/src/app/process-page/overview/process-overview.component.ts @@ -1,20 +1,10 @@ -import { Component, OnDestroy, OnInit } from '@angular/core'; -import { Observable, Subscription } from 'rxjs'; -import { RemoteData } from '../../core/data/remote-data'; -import { PaginatedList } from '../../core/data/paginated-list.model'; -import { Process } from '../processes/process.model'; -import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; -import { EPersonDataService } from '../../core/eperson/eperson-data.service'; -import { getFirstSucceededRemoteDataPayload } from '../../core/shared/operators'; -import { EPerson } from '../../core/eperson/models/eperson.model'; -import { map, switchMap } from 'rxjs/operators'; -import { ProcessDataService } from '../../core/data/processes/process-data.service'; -import { PaginationService } from '../../core/pagination/pagination.service'; -import { FindListOptions } from '../../core/data/find-list-options.model'; +import { Component, OnDestroy, OnInit, TemplateRef } from '@angular/core'; +import { Subscription } from 'rxjs'; import { ProcessBulkDeleteService } from './process-bulk-delete.service'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { hasValue } from '../../shared/empty.util'; -import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { ProcessOverviewService, ProcessSortField } from './process-overview.service'; +import { ProcessStatus } from '../processes/process-status.model'; @Component({ selector: 'ds-process-overview', @@ -25,72 +15,25 @@ import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; */ export class ProcessOverviewComponent implements OnInit, OnDestroy { - /** - * List of all processes - */ - processesRD$: Observable>>; + // Enums are redeclared here so they can be used in the template + protected readonly ProcessStatus = ProcessStatus; + protected readonly ProcessSortField = ProcessSortField; - /** - * The current pagination configuration for the page used by the FindAll method - */ - config: FindListOptions = Object.assign(new FindListOptions(), { - elementsPerPage: 20 - }); - - /** - * The current pagination configuration for the page - */ - pageConfig: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { - id: 'po', - pageSize: 20 - }); - - /** - * Date format to use for start and end time of processes - */ - dateFormat = 'yyyy-MM-dd HH:mm:ss'; - - processesToDelete: string[] = []; private modalRef: any; isProcessingSub: Subscription; - constructor(protected processService: ProcessDataService, - protected paginationService: PaginationService, - protected ePersonService: EPersonDataService, + constructor(protected processOverviewService: ProcessOverviewService, protected modalService: NgbModal, public processBulkDeleteService: ProcessBulkDeleteService, - protected dsoNameService: DSONameService, ) { } ngOnInit(): void { - this.setProcesses(); this.processBulkDeleteService.clearAllProcesses(); } - /** - * Send a request to fetch all processes for the current page - */ - setProcesses() { - this.processesRD$ = this.paginationService.getFindListOptions(this.pageConfig.id, this.config).pipe( - switchMap((config) => this.processService.findAll(config, true, false)) - ); - } - - /** - * Get the name of an EPerson by ID - * @param id ID of the EPerson - */ - getEpersonName(id: string): Observable { - return this.ePersonService.findById(id).pipe( - getFirstSucceededRemoteDataPayload(), - map((eperson: EPerson) => this.dsoNameService.getName(eperson)), - ); - } - ngOnDestroy(): void { - this.paginationService.clearPagination(this.pageConfig.id); if (hasValue(this.isProcessingSub)) { this.isProcessingSub.unsubscribe(); } @@ -100,7 +43,7 @@ export class ProcessOverviewComponent implements OnInit, OnDestroy { * Open a given modal. * @param content - the modal content. */ - openDeleteModal(content) { + openDeleteModal(content: TemplateRef) { this.modalRef = this.modalService.open(content); } @@ -126,7 +69,6 @@ export class ProcessOverviewComponent implements OnInit, OnDestroy { .subscribe((isProcessing) => { if (!isProcessing) { this.closeModal(); - this.setProcesses(); } }); } diff --git a/src/app/process-page/overview/process-overview.service.ts b/src/app/process-page/overview/process-overview.service.ts new file mode 100644 index 0000000000..78287ca182 --- /dev/null +++ b/src/app/process-page/overview/process-overview.service.ts @@ -0,0 +1,97 @@ +import { Injectable } from '@angular/core'; +import { ProcessDataService } from '../../core/data/processes/process-data.service'; +import { FindListOptions } from '../../core/data/find-list-options.model'; +import { Observable } from 'rxjs'; +import { RemoteData } from '../../core/data/remote-data'; +import { PaginatedList } from '../../core/data/paginated-list.model'; +import { Process } from '../processes/process.model'; +import { RequestParam } from '../../core/cache/models/request-param.model'; +import { ProcessStatus } from '../processes/process-status.model'; +import { DatePipe } from '@angular/common'; +import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; +import { SortOptions, SortDirection } from '../../core/cache/models/sort-options.model'; +import { hasValue } from '../../shared/empty.util'; + +/** + * The sortable fields for processes + * See [the endpoint documentation]{@link https://github.com/DSpace/RestContract/blob/main/processes-endpoint.md#search-processes-by-property} + * for details. + */ +export enum ProcessSortField { + creationTime = 'creationTime', + startTime = 'startTime', + endTime = 'endTime', +} + +/** + * Service to manage the processes displayed in the + * {@Link ProcessOverviewComponent} and the {@Link ProcessOverviewTableComponent} + */ +@Injectable({ + providedIn: 'root', +}) +export class ProcessOverviewService { + + constructor(protected processDataService: ProcessDataService) { + } + + /** + * Date format to use for start and end time of processes + */ + dateFormat = 'yyyy-MM-dd HH:mm:ss'; + + datePipe = new DatePipe('en-US'); + + + timeCreated = (process: Process) => this.datePipe.transform(process.creationTime, this.dateFormat, 'UTC'); + timeCompleted = (process: Process) => this.datePipe.transform(process.endTime, this.dateFormat, 'UTC'); + timeStarted = (process: Process) => this.datePipe.transform(process.startTime, this.dateFormat, 'UTC'); + + /** + * Retrieve processes by their status + * @param processStatus The status for which to retrieve processes + * @param findListOptions The FindListOptions object + * @param autoRefreshingIntervalInMs Optional: The interval by which to automatically refresh the retrieved processes. + * Leave empty or set to null to only retrieve the processes once. + */ + getProcessesByProcessStatus(processStatus: ProcessStatus, findListOptions?: FindListOptions, autoRefreshingIntervalInMs: number = null): Observable>> { + let requestParam = new RequestParam('processStatus', processStatus); + let options: FindListOptions = Object.assign(new FindListOptions(), { + searchParams: [requestParam], + elementsPerPage: 5, + }, findListOptions); + + if (hasValue(autoRefreshingIntervalInMs) && autoRefreshingIntervalInMs > 0) { + this.processDataService.stopAutoRefreshing(processStatus); + return this.processDataService.autoRefreshingSearchBy(processStatus, 'byProperty', options, autoRefreshingIntervalInMs); + } else { + return this.processDataService.searchBy('byProperty', options); + } + } + + /** + * Stop auto-refreshing the process with the given status + * @param processStatus the processStatus of the request to stop automatically refreshing + */ + stopAutoRefreshing(processStatus: ProcessStatus) { + this.processDataService.stopAutoRefreshing(processStatus); + } + + /** + * Map the provided paginationOptions to FindListOptions + * @param paginationOptions the PaginationComponentOptions to map + * @param sortField the field on which the processes are sorted + */ + getFindListOptions(paginationOptions: PaginationComponentOptions, sortField: ProcessSortField): FindListOptions { + let sortOptions = new SortOptions(sortField, SortDirection.DESC); + return Object.assign( + new FindListOptions(), + { + currentPage: paginationOptions.currentPage, + elementsPerPage: paginationOptions.pageSize, + sort: sortOptions, + } + ); + } + +} diff --git a/src/app/process-page/overview/table/process-overview-table.component.html b/src/app/process-page/overview/table/process-overview-table.component.html new file mode 100644 index 0000000000..c59c624b0f --- /dev/null +++ b/src/app/process-page/overview/table/process-overview-table.component.html @@ -0,0 +1,66 @@ +
+
+

+ {{'process.overview.table.' + processStatus.toLowerCase() + '.title' | translate}} + + {{processesRD?.payload?.totalElements}} + + + + +

+
+ +
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + +
{{'process.overview.table.id' | translate}}{{'process.overview.table.name' | translate}}{{'process.overview.table.user' | translate}}{{'process.overview.table.' + processStatus.toLowerCase() + '.info' | translate}}{{'process.overview.table.actions' | translate}}
{{tableEntry.process.processId}}{{tableEntry.process.scriptName}}{{tableEntry.user}}{{tableEntry.info}} + +
+
+ +
+ +
+

{{'process.overview.table.empty' | translate}}

+
+
+ +
+
diff --git a/src/app/process-page/overview/table/process-overview-table.component.scss b/src/app/process-page/overview/table/process-overview-table.component.scss new file mode 100644 index 0000000000..a9567be4c7 --- /dev/null +++ b/src/app/process-page/overview/table/process-overview-table.component.scss @@ -0,0 +1,28 @@ +.toggle-icon { + font-size: calc(var(--bs-small-font-size) * 0.6); +} + +.badge-nb-processes { + font-size: var(--ds-process-overview-table-nb-processes-badge-size); + vertical-align: middle; +} + +.id-header { + width: var(--ds-process-overview-table-id-column-width); +} + +.name-header { + width: var(--ds-process-overview-table-name-column-width); +} + +.user-header { + width: var(--ds-process-overview-table-user-column-width); +} + +.info-header { + width: var(--ds-process-overview-table-info-column-width); +} + +.actions-header { + width: var(--ds-process-overview-table-actions-column-width); +} diff --git a/src/app/process-page/overview/table/process-overview-table.component.spec.ts b/src/app/process-page/overview/table/process-overview-table.component.spec.ts new file mode 100644 index 0000000000..39520fa923 --- /dev/null +++ b/src/app/process-page/overview/table/process-overview-table.component.spec.ts @@ -0,0 +1,205 @@ +import { ProcessOverviewTableComponent } from './process-overview-table.component'; +import { ComponentFixture, waitForAsync, TestBed } from '@angular/core/testing'; +import { ProcessDataService } from '../../../core/data/processes/process-data.service'; +import { EPersonDataService } from '../../../core/eperson/eperson-data.service'; +import { Process } from '../../processes/process.model'; +import { EPerson } from '../../../core/eperson/models/eperson.model'; +import { ProcessBulkDeleteService } from '../process-bulk-delete.service'; +import { ProcessStatus } from '../../processes/process-status.model'; +import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { createPaginatedList } from '../../../shared/testing/utils.test'; +import { PaginationServiceStub } from '../../../shared/testing/pagination-service.stub'; +import { BehaviorSubject } from 'rxjs'; +import { NgbModal, NgbCollapse } from '@ng-bootstrap/ng-bootstrap'; +import { VarDirective } from '../../../shared/utils/var.directive'; +import { TranslateModule } from '@ngx-translate/core'; +import { RouterTestingModule } from '@angular/router/testing'; +import { PaginationService } from '../../../core/pagination/pagination.service'; +import { By } from '@angular/platform-browser'; +import { AuthService } from '../../../core/auth/auth.service'; +import { AuthServiceMock } from '../../../shared/mocks/auth.service.mock'; +import { RouteService } from '../../../core/services/route.service'; +import { routeServiceStub } from '../../../shared/testing/route-service.stub'; +import { ProcessOverviewService } from '../process-overview.service'; +import { take } from 'rxjs/operators'; + + +describe('ProcessOverviewTableComponent', () => { + let component: ProcessOverviewTableComponent; + let fixture: ComponentFixture; + + let processOverviewService: ProcessOverviewService; + let processService: ProcessDataService; + let ePersonService: EPersonDataService; + let paginationService; // : PaginationService; Not typed as the stub does not fully implement PaginationService + let processBulkDeleteService: ProcessBulkDeleteService; + let modalService: NgbModal; + let authService; // : AuthService; Not typed as the mock does not fully implement AuthService + let routeService: RouteService; + + let processes: Process[]; + let ePerson: EPerson; + + function init() { + processes = [ + Object.assign(new Process(), { + processId: 1, + scriptName: 'script-a', + startTime: '2020-03-19 00:30:00', + endTime: '2020-03-19 23:30:00', + processStatus: ProcessStatus.COMPLETED + }), + Object.assign(new Process(), { + processId: 2, + scriptName: 'script-b', + startTime: '2020-03-20 00:30:00', + endTime: '2020-03-20 23:30:00', + processStatus: ProcessStatus.FAILED + }), + Object.assign(new Process(), { + processId: 3, + scriptName: 'script-c', + startTime: '2020-03-21 00:30:00', + endTime: '2020-03-21 23:30:00', + processStatus: ProcessStatus.RUNNING + }), + ]; + ePerson = Object.assign(new EPerson(), { + metadata: { + 'eperson.firstname': [ + { + value: 'John', + language: null + } + ], + 'eperson.lastname': [ + { + value: 'Doe', + language: null + } + ] + } + }); + processOverviewService = jasmine.createSpyObj('processOverviewService', { + getFindListOptions: { + currentPage: 1, + elementsPerPage: 5, + sort: 'creationTime' + }, + getProcessesByProcessStatus: createSuccessfulRemoteDataObject$(createPaginatedList(processes)).pipe(take(1)) + }); + processService = jasmine.createSpyObj('processService', { + searchBy: createSuccessfulRemoteDataObject$(createPaginatedList(processes)).pipe(take(1)) + }); + ePersonService = jasmine.createSpyObj('ePersonService', { + findById: createSuccessfulRemoteDataObject$(ePerson) + }); + + paginationService = new PaginationServiceStub(); + + processBulkDeleteService = jasmine.createSpyObj('processBulkDeleteService', { + clearAllProcesses: {}, + deleteSelectedProcesses: {}, + isProcessing$: new BehaviorSubject(false), + hasSelected: true, + isToBeDeleted: true, + toggleDelete: {}, + getAmountOfSelectedProcesses: 5 + + }); + + (processBulkDeleteService.isToBeDeleted as jasmine.Spy).and.callFake((id) => { + return id === 2; + }); + + modalService = jasmine.createSpyObj('modalService', { + open: {} + }); + + authService = new AuthServiceMock(); + routeService = routeServiceStub; + } + + beforeEach(waitForAsync(() => { + init(); + + void TestBed.configureTestingModule({ + declarations: [ProcessOverviewTableComponent, VarDirective, NgbCollapse], + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], + providers: [ + { provide: ProcessOverviewService, useValue: processOverviewService }, + { provide: ProcessDataService, useValue: processService }, + { provide: EPersonDataService, useValue: ePersonService }, + { provide: PaginationService, useValue: paginationService }, + { provide: ProcessBulkDeleteService, useValue: processBulkDeleteService }, + { provide: NgbModal, useValue: modalService }, + { provide: AuthService, useValue: authService }, + { provide: RouteService, useValue: routeService }, + ], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ProcessOverviewTableComponent); + component = fixture.componentInstance; + component.getInfoValueMethod = (_process: Process) => 'process info'; + component.processStatus = ProcessStatus.COMPLETED; + fixture.detectChanges(); + }); + + describe('table structure', () => { + let rowElements; + + beforeEach(() => { + rowElements = fixture.debugElement.queryAll(By.css('tbody tr')); + }); + + it('should contain 3 rows', () => { + expect(rowElements.length).toEqual(3); + }); + + it('should display the process\' ID in the first column', () => { + rowElements.forEach((rowElement, index) => { + const el = rowElement.query(By.css('td:nth-child(1)')).nativeElement; + expect(el.textContent).toContain(processes[index].processId); + }); + }); + + it('should display the scripts name in the second column', () => { + rowElements.forEach((rowElement, index) => { + const el = rowElement.query(By.css('td:nth-child(2)')).nativeElement; + expect(el.textContent).toContain(processes[index].scriptName); + }); + }); + + it('should display the eperson\'s name in the third column', () => { + rowElements.forEach((rowElement, _index) => { + const el = rowElement.query(By.css('td:nth-child(3)')).nativeElement; + expect(el.textContent).toContain(ePerson.name); + }); + }); + + it('should display the requested info in the fourth column', () => { + rowElements.forEach((rowElement, _index) => { + const el = rowElement.query(By.css('td:nth-child(4)')).nativeElement; + expect(el.textContent).toContain('process info'); + }); + }); + + it('should display a delete button in the fifth column', () => { + rowElements.forEach((rowElement, index) => { + const el = rowElement.query(By.css('td:nth-child(5)')); + expect(el.nativeElement.innerHTML).toContain('fas fa-trash'); + + el.query(By.css('button')).triggerEventHandler('click', null); + expect(processBulkDeleteService.toggleDelete).toHaveBeenCalledWith(processes[index].processId); + }); + }); + + it('should indicate a row that has been selected for deletion', () => { + const deleteRow = fixture.debugElement.query(By.css('.table-danger')); + expect(deleteRow.nativeElement.innerHTML).toContain('/processes/' + processes[1].processId); + }); + + }); +}); diff --git a/src/app/process-page/overview/table/process-overview-table.component.ts b/src/app/process-page/overview/table/process-overview-table.component.ts new file mode 100644 index 0000000000..7bd5c02b43 --- /dev/null +++ b/src/app/process-page/overview/table/process-overview-table.component.ts @@ -0,0 +1,249 @@ +import { Component, Input, OnInit, Inject, PLATFORM_ID, OnDestroy } from '@angular/core'; +import { ProcessStatus } from '../../processes/process-status.model'; +import { Observable, mergeMap, from as observableFrom, BehaviorSubject, Subscription } from 'rxjs'; +import { RemoteData } from '../../../core/data/remote-data'; +import { PaginatedList } from '../../../core/data/paginated-list.model'; +import { Process } from '../../processes/process.model'; +import { + PaginationComponentOptions +} from '../../../shared/pagination/pagination-component-options.model'; +import { ProcessOverviewService, ProcessSortField } from '../process-overview.service'; +import { ProcessBulkDeleteService } from '../process-bulk-delete.service'; +import { EPersonDataService } from '../../../core/eperson/eperson-data.service'; +import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; +import { + getFirstSucceededRemoteDataPayload, + getAllCompletedRemoteData +} from '../../../core/shared/operators'; +import { map, switchMap, toArray, take, filter } from 'rxjs/operators'; +import { EPerson } from '../../../core/eperson/models/eperson.model'; +import { PaginationService } from 'src/app/core/pagination/pagination.service'; +import { FindListOptions } from '../../../core/data/find-list-options.model'; +import { redirectOn4xx } from '../../../core/shared/authorized.operators'; +import { Router } from '@angular/router'; +import { AuthService } from '../../../core/auth/auth.service'; +import { isPlatformBrowser } from '@angular/common'; +import { RouteService } from '../../../core/services/route.service'; +import { hasValue } from '../../../shared/empty.util'; + +const NEW_PROCESS_PARAM = 'new_process_id'; + +/** + * An interface to store a process and extra information related to the process + * that is displayed in the overview table. + */ +export interface ProcessOverviewTableEntry { + process: Process, + user: string, + info: string, +} + +@Component({ + selector: 'ds-process-overview-table', + styleUrls: ['./process-overview-table.component.scss'], + templateUrl: './process-overview-table.component.html' +}) +export class ProcessOverviewTableComponent implements OnInit, OnDestroy { + + /** + * The status of the processes this sections should show + */ + @Input() processStatus: ProcessStatus; + + /** + * The field on which the processes in this table are sorted + * {@link ProcessSortField.creationTime} by default as every single process has a creation time, + * but not every process has a start or end time + */ + @Input() sortField: ProcessSortField = ProcessSortField.creationTime; + + /** + * Whether to use auto refresh for the processes shown in this table. + */ + @Input() useAutoRefreshingSearchBy = false; + + /** + * The interval by which to refresh if autoRefreshing is enabled + */ + @Input() autoRefreshInterval = 5000; + + /** + * The function used to retrieve the value that will be shown in the 'info' column of the table. + * {@Link ProcessOverviewService} contains some predefined functions. + */ + @Input() getInfoValueMethod: (process: Process) => string; + + /** + * List of processes and their info to be shown in this table + */ + processesRD$: BehaviorSubject>>; + + /** + * The pagination ID for this overview section + */ + paginationId: string; + + /** + * The current pagination options for the overview section + */ + paginationOptions$: Observable; + + /** + * Whether the table is collapsed + */ + isCollapsed = false; + + /** + * The id of the process to highlight + */ + newProcessId: string; + + /** + * List of subscriptions + */ + subs: Subscription[] = []; + + constructor(protected processOverviewService: ProcessOverviewService, + protected processBulkDeleteService: ProcessBulkDeleteService, + protected ePersonDataService: EPersonDataService, + protected dsoNameService: DSONameService, + protected paginationService: PaginationService, + protected routeService: RouteService, + protected router: Router, + protected auth: AuthService, + @Inject(PLATFORM_ID) protected platformId: object, + ) { + } + + ngOnInit() { + // Only auto refresh on browsers + if (!isPlatformBrowser(this.platformId)) { + this.useAutoRefreshingSearchBy = false; + } + + this.routeService.getQueryParameterValue(NEW_PROCESS_PARAM).pipe(take(1)).subscribe((id) => { + this.newProcessId = id; + }); + + // Creates an ID from the first 2 characters of the process status. + // Should two process status values ever start with the same substring, + // increase the number of characters until the ids are distinct. + this.paginationId = this.processStatus.toLowerCase().substring(0,2); + + let defaultPaginationOptions = Object.assign(new PaginationComponentOptions(), { + id: this.paginationId, + pageSize: 5, + }); + + // Get the current pagination from the route + this.paginationOptions$ = this.paginationService.getCurrentPagination(this.paginationId, defaultPaginationOptions); + + this.processesRD$ = new BehaviorSubject(undefined); + + // Once we have the pagination, retrieve the processes matching the process type and the pagination + // + // Reasoning why this monstrosity is the way it is: + // To avoid having to recalculate the names of the submitters every time the page reloads, these have to be + // retrieved beforehand and stored with the process. This is where the ProcessOverviewTableEntry interface comes in. + // By storing the process together with the submitters name and the additional information to be shown in the table, + // the template can be as dumb as possible. As the retrieval of the name also is done through an observable, this + // complicates the construction of the data a bit though. + // The reason why we store these as RemoteData> and not simply as + // ProcessOverviewTableEntry[] is as follows: + // When storing the PaginatedList and ProcessOverviewTableEntry[] separately, there is a small delay + // between the update of the paginatedList and the entryArray. This results in the processOverviewPage showing + // no processes for a split second every time the processes are updated which in turn causes the different + // sections of the page to jump around. By combining these and causing the page to update only once this is avoided. + this.subs.push(this.paginationOptions$ + .pipe( + // Map the paginationOptions to findListOptions + map((paginationOptions: PaginationComponentOptions) => + this.processOverviewService.getFindListOptions(paginationOptions, this.sortField)), + // Use the findListOptions to retrieve the relevant processes every interval + switchMap((findListOptions: FindListOptions) => + this.processOverviewService.getProcessesByProcessStatus( + this.processStatus, findListOptions, this.useAutoRefreshingSearchBy ? this.autoRefreshInterval : null) + ), + // Redirect the user when he is logged out + redirectOn4xx(this.router, this.auth), + getAllCompletedRemoteData(), + // Map RemoteData> to RemoteData> + switchMap((processesRD: RemoteData>) => { + // Create observable emitting all processes one by one + return observableFrom(processesRD.payload.page).pipe( + // Map every Process to ProcessOverviewTableEntry + mergeMap((process: Process) => { + return this.getEPersonName(process.userId).pipe( + map((name) => { + return { + process: process, + user: name, + info: this.getInfoValueMethod(process), + }; + }), + ); + }), + // Collect processOverviewTableEntries into array + toArray(), + // Create RemoteData> + map((entries: ProcessOverviewTableEntry[]) => { + const entriesPL: PaginatedList = + Object.assign(new PaginatedList(), processesRD.payload, { page: entries }); + const entriesRD: RemoteData> = + Object.assign({}, processesRD, { payload: entriesPL }); + return entriesRD; + }), + ); + }), + + ).subscribe((next: RemoteData>) => { + this.processesRD$.next(next); + })); + + // Collapse this section when the number of processes is zero the first time processes are retrieved + this.subs.push(this.processesRD$.pipe( + filter((processListRd: RemoteData>) => hasValue(processListRd)), + take(1), + ).subscribe( + (processesRD: RemoteData>) => { + if (!(processesRD.payload.totalElements > 0)) { + this.isCollapsed = true; + } + } + )); + + } + + /** + * Get the name of an EPerson by ID + * @param id ID of the EPerson + */ + getEPersonName(id: string): Observable { + return this.ePersonDataService.findById(id).pipe( + getFirstSucceededRemoteDataPayload(), + map((eperson: EPerson) => this.dsoNameService.getName(eperson)), + ); + } + + /** + * Get the css class for a row depending on the state of the process + * @param process + */ + getRowClass(process: Process): string { + if (this.processBulkDeleteService.isToBeDeleted(process.processId)) { + return 'table-danger'; + } else if (this.newProcessId === process.processId) { + return 'table-info'; + } else { + return ''; + } + } + + ngOnDestroy(): void { + this.subs + .filter((sub) => hasValue(sub)) + .forEach((sub) => sub.unsubscribe()); + this.processOverviewService.stopAutoRefreshing(this.processStatus); + } + + } diff --git a/src/app/process-page/process-breadcrumb.resolver.ts b/src/app/process-page/process-breadcrumb.resolver.ts index 23e3dca0a8..fd0c1ad735 100644 --- a/src/app/process-page/process-breadcrumb.resolver.ts +++ b/src/app/process-page/process-breadcrumb.resolver.ts @@ -6,8 +6,9 @@ import { Process } from './processes/process.model'; import { followLink } from '../shared/utils/follow-link-config.model'; import { ProcessDataService } from '../core/data/processes/process-data.service'; import { BreadcrumbConfig } from '../breadcrumbs/breadcrumb/breadcrumb-config.model'; -import { getRemoteDataPayload, getFirstSucceededRemoteData } from '../core/shared/operators'; +import { getFirstCompletedRemoteData } from '../core/shared/operators'; import { ProcessBreadcrumbsService } from './process-breadcrumbs.service'; +import { RemoteData } from '../core/data/remote-data'; /** * This class represents a resolver that requests a specific process before the route is activated @@ -28,12 +29,11 @@ export class ProcessBreadcrumbResolver implements Resolve { + getFirstCompletedRemoteData(), + map((object: RemoteData) => { const fullPath = state.url; const url = fullPath.substr(0, fullPath.indexOf(id)) + id; - return { provider: this.breadcrumbService, key: object, url: url }; + return { provider: this.breadcrumbService, key: object.payload, url: url }; }) ); } diff --git a/src/app/process-page/process-breadcrumbs.service.ts b/src/app/process-page/process-breadcrumbs.service.ts index 26b0787a53..ac490138b8 100644 --- a/src/app/process-page/process-breadcrumbs.service.ts +++ b/src/app/process-page/process-breadcrumbs.service.ts @@ -3,6 +3,7 @@ import { Injectable } from '@angular/core'; import { BreadcrumbsProviderService } from '../core/breadcrumbs/breadcrumbsProviderService'; import { Breadcrumb } from '../breadcrumbs/breadcrumb/breadcrumb.model'; import { Process } from './processes/process.model'; +import { hasValue } from '../shared/empty.util'; /** * Service to calculate process breadcrumbs for a single part of the route @@ -16,6 +17,10 @@ export class ProcessBreadcrumbsService implements BreadcrumbsProviderService { - return observableOf([new Breadcrumb(key.processId + ' - ' + key.scriptName, url)]); + if (hasValue(key)) { + return observableOf([new Breadcrumb(key.processId + ' - ' + key.scriptName, url)]); + } else { + return observableOf([]); + } } } diff --git a/src/app/process-page/process-page-shared.module.ts b/src/app/process-page/process-page-shared.module.ts index e666283e03..2e7266fcf2 100644 --- a/src/app/process-page/process-page-shared.module.ts +++ b/src/app/process-page/process-page-shared.module.ts @@ -16,10 +16,14 @@ import { ProcessDetailFieldComponent } from './detail/process-detail-field/proce import { ProcessBreadcrumbsService } from './process-breadcrumbs.service'; import { ProcessBreadcrumbResolver } from './process-breadcrumb.resolver'; import { ProcessFormComponent } from './form/process-form.component'; +import { NgbCollapseModule } from '@ng-bootstrap/ng-bootstrap'; +import { ProcessOverviewTableComponent } from './overview/table/process-overview-table.component'; +import { DatePipe } from '@angular/common'; @NgModule({ imports: [ SharedModule, + NgbCollapseModule, ], declarations: [ NewProcessComponent, @@ -33,13 +37,15 @@ import { ProcessFormComponent } from './form/process-form.component'; BooleanValueInputComponent, DateValueInputComponent, ProcessOverviewComponent, + ProcessOverviewTableComponent, ProcessDetailComponent, ProcessDetailFieldComponent, ProcessFormComponent ], providers: [ ProcessBreadcrumbResolver, - ProcessBreadcrumbsService + ProcessBreadcrumbsService, + DatePipe, ] }) diff --git a/src/app/process-page/process-page.resolver.ts b/src/app/process-page/process-page.resolver.ts index ba872302b3..2e4843646b 100644 --- a/src/app/process-page/process-page.resolver.ts +++ b/src/app/process-page/process-page.resolver.ts @@ -7,6 +7,10 @@ import { followLink } from '../shared/utils/follow-link-config.model'; import { ProcessDataService } from '../core/data/processes/process-data.service'; import { getFirstCompletedRemoteData } from '../core/shared/operators'; +export const PROCESS_PAGE_FOLLOW_LINKS = [ + followLink('files'), +]; + /** * This class represents a resolver that requests a specific process before the route is activated */ @@ -23,7 +27,7 @@ export class ProcessPageResolver implements Resolve> { * or an error if something went wrong */ resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable> { - return this.processService.findById(route.params.id, false, true, followLink('script')).pipe( + return this.processService.findById(route.params.id, false, true, ...PROCESS_PAGE_FOLLOW_LINKS).pipe( getFirstCompletedRemoteData(), ); } diff --git a/src/app/process-page/processes/filetypes.model.ts b/src/app/process-page/processes/filetypes.model.ts new file mode 100644 index 0000000000..28e9df71cd --- /dev/null +++ b/src/app/process-page/processes/filetypes.model.ts @@ -0,0 +1,34 @@ +import { typedObject } from '../../core/cache/builders/build-decorators'; +import { excludeFromEquals } from '../../core/utilities/equals.decorators'; +import { autoserialize } from 'cerialize'; +import { ResourceType } from '../../core/shared/resource-type'; +import { FILETYPES } from './filetypes.resource-type'; + +/** + * Object representing the file types of the {@link Bitstream}s of a {@link Process} + */ +@typedObject +export class Filetypes { + + static type = FILETYPES; + + /** + * The id of this {@link Filetypes} + */ + @autoserialize + id: string; + + /** + * The values of this {@link Filetypes} + */ + @autoserialize + values: string[]; + + /** + * The object type + */ + @excludeFromEquals + @autoserialize + type: ResourceType; + +} diff --git a/src/app/process-page/processes/filetypes.resource-type.ts b/src/app/process-page/processes/filetypes.resource-type.ts new file mode 100644 index 0000000000..29f9636208 --- /dev/null +++ b/src/app/process-page/processes/filetypes.resource-type.ts @@ -0,0 +1,8 @@ +/** + * The resource type for {@link Filetypes} + * + * Needs to be in a separate file to prevent circular dependencies in webpack. + */ +import { ResourceType } from '../../core/shared/resource-type'; + +export const FILETYPES = new ResourceType('filetypes'); diff --git a/src/app/process-page/processes/process-status.model.ts b/src/app/process-page/processes/process-status.model.ts index b43340bffb..1ff42789d8 100644 --- a/src/app/process-page/processes/process-status.model.ts +++ b/src/app/process-page/processes/process-status.model.ts @@ -2,8 +2,8 @@ * List of process statuses */ export enum ProcessStatus { - SCHEDULED, - RUNNING, - COMPLETED, - FAILED + SCHEDULED = 'SCHEDULED', + RUNNING = 'RUNNING', + COMPLETED = 'COMPLETED', + FAILED = 'FAILED' } diff --git a/src/app/process-page/processes/process.model.ts b/src/app/process-page/processes/process.model.ts index d5f6e77d32..8468b4e43d 100644 --- a/src/app/process-page/processes/process.model.ts +++ b/src/app/process-page/processes/process.model.ts @@ -3,7 +3,7 @@ import { PROCESS_OUTPUT_TYPE } from '../../core/shared/process-output.resource-t import { ProcessStatus } from './process-status.model'; import { ProcessParameter } from './process-parameter.model'; import { HALLink } from '../../core/shared/hal-link.model'; -import { autoserialize, deserialize } from 'cerialize'; +import { autoserialize, deserialize, autoserializeAs } from 'cerialize'; import { PROCESS } from './process.resource-type'; import { excludeFromEquals } from '../../core/utilities/equals.decorators'; import { ResourceType } from '../../core/shared/resource-type'; @@ -13,6 +13,10 @@ import { RemoteData } from '../../core/data/remote-data'; import { SCRIPT } from '../scripts/script.resource-type'; import { Script } from '../scripts/script.model'; import { CacheableObject } from '../../core/cache/cacheable-object.model'; +import { BITSTREAM } from '../../core/shared/bitstream.resource-type'; +import { PaginatedList } from '../../core/data/paginated-list.model'; +import { Filetypes } from './filetypes.model'; +import { FILETYPES } from './filetypes.resource-type'; /** * Object representing a process @@ -31,7 +35,7 @@ export class Process implements CacheableObject { /** * The identifier for this process */ - @autoserialize + @autoserializeAs(String) processId: string; /** @@ -40,6 +44,12 @@ export class Process implements CacheableObject { @autoserialize userId: string; + /** + * The creation time for this process + */ + @autoserialize + creationTime: string; + /** * The start time for this process */ @@ -78,7 +88,8 @@ export class Process implements CacheableObject { self: HALLink, script: HALLink, output: HALLink, - files: HALLink + files: HALLink, + filetypes: HALLink, }; /** @@ -94,4 +105,19 @@ export class Process implements CacheableObject { */ @link(PROCESS_OUTPUT_TYPE) output?: Observable>; + + /** + * The files created by this Process + * Will be undefined unless the output {@link HALLink} has been resolved. + */ + @link(BITSTREAM, true) + files?: Observable>>; + + /** + * The filetypes present in this Process + * Will be undefined unless the output {@link HALLink} has been resolved. + */ + @link(FILETYPES) + filetypes?: Observable>; + } 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/search-navbar/search-navbar.component.spec.ts b/src/app/search-navbar/search-navbar.component.spec.ts index 7edae293e1..ece2391dcb 100644 --- a/src/app/search-navbar/search-navbar.component.spec.ts +++ b/src/app/search-navbar/search-navbar.component.spec.ts @@ -88,7 +88,7 @@ describe('SearchNavbarComponent', () => { fixture.detectChanges(); })); it('to search page with empty query', () => { - const extras: NavigationExtras = { queryParams: { query: '' }, queryParamsHandling: 'merge' }; + const extras: NavigationExtras = { queryParams: { query: '' } }; expect(component.onSubmit).toHaveBeenCalledWith({ query: '' }); expect(router.navigate).toHaveBeenCalledWith(['search'], extras); }); @@ -113,7 +113,7 @@ describe('SearchNavbarComponent', () => { fixture.detectChanges(); })); it('to search page with query', async () => { - const extras: NavigationExtras = { queryParams: { query: 'test' }, queryParamsHandling: 'merge' }; + const extras: NavigationExtras = { queryParams: { query: 'test' } }; expect(component.onSubmit).toHaveBeenCalledWith({ query: 'test' }); expect(router.navigate).toHaveBeenCalledWith(['search'], extras); diff --git a/src/app/search-navbar/search-navbar.component.ts b/src/app/search-navbar/search-navbar.component.ts index 98e64f6e10..7f8f951073 100644 --- a/src/app/search-navbar/search-navbar.component.ts +++ b/src/app/search-navbar/search-navbar.component.ts @@ -66,8 +66,7 @@ export class SearchNavbarComponent { this.searchForm.reset(); this.router.navigate(linkToNavigateTo, { - queryParams: queryParams, - queryParamsHandling: 'merge' + queryParams: queryParams }); } } diff --git a/src/app/search-page/configuration-search-page.component.ts b/src/app/search-page/configuration-search-page.component.ts index 768149de6a..9196dda025 100644 --- a/src/app/search-page/configuration-search-page.component.ts +++ b/src/app/search-page/configuration-search-page.component.ts @@ -8,6 +8,7 @@ import { SearchConfigurationService } from '../core/shared/search/search-configu import { RouteService } from '../core/services/route.service'; import { SearchService } from '../core/shared/search/search.service'; import { Router } from '@angular/router'; +import { APP_CONFIG, AppConfig } from '../../config/app-config.interface'; /** * This component renders a search page using a configuration as input. @@ -32,7 +33,9 @@ export class ConfigurationSearchPageComponent extends SearchComponent { protected windowService: HostWindowService, @Inject(SEARCH_CONFIG_SERVICE) public searchConfigService: SearchConfigurationService, protected routeService: RouteService, - protected router: Router) { - super(service, sidebarService, windowService, searchConfigService, routeService, router); + protected router: Router, + @Inject(APP_CONFIG) protected appConfig: AppConfig, + ) { + super(service, sidebarService, windowService, searchConfigService, routeService, router, appConfig); } } diff --git a/src/app/shared/comcol/comcol-page-browse-by/comcol-page-browse-by.component.ts b/src/app/shared/comcol/comcol-page-browse-by/comcol-page-browse-by.component.ts index 2a3ec220d6..c6e77d49e2 100644 --- a/src/app/shared/comcol/comcol-page-browse-by/comcol-page-browse-by.component.ts +++ b/src/app/shared/comcol/comcol-page-browse-by/comcol-page-browse-by.component.ts @@ -55,16 +55,21 @@ export class ComcolPageBrowseByComponent implements OnDestroy, OnInit { if (this.contentType === 'collection') { comColRoute = getCollectionPageRoute(this.id); allOptions.push({ - id: 'recent_submissions', - label: 'collection.page.browse.recent.head', + id: 'search', + label: 'collection.page.browse.search.head', routerLink: comColRoute, }); } else if (this.contentType === 'community') { comColRoute = getCommunityPageRoute(this.id); + allOptions.push({ + id: 'search', + label: 'collection.page.browse.search.head', + routerLink: comColRoute, + }); allOptions.push({ id: 'comcols', label: 'community.all-lists.head', - routerLink: comColRoute, + routerLink: `${comColRoute}/subcoms-cols`, }); } diff --git a/src/app/shared/comcol/comcol.module.ts b/src/app/shared/comcol/comcol.module.ts index 7ce48a3a55..21c6e36891 100644 --- a/src/app/shared/comcol/comcol.module.ts +++ b/src/app/shared/comcol/comcol.module.ts @@ -18,6 +18,8 @@ import { FormModule } from '../form/form.module'; import { UploadModule } from '../upload/upload.module'; import { ComcolBrowseByComponent } from './sections/comcol-browse-by/comcol-browse-by.component'; import { BrowseByModule } from '../../browse-by/browse-by.module'; +import { SearchModule } from '../search/search.module'; +import { ComcolSearchSectionComponent } from './sections/comcol-search-section/comcol-search-section.component'; const COMPONENTS = [ ComcolPageContentComponent, @@ -33,6 +35,7 @@ const COMPONENTS = [ ComcolRoleComponent, ThemedComcolPageHandleComponent, ComcolBrowseByComponent, + ComcolSearchSectionComponent, ]; @NgModule({ @@ -45,6 +48,7 @@ const COMPONENTS = [ SharedModule, UploadModule, BrowseByModule, + SearchModule, ], exports: [ ...COMPONENTS, diff --git a/src/app/shared/comcol/sections/comcol-search-section/comcol-search-section.component.html b/src/app/shared/comcol/sections/comcol-search-section/comcol-search-section.component.html new file mode 100644 index 0000000000..7c97dabf43 --- /dev/null +++ b/src/app/shared/comcol/sections/comcol-search-section/comcol-search-section.component.html @@ -0,0 +1,7 @@ + + diff --git a/src/app/shared/comcol/sections/comcol-search-section/comcol-search-section.component.scss b/src/app/shared/comcol/sections/comcol-search-section/comcol-search-section.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/shared/comcol/sections/comcol-search-section/comcol-search-section.component.spec.ts b/src/app/shared/comcol/sections/comcol-search-section/comcol-search-section.component.spec.ts new file mode 100644 index 0000000000..6b1f9236b1 --- /dev/null +++ b/src/app/shared/comcol/sections/comcol-search-section/comcol-search-section.component.spec.ts @@ -0,0 +1,35 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ComcolSearchSectionComponent } from './comcol-search-section.component'; +import { ActivatedRoute } from '@angular/router'; +import { ActivatedRouteStub } from '../../../testing/active-router.stub'; +import { APP_CONFIG } from '../../../../../config/app-config.interface'; +import { environment } from '../../../../../environments/environment.test'; + +describe('ComcolSearchSectionComponent', () => { + let component: ComcolSearchSectionComponent; + let fixture: ComponentFixture; + + let route: ActivatedRouteStub; + + beforeEach(async () => { + route = new ActivatedRouteStub(); + + await TestBed.configureTestingModule({ + declarations: [ + ComcolSearchSectionComponent, + ], + providers: [ + { provide: APP_CONFIG, useValue: environment }, + { provide: ActivatedRoute, useValue: route }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ComcolSearchSectionComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/comcol/sections/comcol-search-section/comcol-search-section.component.ts b/src/app/shared/comcol/sections/comcol-search-section/comcol-search-section.component.ts new file mode 100644 index 0000000000..fe50147395 --- /dev/null +++ b/src/app/shared/comcol/sections/comcol-search-section/comcol-search-section.component.ts @@ -0,0 +1,48 @@ +import { Component, OnInit, Inject } from '@angular/core'; +import { Observable } from 'rxjs'; +import { ActivatedRoute, Data } from '@angular/router'; +import { map } from 'rxjs/operators'; +import { SEARCH_CONFIG_SERVICE } from '../../../../my-dspace-page/my-dspace-page.component'; +import { SearchConfigurationService } from '../../../../core/shared/search/search-configuration.service'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { Community } from '../../../../core/shared/community.model'; +import { Collection } from '../../../../core/shared/collection.model'; +import { APP_CONFIG, AppConfig } from '../../../../../config/app-config.interface'; +import { hasValue } from '../../../empty.util'; + +/** + * The search tab on community & collection pages + */ +@Component({ + selector: 'ds-comcol-search-section', + templateUrl: './comcol-search-section.component.html', + styleUrls: ['./comcol-search-section.component.scss'], + providers: [ + { + provide: SEARCH_CONFIG_SERVICE, + useClass: SearchConfigurationService, + }, + ], +}) +export class ComcolSearchSectionComponent implements OnInit { + + comcol$: Observable; + + showSidebar$: Observable; + + constructor( + @Inject(APP_CONFIG) public appConfig: AppConfig, + protected route: ActivatedRoute, + ) { + } + + ngOnInit(): void { + this.comcol$ = this.route.data.pipe( + map((data: Data) => (data.dso as RemoteData).payload), + ); + this.showSidebar$ = this.comcol$.pipe( + map((comcol: Community | Collection) => hasValue(comcol) && this.appConfig[comcol.type as any].searchSection.showSidebar), + ); + } + +} diff --git a/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.spec.ts b/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.spec.ts index e2dbaaa0ff..edefff8592 100644 --- a/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.spec.ts +++ b/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.spec.ts @@ -5,13 +5,14 @@ import { DSpaceObjectType } from '../../../core/shared/dspace-object-type.model' import { Item } from '../../../core/shared/item.model'; import { DSOSelectorModalWrapperComponent, SelectorActionType } from './dso-selector-modal-wrapper.component'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, ActivatedRouteSnapshot } from '@angular/router'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; import { By } from '@angular/platform-browser'; import { DSOSelectorComponent } from '../dso-selector/dso-selector.component'; import { MockComponent } from 'ng-mocks'; import { MetadataValue } from '../../../core/shared/metadata.models'; import { createSuccessfulRemoteDataObject } from '../../remote-data.utils'; +import { hasValue } from '../../empty.util'; describe('DSOSelectorModalWrapperComponent', () => { let component: DSOSelectorModalWrapperComponent; @@ -83,6 +84,20 @@ describe('DSOSelectorModalWrapperComponent', () => { }); }); + describe('selectObject with emit only', () => { + beforeEach(() => { + spyOn(component, 'navigate'); + spyOn(component, 'close'); + spyOn(component.select, 'emit'); + component.emitOnly = true; + component.selectObject(item); + }); + it('should call the close and navigate method on the component with the given DSO', () => { + expect(component.close).toHaveBeenCalled(); + expect(component.select.emit).toHaveBeenCalledWith(item); + }); + }); + describe('close', () => { beforeEach(() => { component.close(); @@ -113,6 +128,19 @@ describe('DSOSelectorModalWrapperComponent', () => { expect(component.close).toHaveBeenCalled(); }); }); + + describe('should find route data', () => { + beforeEach(() => { + spyOn(component, 'findRouteData'); + component.ngOnInit(); + }); + it('should call the findRouteData method on the component', () => { + expect(component.findRouteData).toHaveBeenCalled(); + }); + it('should return undefined', () => { + expect(component.findRouteData((route) => hasValue(route.data), {} as unknown as ActivatedRouteSnapshot)).toEqual(undefined); + }); + }); }); @Component({ diff --git a/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.ts b/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.ts index 3f81687c9f..6798449094 100644 --- a/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.ts +++ b/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.ts @@ -1,4 +1,4 @@ -import { Component, Input, OnInit } from '@angular/core'; +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; import { ActivatedRoute, ActivatedRouteSnapshot } from '@angular/router'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; import { RemoteData } from '../../../core/data/remote-data'; @@ -29,6 +29,11 @@ export abstract class DSOSelectorModalWrapperComponent implements OnInit { */ @Input() dsoRD: RemoteData; + /** + * Representing if component should emit value of selected entries or navigate + */ + @Input() emitOnly = false; + /** * Optional header to display above the selection list * Supports i18n keys @@ -50,6 +55,11 @@ export abstract class DSOSelectorModalWrapperComponent implements OnInit { */ action: SelectorActionType; + /** + * Event emitted when a DSO entry is selected if emitOnly is set to true + */ + @Output() select: EventEmitter = new EventEmitter(); + /** * Default DSO ordering */ @@ -93,7 +103,11 @@ export abstract class DSOSelectorModalWrapperComponent implements OnInit { */ selectObject(dso: DSpaceObject) { this.close(); - this.navigate(dso); + if (this.emitOnly) { + this.select.emit(dso); + } else { + this.navigate(dso); + } } /** diff --git a/src/app/shared/eperson-group-list/eperson-group-list.component.ts b/src/app/shared/eperson-group-list/eperson-group-list.component.ts index 7cad7a9783..1a08740a94 100644 --- a/src/app/shared/eperson-group-list/eperson-group-list.component.ts +++ b/src/app/shared/eperson-group-list/eperson-group-list.component.ts @@ -96,7 +96,7 @@ export class EpersonGroupListComponent implements OnInit, OnDestroy { private pageConfigSub: Subscription; /** - * Initialize instance variables and inject the properly DataService + * Initialize instance variables and inject the properly UpdateDataServiceImpl * * @param {DSONameService} dsoNameService * @param {Injector} parentInjector diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.component.html index ed117a5021..5beb8d52da 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.component.html @@ -1,4 +1,4 @@ -
+
diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.component.scss b/src/app/shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.component.scss index e69de29bb2..206ad17492 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.component.scss +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.component.scss @@ -0,0 +1,16 @@ +div.custom-switch { + &.custom-control-right { + margin-left: 0; + margin-right: 0; + + &::after { + right: -1.5rem; + left: auto; + } + + &::before { + right: -2.35rem; + left: auto; + } + } +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.component.spec.ts index ceb498fe56..4d626e7cdd 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.component.spec.ts @@ -1,11 +1,12 @@ import { DynamicFormsCoreModule, DynamicFormService } from '@ng-dynamic-forms/core'; import { UntypedFormGroup, ReactiveFormsModule } from '@angular/forms'; import { ComponentFixture, inject, TestBed, waitForAsync } from '@angular/core/testing'; -import { DebugElement } from '@angular/core'; +import { DebugElement} from '@angular/core'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { By } from '@angular/platform-browser'; import { DynamicCustomSwitchModel } from './custom-switch.model'; import { CustomSwitchComponent } from './custom-switch.component'; +import { TranslateModule } from '@ngx-translate/core'; describe('CustomSwitchComponent', () => { @@ -20,9 +21,10 @@ describe('CustomSwitchComponent', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ imports: [ + TranslateModule.forRoot(), ReactiveFormsModule, NoopAnimationsModule, - DynamicFormsCoreModule.forRoot() + DynamicFormsCoreModule.forRoot(), ], declarations: [CustomSwitchComponent] diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/dynamic-vocabulary.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/dynamic-vocabulary.component.ts index f19b660295..b0c42ffefd 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/dynamic-vocabulary.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/dynamic-vocabulary.component.ts @@ -50,8 +50,9 @@ export abstract class DsDynamicVocabularyComponent extends DynamicFormControlCom /** * Retrieves the init form value from model + * @param preserveConfidence if the original model confidence value should be used after retrieving the vocabulary's entry */ - getInitValueFromModel(): Observable { + getInitValueFromModel(preserveConfidence = false): Observable { let initValue$: Observable; if (isNotEmpty(this.model.value) && (this.model.value instanceof FormFieldMetadataValueObject) && !this.model.value.hasAuthorityToGenerate()) { let initEntry$: Observable; @@ -63,7 +64,7 @@ export abstract class DsDynamicVocabularyComponent extends DynamicFormControlCom initValue$ = initEntry$.pipe(map((initEntry: VocabularyEntry) => { if (isNotEmpty(initEntry)) { // Integrate FormFieldMetadataValueObject with retrieved information - return new FormFieldMetadataValueObject( + let formField = new FormFieldMetadataValueObject( initEntry.value, null, initEntry.authority, @@ -72,6 +73,11 @@ export abstract class DsDynamicVocabularyComponent extends DynamicFormControlCom null, initEntry.otherInformation || null ); + // Preserve the original confidence + if (preserveConfidence) { + formField.confidence = (this.model.value as any).confidence; + } + return formField; } else { return this.model.value as any; } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.html index 3c19ecda13..8681f13433 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.html @@ -21,8 +21,8 @@
- - +