diff --git a/config/config.example.yml b/config/config.example.yml index 8b010ba6ea..36d6a009d3 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -131,6 +131,10 @@ submission: # NOTE: after how many time (milliseconds) submission is saved automatically # eg. timer: 5 * (1000 * 60); // 5 minutes timer: 0 + # Always show the duplicate detection section if enabled, even if there are no potential duplicates detected + # (a message will be displayed to indicate no matches were found) + duplicateDetection: + alwaysShowSection: false icons: metadata: # NOTE: example of configuration @@ -427,9 +431,67 @@ comcolSelectionSort: # Search settings -search: +search: # Settings to enable/disable or configure advanced search filters. advancedFilters: enabled: false # List of filters to enable in "Advanced Search" dropdown filter: [ 'title', 'author', 'subject', 'entityType' ] + + +# Notify metrics +# Configuration for Notify Admin Dashboard for metrics visualization +notifyMetrics: + # Configuration for received messages +- title: 'admin-notify-dashboard.received-ldn' + boxes: + - color: '#B8DAFF' + title: 'admin-notify-dashboard.NOTIFY.incoming.accepted' + config: 'NOTIFY.incoming.accepted' + description: 'admin-notify-dashboard.NOTIFY.incoming.accepted.description' + - color: '#D4EDDA' + title: 'admin-notify-dashboard.NOTIFY.incoming.processed' + config: 'NOTIFY.incoming.processed' + description: 'admin-notify-dashboard.NOTIFY.incoming.processed.description' + - color: '#FDBBC7' + title: 'admin-notify-dashboard.NOTIFY.incoming.failure' + config: 'NOTIFY.incoming.failure' + description: 'admin-notify-dashboard.NOTIFY.incoming.failure.description' + - color: '#FDBBC7' + title: 'admin-notify-dashboard.NOTIFY.incoming.untrusted' + config: 'NOTIFY.incoming.untrusted' + description: 'admin-notify-dashboard.NOTIFY.incoming.untrusted.description' + - color: '#43515F' + title: 'admin-notify-dashboard.NOTIFY.incoming.involvedItems' + textColor: '#fff' + config: 'NOTIFY.incoming.involvedItems' + description: 'admin-notify-dashboard.NOTIFY.incoming.involvedItems.description' +# Configuration for outgoing messages +- title: 'admin-notify-dashboard.generated-ldn' + boxes: + - color: '#B8DAFF' + title: 'admin-notify-dashboard.NOTIFY.outgoing.queued' + config: 'NOTIFY.outgoing.queued' + description: 'admin-notify-dashboard.NOTIFY.outgoing.queued.description' + - color: '#FDEEBB' + title: 'admin-notify-dashboard.NOTIFY.outgoing.queued_for_retry' + config: 'NOTIFY.outgoing.queued_for_retry' + description: 'admin-notify-dashboard.NOTIFY.outgoing.queued_for_retry.description' + - color: '#FDBBC7' + title: 'admin-notify-dashboard.NOTIFY.outgoing.failure' + config: 'NOTIFY.outgoing.failure' + description: 'admin-notify-dashboard.NOTIFY.outgoing.failure.description' + - color: '#43515F' + title: 'admin-notify-dashboard.NOTIFY.outgoing.involvedItems' + textColor: '#fff' + config: 'NOTIFY.outgoing.involvedItems' + description: 'admin-notify-dashboard.NOTIFY.outgoing.involvedItems.description' + - color: '#D4EDDA' + title: 'admin-notify-dashboard.NOTIFY.outgoing.delivered' + config: 'NOTIFY.outgoing.delivered' + description: 'admin-notify-dashboard.NOTIFY.outgoing.delivered.description' + + + + + diff --git a/karma.conf.js b/karma.conf.js index 8418312b1a..f96558bfaf 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -15,7 +15,10 @@ module.exports = function (config) { ], client: { clearContext: false, // leave Jasmine Spec Runner output visible in browser - captureConsole: false + captureConsole: false, + jasmine: { + failSpecWithNoExpectations: true + } }, coverageIstanbulReporter: { dir: require('path').join(__dirname, './coverage/dspace-angular'), diff --git a/package.json b/package.json index abd25f4148..50dab5fbd0 100644 --- a/package.json +++ b/package.json @@ -128,7 +128,7 @@ "react-copy-to-clipboard": "^5.1.0", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.0", - "sanitize-html": "^2.10.0", + "sanitize-html": "^2.12.1", "sortablejs": "1.15.0", "uuid": "^8.3.2", "webfontloader": "1.6.28", diff --git a/src/app/access-control/epeople-registry/epeople-registry.component.spec.ts b/src/app/access-control/epeople-registry/epeople-registry.component.spec.ts index e2cee5e935..db5405c70b 100644 --- a/src/app/access-control/epeople-registry/epeople-registry.component.spec.ts +++ b/src/app/access-control/epeople-registry/epeople-registry.component.spec.ts @@ -5,8 +5,8 @@ import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { BrowserModule, By } from '@angular/platform-browser'; -import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; -import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; +import { NgbModule, NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; import { buildPaginatedList, PaginatedList } from '../../core/data/paginated-list.model'; import { RemoteData } from '../../core/data/remote-data'; import { EPersonDataService } from '../../core/eperson/eperson-data.service'; @@ -18,8 +18,6 @@ import { EPeopleRegistryComponent } from './epeople-registry.component'; import { EPersonMock, EPersonMock2 } from '../../shared/testing/eperson.mock'; import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { getMockFormBuilderService } from '../../shared/mocks/form-builder-service.mock'; -import { getMockTranslateService } from '../../shared/mocks/translate.service.mock'; -import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; import { RouterStub } from '../../shared/testing/router.stub'; import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; @@ -31,17 +29,15 @@ import { FindListOptions } from '../../core/data/find-list-options.model'; describe('EPeopleRegistryComponent', () => { let component: EPeopleRegistryComponent; let fixture: ComponentFixture; - let translateService: TranslateService; let builderService: FormBuilderService; - let mockEPeople; + let mockEPeople: EPerson[]; let ePersonDataServiceStub: any; let authorizationService: AuthorizationDataService; - let modalService; + let modalService: NgbModal; + let paginationService: PaginationServiceStub; - let paginationService; - - beforeEach(waitForAsync(() => { + beforeEach(waitForAsync(async () => { jasmine.getEnv().allowRespy(true); mockEPeople = [EPersonMock, EPersonMock2]; ePersonDataServiceStub = { @@ -99,7 +95,7 @@ describe('EPeopleRegistryComponent', () => { deleteEPerson(ePerson: EPerson): Observable { this.allEpeople = this.allEpeople.filter((ePerson2: EPerson) => { return (ePerson2.uuid !== ePerson.uuid); - }); + }); return observableOf(true); }, editEPerson(ePerson: EPerson) { @@ -119,17 +115,11 @@ describe('EPeopleRegistryComponent', () => { isAuthorized: observableOf(true) }); builderService = getMockFormBuilderService(); - translateService = getMockTranslateService(); paginationService = new PaginationServiceStub(); - TestBed.configureTestingModule({ + await TestBed.configureTestingModule({ imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule, - TranslateModule.forRoot({ - loader: { - provide: TranslateLoader, - useClass: TranslateLoaderMock - } - }), + TranslateModule.forRoot(), ], declarations: [EPeopleRegistryComponent], providers: [ @@ -148,7 +138,7 @@ describe('EPeopleRegistryComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(EPeopleRegistryComponent); component = fixture.componentInstance; - modalService = (component as any).modalService; + modalService = TestBed.inject(NgbModal); spyOn(modalService, 'open').and.returnValue(Object.assign({ componentInstance: Object.assign({ response: observableOf(true) }) })); fixture.detectChanges(); }); @@ -158,10 +148,10 @@ describe('EPeopleRegistryComponent', () => { }); it('should display list of ePeople', () => { - const ePeopleIdsFound = fixture.debugElement.queryAll(By.css('#epeople tr td:first-child')); + const ePeopleIdsFound: DebugElement[] = fixture.debugElement.queryAll(By.css('#epeople tr td:first-child')); expect(ePeopleIdsFound.length).toEqual(2); mockEPeople.map((ePerson: EPerson) => { - expect(ePeopleIdsFound.find((foundEl) => { + expect(ePeopleIdsFound.find((foundEl: DebugElement) => { return (foundEl.nativeElement.textContent.trim() === ePerson.uuid); })).toBeTruthy(); }); @@ -169,7 +159,7 @@ describe('EPeopleRegistryComponent', () => { describe('search', () => { describe('when searching with scope/query (scope metadata)', () => { - let ePeopleIdsFound; + let ePeopleIdsFound: DebugElement[]; beforeEach(fakeAsync(() => { component.search({ scope: 'metadata', query: EPersonMock2.name }); tick(); @@ -179,14 +169,14 @@ describe('EPeopleRegistryComponent', () => { it('should display search result', () => { expect(ePeopleIdsFound.length).toEqual(1); - expect(ePeopleIdsFound.find((foundEl) => { + expect(ePeopleIdsFound.find((foundEl: DebugElement) => { return (foundEl.nativeElement.textContent.trim() === EPersonMock2.uuid); })).toBeTruthy(); }); }); describe('when searching with scope/query (scope email)', () => { - let ePeopleIdsFound; + let ePeopleIdsFound: DebugElement[]; beforeEach(fakeAsync(() => { component.search({ scope: 'email', query: EPersonMock.email }); tick(); @@ -196,7 +186,7 @@ describe('EPeopleRegistryComponent', () => { it('should display search result', () => { expect(ePeopleIdsFound.length).toEqual(1); - expect(ePeopleIdsFound.find((foundEl) => { + expect(ePeopleIdsFound.find((foundEl: DebugElement) => { return (foundEl.nativeElement.textContent.trim() === EPersonMock.uuid); })).toBeTruthy(); }); @@ -228,19 +218,12 @@ describe('EPeopleRegistryComponent', () => { }); }); - describe('delete EPerson button when the isAuthorized returns false', () => { - let ePeopleDeleteButton; - beforeEach(() => { - spyOn(authorizationService, 'isAuthorized').and.returnValue(observableOf(false)); - component.initialisePage(); - fixture.detectChanges(); - }); - it('should be disabled', () => { - ePeopleDeleteButton = fixture.debugElement.queryAll(By.css('#epeople tr td div button.delete-button')); - ePeopleDeleteButton.forEach((deleteButton: DebugElement) => { - expect(deleteButton.nativeElement.disabled).toBe(true); - }); - }); + it('should hide delete EPerson button when the isAuthorized returns false', () => { + spyOn(authorizationService, 'isAuthorized').and.returnValue(observableOf(false)); + component.initialisePage(); + fixture.detectChanges(); + + expect(fixture.debugElement.query(By.css('#epeople tr td div button.delete-button'))).toBeNull(); }); }); diff --git a/src/app/admin/admin-ldn-services/ldn-service-form/ldn-service-form.component.html b/src/app/admin/admin-ldn-services/ldn-service-form/ldn-service-form.component.html index c11a41d887..0a7bc39fa3 100644 --- a/src/app/admin/admin-ldn-services/ldn-service-form/ldn-service-form.component.html +++ b/src/app/admin/admin-ldn-services/ldn-service-form/ldn-service-form.component.html @@ -102,8 +102,13 @@ id="ldnUrl" name="ldnUrl" type="text"> -
- {{ 'ldn-new-service.form.error.ldnurl' | translate }} +
+
+ {{ 'ldn-new-service.form.error.ldnurl' | translate }} +
+
+ {{ 'ldn-new-service.form.error.ldnurl.ldnUrlAlreadyAssociated' | translate }} +
diff --git a/src/app/admin/admin-ldn-services/ldn-service-form/ldn-service-form.component.ts b/src/app/admin/admin-ldn-services/ldn-service-form/ldn-service-form.component.ts index 0a08264bda..93f7911057 100644 --- a/src/app/admin/admin-ldn-services/ldn-service-form/ldn-service-form.component.ts +++ b/src/app/admin/admin-ldn-services/ldn-service-form/ldn-service-form.component.ts @@ -6,7 +6,12 @@ import { TemplateRef, ViewChild } from '@angular/core'; -import {FormArray, FormBuilder, FormGroup, Validators} from '@angular/forms'; +import { + FormArray, + FormBuilder, + FormGroup, + Validators +} from '@angular/forms'; import {LDN_SERVICE} from '../ldn-services-model/ldn-service.resource-type'; import {ActivatedRoute, Router} from '@angular/router'; import {LdnServicesService} from '../ldn-services-data/ldn-services-data.service'; @@ -167,6 +172,9 @@ export class LdnServiceFormComponent implements OnInit, OnDestroy { this.closeModal(); this.sendBack(); } else { + if (!this.formModel.errors) { + this.setLdnUrlError(); + } this.notificationService.error(this.translateService.get('ldn-service-notification.created.failure.title'), this.translateService.get('ldn-service-notification.created.failure.body')); this.closeModal(); @@ -405,6 +413,9 @@ export class LdnServiceFormComponent implements OnInit, OnDestroy { this.notificationService.success(this.translateService.get('admin.registries.services-formats.modify.success.head'), this.translateService.get('admin.registries.services-formats.modify.success.content')); } else { + if (!this.formModel.errors) { + this.setLdnUrlError(); + } this.notificationService.error(this.translateService.get('admin.registries.services-formats.modify.failure.head'), this.translateService.get('admin.registries.services-formats.modify.failure.content')); this.closeModal(); @@ -554,4 +565,14 @@ export class LdnServiceFormComponent implements OnInit, OnDestroy { automatic: '', }); } + + + /** + * set ldnUrl error in case of unprocessable entity and provided value + */ + private setLdnUrlError(): void { + const control = this.formModel.controls.ldnUrl; + const controlErrors = control.errors || {}; + control.setErrors({...controlErrors, ldnUrlAlreadyAssociated: true }); + } } diff --git a/src/app/admin/admin-ldn-services/ldn-services-data/ldn-services-data.service.spec.ts b/src/app/admin/admin-ldn-services/ldn-services-data/ldn-services-data.service.spec.ts index 9d17fc244c..c661a034e4 100644 --- a/src/app/admin/admin-ldn-services/ldn-services-data/ldn-services-data.service.spec.ts +++ b/src/app/admin/admin-ldn-services/ldn-services-data/ldn-services-data.service.spec.ts @@ -78,6 +78,7 @@ describe('LdnServicesService test', () => { rdbService = jasmine.createSpyObj('rdbService', { buildSingle: createSuccessfulRemoteDataObject$({}, 500), + buildFromRequestUUID: createSuccessfulRemoteDataObject$({}, 500), buildList: cold('a', { a: remoteDataMocks.Success }) }); @@ -111,6 +112,20 @@ describe('LdnServicesService test', () => { done(); }); }); + + it('should invoke service', (done) => { + const constraints = [{void: true}]; + const files = [new File([],'fileName')]; + spyOn(service as any, 'getInvocationFormData'); + spyOn(service, 'getBrowseEndpoint').and.returnValue(observableOf('testEndpoint')); + service.invoke('serviceName', 'serviceId', constraints, files).subscribe(result => { + expect((service as any).getInvocationFormData).toHaveBeenCalledWith(constraints, files); + expect(service.getBrowseEndpoint).toHaveBeenCalled(); + expect(result).toBeInstanceOf(RemoteData); + done(); + }); + + }); }); }); diff --git a/src/app/admin/admin-ldn-services/ldn-services-directory/ldn-services-directory.component.spec.ts b/src/app/admin/admin-ldn-services/ldn-services-directory/ldn-services-directory.component.spec.ts index 664edcb27d..ddb7a9fbb9 100644 --- a/src/app/admin/admin-ldn-services/ldn-services-directory/ldn-services-directory.component.spec.ts +++ b/src/app/admin/admin-ldn-services/ldn-services-directory/ldn-services-directory.component.spec.ts @@ -1,5 +1,5 @@ import {ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing'; -import {ChangeDetectorRef, EventEmitter} from '@angular/core'; +import { ChangeDetectorRef, EventEmitter, NO_ERRORS_SCHEMA } from '@angular/core'; import {NotificationsService} from '../../../shared/notifications/notifications.service'; import {NotificationsServiceStub} from '../../../shared/testing/notifications-service.stub'; import {TranslateModule, TranslateService} from '@ngx-translate/core'; @@ -21,8 +21,6 @@ describe('LdnServicesOverviewComponent', () => { let ldnServicesService; let paginationService; let modalService: NgbModal; - let notificationsService: NotificationsService; - let translateService: TranslateService; const translateServiceStub = { get: () => of('translated-text'), @@ -33,7 +31,11 @@ describe('LdnServicesOverviewComponent', () => { beforeEach(async () => { paginationService = new PaginationServiceStub(); - ldnServicesService = jasmine.createSpyObj('LdnServicesService', ['findAll', 'delete', 'patch']); + ldnServicesService = jasmine.createSpyObj('ldnServicesService', { + 'findAll': createSuccessfulRemoteDataObject$({}), + 'delete': createSuccessfulRemoteDataObject$({}), + 'patch': createSuccessfulRemoteDataObject$({}), + }); await TestBed.configureTestingModule({ imports: [TranslateModule.forRoot()], declarations: [LdnServicesOverviewComponent], @@ -50,9 +52,10 @@ describe('LdnServicesOverviewComponent', () => { } }, {provide: ChangeDetectorRef, useValue: {}}, - {provide: NotificationsService, useValue: NotificationsServiceStub}, + {provide: NotificationsService, useValue: new NotificationsServiceStub()}, {provide: TranslateService, useValue: translateServiceStub}, - ] + ], + schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); }); @@ -62,8 +65,6 @@ describe('LdnServicesOverviewComponent', () => { ldnServicesService = TestBed.inject(LdnServicesService); paginationService = TestBed.inject(PaginationService); modalService = TestBed.inject(NgbModal); - notificationsService = TestBed.inject(NotificationsService); - translateService = TestBed.inject(TranslateService); component.modalRef = jasmine.createSpyObj({close: null}); component.isProcessingSub = jasmine.createSpyObj({unsubscribe: null}); component.ldnServicesRD$ = of({} as RemoteData>); @@ -141,4 +142,22 @@ describe('LdnServicesOverviewComponent', () => { expect(deleteSpy).toHaveBeenCalledWith(serviceId); })); }); + + describe('selectServiceToDelete', () => { + it('should set service to delete', fakeAsync(() => { + spyOn(component, 'openDeleteModal'); + const serviceId = 123; + component.selectServiceToDelete(serviceId); + expect(component.selectedServiceId).toEqual(serviceId); + expect(component.openDeleteModal).toHaveBeenCalled(); + })); + }); + + describe('toggleStatus', () => { + it('should toggle status', (() => { + component.toggleStatus({enabled: false}, ldnServicesService); + expect(ldnServicesService.patch).toHaveBeenCalled(); + })); + }); + }); diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-dashboard-routing.module.ts b/src/app/admin/admin-notify-dashboard/admin-notify-dashboard-routing.module.ts new file mode 100644 index 0000000000..6fb3b46977 --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-dashboard-routing.module.ts @@ -0,0 +1,64 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { I18nBreadcrumbResolver } from '../../core/breadcrumbs/i18n-breadcrumb.resolver'; +import { AdminNotifyDashboardComponent } from './admin-notify-dashboard.component'; +import { + SiteAdministratorGuard +} from '../../core/data/feature-authorization/feature-authorization-guard/site-administrator.guard'; +import { + AdminNotifyIncomingComponent +} from './admin-notify-logs/admin-notify-incoming/admin-notify-incoming.component'; +import { + AdminNotifyOutgoingComponent +} from './admin-notify-logs/admin-notify-outgoing/admin-notify-outgoing.component'; +import { NotifyInfoGuard } from '../../core/coar-notify/notify-info/notify-info.guard'; + +@NgModule({ + imports: [ + RouterModule.forChild([ + { + canActivate: [SiteAdministratorGuard, NotifyInfoGuard], + path: '', + resolve: { + breadcrumb: I18nBreadcrumbResolver, + }, + component: AdminNotifyDashboardComponent, + pathMatch: 'full', + data: { + title: 'admin.notify.dashboard.page.title', + breadcrumbKey: 'admin.notify.dashboard', + }, + }, + { + path: 'inbound', + resolve: { + breadcrumb: I18nBreadcrumbResolver, + }, + component: AdminNotifyIncomingComponent, + canActivate: [SiteAdministratorGuard, NotifyInfoGuard], + data: { + title: 'admin.notify.dashboard.page.title', + breadcrumbKey: 'admin.notify.dashboard', + }, + }, + { + path: 'outbound', + resolve: { + breadcrumb: I18nBreadcrumbResolver, + }, + component: AdminNotifyOutgoingComponent, + canActivate: [SiteAdministratorGuard, NotifyInfoGuard], + data: { + title: 'admin.notify.dashboard.page.title', + breadcrumbKey: 'admin.notify.dashboard', + }, + } + ]) + ], +}) +/** + * Routing module for the Notifications section of the admin sidebar + */ +export class AdminNotifyDashboardRoutingModule { + +} diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-dashboard.component.html b/src/app/admin/admin-notify-dashboard/admin-notify-dashboard.component.html new file mode 100644 index 0000000000..3adb7e857b --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-dashboard.component.html @@ -0,0 +1,23 @@ +
+
+
+

{{'admin-notify-dashboard.title'| translate}}

+
{{'admin-notify-dashboard.description' | translate}}
+ +
+
+
diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-dashboard.component.spec.ts b/src/app/admin/admin-notify-dashboard/admin-notify-dashboard.component.spec.ts new file mode 100644 index 0000000000..74007055b4 --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-dashboard.component.spec.ts @@ -0,0 +1,57 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AdminNotifyDashboardComponent } from './admin-notify-dashboard.component'; +import { TranslateModule } from '@ngx-translate/core'; +import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap'; +import { SearchService } from '../../core/shared/search/search.service'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { buildPaginatedList } from '../../core/data/paginated-list.model'; +import { AdminNotifySearchResult } from './models/admin-notify-message-search-result.model'; +import { AdminNotifyMessage } from './models/admin-notify-message.model'; + +describe('AdminNotifyDashboardComponent', () => { + let component: AdminNotifyDashboardComponent; + let fixture: ComponentFixture; + + let item1; + let item2; + let item3; + let searchResult1; + let searchResult2; + let searchResult3; + let results; + + const mockBoxes = [ + { title: 'admin-notify-dashboard.received-ldn', boxes: [ undefined, undefined, undefined, undefined, undefined ] }, + { title: 'admin-notify-dashboard.generated-ldn', boxes: [ undefined, undefined, undefined, undefined, undefined ] } + ]; + + beforeEach(async () => { + item1 = Object.assign(new AdminNotifyMessage(), { uuid: 'e1c51c69-896d-42dc-8221-1d5f2ad5516e' }); + item2 = Object.assign(new AdminNotifyMessage(), { uuid: 'c8279647-1acc-41ae-b036-951d5f65649b' }); + item3 = Object.assign(new AdminNotifyMessage(), { uuid: 'c3bcbff5-ec0c-4831-8e4c-94b9c933ccac' }); + searchResult1 = Object.assign(new AdminNotifySearchResult(), { indexableObject: item1 }); + searchResult2 = Object.assign(new AdminNotifySearchResult(), { indexableObject: item2 }); + searchResult3 = Object.assign(new AdminNotifySearchResult(), { indexableObject: item3 }); + results = buildPaginatedList(undefined, [searchResult1, searchResult2, searchResult3]); + + await TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), NgbNavModule], + declarations: [ AdminNotifyDashboardComponent ], + providers: [{ provide: SearchService, useValue: { search: () => createSuccessfulRemoteDataObject$(results)}}] + }) + .compileComponents(); + + fixture = TestBed.createComponent(AdminNotifyDashboardComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', (done) => { + component.notifyMetricsRows$.subscribe(boxes => { + expect(boxes).toEqual(mockBoxes); + done(); + }); + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-dashboard.component.ts b/src/app/admin/admin-notify-dashboard/admin-notify-dashboard.component.ts new file mode 100644 index 0000000000..9aa738b29b --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-dashboard.component.ts @@ -0,0 +1,95 @@ +import { Component, OnInit } from '@angular/core'; +import { SearchService } from '../../core/shared/search/search.service'; +import { environment } from '../../../environments/environment'; +import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model'; +import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; +import { forkJoin, Observable } from 'rxjs'; +import { getFirstCompletedRemoteData } from '../../core/shared/operators'; +import { map } from 'rxjs/operators'; +import { SearchObjects } from '../../shared/search/models/search-objects.model'; +import { AdminNotifyMetricsBox, AdminNotifyMetricsRow } from './admin-notify-metrics/admin-notify-metrics.model'; +import { DSpaceObject } from '../../core/shared/dspace-object.model'; +import { SEARCH_CONFIG_SERVICE } from '../../my-dspace-page/my-dspace-page.component'; +import { SearchConfigurationService } from '../../core/shared/search/search-configuration.service'; + + +@Component({ + selector: 'ds-admin-notify-dashboard', + templateUrl: './admin-notify-dashboard.component.html', + providers: [ + { + provide: SEARCH_CONFIG_SERVICE, + useClass: SearchConfigurationService + } + ] +}) + +/** + * Component used for visual representation and search of LDN messages for Admins + */ +export class AdminNotifyDashboardComponent implements OnInit{ + + public notifyMetricsRows$: Observable; + + private metricsConfig = environment.notifyMetrics; + private singleResultOptions = Object.assign(new PaginationComponentOptions(), { + id: 'single-result-options', + pageSize: 1 + }); + + constructor(private searchService: SearchService) { + } + + ngOnInit() { + const mertricsRowsConfigurations = this.metricsConfig + .map(row => row.boxes) + .map(boxes => boxes.map(box => box.config).filter(config => !!config)); + const flatConfigurations = [].concat(...mertricsRowsConfigurations.map((config) => config)); + const searchConfigurations = flatConfigurations + .map(config => Object.assign(new PaginatedSearchOptions({}), + { configuration: config, pagination: this.singleResultOptions } + )); + + this.notifyMetricsRows$ = forkJoin(searchConfigurations.map(config => this.searchService.search(config) + .pipe( + getFirstCompletedRemoteData(), + map(response => this.mapSearchObjectsToMetricsBox(response.payload)), + ) + ) + ).pipe( + map(metricBoxes => this.mapUpdatedBoxesToMetricsRows(metricBoxes)) + ); + } + + /** + * Function to map received SearchObjects to notify boxes config + * + * @param searchObject The object to map + * @private + */ + private mapSearchObjectsToMetricsBox(searchObject: SearchObjects): AdminNotifyMetricsBox { + const count = searchObject.pageInfo.totalElements; + const objectConfig = searchObject.configuration; + const metricsBoxes = [].concat(...this.metricsConfig.map((config) => config.boxes)); + + return { + ...metricsBoxes.find(box => box.config === objectConfig), + count + }; + } + + /** + * Function to map updated boxes with count to each row of the configuration + * + * @param boxesWithCount The object to map + * @private + */ + private mapUpdatedBoxesToMetricsRows(boxesWithCount: AdminNotifyMetricsBox[]): AdminNotifyMetricsRow[] { + return this.metricsConfig.map(row => { + return { + ...row, + boxes: row.boxes.map(rowBox => boxesWithCount.find(boxWithCount => boxWithCount.config === rowBox.config)) + }; + }); + } +} diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-dashboard.module.ts b/src/app/admin/admin-notify-dashboard/admin-notify-dashboard.module.ts new file mode 100644 index 0000000000..0598fc3304 --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-dashboard.module.ts @@ -0,0 +1,56 @@ +import { NgModule } from '@angular/core'; +import { CommonModule, DatePipe } from '@angular/common'; +import { RouterModule } from '@angular/router'; +import { AdminNotifyDashboardComponent } from './admin-notify-dashboard.component'; +import { AdminNotifyDashboardRoutingModule } from './admin-notify-dashboard-routing.module'; +import { AdminNotifyMetricsComponent } from './admin-notify-metrics/admin-notify-metrics.component'; +import { AdminNotifyIncomingComponent } from './admin-notify-logs/admin-notify-incoming/admin-notify-incoming.component'; +import { SharedModule } from '../../shared/shared.module'; +import { SearchModule } from '../../shared/search/search.module'; +import { SearchPageModule } from '../../search-page/search-page.module'; +import { + AdminNotifyOutgoingComponent +} from './admin-notify-logs/admin-notify-outgoing/admin-notify-outgoing.component'; +import { AdminNotifyDetailModalComponent } from './admin-notify-detail-modal/admin-notify-detail-modal.component'; +import { + AdminNotifySearchResultComponent +} from './admin-notify-search-result/admin-notify-search-result.component'; +import { AdminNotifyMessagesService } from './services/admin-notify-messages.service'; +import { AdminNotifyLogsResultComponent } from './admin-notify-logs/admin-notify-logs-result/admin-notify-logs-result.component'; + + +const ENTRY_COMPONENTS = [ + AdminNotifySearchResultComponent +]; +@NgModule({ + imports: [ + CommonModule, + RouterModule, + SharedModule, + AdminNotifyDashboardRoutingModule, + SearchModule, + SearchPageModule + ], + providers: [ + AdminNotifyMessagesService, + DatePipe + ], + declarations: [ + ...ENTRY_COMPONENTS, + AdminNotifyDashboardComponent, + AdminNotifyMetricsComponent, + AdminNotifyIncomingComponent, + AdminNotifyOutgoingComponent, + AdminNotifyDetailModalComponent, + AdminNotifySearchResultComponent, + AdminNotifyLogsResultComponent + ] +}) +export class AdminNotifyDashboardModule { + static withEntryComponents() { + return { + ngModule: AdminNotifyDashboardModule, + providers: ENTRY_COMPONENTS.map((component) => ({provide: component})) + }; + } +} diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-detail-modal/admin-notify-detail-modal.component.html b/src/app/admin/admin-notify-dashboard/admin-notify-detail-modal/admin-notify-detail-modal.component.html new file mode 100644 index 0000000000..52d93cbb62 --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-detail-modal/admin-notify-detail-modal.component.html @@ -0,0 +1,22 @@ + + diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-detail-modal/admin-notify-detail-modal.component.spec.ts b/src/app/admin/admin-notify-dashboard/admin-notify-detail-modal/admin-notify-detail-modal.component.spec.ts new file mode 100644 index 0000000000..0ddf449e5c --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-detail-modal/admin-notify-detail-modal.component.spec.ts @@ -0,0 +1,36 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AdminNotifyDetailModalComponent } from './admin-notify-detail-modal.component'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; + +describe('AdminNotifyDetailModalComponent', () => { + let component: AdminNotifyDetailModalComponent; + let fixture: ComponentFixture; + const modalStub = jasmine.createSpyObj('modalStub', ['close']); + + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [ AdminNotifyDetailModalComponent ], + providers: [{ provide: NgbActiveModal, useValue: modalStub }] + }) + .compileComponents(); + + fixture = TestBed.createComponent(AdminNotifyDetailModalComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should close', () => { + spyOn(component.response, 'emit'); + component.closeModal(); + expect(modalStub.close).toHaveBeenCalled(); + expect(component.response.emit).toHaveBeenCalledWith(true); + }); +}); diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-detail-modal/admin-notify-detail-modal.component.ts b/src/app/admin/admin-notify-dashboard/admin-notify-detail-modal/admin-notify-detail-modal.component.ts new file mode 100644 index 0000000000..54b14be64c --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-detail-modal/admin-notify-detail-modal.component.ts @@ -0,0 +1,49 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { AdminNotifyMessage } from '../models/admin-notify-message.model'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateService } from '@ngx-translate/core'; +import { MissingTranslationHelper } from '../../../shared/translate/missing-translation.helper'; +import { fadeIn } from '../../../shared/animations/fade'; + +@Component({ + selector: 'ds-admin-notify-detail-modal', + templateUrl: './admin-notify-detail-modal.component.html', + animations: [ + fadeIn + ] +}) +/** + * Component for detailed view of LDN messages displayed in search result in AdminNotifyDashboardComponent + */ + +export class AdminNotifyDetailModalComponent { + @Input() notifyMessage: AdminNotifyMessage; + @Input() notifyMessageKeys: string[]; + + /** + * An event fired when the modal is closed + */ + @Output() + response = new EventEmitter(); + + public isCoarMessageVisible = false; + + + constructor(protected activeModal: NgbActiveModal, + public translationsService: TranslateService) { + this.translationsService.missingTranslationHandler = new MissingTranslationHelper(); + } + + + /** + * Close the modal and set the response to true so RootComponent knows the modal was closed + */ + closeModal() { + this.activeModal.close(); + this.response.emit(true); + } + + toggleCoarMessage() { + this.isCoarMessageVisible = !this.isCoarMessageVisible; + } +} diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-incoming/admin-notify-incoming.component.html b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-incoming/admin-notify-incoming.component.html new file mode 100644 index 0000000000..4c957ca630 --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-incoming/admin-notify-incoming.component.html @@ -0,0 +1,23 @@ + diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-incoming/admin-notify-incoming.component.spec.ts b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-incoming/admin-notify-incoming.component.spec.ts new file mode 100644 index 0000000000..f7388c5210 --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-incoming/admin-notify-incoming.component.spec.ts @@ -0,0 +1,58 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AdminNotifyIncomingComponent } from './admin-notify-incoming.component'; +import { TranslateModule } from '@ngx-translate/core'; +import { ActivatedRoute } from '@angular/router'; +import { MockActivatedRoute } from '../../../../shared/mocks/active-router.mock'; +import { provideMockStore } from '@ngrx/store/testing'; +import { HALEndpointService } from '../../../../core/shared/hal-endpoint.service'; +import { SEARCH_CONFIG_SERVICE } from '../../../../my-dspace-page/my-dspace-page.component'; +import { RouteService } from '../../../../core/services/route.service'; +import { routeServiceStub } from '../../../../shared/testing/route-service.stub'; +import { RequestService } from '../../../../core/data/request.service'; +import { RemoteDataBuildService } from '../../../../core/cache/builders/remote-data-build.service'; +import { getMockRemoteDataBuildService } from '../../../../shared/mocks/remote-data-build.service.mock'; +import { SearchConfigurationService } from '../../../../core/shared/search/search-configuration.service'; + +describe('AdminNotifyIncomingComponent', () => { + let component: AdminNotifyIncomingComponent; + let fixture: ComponentFixture; + let halService: HALEndpointService; + let requestService: RequestService; + let rdbService: RemoteDataBuildService; + + + + beforeEach(async () => { + rdbService = getMockRemoteDataBuildService(); + halService = jasmine.createSpyObj('halService', { + 'getRootHref': '/api' + }); + requestService = jasmine.createSpyObj('requestService', { + 'generateRequestId': 'client/1234', + 'send': '', + }); + await TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [ AdminNotifyIncomingComponent ], + providers: [ + { provide: SEARCH_CONFIG_SERVICE, useValue: SearchConfigurationService }, + { provide: RouteService, useValue: routeServiceStub }, + { provide: ActivatedRoute, useValue: new MockActivatedRoute() }, + { provide: HALEndpointService, useValue: halService }, + { provide: RequestService, useValue: requestService }, + { provide: RemoteDataBuildService, useValue: rdbService }, + provideMockStore({}), + ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(AdminNotifyIncomingComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-incoming/admin-notify-incoming.component.ts b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-incoming/admin-notify-incoming.component.ts new file mode 100644 index 0000000000..b259d9a13c --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-incoming/admin-notify-incoming.component.ts @@ -0,0 +1,19 @@ +import { Component, Inject } from '@angular/core'; +import { SEARCH_CONFIG_SERVICE } from '../../../../my-dspace-page/my-dspace-page.component'; +import { SearchConfigurationService } from '../../../../core/shared/search/search-configuration.service'; + + +@Component({ + selector: 'ds-admin-notify-incoming', + templateUrl: './admin-notify-incoming.component.html', + providers: [ + { + provide: SEARCH_CONFIG_SERVICE, + useClass: SearchConfigurationService + } + ] +}) +export class AdminNotifyIncomingComponent { + constructor(@Inject(SEARCH_CONFIG_SERVICE) public searchConfigService: SearchConfigurationService) { + } +} diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-logs-result/admin-notify-logs-result.component.html b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-logs-result/admin-notify-logs-result.component.html new file mode 100644 index 0000000000..c26c2682e5 --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-logs-result/admin-notify-logs-result.component.html @@ -0,0 +1,25 @@ +
+
+
{{((isInbound$ | async) ? 'admin.notify.dashboard.inbound' : 'admin.notify.dashboard.outbound') | translate}}
+
+
+ +
+ +
+
+
+ + +
+ +
+ diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-logs-result/admin-notify-logs-result.component.spec.ts b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-logs-result/admin-notify-logs-result.component.spec.ts new file mode 100644 index 0000000000..e59a52198d --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-logs-result/admin-notify-logs-result.component.spec.ts @@ -0,0 +1,49 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AdminNotifyLogsResultComponent } from './admin-notify-logs-result.component'; +import { ActivatedRoute, Router } from '@angular/router'; +import { MockActivatedRoute } from '../../../../shared/mocks/active-router.mock'; +import { provideMockStore } from '@ngrx/store/testing'; +import { HALEndpointService } from '../../../../core/shared/hal-endpoint.service'; +import { ObjectCacheService } from '../../../../core/cache/object-cache.service'; +import { RequestService } from '../../../../core/data/request.service'; +import { RemoteDataBuildService } from '../../../../core/cache/builders/remote-data-build.service'; +import { TranslateModule } from '@ngx-translate/core'; +import { RouterStub } from '../../../../shared/testing/router.stub'; +import { RouteService } from '../../../../core/services/route.service'; +import { routeServiceStub } from '../../../../shared/testing/route-service.stub'; + +describe('AdminNotifyLogsResultComponent', () => { + let component: AdminNotifyLogsResultComponent; + let fixture: ComponentFixture; + let objectCache: ObjectCacheService; + let requestService: RequestService; + let halService: HALEndpointService; + let rdbService: RemoteDataBuildService; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [ AdminNotifyLogsResultComponent ], + providers: [ + { provide: RouteService, useValue: routeServiceStub }, + { provide: Router, useValue: new RouterStub() }, + { provide: ActivatedRoute, useValue: new MockActivatedRoute() }, + { provide: HALEndpointService, useValue: halService }, + { provide: ObjectCacheService, useValue: objectCache }, + { provide: RequestService, useValue: requestService }, + { provide: RemoteDataBuildService, useValue: rdbService }, + provideMockStore({}), + ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(AdminNotifyLogsResultComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-logs-result/admin-notify-logs-result.component.ts b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-logs-result/admin-notify-logs-result.component.ts new file mode 100644 index 0000000000..4f0407ce88 --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-logs-result/admin-notify-logs-result.component.ts @@ -0,0 +1,79 @@ +import { + ChangeDetectorRef, + Component, + Inject, + Input, + OnInit +} from '@angular/core'; +import { SEARCH_CONFIG_SERVICE } from '../../../../my-dspace-page/my-dspace-page.component'; +import { Context } from '../../../../core/shared/context.model'; +import { SearchConfigurationService } from '../../../../core/shared/search/search-configuration.service'; +import { Observable } from 'rxjs'; +import { ActivatedRoute, ActivatedRouteSnapshot, Router } from '@angular/router'; +import { ViewMode } from '../../../../core/shared/view-mode.model'; +import { map } from 'rxjs/operators'; + +@Component({ + selector: 'ds-admin-notify-logs-result', + templateUrl: './admin-notify-logs-result.component.html', + providers: [ + { + provide: SEARCH_CONFIG_SERVICE, + useClass: SearchConfigurationService + } + ] +}) + +/** + * Component for visualization of search page and related results for the logs of the Notify dashboard + */ + +export class AdminNotifyLogsResultComponent implements OnInit { + + @Input() + defaultConfiguration: string; + + + public selectedSearchConfig$: Observable; + public isInbound$: Observable; + + protected readonly context = Context.CoarNotify; + + constructor(@Inject(SEARCH_CONFIG_SERVICE) public searchConfigService: SearchConfigurationService, + private router: Router, + private route: ActivatedRoute, + protected cdRef: ChangeDetectorRef) { + } + + ngOnInit() { + this.selectedSearchConfig$ = this.searchConfigService.getCurrentConfiguration(this.defaultConfiguration); + this.isInbound$ = this.selectedSearchConfig$.pipe( + map(config => config.startsWith('NOTIFY.incoming')) + ); + } + + /** + * Reset route state to default configuration + */ + public resetDefaultConfiguration() { + //Idle navigation to trigger rendering of result on same page + this.router.navigateByUrl('/', { skipLocationChange: true }).then(() => { + this.router.navigate([this.getResolvedUrl(this.route.snapshot)], { + queryParams: { + configuration: this.defaultConfiguration, + view: ViewMode.Table, + }, + }); + }); + } + + /** + * Get resolved url from route + * + * @param route url path + * @returns url path + */ + private getResolvedUrl(route: ActivatedRouteSnapshot): string { + return route.pathFromRoot.map(v => v.url.map(segment => segment.toString()).join('/')).join('/'); + } +} diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-outgoing/admin-notify-outgoing.component.html b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-outgoing/admin-notify-outgoing.component.html new file mode 100644 index 0000000000..e9bc1d10b2 --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-outgoing/admin-notify-outgoing.component.html @@ -0,0 +1,24 @@ + diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-outgoing/admin-notify-outgoing.component.spec.ts b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-outgoing/admin-notify-outgoing.component.spec.ts new file mode 100644 index 0000000000..a8af9a7fd6 --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-outgoing/admin-notify-outgoing.component.spec.ts @@ -0,0 +1,57 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AdminNotifyOutgoingComponent } from './admin-notify-outgoing.component'; +import { TranslateModule } from '@ngx-translate/core'; +import { ActivatedRoute } from '@angular/router'; +import { MockActivatedRoute } from '../../../../shared/mocks/active-router.mock'; +import { provideMockStore } from '@ngrx/store/testing'; +import { HALEndpointService } from '../../../../core/shared/hal-endpoint.service'; +import { SEARCH_CONFIG_SERVICE } from '../../../../my-dspace-page/my-dspace-page.component'; +import { RouteService } from '../../../../core/services/route.service'; +import { routeServiceStub } from '../../../../shared/testing/route-service.stub'; +import { RequestService } from '../../../../core/data/request.service'; +import { getMockRemoteDataBuildService } from '../../../../shared/mocks/remote-data-build.service.mock'; +import { RemoteDataBuildService } from '../../../../core/cache/builders/remote-data-build.service'; +import { SearchConfigurationService } from '../../../../core/shared/search/search-configuration.service'; + +describe('AdminNotifyOutgoingComponent', () => { + let component: AdminNotifyOutgoingComponent; + let fixture: ComponentFixture; + let halService: HALEndpointService; + let requestService: RequestService; + let rdbService: RemoteDataBuildService; + + + beforeEach(async () => { + rdbService = getMockRemoteDataBuildService(); + requestService = jasmine.createSpyObj('requestService', { + 'generateRequestId': 'client/1234', + 'send': '', + }); + halService = jasmine.createSpyObj('halService', { + 'getRootHref': '/api' + }); + await TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [ AdminNotifyOutgoingComponent ], + providers: [ + { provide: SEARCH_CONFIG_SERVICE, useValue: SearchConfigurationService }, + { provide: RouteService, useValue: routeServiceStub }, + { provide: ActivatedRoute, useValue: new MockActivatedRoute() }, + { provide: HALEndpointService, useValue: halService }, + { provide: RequestService, useValue: requestService }, + { provide: RemoteDataBuildService, useValue: rdbService }, + provideMockStore({}), + ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(AdminNotifyOutgoingComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-outgoing/admin-notify-outgoing.component.ts b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-outgoing/admin-notify-outgoing.component.ts new file mode 100644 index 0000000000..a37ddc3bd6 --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-outgoing/admin-notify-outgoing.component.ts @@ -0,0 +1,19 @@ +import { Component, Inject } from '@angular/core'; +import { SEARCH_CONFIG_SERVICE } from '../../../../my-dspace-page/my-dspace-page.component'; +import { SearchConfigurationService } from '../../../../core/shared/search/search-configuration.service'; + + +@Component({ + selector: 'ds-admin-notify-outgoing', + templateUrl: './admin-notify-outgoing.component.html', + providers: [ + { + provide: SEARCH_CONFIG_SERVICE, + useClass: SearchConfigurationService + } + ] +}) +export class AdminNotifyOutgoingComponent { + constructor(@Inject(SEARCH_CONFIG_SERVICE) public searchConfigService: SearchConfigurationService) { + } +} diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.component.html b/src/app/admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.component.html new file mode 100644 index 0000000000..3257bdd5ba --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.component.html @@ -0,0 +1,9 @@ +
+
{{ row.title | translate }}
+
+
+ +
+
+
+ diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.component.spec.ts b/src/app/admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.component.spec.ts new file mode 100644 index 0000000000..57f21a4ef3 --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.component.spec.ts @@ -0,0 +1,71 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AdminNotifyMetricsComponent } from './admin-notify-metrics.component'; +import { TranslateModule } from '@ngx-translate/core'; +import { Router } from '@angular/router'; +import { ViewMode } from '../../../core/shared/view-mode.model'; +import { RouterStub } from '../../../shared/testing/router.stub'; + +describe('AdminNotifyMetricsComponent', () => { + let component: AdminNotifyMetricsComponent; + let fixture: ComponentFixture; + let router: RouterStub; + + beforeEach(async () => { + router = Object.assign(new RouterStub(), + {url : '/notify-dashboard'} + ); + + + await TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [ AdminNotifyMetricsComponent ], + providers: [{provide: Router, useValue: router}] + }) + .compileComponents(); + + + + fixture = TestBed.createComponent(AdminNotifyMetricsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + it('should navigate to correct url based on config', () => { + const searchConfig = 'test.involvedItems'; + const incomingConfig = 'NOTIFY.incoming.test'; + const outgoingConfig = 'NOTIFY.outgoing.test'; + const adminPath = '/admin/search'; + const routeExtras = { + queryParams: { + configuration: searchConfig, + view: ViewMode.ListElement + }, + }; + + const routeExtrasTable = { + queryParams: { + configuration: incomingConfig, + view: ViewMode.Table + }, + }; + + const routeExtrasTableOutgoing = { + queryParams: { + configuration: outgoingConfig, + view: ViewMode.Table + }, + }; + component.navigateToSelectedSearchConfig(searchConfig); + expect(router.navigate).toHaveBeenCalledWith([adminPath], routeExtras); + + component.navigateToSelectedSearchConfig(incomingConfig); + expect(router.navigate).toHaveBeenCalledWith(['/notify-dashboard/inbound'], routeExtrasTable); + + component.navigateToSelectedSearchConfig(outgoingConfig); + expect(router.navigate).toHaveBeenCalledWith(['/notify-dashboard/outbound'], routeExtrasTableOutgoing); + }); +}); diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.component.ts b/src/app/admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.component.ts new file mode 100644 index 0000000000..8822e2bd1e --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.component.ts @@ -0,0 +1,53 @@ +import { Component, Input } from '@angular/core'; +import { AdminNotifyMetricsRow } from './admin-notify-metrics.model'; +import { Router } from '@angular/router'; +import { ViewMode } from '../../../core/shared/view-mode.model'; + +@Component({ + selector: 'ds-admin-notify-metrics', + templateUrl: './admin-notify-metrics.component.html', +}) +/** + * Component used to display the number of notification for each configured box in the notifyMetrics section + */ + +export class AdminNotifyMetricsComponent { + + @Input() + boxesConfig: AdminNotifyMetricsRow[]; + + private incomingConfiguration = 'NOTIFY.incoming'; + private involvedItemsSuffix = 'involvedItems'; + private inboundPath = '/inbound'; + private outboundPath = '/outbound'; + private adminSearchPath = '/admin/search'; + + constructor(private router: Router) { + } + + + public navigateToSelectedSearchConfig(searchConfig: string) { + const isRelatedItemsConfig = searchConfig.endsWith(this.involvedItemsSuffix); + + if (isRelatedItemsConfig) { + this.router.navigate([this.adminSearchPath], { + queryParams: { + configuration: searchConfig, + view: ViewMode.ListElement + }, + }); + + return; + } + + const isIncomingConfig = searchConfig.startsWith(this.incomingConfiguration); + const selectedPath = isIncomingConfig ? this.inboundPath : this.outboundPath; + + this.router.navigate([`${this.router.url}${selectedPath}`], { + queryParams: { + configuration: searchConfig, + view: ViewMode.Table + }, + }); + } +} diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.model.ts b/src/app/admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.model.ts new file mode 100644 index 0000000000..83b931c866 --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.model.ts @@ -0,0 +1,19 @@ +/** + * The properties for each Box to be displayed in rows in the AdminNotifyMetricsComponent + */ + +export interface AdminNotifyMetricsBox { + color: string; + textColor?: string; + title: string; + description: string; + config: string; + count?: number; +} +/** + * The properties for each Row containing a list of AdminNotifyMetricsBox to be displayed in the AdminNotifyMetricsComponent + */ +export interface AdminNotifyMetricsRow { + title: string; + boxes: AdminNotifyMetricsBox[] +} diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-search-result/admin-notify-search-result.component.html b/src/app/admin/admin-notify-dashboard/admin-notify-search-result/admin-notify-search-result.component.html new file mode 100644 index 0000000000..af540b094e --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-search-result/admin-notify-search-result.component.html @@ -0,0 +1,51 @@ +
+ + + + + + + + + + + + + + + + + + + + + +
{{ 'notify-message-result.timestamp' | translate}}{{'notify-message-result.repositoryItem' | translate}}{{ 'notify-message-result.ldnService' | translate}}{{ 'notify-message-result.type' | translate }}{{ 'notify-message-result.status' | translate }}{{ 'notify-message-result.action' | translate }}
+
{{ message.queueLastStartTime | date:"YYYY/MM/d hh:mm:ss" }}
+
n/a
+
+ + + {{ message.relatedItem }} + + +
n/a
+
+
{{ message.ldnService }}
+
n/a
+
+
{{ message.activityStreamType }}
+
+
{{ 'notify-detail-modal.' + message.queueStatusLabel | translate }}
+
+
+ + +
+
+
diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-search-result/admin-notify-search-result.component.spec.ts b/src/app/admin/admin-notify-dashboard/admin-notify-search-result/admin-notify-search-result.component.spec.ts new file mode 100644 index 0000000000..08f60a8f5c --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-search-result/admin-notify-search-result.component.spec.ts @@ -0,0 +1,182 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { AdminNotifySearchResultComponent } from './admin-notify-search-result.component'; +import { AdminNotifyMessagesService } from '../services/admin-notify-messages.service'; +import { ObjectCacheService } from '../../../core/cache/object-cache.service'; +import { RequestService } from '../../../core/data/request.service'; +import { HALEndpointService } from '../../../core/shared/hal-endpoint.service'; +import { RemoteDataBuildService } from '../../../core/cache/builders/remote-data-build.service'; +import { cold } from 'jasmine-marbles'; +import { ConfigurationProperty } from '../../../core/shared/configuration-property.model'; +import { RouteService } from '../../../core/services/route.service'; +import { routeServiceStub } from '../../../shared/testing/route-service.stub'; +import { ActivatedRoute } from '@angular/router'; +import { RouterStub } from '../../../shared/testing/router.stub'; +import { TranslateModule } from '@ngx-translate/core'; +import { of as observableOf, of } from 'rxjs'; +import { AdminNotifyMessage } from '../models/admin-notify-message.model'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { AdminNotifyDetailModalComponent } from '../admin-notify-detail-modal/admin-notify-detail-modal.component'; +import { SearchConfigurationService } from '../../../core/shared/search/search-configuration.service'; +import { SEARCH_CONFIG_SERVICE } from '../../../my-dspace-page/my-dspace-page.component'; +import { DatePipe } from '@angular/common'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; + + +export const mockAdminNotifyMessages = [ + { + 'type': 'message', + 'id': 'urn:uuid:5fb3af44-d4f8-4226-9475-2d09c2d8d9e0', + 'coarNotifyType': 'coar-notify:ReviewAction', + 'activityStreamType': 'TentativeReject', + 'inReplyTo': 'urn:uuid:f7289ad5-0955-4c86-834c-fb54a736778b', + 'object': null, + 'context': '24d50450-9ff0-485f-82d4-fba1be42f3f9', + 'queueAttempts': 1, + 'queueLastStartTime': '2023-11-24T14:44:00.064+00:00', + 'origin': 12, + 'target': null, + 'queueStatusLabel': 'notify-queue-status.processed', + 'queueTimeout': '2023-11-24T15:44:00.064+00:00', + 'queueStatus': 3, + '_links': { + 'self': { + 'href': 'http://localhost:8080/server/api/ldn/messages/urn:uuid:5fb3af44-d4f8-4226-9475-2d09c2d8d9e0' + } + }, + 'thumbnail': 'test', + 'item': {}, + 'accessStatus': {}, + 'ldnService': 'NOTIFY inbox - Automatic service', + 'relatedItem': 'test coar 2 demo', + 'message': '{"@context":["https://www.w3.org/ns/activitystreams","https://purl.org/coar/notify"],"id":"urn:uuid:668f26e0-2e8d-4118-b0d2-ee713523bc45","type":["Reject","coar-notify:IngestAction"],"actor":{"id":"https://generic-service.com","type":["Service"],"name":"Generic Service"},"context":{"id":"https://dspace-coar.4science.cloud/handle/123456789/28","type":["Document"],"ietf:cite-as":"https://doi.org/10.4598/12123488"},"object":{"id":"https://dspace-coar.4science.cloud/handle/123456789/28","type":["Offer"]},"origin":{"id":"https://generic-service.com/system","type":["Service"],"inbox":"https://notify-inbox.info/inbox7"},"target":{"id":"https://some-organisation.org","type":["Organization"],"inbox":"https://dspace-coar.4science.cloud/server/ldn/inbox"},"inReplyTo":"urn:uuid:d9b4010a-f128-4815-abb2-83707a2ee9cf"}' + }, + { + 'type': 'message', + 'id': 'urn:uuid:544c8777-e826-4810-a625-3e394cc3660d', + 'coarNotifyType': 'coar-notify:IngestAction', + 'activityStreamType': 'Announce', + 'inReplyTo': 'urn:uuid:b2ad72d6-6ea9-464f-b385-29a78417f6b8', + 'object': null, + 'context': 'e657437a-0ee2-437d-916a-bba8c57bf40b', + 'queueAttempts': 1, + 'queueLastStartTime': null, + 'origin': 12, + 'target': null, + 'queueStatusLabel': 'notify-queue-status.unmapped_action', + 'queueTimeout': '2023-11-24T14:15:34.945+00:00', + 'queueStatus': 6, + '_links': { + 'self': { + 'href': 'http://localhost:8080/server/api/ldn/messages/urn:uuid:544c8777-e826-4810-a625-3e394cc3660d' + } + }, + 'thumbnail': {}, + 'item': {}, + 'accessStatus': {}, + 'ldnService': 'NOTIFY inbox - Automatic service', + 'relatedItem': 'test coar demo', + 'message': '{"@context":["https://www.w3.org/ns/activitystreams","https://purl.org/coar/notify"],"id":"urn:uuid:668f26e0-2e8d-4118-b0d2-ee713523bc45","type":["Reject","coar-notify:IngestAction"],"actor":{"id":"https://generic-service.com","type":["Service"],"name":"Generic Service"},"context":{"id":"https://dspace-coar.4science.cloud/handle/123456789/28","type":["Document"],"ietf:cite-as":"https://doi.org/10.4598/12123488"},"object":{"id":"https://dspace-coar.4science.cloud/handle/123456789/28","type":["Offer"]},"origin":{"id":"https://generic-service.com/system","type":["Service"],"inbox":"https://notify-inbox.info/inbox7"},"target":{"id":"https://some-organisation.org","type":["Organization"],"inbox":"https://dspace-coar.4science.cloud/server/ldn/inbox"},"inReplyTo":"urn:uuid:d9b4010a-f128-4815-abb2-83707a2ee9cf"}' + } +] as unknown as AdminNotifyMessage[]; +describe('AdminNotifySearchResultComponent', () => { + let component: AdminNotifySearchResultComponent; + let fixture: ComponentFixture; + let objectCache: ObjectCacheService; + let requestService: RequestService; + let halService: HALEndpointService; + let rdbService: RemoteDataBuildService; + let adminNotifyMessageService: AdminNotifyMessagesService; + let searchConfigService: SearchConfigurationService; + let modalService: NgbModal; + const requestUUID = '34cfed7c-f597-49ef-9cbe-ea351f0023c2'; + const testObject = { + uuid: 'test-property', + name: 'test-property', + values: ['value-1', 'value-2'] + } as ConfigurationProperty; + + beforeEach(async () => { + halService = jasmine.createSpyObj('halService', { + getEndpoint: cold('a', { a: '' }) + }); + adminNotifyMessageService = jasmine.createSpyObj('adminNotifyMessageService', { + getDetailedMessages: of(mockAdminNotifyMessages), + reprocessMessage: of(mockAdminNotifyMessages), + }); + requestService = jasmine.createSpyObj('requestService', { + generateRequestId: requestUUID, + send: true + }); + rdbService = jasmine.createSpyObj('rdbService', { + buildSingle: cold('a', { + a: { + payload: testObject + } + }) + }); + + searchConfigService = jasmine.createSpyObj('searchConfigService', { + getCurrentConfiguration: of('NOTIFY.outgoing') + }); + objectCache = {} as ObjectCacheService; + + + await TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [ AdminNotifySearchResultComponent, AdminNotifyDetailModalComponent ], + providers: [ + { provide: AdminNotifyMessagesService, useValue: adminNotifyMessageService }, + { provide: RouteService, useValue: routeServiceStub }, + { provide: ActivatedRoute, useValue: new RouterStub() }, + { provide: HALEndpointService, useValue: halService }, + { provide: ObjectCacheService, useValue: objectCache }, + { provide: RequestService, useValue: requestService }, + { provide: RemoteDataBuildService, useValue: rdbService }, + { provide: SEARCH_CONFIG_SERVICE, useValue: searchConfigService }, + DatePipe + ], + schemas: [NO_ERRORS_SCHEMA] + }) + .compileComponents(); + + fixture = TestBed.createComponent(AdminNotifySearchResultComponent); + component = fixture.componentInstance; + component.searchConfigService = searchConfigService; + modalService = (component as any).modalService; + spyOn(modalService, 'open').and.returnValue(Object.assign({ componentInstance: Object.assign({ response: observableOf(true) }) })); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + expect(component.isInbound).toBeFalsy(); + }); + + it('should open modal', () => { + component.openDetailModal(mockAdminNotifyMessages[0]); + expect(modalService.open).toHaveBeenCalledWith(AdminNotifyDetailModalComponent); + }); + + it('should map messages', (done) => { + component.messagesSubject$.subscribe((messages) => { + expect(messages).toEqual(mockAdminNotifyMessages); + done(); + }); + }); + + it('should reprocess message', (done) => { + component.reprocessMessage(mockAdminNotifyMessages[0]); + component.messagesSubject$.subscribe((messages) => { + expect(messages).toEqual(mockAdminNotifyMessages); + done(); + }); + }); + + it('should unsubscribe on destroy', () => { + (component as any).subs = [of(null).subscribe()]; + + spyOn((component as any).subs[0], 'unsubscribe'); + component.ngOnDestroy(); + expect((component as any).subs[0].unsubscribe).toHaveBeenCalled(); + }); +}); diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-search-result/admin-notify-search-result.component.ts b/src/app/admin/admin-notify-dashboard/admin-notify-search-result/admin-notify-search-result.component.ts new file mode 100644 index 0000000000..11e8020986 --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-search-result/admin-notify-search-result.component.ts @@ -0,0 +1,157 @@ +import { Component, Inject, OnDestroy, OnInit } from '@angular/core'; +import { AdminNotifySearchResult } from '../models/admin-notify-message-search-result.model'; +import { ViewMode } from '../../../core/shared/view-mode.model'; +import { Context } from '../../../core/shared/context.model'; +import { AdminNotifyMessage } from '../models/admin-notify-message.model'; +import { + tabulatableObjectsComponent +} from '../../../shared/object-collection/shared/tabulatable-objects/tabulatable-objects.decorator'; +import { + TabulatableResultListElementsComponent +} from '../../../shared/object-list/search-result-list-element/tabulatable-search-result/tabulatable-result-list-elements.component'; +import { PaginatedList } from '../../../core/data/paginated-list.model'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { AdminNotifyDetailModalComponent } from '../admin-notify-detail-modal/admin-notify-detail-modal.component'; +import { BehaviorSubject, Subscription } from 'rxjs'; +import { AdminNotifyMessagesService } from '../services/admin-notify-messages.service'; +import { SearchConfigurationService } from '../../../core/shared/search/search-configuration.service'; +import { SEARCH_CONFIG_SERVICE } from '../../../my-dspace-page/my-dspace-page.component'; +import { DatePipe } from '@angular/common'; + +@tabulatableObjectsComponent(PaginatedList, ViewMode.Table, Context.CoarNotify) +@Component({ + selector: 'ds-admin-notify-search-result', + templateUrl: './admin-notify-search-result.component.html', + providers: [ + { + provide: SEARCH_CONFIG_SERVICE, + useClass: SearchConfigurationService + } + ] +}) +/** + * Component for visualization in table format of the search results related to the AdminNotifyDashboardComponent + */ + + +export class AdminNotifySearchResultComponent extends TabulatableResultListElementsComponent, AdminNotifySearchResult> implements OnInit, OnDestroy{ + public messagesSubject$: BehaviorSubject = new BehaviorSubject([]); + public reprocessStatus = 'QUEUE_STATUS_QUEUED_FOR_RETRY'; + //we check on one type of config to render specific table headers + public isInbound: boolean; + + /** + * Statuses for which we display the reprocess button + */ + public validStatusesForReprocess = [ + 'QUEUE_STATUS_UNTRUSTED', + 'QUEUE_STATUS_UNTRUSTED_IP', + 'QUEUE_STATUS_FAILED', + 'QUEUE_STATUS_UNMAPPED_ACTION' + ]; + + + /** + * Array to track all subscriptions and unsubscribe them onDestroy + * @type {Array} + */ + private subs: Subscription[] = []; + + /** + * Keys to be formatted as date + * @private + */ + + private dateTypeKeys: string[] = ['queueLastStartTime', 'queueTimeout']; + + /** + * Keys to be not shown in detail + * @private + */ + private messageKeys: string[] = [ + 'type', + 'id', + 'coarNotifyType', + 'activityStreamType', + 'inReplyTo', + 'queueAttempts', + 'queueLastStartTime', + 'queueStatusLabel', + 'queueTimeout' + ]; + + /** + * The format for the date values + * @private + */ + private dateFormat = 'YYYY/MM/d hh:mm:ss'; + + constructor(private modalService: NgbModal, + private adminNotifyMessagesService: AdminNotifyMessagesService, + private datePipe: DatePipe, + @Inject(SEARCH_CONFIG_SERVICE) public searchConfigService: SearchConfigurationService) { + super(); + } + + /** + * Map messages on init for readable representation + */ + ngOnInit() { + this.mapDetailsToMessages(); + this.subs.push(this.searchConfigService.getCurrentConfiguration('') + .subscribe(configuration => { + this.isInbound = configuration.startsWith('NOTIFY.incoming'); + }) + ); + } + + ngOnDestroy() { + this.subs.forEach(sub => sub.unsubscribe()); + } + + /** + * Open modal for details visualization + * @param notifyMessage the message to be displayed + */ + openDetailModal(notifyMessage: AdminNotifyMessage) { + const modalRef = this.modalService.open(AdminNotifyDetailModalComponent); + const messageToOpen = {...notifyMessage}; + + this.messageKeys.forEach(key => { + if (this.dateTypeKeys.includes(key)) { + messageToOpen[key] = this.datePipe.transform(messageToOpen[key], this.dateFormat); + } + }); + // format COAR message for technical visualization + messageToOpen.message = JSON.stringify(JSON.parse(notifyMessage.message), null, 2); + + modalRef.componentInstance.notifyMessage = messageToOpen; + modalRef.componentInstance.notifyMessageKeys = this.messageKeys; + } + + /** + * Reprocess message in status QUEUE_STATUS_QUEUED_FOR_RETRY and update results + * @param message the message to be reprocessed + */ + reprocessMessage(message: AdminNotifyMessage) { + this.subs.push( + this.adminNotifyMessagesService.reprocessMessage(message, this.messagesSubject$) + .subscribe(response => { + this.messagesSubject$.next(response); + } + ) + ); + } + + + /** + * Map readable results to messages + * @private + */ + private mapDetailsToMessages() { + this.subs.push(this.adminNotifyMessagesService.getDetailedMessages(this.objects?.page.map(pageResult => pageResult.indexableObject)) + .subscribe(response => { + this.messagesSubject$.next(response); + })); + } +} diff --git a/src/app/admin/admin-notify-dashboard/models/admin-notify-message-search-result.model.ts b/src/app/admin/admin-notify-dashboard/models/admin-notify-message-search-result.model.ts new file mode 100644 index 0000000000..236a564f20 --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/models/admin-notify-message-search-result.model.ts @@ -0,0 +1,8 @@ +import { AdminNotifyMessage } from './admin-notify-message.model'; +import { searchResultFor } from '../../../shared/search/search-result-element-decorator'; +import { SearchResult } from '../../../shared/search/models/search-result.model'; + + +@searchResultFor(AdminNotifyMessage) +export class AdminNotifySearchResult extends SearchResult { +} diff --git a/src/app/admin/admin-notify-dashboard/models/admin-notify-message.model.ts b/src/app/admin/admin-notify-dashboard/models/admin-notify-message.model.ts new file mode 100644 index 0000000000..cca26f0fb6 --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/models/admin-notify-message.model.ts @@ -0,0 +1,165 @@ +import { autoserialize, deserialize, inheritSerialization } from 'cerialize'; +import { typedObject } from '../../../core/cache/builders/build-decorators'; +import { ADMIN_NOTIFY_MESSAGE } from './admin-notify-message.resource-type'; +import { excludeFromEquals } from '../../../core/utilities/equals.decorators'; +import { DSpaceObject } from '../../../core/shared/dspace-object.model'; +import { GenericConstructor } from '../../../core/shared/generic-constructor'; +import { ListableObject } from '../../../shared/object-collection/shared/listable-object.model'; +import { Observable } from 'rxjs'; +/** + * A message that includes admin notify info + */ +@typedObject +@inheritSerialization(DSpaceObject) +export class AdminNotifyMessage extends DSpaceObject { + static type = ADMIN_NOTIFY_MESSAGE; + + /** + * The type of the resource + */ + @excludeFromEquals + type = ADMIN_NOTIFY_MESSAGE; + + /** + * The id of the message + */ + @autoserialize + id: string; + + /** + * The id of the notification + */ + @autoserialize + notificationId: string; + + /** + * The type of the notification + */ + @autoserialize + notificationType: string; + + /** + * The type of the notification + */ + @autoserialize + coarNotifyType: string; + + /** + * The type of the activity + */ + @autoserialize + activityStreamType: string; + + /** + * The object the message reply to + */ + @autoserialize + inReplyTo: string; + + /** + * The object the message relates to + */ + @autoserialize + object: string; + + /** + * The name of the related item + */ + @autoserialize + relatedItem: string; + + /** + * The name of the related ldn service + */ + @autoserialize + ldnService: string; + + /** + * The context of the message + */ + @autoserialize + context: string; + + /** + * The related COAR message + */ + @autoserialize + message: string; + + /** + * The attempts of the queue + */ + @autoserialize + queueAttempts: number; + + /** + * Timestamp of the last queue attempt + */ + @autoserialize + queueLastStartTime: string; + + /** + * The type of the activity stream + */ + @autoserialize + origin: number | string; + + /** + * The type of the activity stream + */ + @autoserialize + target: number | string; + + /** + * The label for the status of the queue + */ + @autoserialize + queueStatusLabel: string; + + /** + * The timeout of the queue + */ + @autoserialize + queueTimeout: string; + + /** + * The status of the queue + */ + @autoserialize + queueStatus: number; + + /** + * Thumbnail link used when browsing items with showThumbs config enabled. + */ + @autoserialize + thumbnail: string; + + /** + * The observable pointing to the item itself + */ + @autoserialize + item: Observable; + + /** + * The observable pointing to the access status of the item + */ + @autoserialize + accessStatus: Observable; + + + + @deserialize + _links: { + self: { + href: string; + }; + }; + + get self(): string { + return this._links.self.href; + } + + getRenderTypes(): (string | GenericConstructor)[] { + return [this.constructor as GenericConstructor]; + } +} diff --git a/src/app/admin/admin-notify-dashboard/models/admin-notify-message.resource-type.ts b/src/app/admin/admin-notify-dashboard/models/admin-notify-message.resource-type.ts new file mode 100644 index 0000000000..994146adb3 --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/models/admin-notify-message.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from '../../../core/shared/resource-type'; + +/** + * The resource type for AdminNotifyMessage + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const ADMIN_NOTIFY_MESSAGE = new ResourceType('message'); diff --git a/src/app/admin/admin-notify-dashboard/services/admin-notify-messages.service.spec.ts b/src/app/admin/admin-notify-dashboard/services/admin-notify-messages.service.spec.ts new file mode 100644 index 0000000000..975950a33d --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/services/admin-notify-messages.service.spec.ts @@ -0,0 +1,114 @@ +import { cold } from 'jasmine-marbles'; +import { AdminNotifyMessagesService } from './admin-notify-messages.service'; +import { RequestService } from '../../../core/data/request.service'; +import { RemoteDataBuildService } from '../../../core/cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../../../core/cache/object-cache.service'; +import { HALEndpointService } from '../../../core/shared/hal-endpoint.service'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { LdnServicesService } from '../../admin-ldn-services/ldn-services-data/ldn-services-data.service'; +import { ItemDataService } from '../../../core/data/item-data.service'; +import { RequestEntry } from '../../../core/data/request-entry.model'; +import { RestResponse } from '../../../core/cache/response.models'; +import { BehaviorSubject, of } from 'rxjs'; +import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { RemoteData } from '../../../core/data/remote-data'; +import { RequestEntryState } from '../../../core/data/request-entry-state.model'; +import { + mockAdminNotifyMessages} from '../admin-notify-search-result/admin-notify-search-result.component.spec'; +import { take } from 'rxjs/operators'; +import { deepClone } from 'fast-json-patch'; +import { AdminNotifyMessage } from '../models/admin-notify-message.model'; + +describe('AdminNotifyMessagesService test', () => { + let service: AdminNotifyMessagesService; + let requestService: RequestService; + let rdbService: RemoteDataBuildService; + let objectCache: ObjectCacheService; + let halService: HALEndpointService; + let notificationsService: NotificationsService; + let ldnServicesService: LdnServicesService; + let itemDataService: ItemDataService; + let responseCacheEntry: RequestEntry; + let mockMessages: AdminNotifyMessage[]; + + const endpointURL = `https://rest.api/rest/api/ldn/messages`; + const requestUUID = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a'; + const remoteDataMocks = { + Success: new RemoteData(null, null, null, RequestEntryState.Success, null, null, 200), + }; + const testLdnServiceName = 'testLdnService'; + const testRelatedItemName = 'testRelatedItem'; + + function initTestService() { + return new AdminNotifyMessagesService( + requestService, + rdbService, + objectCache, + halService, + notificationsService, + ldnServicesService, + itemDataService + ); + } + + beforeEach(() => { + mockMessages = deepClone(mockAdminNotifyMessages); + objectCache = {} as ObjectCacheService; + notificationsService = {} as NotificationsService; + responseCacheEntry = new RequestEntry(); + responseCacheEntry.request = { href: endpointURL } as any; + responseCacheEntry.response = new RestResponse(true, 200, 'Success'); + + + requestService = jasmine.createSpyObj('requestService', { + generateRequestId: requestUUID, + send: true, + removeByHrefSubstring: {}, + getByHref: of(responseCacheEntry), + getByUUID: of(responseCacheEntry), + }); + + halService = jasmine.createSpyObj('halService', { + getEndpoint: of(endpointURL) + }); + + rdbService = jasmine.createSpyObj('rdbService', { + buildSingle: createSuccessfulRemoteDataObject$({}, 500), + buildList: cold('a', { a: remoteDataMocks.Success }), + buildFromRequestUUID: createSuccessfulRemoteDataObject$(mockMessages) + }); + + ldnServicesService = jasmine.createSpyObj('ldnServicesService', { + findById: createSuccessfulRemoteDataObject$({name: testLdnServiceName}), + }); + + itemDataService = jasmine.createSpyObj('itemDataService', { + findById: createSuccessfulRemoteDataObject$({name: testRelatedItemName}), + }); + + service = initTestService(); + }); + + describe('Admin Notify service', () => { + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('should get details for messages', (done) => { + service.getDetailedMessages(mockMessages).pipe(take(1)).subscribe((detailedMessages) => { + expect(detailedMessages[0].ldnService).toEqual(testLdnServiceName); + expect(detailedMessages[0].relatedItem).toEqual(testRelatedItemName); + done(); + }); + }); + + it('should reprocess message', (done) => { + const behaviorSubject = new BehaviorSubject(mockMessages); + service.reprocessMessage(mockMessages[0], behaviorSubject).pipe(take(1)).subscribe((reprocessedMessages) => { + expect(reprocessedMessages.length).toEqual(2); + expect(reprocessedMessages).toEqual(mockMessages); + done(); + }); + }); + }); +}); diff --git a/src/app/admin/admin-notify-dashboard/services/admin-notify-messages.service.ts b/src/app/admin/admin-notify-dashboard/services/admin-notify-messages.service.ts new file mode 100644 index 0000000000..ee78957abe --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/services/admin-notify-messages.service.ts @@ -0,0 +1,100 @@ +import {Injectable} from '@angular/core'; +import {dataService} from '../../../core/data/base/data-service.decorator'; +import {IdentifiableDataService} from '../../../core/data/base/identifiable-data.service'; +import {RequestService} from '../../../core/data/request.service'; +import {RemoteDataBuildService} from '../../../core/cache/builders/remote-data-build.service'; +import {ObjectCacheService} from '../../../core/cache/object-cache.service'; +import {HALEndpointService} from '../../../core/shared/hal-endpoint.service'; +import {NotificationsService} from '../../../shared/notifications/notifications.service'; +import { BehaviorSubject, from, Observable, of, scan } from 'rxjs'; +import { ADMIN_NOTIFY_MESSAGE } from '../models/admin-notify-message.resource-type'; +import { AdminNotifyMessage } from '../models/admin-notify-message.model'; +import { map, mergeMap, switchMap, tap } from 'rxjs/operators'; +import { getAllSucceededRemoteDataPayload, getFirstCompletedRemoteData } from '../../../core/shared/operators'; +import { LdnServicesService } from '../../admin-ldn-services/ldn-services-data/ldn-services-data.service'; +import { ItemDataService } from '../../../core/data/item-data.service'; +import { PostRequest } from '../../../core/data/request.models'; +import { RestRequest } from '../../../core/data/rest-request.model'; + +/** + * Injectable service responsible for fetching/sending data from/to the REST API on the messages endpoint. + * + * @export + * @class AdminNotifyMessagesService + * @extends {IdentifiableDataService} + */ +@Injectable() +@dataService(ADMIN_NOTIFY_MESSAGE) +export class AdminNotifyMessagesService extends IdentifiableDataService { + + protected reprocessEndpoint = 'enqueueretry'; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + private ldnServicesService: LdnServicesService, + private itemDataService: ItemDataService, + ) { + super('messages', requestService, rdbService, objectCache, halService); + } + + + /** + * Add detailed information to each message + * @param messages the messages to which add detailded info + */ + public getDetailedMessages(messages: AdminNotifyMessage[]): Observable { + return from(messages).pipe( + mergeMap(message => + message.target || message.origin ? this.ldnServicesService.findById((message.target || message.origin).toString()).pipe( + getAllSucceededRemoteDataPayload(), + map(detail => ({...message, ldnService: detail.name})) + ) : of(message), + ), + mergeMap(message => + message.object || message.context ? this.itemDataService.findById(message.object || message.context).pipe( + getAllSucceededRemoteDataPayload(), + map(detail => ({...message, relatedItem: detail.name})) + ) : of(message), + ), + scan((acc: any, value: any) => [...acc, value], []), + ); + } + + /** + * Reprocess message in status QUEUE_STATUS_QUEUED_FOR_RETRY and update results + * @param message the message to reprocess + * @param messageSubject the current visualised messages source + */ + public reprocessMessage(message: AdminNotifyMessage, messageSubject: BehaviorSubject): Observable { + const requestId = this.requestService.generateRequestId(); + + return this.halService.getEndpoint(this.reprocessEndpoint).pipe( + map(endpoint => endpoint.replace('{id}', message.id)), + map((endpointURL: string) => new PostRequest(requestId, endpointURL)), + tap(request => this.requestService.send(request)), + switchMap((request: RestRequest) => this.rdbService.buildFromRequestUUID(request.uuid)), + getFirstCompletedRemoteData(), + getAllSucceededRemoteDataPayload(), + mergeMap(reprocessedMessage => this.getDetailedMessages([reprocessedMessage])), + ).pipe( + mergeMap((newMessages) => messageSubject.pipe( + map(messages => { + const detailedReprocessedMessage = newMessages[0]; + const messageToUpdate = messages.find(currentMessage => currentMessage.id === message.id); + const indexOfMessageToUpdate = messages.indexOf(messageToUpdate); + detailedReprocessedMessage.target = message.target; + detailedReprocessedMessage.object = message.object; + detailedReprocessedMessage.origin = message.origin; + detailedReprocessedMessage.context = message.context; + messages[indexOfMessageToUpdate] = detailedReprocessedMessage; + + return messages; + }) + )), + ); + } +} diff --git a/src/app/admin/admin-routing-paths.ts b/src/app/admin/admin-routing-paths.ts index 9c4c6eb15a..3c45081d70 100644 --- a/src/app/admin/admin-routing-paths.ts +++ b/src/app/admin/admin-routing-paths.ts @@ -6,7 +6,7 @@ export const REGISTRIES_MODULE_PATH = 'registries'; export const NOTIFICATIONS_MODULE_PATH = 'notifications'; export const LDN_PATH = 'ldn'; export const REPORTS_MODULE_PATH = 'reports'; - +export const NOTIFY_DASHBOARD_MODULE_PATH = 'notify-dashboard'; export function getRegistriesModuleRoute() { diff --git a/src/app/admin/admin-routing.module.ts b/src/app/admin/admin-routing.module.ts index ae7d49a915..05167956db 100644 --- a/src/app/admin/admin-routing.module.ts +++ b/src/app/admin/admin-routing.module.ts @@ -8,7 +8,7 @@ import { I18nBreadcrumbsService } from '../core/breadcrumbs/i18n-breadcrumbs.ser import { AdminCurationTasksComponent } from './admin-curation-tasks/admin-curation-tasks.component'; import { LDN_PATH, - NOTIFICATIONS_MODULE_PATH, + NOTIFICATIONS_MODULE_PATH, NOTIFY_DASHBOARD_MODULE_PATH, REGISTRIES_MODULE_PATH, REPORTS_MODULE_PATH, } from './admin-routing-paths'; import { BatchImportPageComponent } from './admin-import-batch-page/batch-import-page.component'; @@ -88,6 +88,11 @@ import { loadChildren: () => import('./admin-reports/admin-reports.module') .then((m) => m.AdminReportsModule), }, + { + path: NOTIFY_DASHBOARD_MODULE_PATH, + loadChildren: () => import('./admin-notify-dashboard/admin-notify-dashboard.module') + .then((m) => m.AdminNotifyDashboardModule), + }, ]) ], providers: [ diff --git a/src/app/collection-page/edit-collection-page/collection-access-control/collection-access-control.component.spec.ts b/src/app/collection-page/edit-collection-page/collection-access-control/collection-access-control.component.spec.ts index 04da8bbcd9..53e4921af0 100644 --- a/src/app/collection-page/edit-collection-page/collection-access-control/collection-access-control.component.spec.ts +++ b/src/app/collection-page/edit-collection-page/collection-access-control/collection-access-control.component.spec.ts @@ -1,14 +1,44 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { CollectionAccessControlComponent } from './collection-access-control.component'; +import { ActivatedRoute } from '@angular/router'; +import { of, of as observableOf } from 'rxjs'; +import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; +import { Community } from '../../../core/shared/community.model'; -xdescribe('CollectionAccessControlComponent', () => { +describe('CollectionAccessControlComponent', () => { let component: CollectionAccessControlComponent; let fixture: ComponentFixture; + const testCommunity = Object.assign(new Community(), + { + type: 'community', + metadata: { + 'dc.title': [{ value: 'community' }] + }, + uuid: 'communityUUID', + parentCommunity: observableOf(Object.assign(createSuccessfulRemoteDataObject(undefined), { statusCode: 204 })), + + _links: { + parentCommunity: 'site', + self: '/' + 'communityUUID' + } + } + ); + + const routeStub = { + parent: { + parent: { + data: of({ + dso: createSuccessfulRemoteDataObject(testCommunity) + }) + } + } + }; beforeEach(async () => { await TestBed.configureTestingModule({ - declarations: [ CollectionAccessControlComponent ] + declarations: [ CollectionAccessControlComponent ], + providers: [{ provide: ActivatedRoute, useValue: routeStub }] }) .compileComponents(); }); @@ -17,9 +47,17 @@ xdescribe('CollectionAccessControlComponent', () => { fixture = TestBed.createComponent(CollectionAccessControlComponent); component = fixture.componentInstance; fixture.detectChanges(); + component.ngOnInit(); }); it('should create', () => { expect(component).toBeTruthy(); }); + + it('should set itemRD$', (done) => { + component.itemRD$.subscribe(result => { + expect(result).toEqual(createSuccessfulRemoteDataObject(testCommunity)); + done(); + }); + }); }); diff --git a/src/app/community-page/edit-community-page/community-access-control/community-access-control.component.spec.ts b/src/app/community-page/edit-community-page/community-access-control/community-access-control.component.spec.ts index d895cfd820..561cf30c15 100644 --- a/src/app/community-page/edit-community-page/community-access-control/community-access-control.component.spec.ts +++ b/src/app/community-page/edit-community-page/community-access-control/community-access-control.component.spec.ts @@ -1,25 +1,66 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { CommunityAccessControlComponent } from './community-access-control.component'; +import { Community } from '../../../core/shared/community.model'; +import { of, of as observableOf } from 'rxjs'; +import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; -xdescribe('CommunityAccessControlComponent', () => { +import { ActivatedRoute } from '@angular/router'; + +describe('CommunityAccessControlComponent', () => { let component: CommunityAccessControlComponent; let fixture: ComponentFixture; + const testCommunity = Object.assign(new Community(), + { + type: 'community', + metadata: { + 'dc.title': [{ value: 'community' }] + }, + uuid: 'communityUUID', + parentCommunity: observableOf(Object.assign(createSuccessfulRemoteDataObject(undefined), { statusCode: 204 })), + + _links: { + parentCommunity: 'site', + self: '/' + 'communityUUID' + } + } + ); + + const routeStub = { + parent: { + parent: { + data: of({ + dso: createSuccessfulRemoteDataObject(testCommunity) + }) + } + } + }; + beforeEach(async () => { await TestBed.configureTestingModule({ - declarations: [ CommunityAccessControlComponent ] + declarations: [ CommunityAccessControlComponent ], + providers: [{ provide: ActivatedRoute, useValue: routeStub }] }) - .compileComponents(); + .compileComponents(); }); + beforeEach(() => { fixture = TestBed.createComponent(CommunityAccessControlComponent); component = fixture.componentInstance; fixture.detectChanges(); + component.ngOnInit(); }); it('should create', () => { expect(component).toBeTruthy(); }); + + it('should set itemRD$', (done) => { + component.itemRD$.subscribe(result => { + expect(result).toEqual(createSuccessfulRemoteDataObject(testCommunity)); + done(); + }); + }); }); diff --git a/src/app/community-page/sections/sub-com-col-section/sub-collection-list/community-page-sub-collection-list.component.spec.ts b/src/app/community-page/sections/sub-com-col-section/sub-collection-list/community-page-sub-collection-list.component.spec.ts index 8c4af30991..2655753021 100644 --- a/src/app/community-page/sections/sub-com-col-section/sub-collection-list/community-page-sub-collection-list.component.spec.ts +++ b/src/app/community-page/sections/sub-com-col-section/sub-collection-list/community-page-sub-collection-list.component.spec.ts @@ -1,6 +1,6 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { TranslateModule } from '@ngx-translate/core'; -import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; import { By } from '@angular/platform-browser'; import { RouterTestingModule } from '@angular/router/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; @@ -30,7 +30,7 @@ import { ConfigurationProperty } from '../../../../core/shared/configuration-pro import { createPaginatedList } from '../../../../shared/testing/utils.test'; import { SearchConfigurationServiceStub } from '../../../../shared/testing/search-configuration-service.stub'; -describe('CommunityPageSubCollectionList Component', () => { +describe('CommunityPageSubCollectionListComponent', () => { let comp: CommunityPageSubCollectionListComponent; let fixture: ComponentFixture; let collectionDataServiceStub: any; @@ -177,19 +177,19 @@ describe('CommunityPageSubCollectionList Component', () => { }); - it('should display a list of collections', () => { - waitForAsync(() => { - subCollList = collections; - fixture.detectChanges(); + it('should display a list of collections', async () => { + subCollList = collections; + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); - const collList = fixture.debugElement.queryAll(By.css('li')); - expect(collList.length).toEqual(5); - expect(collList[0].nativeElement.textContent).toContain('Collection 1'); - expect(collList[1].nativeElement.textContent).toContain('Collection 2'); - expect(collList[2].nativeElement.textContent).toContain('Collection 3'); - expect(collList[3].nativeElement.textContent).toContain('Collection 4'); - expect(collList[4].nativeElement.textContent).toContain('Collection 5'); - }); + const collList: DebugElement[] = fixture.debugElement.queryAll(By.css('ul[data-test="objects"] li')); + expect(collList.length).toEqual(5); + expect(collList[0].nativeElement.textContent).toContain('Collection 1'); + expect(collList[1].nativeElement.textContent).toContain('Collection 2'); + expect(collList[2].nativeElement.textContent).toContain('Collection 3'); + expect(collList[3].nativeElement.textContent).toContain('Collection 4'); + expect(collList[4].nativeElement.textContent).toContain('Collection 5'); }); it('should not display the header when list of collections is empty', () => { diff --git a/src/app/community-page/sections/sub-com-col-section/sub-community-list/community-page-sub-community-list.component.spec.ts b/src/app/community-page/sections/sub-com-col-section/sub-community-list/community-page-sub-community-list.component.spec.ts index c5efc9c2c1..a51f09d7a5 100644 --- a/src/app/community-page/sections/sub-com-col-section/sub-community-list/community-page-sub-community-list.component.spec.ts +++ b/src/app/community-page/sections/sub-com-col-section/sub-community-list/community-page-sub-community-list.component.spec.ts @@ -1,6 +1,6 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { TranslateModule } from '@ngx-translate/core'; -import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; import { RouterTestingModule } from '@angular/router/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { By } from '@angular/platform-browser'; @@ -30,7 +30,7 @@ import { SearchConfigurationServiceStub } from '../../../../shared/testing/searc import { ConfigurationProperty } from '../../../../core/shared/configuration-property.model'; import { createPaginatedList } from '../../../../shared/testing/utils.test'; -describe('CommunityPageSubCommunityListComponent Component', () => { +describe('CommunityPageSubCommunityListComponent', () => { let comp: CommunityPageSubCommunityListComponent; let fixture: ComponentFixture; let communityDataServiceStub: any; @@ -179,19 +179,19 @@ describe('CommunityPageSubCommunityListComponent Component', () => { }); - it('should display a list of sub-communities', () => { - waitForAsync(() => { - subCommList = subcommunities; - fixture.detectChanges(); + it('should display a list of sub-communities', async () => { + subCommList = subcommunities; + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); - const subComList = fixture.debugElement.queryAll(By.css('li')); - expect(subComList.length).toEqual(5); - expect(subComList[0].nativeElement.textContent).toContain('SubCommunity 1'); - expect(subComList[1].nativeElement.textContent).toContain('SubCommunity 2'); - expect(subComList[2].nativeElement.textContent).toContain('SubCommunity 3'); - expect(subComList[3].nativeElement.textContent).toContain('SubCommunity 4'); - expect(subComList[4].nativeElement.textContent).toContain('SubCommunity 5'); - }); + const subComList: DebugElement[] = fixture.debugElement.queryAll(By.css('ul[data-test="objects"] li')); + expect(subComList.length).toEqual(5); + expect(subComList[0].nativeElement.textContent).toContain('SubCommunity 1'); + expect(subComList[1].nativeElement.textContent).toContain('SubCommunity 2'); + expect(subComList[2].nativeElement.textContent).toContain('SubCommunity 3'); + expect(subComList[3].nativeElement.textContent).toContain('SubCommunity 4'); + expect(subComList[4].nativeElement.textContent).toContain('SubCommunity 5'); }); it('should not display the header when list of sub-communities is empty', () => { diff --git a/src/app/core/auth/auth.interceptor.spec.ts b/src/app/core/auth/auth.interceptor.spec.ts index 04bbc4acaf..1cf812fcbf 100644 --- a/src/app/core/auth/auth.interceptor.spec.ts +++ b/src/app/core/auth/auth.interceptor.spec.ts @@ -20,9 +20,7 @@ describe(`AuthInterceptor`, () => { const authServiceStub = new AuthServiceStub(); const store: Store = jasmine.createSpyObj('store', { - /* eslint-disable no-empty,@typescript-eslint/no-empty-function */ dispatch: {}, - /* eslint-enable no-empty, @typescript-eslint/no-empty-function */ select: observableOf(true) }); @@ -46,6 +44,10 @@ describe(`AuthInterceptor`, () => { httpMock = TestBed.inject(HttpTestingController); }); + afterEach(() => { + httpMock.verify(); + }); + describe('when has a valid token', () => { it('should not add an Authorization header when we’re sending a HTTP request to \'authn\' endpoint that is not the logout endpoint', () => { @@ -95,14 +97,11 @@ describe(`AuthInterceptor`, () => { }); it('should redirect to login', () => { - - service.request(RestRequestMethod.POST, 'dspace-spring-rest/api/submission/workspaceitems', 'password=password&user=user').subscribe((response) => { - expect(response).toBeTruthy(); - }); - service.request(RestRequestMethod.POST, 'dspace-spring-rest/api/submission/workspaceitems', 'password=password&user=user'); httpMock.expectNone('dspace-spring-rest/api/submission/workspaceitems'); + // HttpTestingController.expectNone will throw an error when a requests is made + expect().nothing(); }); }); diff --git a/src/app/core/cache/object-cache.reducer.spec.ts b/src/app/core/cache/object-cache.reducer.spec.ts index 919edc8e57..df22a65da7 100644 --- a/src/app/core/cache/object-cache.reducer.spec.ts +++ b/src/app/core/cache/object-cache.reducer.spec.ts @@ -126,6 +126,10 @@ describe('objectCacheReducer', () => { deepFreeze(state); objectCacheReducer(state, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + expect().nothing(); }); it('should remove the specified object from the cache in response to the REMOVE action', () => { @@ -149,6 +153,10 @@ describe('objectCacheReducer', () => { const action = new RemoveFromObjectCacheAction(selfLink1); // testState has already been frozen above objectCacheReducer(testState, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + expect().nothing(); }); it('should set the timestamp of all objects in the cache in response to a RESET_TIMESTAMPS action', () => { @@ -164,6 +172,10 @@ describe('objectCacheReducer', () => { const action = new ResetObjectCacheTimestampsAction(new Date().getTime()); // testState has already been frozen above objectCacheReducer(testState, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + expect().nothing(); }); it('should perform the ADD_PATCH action without affecting the previous state', () => { @@ -174,6 +186,10 @@ describe('objectCacheReducer', () => { }]); // testState has already been frozen above objectCacheReducer(testState, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + expect().nothing(); }); it('should when the ADD_PATCH action dispatched', () => { diff --git a/src/app/core/cache/server-sync-buffer.reducer.spec.ts b/src/app/core/cache/server-sync-buffer.reducer.spec.ts index 51ba010c1e..087080a194 100644 --- a/src/app/core/cache/server-sync-buffer.reducer.spec.ts +++ b/src/app/core/cache/server-sync-buffer.reducer.spec.ts @@ -52,12 +52,20 @@ describe('serverSyncBufferReducer', () => { const action = new AddToSSBAction(selfLink1, RestRequestMethod.POST); // testState has already been frozen above serverSyncBufferReducer(testState, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + expect().nothing(); }); it('should perform the EMPTY action without affecting the previous state', () => { const action = new EmptySSBAction(); // testState has already been frozen above serverSyncBufferReducer(testState, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + expect().nothing(); }); it('should empty the buffer if the EmptySSBAction is dispatched without a payload', () => { diff --git a/src/app/core/coar-notify/notify-info/notify-info.service.spec.ts b/src/app/core/coar-notify/notify-info/notify-info.service.spec.ts index a3cc360a96..6d51c33c99 100644 --- a/src/app/core/coar-notify/notify-info/notify-info.service.spec.ts +++ b/src/app/core/coar-notify/notify-info/notify-info.service.spec.ts @@ -1,9 +1,9 @@ import { TestBed } from '@angular/core/testing'; - import { NotifyInfoService } from './notify-info.service'; import { ConfigurationDataService } from '../../data/configuration-data.service'; import { of } from 'rxjs'; import { AuthorizationDataService } from '../../data/feature-authorization/authorization-data.service'; +import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; describe('NotifyInfoService', () => { let service: NotifyInfoService; @@ -32,21 +32,21 @@ describe('NotifyInfoService', () => { expect(service).toBeTruthy(); }); - it('should retrieve and map coar configuration', () => { - const mockResponse = { payload: { values: ['true'] } }; - (configurationDataService.findByPropertyName as jasmine.Spy).and.returnValue(of(mockResponse)); + it('should retrieve and map coar configuration', (done: DoneFn) => { + (configurationDataService.findByPropertyName as jasmine.Spy).and.returnValue(createSuccessfulRemoteDataObject$({ values: ['true'] })); service.isCoarConfigEnabled().subscribe((result) => { expect(result).toBe(true); + done(); }); }); - it('should retrieve and map LDN local inbox URLs', () => { - const mockResponse = { values: ['inbox1', 'inbox2'] }; - (configurationDataService.findByPropertyName as jasmine.Spy).and.returnValue(of(mockResponse)); + it('should retrieve and map LDN local inbox URLs', (done: DoneFn) => { + (configurationDataService.findByPropertyName as jasmine.Spy).and.returnValue(createSuccessfulRemoteDataObject$({ values: ['inbox1', 'inbox2'] })); service.getCoarLdnLocalInboxUrls().subscribe((result) => { expect(result).toEqual(['inbox1', 'inbox2']); + done(); }); }); diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 7088860674..2efd4c478c 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -198,6 +198,8 @@ import { NotifyRequestsStatus } from '../item-page/simple/notify-requests-status import { LdnService } from '../admin/admin-ldn-services/ldn-services-model/ldn-services.model'; import { Itemfilter } from '../admin/admin-ldn-services/ldn-services-model/ldn-service-itemfilters'; import { SubmissionCoarNotifyConfig } from '../submission/sections/section-coar-notify/submission-coar-notify.config'; +import { AdminNotifyMessage } from '../admin/admin-notify-dashboard/models/admin-notify-message.model'; +import { SubmissionDuplicateDataService } from './submission/submission-duplicate-data.service'; /** * When not in production, endpoint responses can be mocked for testing purposes @@ -234,6 +236,7 @@ const PROVIDERS = [ HALEndpointService, HostWindowService, ItemDataService, + SubmissionDuplicateDataService, MetadataService, ObjectCacheService, PaginationComponentOptions, @@ -411,6 +414,7 @@ export const models = Itemfilter, SubmissionCoarNotifyConfig, NotifyRequestsStatus, + AdminNotifyMessage ]; @NgModule({ diff --git a/src/app/core/data/external-source-data.service.spec.ts b/src/app/core/data/external-source-data.service.spec.ts index 723d7f9bed..78d91437ef 100644 --- a/src/app/core/data/external-source-data.service.spec.ts +++ b/src/app/core/data/external-source-data.service.spec.ts @@ -96,12 +96,6 @@ describe('ExternalSourceService', () => { result.pipe(take(1)).subscribe(); expect(requestService.send).toHaveBeenCalledWith(jasmine.any(GetRequest), false); }); - - it('should return the entries', () => { - result.subscribe((resultRD) => { - expect(resultRD.payload.page).toBe(entries); - }); - }); }); }); }); diff --git a/src/app/core/data/notify-services-status-data.service.spec.ts b/src/app/core/data/notify-services-status-data.service.spec.ts new file mode 100644 index 0000000000..ade6ae4156 --- /dev/null +++ b/src/app/core/data/notify-services-status-data.service.spec.ts @@ -0,0 +1,81 @@ +import { NotifyRequestsStatusDataService } from './notify-services-status-data.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { TestScheduler } from 'rxjs/testing'; +import { RequestService } from './request.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { RequestEntry } from './request-entry.model'; +import { RemoteData } from './remote-data'; +import { RequestEntryState } from './request-entry-state.model'; +import { cold, getTestScheduler } from 'jasmine-marbles'; +import { RestResponse } from '../cache/response.models'; +import { of } from 'rxjs'; +import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { NotifyRequestsStatus } from '../../item-page/simple/notify-requests-status/notify-requests-status.model'; + +describe('NotifyRequestsStatusDataService test', () => { + let scheduler: TestScheduler; + let service: NotifyRequestsStatusDataService; + let requestService: RequestService; + let rdbService: RemoteDataBuildService; + let objectCache: ObjectCacheService; + let halService: HALEndpointService; + let responseCacheEntry: RequestEntry; + + 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 NotifyRequestsStatusDataService( + requestService, + rdbService, + objectCache, + halService, + ); + } + + beforeEach(() => { + scheduler = getTestScheduler(); + + objectCache = {} as ObjectCacheService; + 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: of(responseCacheEntry), + getByUUID: of(responseCacheEntry), + }); + + halService = jasmine.createSpyObj('halService', { + getEndpoint: of(endpointURL) + }); + + rdbService = jasmine.createSpyObj('rdbService', { + buildSingle: createSuccessfulRemoteDataObject$({}, 500), + buildList: cold('a', { a: remoteDataMocks.Success }), + buildFromHref: createSuccessfulRemoteDataObject$({test: 'test'}) + }); + + + service = initTestService(); + }); + + describe('getNotifyRequestsStatus', () => { + it('should get notify status', (done) => { + service.getNotifyRequestsStatus(requestUUID).subscribe((status) => { + expect(halService.getEndpoint).toHaveBeenCalled(); + expect(requestService.generateRequestId).toHaveBeenCalled(); + expect(status).toEqual(createSuccessfulRemoteDataObject({test: 'test'} as unknown as NotifyRequestsStatus)); + done(); + }); + }); + }); +}); diff --git a/src/app/core/data/notify-services-status-data.service.ts b/src/app/core/data/notify-services-status-data.service.ts index 9c354d0851..84fe4e9d81 100644 --- a/src/app/core/data/notify-services-status-data.service.ts +++ b/src/app/core/data/notify-services-status-data.service.ts @@ -21,7 +21,6 @@ export class NotifyRequestsStatusDataService extends IdentifiableDataService { @@ -31,13 +29,7 @@ describe('ObjectUpdatesEffects', () => { providers: [ ObjectUpdatesEffects, provideMockActions(() => actions), - { - provide: NotificationsService, - useValue: { - remove: (notification) => { /* empty */ - } - } - }, + { provide: NotificationsService, useClass: NotificationsServiceStub }, ], }); })); @@ -59,7 +51,6 @@ describe('ObjectUpdatesEffects', () => { action = new RemoveObjectUpdatesAction(testURL); }); it('should emit the action from the actionMap\'s value which key matches the action\'s URL', () => { - action = new RemoveObjectUpdatesAction(testURL); actions = hot('--a-', { a: action }); (updatesEffects as any).actionMap$[testURL].subscribe((act) => emittedAction = act); const expected = cold('--b-', { b: undefined }); @@ -81,14 +72,19 @@ describe('ObjectUpdatesEffects', () => { removeAction = new RemoveObjectUpdatesAction(testURL); }); it('should return a RemoveObjectUpdatesAction', () => { - actions = hot('a|', { a: new DiscardObjectUpdatesAction(testURL, infoNotification) }); - updatesEffects.removeAfterDiscardOrReinstateOnUndo$.pipe( - filter(((action) => hasValue(action)))) - .subscribe((t) => { - expect(t).toEqual(removeAction); - } - ) - ; + actions = hot('a', { a: new DiscardObjectUpdatesAction(testURL, infoNotification) }); + + // Because we use Subject and not BehaviourSubject we need to subscribe to it beforehand because it does not + // keep track of the current state + let emittedAction: Action | undefined; + updatesEffects.removeAfterDiscardOrReinstateOnUndo$.subscribe((action: Action | NoOpAction) => { + emittedAction = action; + }); + + // This expect ensures that the mapLastActions$ was processed + expect(updatesEffects.mapLastActions$).toBeObservable(cold('a', { a: undefined })); + + expect(emittedAction).toEqual(removeAction); }); }); @@ -98,12 +94,24 @@ describe('ObjectUpdatesEffects', () => { infoNotification.options.timeOut = 10; }); it('should return an action with type NO_ACTION', () => { - actions = hot('a', { a: new DiscardObjectUpdatesAction(testURL, infoNotification) }); - actions = hot('b', { b: new ReinstateObjectUpdatesAction(testURL) }); - updatesEffects.removeAfterDiscardOrReinstateOnUndo$.subscribe((t) => { - expect(t).toEqual(new NoOpAction()); - } - ); + actions = hot('--(ab)', { + a: new DiscardObjectUpdatesAction(testURL, infoNotification), + b: new ReinstateObjectUpdatesAction(testURL), + }); + + // Because we use Subject and not BehaviourSubject we need to subscribe to it beforehand because it does not + // keep track of the current state + let emittedAction: Action | undefined; + updatesEffects.removeAfterDiscardOrReinstateOnUndo$.pipe( + take(2) + ).subscribe((action: Action | NoOpAction) => { + emittedAction = action; + }); + + // This expect ensures that the mapLastActions$ was processed + expect(updatesEffects.mapLastActions$).toBeObservable(cold('--(ab)', { a: undefined, b: undefined })); + + expect(emittedAction).toEqual(new RemoveObjectUpdatesAction(testURL)); }); }); @@ -113,12 +121,22 @@ describe('ObjectUpdatesEffects', () => { infoNotification.options.timeOut = 10; }); it('should return a RemoveObjectUpdatesAction', () => { - actions = hot('a', { a: new DiscardObjectUpdatesAction(testURL, infoNotification) }); - actions = hot('b', { b: new RemoveFieldUpdateAction(testURL, testUUID) }); + actions = hot('--(ab)', { + a: new DiscardObjectUpdatesAction(testURL, infoNotification), + b: new RemoveFieldUpdateAction(testURL, testUUID), + }); - updatesEffects.removeAfterDiscardOrReinstateOnUndo$.subscribe((t) => - expect(t).toEqual(new RemoveObjectUpdatesAction(testURL)) - ); + // Because we use Subject and not BehaviourSubject we need to subscribe to it beforehand because it does not + // keep track of the current state + let emittedAction: Action | undefined; + updatesEffects.removeAfterDiscardOrReinstateOnUndo$.subscribe((action: Action | NoOpAction) => { + emittedAction = action; + }); + + // This expect ensures that the mapLastActions$ was processed + expect(updatesEffects.mapLastActions$).toBeObservable(cold('--(ab)', { a: undefined, b: undefined })); + + expect(emittedAction).toEqual(new RemoveObjectUpdatesAction(testURL)); }); }); }); diff --git a/src/app/core/data/object-updates/object-updates.reducer.spec.ts b/src/app/core/data/object-updates/object-updates.reducer.spec.ts index 08944a073f..90e0b70dbd 100644 --- a/src/app/core/data/object-updates/object-updates.reducer.spec.ts +++ b/src/app/core/data/object-updates/object-updates.reducer.spec.ts @@ -12,7 +12,7 @@ import { SetEditableFieldUpdateAction, SetValidFieldUpdateAction } from './object-updates.actions'; -import { OBJECT_UPDATES_TRASH_PATH, objectUpdatesReducer } from './object-updates.reducer'; +import { OBJECT_UPDATES_TRASH_PATH, objectUpdatesReducer, ObjectUpdatesState } from './object-updates.reducer'; import { Relationship } from '../../shared/item-relationships/relationship.model'; import { FieldChangeType } from './field-change-type.model'; @@ -56,7 +56,7 @@ const modDate = new Date(2010, 2, 11); const uuid = identifiable1.uuid; const url = 'test-object.url/edit'; describe('objectUpdatesReducer', () => { - const testState = { + const testState: ObjectUpdatesState = { [url]: { fieldStates: { [identifiable1.uuid]: { @@ -79,9 +79,6 @@ describe('objectUpdatesReducer', () => { [identifiable2.uuid]: { field: { uuid: identifiable2.uuid, - key: 'dc.titl', - language: null, - value: 'New title' }, changeType: FieldChangeType.ADD } @@ -93,7 +90,7 @@ describe('objectUpdatesReducer', () => { } }; - const discardedTestState = { + const discardedTestState: ObjectUpdatesState = { [url]: { fieldStates: { [identifiable1.uuid]: { @@ -139,9 +136,6 @@ describe('objectUpdatesReducer', () => { [identifiable2.uuid]: { field: { uuid: identifiable2.uuid, - key: 'dc.titl', - language: null, - value: 'New title' }, changeType: FieldChangeType.ADD } @@ -173,48 +167,80 @@ describe('objectUpdatesReducer', () => { const action = new InitializeFieldsAction(url, [identifiable1, identifiable2], modDate); // testState has already been frozen above objectUpdatesReducer(testState, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + expect().nothing(); }); it('should perform the SET_EDITABLE_FIELD action without affecting the previous state', () => { const action = new SetEditableFieldUpdateAction(url, uuid, false); // testState has already been frozen above objectUpdatesReducer(testState, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + expect().nothing(); }); it('should perform the ADD_FIELD action without affecting the previous state', () => { const action = new AddFieldUpdateAction(url, identifiable1update, FieldChangeType.UPDATE); // testState has already been frozen above objectUpdatesReducer(testState, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + expect().nothing(); }); it('should perform the DISCARD action without affecting the previous state', () => { const action = new DiscardObjectUpdatesAction(url, null); // testState has already been frozen above objectUpdatesReducer(testState, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + expect().nothing(); }); it('should perform the REINSTATE action without affecting the previous state', () => { const action = new ReinstateObjectUpdatesAction(url); // testState has already been frozen above objectUpdatesReducer(testState, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + expect().nothing(); }); it('should perform the REMOVE action without affecting the previous state', () => { const action = new RemoveFieldUpdateAction(url, uuid); // testState has already been frozen above objectUpdatesReducer(testState, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + expect().nothing(); }); it('should perform the REMOVE_FIELD action without affecting the previous state', () => { const action = new RemoveFieldUpdateAction(url, uuid); // testState has already been frozen above objectUpdatesReducer(testState, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + expect().nothing(); }); it('should perform the SELECT_VIRTUAL_METADATA action without affecting the previous state', () => { const action = new SelectVirtualMetadataAction(url, relationship.uuid, identifiable1.uuid, true); // testState has already been frozen above objectUpdatesReducer(testState, action); + + // no expect required, deepFreeze will ensure an exception is thrown if the state + // is mutated, and any uncaught exception will cause the test to fail + expect().nothing(); }); it('should initialize all fields when the INITIALIZE action is dispatched, based on the payload', () => { diff --git a/src/app/core/data/object-updates/object-updates.reducer.ts b/src/app/core/data/object-updates/object-updates.reducer.ts index 14bacc52db..5c03001972 100644 --- a/src/app/core/data/object-updates/object-updates.reducer.ts +++ b/src/app/core/data/object-updates/object-updates.reducer.ts @@ -77,7 +77,7 @@ export interface DeleteRelationship extends RelationshipIdentifiable { */ export interface ObjectUpdatesEntry { fieldStates: FieldStates; - fieldUpdates: FieldUpdates; + fieldUpdates?: FieldUpdates; virtualMetadataSources: VirtualMetadataSources; lastModified: Date; patchOperationService?: GenericConstructor; diff --git a/src/app/core/eperson/eperson-data.service.spec.ts b/src/app/core/eperson/eperson-data.service.spec.ts index cbddf1e6c3..a2d3234254 100644 --- a/src/app/core/eperson/eperson-data.service.spec.ts +++ b/src/app/core/eperson/eperson-data.service.spec.ts @@ -1,40 +1,40 @@ -import { CommonModule } from '@angular/common'; -import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; -import { TestBed, waitForAsync } from '@angular/core/testing'; -import { Store, StoreModule } from '@ngrx/store'; -import { compare, Operation } from 'fast-json-patch'; -import { getTestScheduler } from 'jasmine-marbles'; -import { Observable, of as observableOf } from 'rxjs'; -import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; -import { TestScheduler } from 'rxjs/testing'; +import { fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; +import { Store } from '@ngrx/store'; +import { cold } from 'jasmine-marbles'; +import { of as observableOf } from 'rxjs'; import { EPeopleRegistryCancelEPersonAction, EPeopleRegistryEditEPersonAction } from '../../access-control/epeople-registry/epeople-registry.actions'; import { GroupMock } from '../../shared/testing/group-mock'; import { RequestParam } from '../cache/models/request-param.model'; -import { ChangeAnalyzer } from '../data/change-analyzer'; import { PatchRequest, PostRequest } from '../data/request.models'; import { RequestService } from '../data/request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { Item } from '../shared/item.model'; -import { EPersonDataService } from './eperson-data.service'; +import { editEPersonSelector, EPersonDataService } from './eperson-data.service'; import { EPerson } from './models/eperson.model'; import { EPersonMock, EPersonMock2 } from '../../shared/testing/eperson.mock'; import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; import { createNoContentRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { getMockRemoteDataBuildServiceHrefMap } from '../../shared/mocks/remote-data-build.service.mock'; -import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; import { getMockRequestService } from '../../shared/mocks/request.service.mock'; import { createPaginatedList, createRequestEntry$ } from '../../shared/testing/utils.test'; import { CoreState } from '../core-state.model'; import { FindListOptions } from '../data/find-list-options.model'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; +import { MockStore, provideMockStore } from '@ngrx/store/testing'; +import { compare, Operation } from 'fast-json-patch'; +import { Item } from '../shared/item.model'; +import { ChangeAnalyzer } from '../data/change-analyzer'; describe('EPersonDataService', () => { let service: EPersonDataService; - let store: Store; + let store: MockStore; let requestService: RequestService; - let scheduler: TestScheduler; let epeople; @@ -44,50 +44,38 @@ describe('EPersonDataService', () => { let epeople$; let rdbService; - function initTestService() { - return new EPersonDataService( - requestService, - rdbService, - null, - halService, - new DummyChangeAnalyzer() as any, - null, - store, - ); - } + const initialState = { + epeopleRegistry: { + editEPerson: null + }, + }; - function init() { + beforeEach(waitForAsync(() => { restEndpointURL = 'https://rest.api/dspace-spring-rest/api/eperson'; epersonsEndpoint = `${restEndpointURL}/epersons`; epeople = [EPersonMock, EPersonMock2]; epeople$ = createSuccessfulRemoteDataObject$(createPaginatedList([epeople])); rdbService = getMockRemoteDataBuildServiceHrefMap(undefined, { 'https://rest.api/dspace-spring-rest/api/eperson/epersons': epeople$ }); halService = new HALEndpointServiceStub(restEndpointURL); + requestService = getMockRequestService(createRequestEntry$(epeople)); TestBed.configureTestingModule({ - imports: [ - CommonModule, - StoreModule.forRoot({}), - TranslateModule.forRoot({ - loader: { - provide: TranslateLoader, - useClass: TranslateLoaderMock - } - }), + providers: [ + EPersonDataService, + { provide: RequestService, useValue: requestService }, + { provide: RemoteDataBuildService, useValue: rdbService }, + { provide: HALEndpointService, useValue: halService }, + provideMockStore({ initialState }), + { provide: ObjectCacheService, useValue: {} }, + { provide: DSOChangeAnalyzer, useClass: DummyChangeAnalyzer }, + { provide: NotificationsService, useClass: NotificationsServiceStub }, ], - declarations: [], - providers: [], - schemas: [CUSTOM_ELEMENTS_SCHEMA] }); - } - beforeEach(() => { - init(); - requestService = getMockRequestService(createRequestEntry$(epeople)); - store = new Store(undefined, undefined, undefined); - service = initTestService(); - spyOn(store, 'dispatch'); - }); + service = TestBed.inject(EPersonDataService); + store = TestBed.inject(Store) as MockStore; + spyOn(store, 'dispatch').and.callThrough(); + })); describe('searchByScope', () => { beforeEach(() => { @@ -264,34 +252,29 @@ describe('EPersonDataService', () => { }); describe('clearEPersonRequests', () => { - beforeEach(waitForAsync(() => { - scheduler = getTestScheduler(); - halService = { - getEndpoint(linkPath: string): Observable { - return observableOf(restEndpointURL + '/' + linkPath); - } - } as HALEndpointService; - initTestService(); - service.clearEPersonRequests(); - })); - it('should remove the eperson hrefs in the request service', () => { - expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith(epersonsEndpoint); + beforeEach(() => { + spyOn(halService, 'getEndpoint').and.callFake((linkPath: string) => { + return observableOf(`${restEndpointURL}/${linkPath}`); + }); }); + it('should remove the eperson hrefs in the request service', fakeAsync(() => { + service.clearEPersonRequests(); + tick(); + + expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith(epersonsEndpoint); + })); }); describe('getActiveEPerson', () => { it('should retrieve the ePerson currently getting edited, if any', () => { - service.editEPerson(EPersonMock); + // Update the state with the ePerson (the provideMockStore doesn't update itself when dispatch is called) + store.overrideSelector(editEPersonSelector, EPersonMock); - service.getActiveEPerson().subscribe((activeEPerson: EPerson) => { - expect(activeEPerson).toEqual(EPersonMock); - }); + expect(service.getActiveEPerson()).toBeObservable(cold('a', { a: EPersonMock })); }); it('should retrieve the ePerson currently getting edited, null if none being edited', () => { - service.getActiveEPerson().subscribe((activeEPerson: EPerson) => { - expect(activeEPerson).toEqual(null); - }); + expect(service.getActiveEPerson()).toBeObservable(cold('a', { a: null })); }); }); diff --git a/src/app/core/eperson/eperson-data.service.ts b/src/app/core/eperson/eperson-data.service.ts index a85d471e7d..8ded53dd9a 100644 --- a/src/app/core/eperson/eperson-data.service.ts +++ b/src/app/core/eperson/eperson-data.service.ts @@ -37,7 +37,7 @@ import { dataService } from '../data/base/data-service.decorator'; import { getEPersonEditRoute } from '../../access-control/access-control-routing-paths'; const ePeopleRegistryStateSelector = (state: AppState) => state.epeopleRegistry; -const editEPersonSelector = createSelector(ePeopleRegistryStateSelector, (ePeopleRegistryState: EPeopleRegistryState) => ePeopleRegistryState.editEPerson); +export const editEPersonSelector = createSelector(ePeopleRegistryStateSelector, (ePeopleRegistryState: EPeopleRegistryState) => ePeopleRegistryState.editEPerson); /** * A service to retrieve {@link EPerson}s from the REST API & EPerson related CRUD actions diff --git a/src/app/core/shared/context.model.ts b/src/app/core/shared/context.model.ts index b4c02bee63..dcebf5794c 100644 --- a/src/app/core/shared/context.model.ts +++ b/src/app/core/shared/context.model.ts @@ -39,4 +39,6 @@ export enum Context { MyDSpaceValidation = 'mydspaceValidation', Bitstream = 'bitstream', + + CoarNotify = 'coarNotify', } diff --git a/src/app/core/shared/item.model.spec.ts b/src/app/core/shared/item.model.spec.ts index 732ae5b19c..96cca5dd50 100644 --- a/src/app/core/shared/item.model.spec.ts +++ b/src/app/core/shared/item.model.spec.ts @@ -12,14 +12,13 @@ describe('Item', () => { const bitstream1Path = 'document.pdf'; const bitstream2Path = 'otherfile.doc'; - const nonExistingBundleName = 'c1e568f7-d14e-496b-bdd7-07026998cc00'; let bitstreams; let remoteDataThumbnail; let remoteDataThumbnailList; let remoteDataFiles; let remoteDataBundles; - beforeEach(() => { + it('should be possible to create an Item without any errors', () => { const thumbnail = { content: thumbnailPath }; @@ -51,5 +50,6 @@ describe('Item', () => { remoteDataBundles = createSuccessfulRemoteDataObject$(createPaginatedList(bundles)); item = Object.assign(new Item(), { bundles: remoteDataBundles }); + expect().nothing(); }); }); diff --git a/src/app/core/shared/search/search-configuration.service.ts b/src/app/core/shared/search/search-configuration.service.ts index eed93ae201..9d93eced8e 100644 --- a/src/app/core/shared/search/search-configuration.service.ts +++ b/src/app/core/shared/search/search-configuration.service.ts @@ -105,7 +105,7 @@ export class SearchConfigurationService implements OnDestroy { protected linkService: LinkService, protected halService: HALEndpointService, protected requestService: RequestService, - protected rdb: RemoteDataBuildService,) { + protected rdb: RemoteDataBuildService) { this.initDefaults(); } diff --git a/src/app/core/shared/view-mode.model.ts b/src/app/core/shared/view-mode.model.ts index a27cb6954b..9c2f499b3d 100644 --- a/src/app/core/shared/view-mode.model.ts +++ b/src/app/core/shared/view-mode.model.ts @@ -7,4 +7,5 @@ export enum ViewMode { GridElement = 'grid', DetailedListElement = 'detailed', StandalonePage = 'standalone', + Table = 'table', } diff --git a/src/app/core/submission/models/workspaceitem-section-duplicates.model.ts b/src/app/core/submission/models/workspaceitem-section-duplicates.model.ts new file mode 100644 index 0000000000..f9441fa790 --- /dev/null +++ b/src/app/core/submission/models/workspaceitem-section-duplicates.model.ts @@ -0,0 +1,8 @@ +/* + * Object model for the data returned by the REST API to present potential duplicates in a submission section + */ +import { Duplicate } from '../../../shared/object-list/duplicate-data/duplicate.model'; + +export interface WorkspaceitemSectionDuplicatesObject { + potentialDuplicates?: Duplicate[] +} diff --git a/src/app/core/submission/models/workspaceitem-sections.model.ts b/src/app/core/submission/models/workspaceitem-sections.model.ts index dd19c3fb8b..4c90f3ede8 100644 --- a/src/app/core/submission/models/workspaceitem-sections.model.ts +++ b/src/app/core/submission/models/workspaceitem-sections.model.ts @@ -3,8 +3,9 @@ import { WorkspaceitemSectionFormObject } from './workspaceitem-section-form.mod import { WorkspaceitemSectionLicenseObject } from './workspaceitem-section-license.model'; import { WorkspaceitemSectionUploadObject } from './workspaceitem-section-upload.model'; import { WorkspaceitemSectionCcLicenseObject } from './workspaceitem-section-cc-license.model'; -import {WorkspaceitemSectionIdentifiersObject} from './workspaceitem-section-identifiers.model'; +import { WorkspaceitemSectionIdentifiersObject } from './workspaceitem-section-identifiers.model'; import { WorkspaceitemSectionSherpaPoliciesObject } from './workspaceitem-section-sherpa-policies.model'; +import { WorkspaceitemSectionDuplicatesObject } from './workspaceitem-section-duplicates.model'; /** * An interface to represent submission's section object. @@ -25,6 +26,7 @@ export type WorkspaceitemSectionDataType | WorkspaceitemSectionAccessesObject | WorkspaceitemSectionSherpaPoliciesObject | WorkspaceitemSectionIdentifiersObject + | WorkspaceitemSectionDuplicatesObject | string; diff --git a/src/app/core/submission/submission-duplicate-data.service.spec.ts b/src/app/core/submission/submission-duplicate-data.service.spec.ts new file mode 100644 index 0000000000..fff4f3a0bc --- /dev/null +++ b/src/app/core/submission/submission-duplicate-data.service.spec.ts @@ -0,0 +1,30 @@ +import { SubmissionDuplicateDataService } from './submission-duplicate-data.service'; +import { FindListOptions } from '../data/find-list-options.model'; +import { RequestParam } from '../cache/models/request-param.model'; + +/** + * Basic tests for the submission-duplicate-data.service.ts service + */ +describe('SubmissionDuplicateDataService', () => { + const duplicateDataService = new SubmissionDuplicateDataService(null, null, null, null); + + // Test the findDuplicates method to make sure that a call results in an expected + // call to searchBy, using the 'findByItem' search method + describe('findDuplicates', () => { + beforeEach(() => { + spyOn(duplicateDataService, 'searchBy'); + }); + + it('should call searchBy with the correct arguments', () => { + // Set up expected search parameters and find options + const searchParams = []; + searchParams.push(new RequestParam('uuid', 'test')); + let findListOptions = new FindListOptions(); + findListOptions.searchParams = searchParams; + // Perform test search using uuid 'test' using the findDuplicates method + const result = duplicateDataService.findDuplicates('test', new FindListOptions(), true, true); + // Expect searchBy('findByItem'...) to have been used as SearchData impl with the expected options (uuid=test) + expect(duplicateDataService.searchBy).toHaveBeenCalledWith('findByItem', findListOptions, true, true); + }); + }); +}); diff --git a/src/app/core/submission/submission-duplicate-data.service.ts b/src/app/core/submission/submission-duplicate-data.service.ts new file mode 100644 index 0000000000..7e0e97e80b --- /dev/null +++ b/src/app/core/submission/submission-duplicate-data.service.ts @@ -0,0 +1,139 @@ +/* eslint-disable max-classes-per-file */ +import { Observable } from 'rxjs'; +import { Injectable } from '@angular/core'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { ResponseParsingService } from '../data/parsing.service'; +import { RemoteData } from '../data/remote-data'; +import { GetRequest } from '../data/request.models'; +import { RequestService } from '../data/request.service'; +import { GenericConstructor } from '../shared/generic-constructor'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { SearchResponseParsingService } from '../data/search-response-parsing.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { RestRequest } from '../data/rest-request.model'; +import { BaseDataService } from '../data/base/base-data.service'; +import { FindListOptions } from '../data/find-list-options.model'; +import { Duplicate } from '../../shared/object-list/duplicate-data/duplicate.model'; +import { PaginatedList } from '../data/paginated-list.model'; +import { RequestParam } from '../cache/models/request-param.model'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { SearchData, SearchDataImpl } from '../data/base/search-data'; +import { DUPLICATE } from '../../shared/object-list/duplicate-data/duplicate.resource-type'; +import { dataService } from '../data/base/data-service.decorator'; + + +/** + * Service that handles search requests for potential duplicate items. + * This uses the /api/submission/duplicates endpoint to look for other archived or in-progress items (if user + * has READ permission) that match the item (for the given uuid). + * Matching is configured in the backend in dspace/config/modulesduplicate-detection.cfg + * The returned results are small preview 'stubs' of items, and displayed in either a submission section + * or the workflow pooled/claimed task page. + * + */ +@Injectable() +@dataService(DUPLICATE) +export class SubmissionDuplicateDataService extends BaseDataService implements SearchData { + + /** + * The ResponseParsingService constructor name + */ + private parser: GenericConstructor = SearchResponseParsingService; + + /** + * The RestRequest constructor name + */ + private request: GenericConstructor = GetRequest; + + /** + * SearchData interface to implement + * @private + */ + private searchData: SearchData; + + /** + * Subscription to unsubscribe from + */ + private sub; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + ) { + super('duplicates', requestService, rdbService, objectCache, halService); + this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + } + + /** + * Implement the searchBy method to return paginated lists of Duplicate resources + * + * @param searchMethod the search method name + * @param options find list options + * @param useCachedVersionIfAvailable whether to use cached version if available + * @param reRequestOnStale whether to rerequest results on stale + * @param linksToFollow links to follow in results + */ + searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * Helper method to get the duplicates endpoint + * @protected + */ + protected getEndpoint(): Observable { + return this.halService.getEndpoint(this.linkPath); + } + + /** + * Method to set service options + * @param {GenericConstructor} parser The ResponseParsingService constructor name + * @param {boolean} request The RestRequest constructor name + */ + setServiceOptions(parser: GenericConstructor, request: GenericConstructor) { + if (parser) { + this.parser = parser; + } + if (request) { + this.request = request; + } + } + + /** + * Find duplicates for a given item UUID. Locates and returns results from the /api/submission/duplicates/search/findByItem + * SearchRestMethod, which is why this implements SearchData and searchBy + * + * @param uuid the item UUID + * @param options any find list options e.g. paging + * @param useCachedVersionIfAvailable whether to use cached version if available + * @param reRequestOnStale whether to rerequest results on stale + * @param linksToFollow links to follow in results + */ + public findDuplicates(uuid: string, options?: FindListOptions, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { + const searchParams = [new RequestParam('uuid', uuid)]; + let findListOptions = new FindListOptions(); + if (options) { + findListOptions = Object.assign(new FindListOptions(), options); + } + if (findListOptions.searchParams) { + findListOptions.searchParams = [...findListOptions.searchParams, ...searchParams]; + } else { + findListOptions.searchParams = searchParams; + } + + // Return actual search/findByItem results + return this.searchBy('findByItem', findListOptions, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + + } + + /** + * Unsubscribe from the subscription + */ + ngOnDestroy(): void { + if (this.sub !== undefined) { + this.sub.unsubscribe(); + } + } +} diff --git a/src/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.html b/src/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.html index 75f4587a3d..3648b53b51 100644 --- a/src/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.html +++ b/src/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.html @@ -1,4 +1,4 @@ - +
diff --git a/src/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.html b/src/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.html index 213581a519..b906905b47 100644 --- a/src/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.html +++ b/src/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.html @@ -1,4 +1,4 @@ - +
diff --git a/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.html b/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.html index 3b2cce061b..e8a6904242 100644 --- a/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.html +++ b/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.html @@ -1,4 +1,4 @@ - +
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 52df841d3b..f28550a4e0 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 @@ -1,4 +1,4 @@ - +
diff --git a/src/app/entity-groups/research-entities/item-pages/person/person.component.html b/src/app/entity-groups/research-entities/item-pages/person/person.component.html index 83a2e76dd8..ec99c845be 100644 --- a/src/app/entity-groups/research-entities/item-pages/person/person.component.html +++ b/src/app/entity-groups/research-entities/item-pages/person/person.component.html @@ -1,4 +1,4 @@ - +
diff --git a/src/app/entity-groups/research-entities/item-pages/project/project.component.html b/src/app/entity-groups/research-entities/item-pages/project/project.component.html index 0d9679ef1e..944766b6f7 100644 --- a/src/app/entity-groups/research-entities/item-pages/project/project.component.html +++ b/src/app/entity-groups/research-entities/item-pages/project/project.component.html @@ -1,4 +1,4 @@ - +
diff --git a/src/app/home-page/home-page.component.ts b/src/app/home-page/home-page.component.ts index b4dfd06c33..e93b70f9ba 100644 --- a/src/app/home-page/home-page.component.ts +++ b/src/app/home-page/home-page.component.ts @@ -1,7 +1,7 @@ import { Component, Inject, OnDestroy, OnInit, PLATFORM_ID } from '@angular/core'; import { map, switchMap } from 'rxjs/operators'; import { ActivatedRoute } from '@angular/router'; -import { Observable } from 'rxjs'; +import { Observable, of } from 'rxjs'; import { Site } from '../core/shared/site.model'; import { environment } from '../../environments/environment'; import { isPlatformServer } from '@angular/common'; @@ -40,6 +40,8 @@ export class HomePageComponent implements OnInit, OnDestroy { switchMap((coarLdnEnabled: boolean) => { if (coarLdnEnabled) { return this.notifyInfoService.getCoarLdnLocalInboxUrls(); + } else { + return of([]); } }) ).subscribe((coarRestApiUrls: string[]) => { diff --git a/src/app/home-page/top-level-community-list/top-level-community-list.component.spec.ts b/src/app/home-page/top-level-community-list/top-level-community-list.component.spec.ts index d1a3d3631f..a3332e4a83 100644 --- a/src/app/home-page/top-level-community-list/top-level-community-list.component.spec.ts +++ b/src/app/home-page/top-level-community-list/top-level-community-list.component.spec.ts @@ -32,7 +32,7 @@ import { SearchConfigurationServiceStub } from '../../shared/testing/search-conf import { APP_CONFIG } from 'src/config/app-config.interface'; import { environment } from 'src/environments/environment.test'; -describe('TopLevelCommunityList Component', () => { +describe('TopLevelCommunityListComponent', () => { let comp: TopLevelCommunityListComponent; let fixture: ComponentFixture; let communityDataServiceStub: any; @@ -173,17 +173,17 @@ describe('TopLevelCommunityList Component', () => { }); - it('should display a list of top-communities', () => { - waitForAsync(() => { - const subComList = fixture.debugElement.queryAll(By.css('li')); + it('should display a list of top-communities', async () => { + await fixture.whenStable(); + fixture.detectChanges(); + const subComList = fixture.debugElement.queryAll(By.css('li')); - expect(subComList.length).toEqual(5); - expect(subComList[0].nativeElement.textContent).toContain('TopCommunity 1'); - expect(subComList[1].nativeElement.textContent).toContain('TopCommunity 2'); - expect(subComList[2].nativeElement.textContent).toContain('TopCommunity 3'); - expect(subComList[3].nativeElement.textContent).toContain('TopCommunity 4'); - expect(subComList[4].nativeElement.textContent).toContain('TopCommunity 5'); - }); + expect(subComList.length).toEqual(5); + expect(subComList[0].nativeElement.textContent).toContain('TopCommunity 1'); + expect(subComList[1].nativeElement.textContent).toContain('TopCommunity 2'); + expect(subComList[2].nativeElement.textContent).toContain('TopCommunity 3'); + expect(subComList[3].nativeElement.textContent).toContain('TopCommunity 4'); + expect(subComList[4].nativeElement.textContent).toContain('TopCommunity 5'); }); }); diff --git a/src/app/item-page/edit-item-page/edit-item-operators.spec.ts b/src/app/item-page/edit-item-page/edit-item-operators.spec.ts index e7bd5b98ce..f72ace495f 100644 --- a/src/app/item-page/edit-item-page/edit-item-operators.spec.ts +++ b/src/app/item-page/edit-item-page/edit-item-operators.spec.ts @@ -18,7 +18,7 @@ describe('findSuccessfulAccordingTo', () => { mockItem1.isWithdrawn = true; mockItem2 = new Item(); - mockItem1.isWithdrawn = false; + mockItem2.isWithdrawn = false; predicate = (rd: RemoteData) => isNotEmpty(rd.payload) ? rd.payload.isWithdrawn : false; }); @@ -34,7 +34,7 @@ describe('findSuccessfulAccordingTo', () => { const source = hot('abcde', testRD); const result = source.pipe(findSuccessfulAccordingTo(predicate)); - result.subscribe((value) => expect(value).toEqual(testRD.d)); + expect(result).toBeObservable(hot('---(d|)', { d: testRD.d })); }); }); diff --git a/src/app/item-page/edit-item-page/edit-item-page.component.html b/src/app/item-page/edit-item-page/edit-item-page.component.html index 63dadef3b1..f697e0b8f4 100644 --- a/src/app/item-page/edit-item-page/edit-item-page.component.html +++ b/src/app/item-page/edit-item-page/edit-item-page.component.html @@ -3,7 +3,7 @@

{{'item.edit.head' | translate}}

-