Merge remote-tracking branch 'origin/main' into fix-versioning-button

This commit is contained in:
Yury Bondarenko
2024-03-07 10:10:45 +01:00
161 changed files with 5127 additions and 573 deletions

View File

@@ -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'

View File

@@ -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'),

View File

@@ -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",

View File

@@ -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<EPeopleRegistryComponent>;
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<boolean> {
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();
});
});

View File

@@ -102,8 +102,13 @@
id="ldnUrl"
name="ldnUrl"
type="text">
<div *ngIf="formModel.get('ldnUrl').invalid && formModel.get('ldnUrl').touched" class="error-text">
{{ 'ldn-new-service.form.error.ldnurl' | translate }}
<div *ngIf="formModel.get('ldnUrl').invalid && formModel.get('ldnUrl').touched" >
<div *ngIf="formModel.get('ldnUrl').errors['required']" class="error-text">
{{ 'ldn-new-service.form.error.ldnurl' | translate }}
</div>
<div *ngIf="formModel.get('ldnUrl').errors['ldnUrlAlreadyAssociated']" class="error-text">
{{ 'ldn-new-service.form.error.ldnurl.ldnUrlAlreadyAssociated' | translate }}
</div>
</div>
</div>

View File

@@ -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 });
}
}

View File

@@ -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();
});
});
});
});

View File

@@ -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<PaginatedList<LdnService>>);
@@ -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();
}));
});
});

View File

@@ -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 {
}

View File

@@ -0,0 +1,23 @@
<div class="container">
<div class="row">
<div class="col-12">
<h1 class="border-bottom pb-2">{{'admin-notify-dashboard.title'| translate}}</h1>
<div class="my-4">{{'admin-notify-dashboard.description' | translate}}</div>
<div>
<ul class="nav nav-tabs">
<li class="nav-item">
<a class="nav-link active">{{'admin-notify-dashboard.metrics' | translate}}</a>
</li>
<li class="nav-item">
<a class="nav-link" [routerLink]="'inbound'" [queryParams]="{view: 'table'}">{{'admin.notify.dashboard.inbound-logs' | translate}}</a>
</li>
<li class="nav-item">
<a class="nav-link" [routerLink]="'outbound'" [queryParams]="{view: 'table'}">{{'admin.notify.dashboard.outbound-logs' | translate}}</a>
</ul>
<div class="mt-2">
<ds-admin-notify-metrics *ngIf="(notifyMetricsRows$ | async)?.length" [boxesConfig]="notifyMetricsRows$ | async"></ds-admin-notify-metrics>
</div>
</div>
</div>
</div>
</div>

View File

@@ -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<AdminNotifyDashboardComponent>;
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();
});
});

View File

@@ -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<AdminNotifyMetricsRow[]>;
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<DSpaceObject>): 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))
};
});
}
}

View File

@@ -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}))
};
}
}

View File

@@ -0,0 +1,22 @@
<div class="modal-header">
<h4 class="modal-title">{{'notify-message-modal.title' | translate}}</h4>
<button type="button" class="close" aria-label="Close" (click)="activeModal.dismiss('Cross click')">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body p-4">
<div *ngFor="let key of notifyMessageKeys">
<div class="row mb-4">
<div class="font-weight-bold col">{{ key + '.notify-detail-modal' | translate}}</div>
<div class="col text-right">{{'notify-detail-modal.' + notifyMessage[key] | translate: {default: notifyMessage[key] ?? "n/a" } }}</div>
</div>
</div>
<div class="d-flex justify-content-end">
<button class="btn-primary" (click)="toggleCoarMessage()">
{{'notify-message-modal.show-message' | translate}}
</button>
</div>
<pre @fadeIn [innerHTML]="notifyMessage.message" class="bg-secondary text-white mt-2 p-2" *ngIf="isCoarMessageVisible"></pre>
</div>

View File

@@ -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<AdminNotifyDetailModalComponent>;
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);
});
});

View File

@@ -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<boolean>();
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;
}
}

View File

@@ -0,0 +1,23 @@
<div class="container">
<div class="row">
<div class="col-12">
<h1 class="border-bottom pb-2">{{'admin-notify-dashboard.title'| translate}}</h1>
<div>
<div>
<ul class="nav nav-tabs">
<li class="nav-item">
<a class="nav-link" [routerLink]="'../'">{{'admin-notify-dashboard.metrics' | translate}}</a>
</li>
<li class="nav-item">
<a class="nav-link active">{{'admin.notify.dashboard.inbound-logs' | translate}}</a>
</li>
<li class="nav-item">
<a class="nav-link" [routerLink]="'../outbound'" [queryParams]="{view: 'table', configuration: 'NOTIFY.outgoing'}">{{'admin.notify.dashboard.outbound-logs' | translate}}</a>
</ul>
<ds-admin-notify-logs-result [defaultConfiguration]="'NOTIFY.incoming'" ></ds-admin-notify-logs-result>
</div>
</div>
</div>
</div>
</div>

View File

@@ -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<AdminNotifyIncomingComponent>;
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();
});
});

View File

@@ -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) {
}
}

View File

@@ -0,0 +1,25 @@
<div class="container my-4">
<div class="row">
<div class="col-12 col-md-3 text-left h4">{{((isInbound$ | async) ? 'admin.notify.dashboard.inbound' : 'admin.notify.dashboard.outbound') | translate}}</div>
<div class="col-md-9">
<div class="h4">
<button (click)="resetDefaultConfiguration()" *ngIf="(selectedSearchConfig$ | async) !== defaultConfiguration" class="badge badge-primary mr-1 mb-1">
{{ 'admin-notify-logs.' + (selectedSearchConfig$ | async) | translate}}
<span> ×</span>
</button>
</div>
<ds-search-labels [inPlaceSearch]="true"></ds-search-labels>
</div>
</div>
</div>
<div>
<ds-themed-search
[configuration]="selectedSearchConfig$ | async"
[showViewModes]="false"
[searchEnabled]="false"
[context]="context"
></ds-themed-search>
</div>

View File

@@ -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<AdminNotifyLogsResultComponent>;
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();
});
});

View File

@@ -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<string>;
public isInbound$: Observable<boolean>;
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('/');
}
}

View File

@@ -0,0 +1,24 @@
<div class="container">
<div class="row">
<div class="col-12">
<h1 class="border-bottom pb-2">{{'admin-notify-dashboard.title'| translate}}</h1>
<div>
<div>
<ul class="nav nav-tabs">
<li class="nav-item">
<a class="nav-link" [routerLink]="'../'">{{'admin-notify-dashboard.metrics' | translate}}</a>
</li>
<li class="nav-item">
<a class="nav-link" [routerLink]="'../inbound'" [queryParams]="{view: 'table', configuration: 'NOTIFY.incoming'}">{{'admin.notify.dashboard.inbound-logs' | translate}}</a>
</li>
<li class="nav-item">
<a class="nav-link active">{{'admin.notify.dashboard.outbound-logs' | translate}}</a>
</ul>
<ds-admin-notify-logs-result [defaultConfiguration]="'NOTIFY.outgoing'" ></ds-admin-notify-logs-result>
</div>
</div>
</div>
</div>
</div>

View File

@@ -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<AdminNotifyOutgoingComponent>;
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();
});
});

View File

@@ -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) {
}
}

View File

@@ -0,0 +1,9 @@
<div class="mb-5" *ngFor="let row of boxesConfig">
<div class="mb-2">{{ row.title | translate }}</div>
<div class="row justify-content-between">
<div class="col-sm" *ngFor="let box of row.boxes">
<ds-notification-box (selectedBoxConfig)="navigateToSelectedSearchConfig($event)" [boxConfig]="box"></ds-notification-box>
</div>
</div>
</div>

View File

@@ -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<AdminNotifyMetricsComponent>;
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);
});
});

View File

@@ -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
},
});
}
}

View File

@@ -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[]
}

View File

@@ -0,0 +1,51 @@
<div class="table-responsive mt-2">
<table class="table table-striped table-hover">
<thead>
<tr class="text-nowrap">
<th scope="col">{{ 'notify-message-result.timestamp' | translate}}</th>
<th scope="col">{{'notify-message-result.repositoryItem' | translate}}</th>
<th scope="col">{{ 'notify-message-result.ldnService' | translate}}</th>
<th scope="col">{{ 'notify-message-result.type' | translate }}</th>
<th scope="col">{{ 'notify-message-result.status' | translate }}</th>
<th scope="col">{{ 'notify-message-result.action' | translate }}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let message of (messagesSubject$ | async)">
<td class="text-nowrap">
<div *ngIf="message.queueLastStartTime">{{ message.queueLastStartTime | date:"YYYY/MM/d hh:mm:ss" }}</div>
<div *ngIf="!message.queueLastStartTime">n/a</div>
</td>
<td>
<ds-truncatable [id]="message.id">
<ds-truncatable-part [id]="message.id" [minLines]="2">
<a *ngIf="message.relatedItem" [routerLink]="'/items/' + (message.context || message.object)">{{ message.relatedItem }}</a>
</ds-truncatable-part>
</ds-truncatable>
<div *ngIf="!message.relatedItem">n/a</div>
</td>
<td>
<div *ngIf="message.ldnService">{{ message.ldnService }}</div>
<div *ngIf="!message.ldnService">n/a</div>
</td>
<td>
<div>{{ message.activityStreamType }}</div>
</td>
<td>
<div class="text-nowrap">{{ 'notify-detail-modal.' + message.queueStatusLabel | translate }}</div>
</td>
<td>
<div class="d-flex flex-column">
<button class="btn mb-2 btn-dark" (click)="openDetailModal(message)">{{ 'notify-message-result.detail' | translate }}</button>
<button *ngIf="message.queueStatusLabel !== reprocessStatus && validStatusesForReprocess.includes(message.queueStatusLabel)"
(click)="reprocessMessage(message)"
class="btn btn-warning"
>
{{ 'notify-message-result.reprocess' | translate }}
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>

View File

@@ -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<AdminNotifySearchResultComponent>;
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();
});
});

View File

@@ -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<AdminNotifySearchResult>, 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<PaginatedList<AdminNotifySearchResult>, AdminNotifySearchResult> implements OnInit, OnDestroy{
public messagesSubject$: BehaviorSubject<AdminNotifyMessage[]> = 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);
}));
}
}

View File

@@ -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<AdminNotifyMessage> {
}

View File

@@ -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<AdminNotifyMessage>;
/**
* The observable pointing to the access status of the item
*/
@autoserialize
accessStatus: Observable<AdminNotifyMessage>;
@deserialize
_links: {
self: {
href: string;
};
};
get self(): string {
return this._links.self.href;
}
getRenderTypes(): (string | GenericConstructor<ListableObject>)[] {
return [this.constructor as GenericConstructor<ListableObject>];
}
}

View File

@@ -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');

View File

@@ -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();
});
});
});
});

View File

@@ -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<AdminNotifyMessage>}
*/
@Injectable()
@dataService(ADMIN_NOTIFY_MESSAGE)
export class AdminNotifyMessagesService extends IdentifiableDataService<AdminNotifyMessage> {
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<AdminNotifyMessage[]> {
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<AdminNotifyMessage[]>): Observable<AdminNotifyMessage[]> {
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<AdminNotifyMessage>(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;
})
)),
);
}
}

View File

@@ -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() {

View File

@@ -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: [

View File

@@ -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<CollectionAccessControlComponent>;
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();
});
});
});

View File

@@ -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<CommunityAccessControlComponent>;
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();
});
});
});

View File

@@ -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<CommunityPageSubCollectionListComponent>;
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', () => {

View File

@@ -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<CommunityPageSubCommunityListComponent>;
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', () => {

View File

@@ -20,9 +20,7 @@ describe(`AuthInterceptor`, () => {
const authServiceStub = new AuthServiceStub();
const store: Store<TruncatablesState> = 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 were 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();
});
});

View File

@@ -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', () => {

View File

@@ -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', () => {

View File

@@ -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();
});
});

View File

@@ -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({

View File

@@ -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);
});
});
});
});
});

View File

@@ -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();
});
});
});
});

View File

@@ -21,7 +21,6 @@ export class NotifyRequestsStatusDataService extends IdentifiableDataService<Not
protected rdbService: RemoteDataBuildService,
protected objectCache: ObjectCacheService,
protected halService: HALEndpointService,
protected rdb: RemoteDataBuildService,
) {
super('notifyrequests', requestService, rdbService, objectCache, halService);
}
@@ -41,6 +40,6 @@ export class NotifyRequestsStatusDataService extends IdentifiableDataService<Not
this.requestService.send(request, true);
});
return this.rdb.buildFromHref(href$);
return this.rdbService.buildFromHref(href$);
}
}

View File

@@ -1,5 +1,6 @@
import { TestBed, waitForAsync } from '@angular/core/testing';
import { Observable, Subject } from 'rxjs';
import { take } from 'rxjs/operators';
import { provideMockActions } from '@ngrx/effects/testing';
import { cold, hot } from 'jasmine-marbles';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
@@ -11,13 +12,10 @@ import {
RemoveFieldUpdateAction,
RemoveObjectUpdatesAction
} from './object-updates.actions';
import {
INotification,
Notification
} from '../../../shared/notifications/models/notification.model';
import { INotification, Notification } from '../../../shared/notifications/models/notification.model';
import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub';
import { Action } from '@ngrx/store';
import { NotificationType } from '../../../shared/notifications/models/notification-type';
import { filter } from 'rxjs/operators';
import { hasValue } from '../../../shared/empty.util';
import { NoOpAction } from '../../../shared/ngrx/no-op.action';
describe('ObjectUpdatesEffects', () => {
@@ -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));
});
});
});

View File

@@ -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', () => {

View File

@@ -77,7 +77,7 @@ export interface DeleteRelationship extends RelationshipIdentifiable {
*/
export interface ObjectUpdatesEntry {
fieldStates: FieldStates;
fieldUpdates: FieldUpdates;
fieldUpdates?: FieldUpdates;
virtualMetadataSources: VirtualMetadataSources;
lastModified: Date;
patchOperationService?: GenericConstructor<PatchOperationService>;

View File

@@ -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<CoreState>;
let store: MockStore<CoreState>;
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<CoreState>(undefined, undefined, undefined);
service = initTestService();
spyOn(store, 'dispatch');
});
service = TestBed.inject(EPersonDataService);
store = TestBed.inject(Store) as MockStore<CoreState>;
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<string> {
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 }));
});
});

View File

@@ -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

View File

@@ -39,4 +39,6 @@ export enum Context {
MyDSpaceValidation = 'mydspaceValidation',
Bitstream = 'bitstream',
CoarNotify = 'coarNotify',
}

View File

@@ -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();
});
});

View File

@@ -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();
}

View File

@@ -7,4 +7,5 @@ export enum ViewMode {
GridElement = 'grid',
DetailedListElement = 'detailed',
StandalonePage = 'standalone',
Table = 'table',
}

View File

@@ -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[]
}

View File

@@ -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;

View File

@@ -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);
});
});
});

View File

@@ -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<Duplicate> implements SearchData<Duplicate> {
/**
* The ResponseParsingService constructor name
*/
private parser: GenericConstructor<ResponseParsingService> = SearchResponseParsingService;
/**
* The RestRequest constructor name
*/
private request: GenericConstructor<RestRequest> = GetRequest;
/**
* SearchData interface to implement
* @private
*/
private searchData: SearchData<Duplicate>;
/**
* 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<Duplicate>[]): Observable<RemoteData<PaginatedList<Duplicate>>> {
return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
}
/**
* Helper method to get the duplicates endpoint
* @protected
*/
protected getEndpoint(): Observable<string> {
return this.halService.getEndpoint(this.linkPath);
}
/**
* Method to set service options
* @param {GenericConstructor<ResponseParsingService>} parser The ResponseParsingService constructor name
* @param {boolean} request The RestRequest constructor name
*/
setServiceOptions(parser: GenericConstructor<ResponseParsingService>, request: GenericConstructor<RestRequest>) {
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<Duplicate> 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<Duplicate>[]): Observable<RemoteData<PaginatedList<Duplicate>>> {
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();
}
}
}

View File

@@ -1,4 +1,4 @@
<ds-themed-results-back-button *ngIf="showBackButton | async" [back]="back"></ds-themed-results-back-button>
<ds-themed-results-back-button *ngIf="showBackButton$ | async" [back]="back"></ds-themed-results-back-button>
<div class="d-flex flex-row">
<ds-themed-item-page-title-field [item]="object" class="mr-auto">
</ds-themed-item-page-title-field>

View File

@@ -1,4 +1,4 @@
<ds-themed-results-back-button *ngIf="showBackButton | async" [back]="back"></ds-themed-results-back-button>
<ds-themed-results-back-button *ngIf="showBackButton$ | async" [back]="back"></ds-themed-results-back-button>
<div class="d-flex flex-row">
<ds-themed-item-page-title-field [item]="object" class="mr-auto">
</ds-themed-item-page-title-field>

View File

@@ -1,4 +1,4 @@
<ds-themed-results-back-button *ngIf="showBackButton | async" [back]="back"></ds-themed-results-back-button>
<ds-themed-results-back-button *ngIf="showBackButton$ | async" [back]="back"></ds-themed-results-back-button>
<div class="d-flex flex-row">
<ds-themed-item-page-title-field [item]="object" class="mr-auto">
</ds-themed-item-page-title-field>

View File

@@ -1,4 +1,4 @@
<ds-themed-results-back-button *ngIf="showBackButton | async" [back]="back"></ds-themed-results-back-button>
<ds-themed-results-back-button *ngIf="showBackButton$ | async" [back]="back"></ds-themed-results-back-button>
<div class="d-flex flex-row">
<ds-themed-item-page-title-field [item]="object" class="mr-auto">
</ds-themed-item-page-title-field>

View File

@@ -1,4 +1,4 @@
<ds-themed-results-back-button *ngIf="showBackButton | async" [back]="back"></ds-themed-results-back-button>
<ds-themed-results-back-button *ngIf="showBackButton$ | async" [back]="back"></ds-themed-results-back-button>
<div class="d-flex flex-row">
<ds-themed-item-page-title-field class="mr-auto" [item]="object">
</ds-themed-item-page-title-field>

View File

@@ -1,4 +1,4 @@
<ds-themed-results-back-button *ngIf="showBackButton | async" [back]="back"></ds-themed-results-back-button>
<ds-themed-results-back-button *ngIf="showBackButton$ | async" [back]="back"></ds-themed-results-back-button>
<div class="d-flex flex-row">
<ds-themed-item-page-title-field [item]="object" class="mr-auto">
</ds-themed-item-page-title-field>

View File

@@ -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[]) => {

View File

@@ -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<TopLevelCommunityListComponent>;
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');
});
});

View File

@@ -18,7 +18,7 @@ describe('findSuccessfulAccordingTo', () => {
mockItem1.isWithdrawn = true;
mockItem2 = new Item();
mockItem1.isWithdrawn = false;
mockItem2.isWithdrawn = false;
predicate = (rd: RemoteData<Item>) => 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 }));
});
});

View File

@@ -3,7 +3,7 @@
<div class="col-12">
<h1 class="border-bottom">{{'item.edit.head' | translate}}</h1>
<div class="pt-2">
<ul class="nav nav-tabs justify-content-start" role="tablist">
<ul *ngIf="pages.length > 0" class="nav nav-tabs justify-content-start" role="tablist">
<li *ngFor="let page of pages" class="nav-item" role="presentation">
<a *ngIf="(page.enabled | async)"
[attr.aria-selected]="page.page === currentPage"

View File

@@ -191,20 +191,6 @@ describe('ItemBitstreamsComponent', () => {
});
});
describe('when dropBitstream is called', () => {
const event = {
fromIndex: 0,
toIndex: 50,
// eslint-disable-next-line no-empty,@typescript-eslint/no-empty-function
finish: () => {
}
};
beforeEach(() => {
comp.dropBitstream(bundle, event);
});
});
describe('when dropBitstream is called', () => {
beforeEach((done) => {
comp.dropBitstream(bundle, {

View File

@@ -1,4 +1,4 @@
import { Component, EventEmitter, Input, OnInit, Output, ViewChild, ViewContainerRef } from '@angular/core';
import { Component, EventEmitter, Input, OnInit, Output, ViewChild, ViewContainerRef, OnDestroy } from '@angular/core';
import { Bundle } from '../../../../core/shared/bundle.model';
import { Item } from '../../../../core/shared/item.model';
import { ResponsiveColumnSizes } from '../../../../shared/responsive-table-sizes/responsive-column-sizes';
@@ -16,7 +16,7 @@ import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service';
* Creates an embedded view of the contents. This is to ensure the table structure won't break.
* (which means it'll be added to the parents html without a wrapping ds-item-edit-bitstream-bundle element)
*/
export class ItemEditBitstreamBundleComponent implements OnInit {
export class ItemEditBitstreamBundleComponent implements OnInit, OnDestroy {
/**
* The view on the bundle information and bitstreams
@@ -67,4 +67,9 @@ export class ItemEditBitstreamBundleComponent implements OnInit {
this.viewContainerRef.createEmbeddedView(this.bundleView);
this.itemPageRoute = getItemPageRoute(this.item);
}
ngOnDestroy(): void {
this.viewContainerRef.clear();
}
}

View File

@@ -1,4 +1,4 @@
import { Component, OnInit, ViewChild, ViewContainerRef } from '@angular/core';
import { Component, OnInit, ViewChild, ViewContainerRef, OnDestroy } from '@angular/core';
@Component({
selector: 'ds-item-edit-bitstream-drag-handle',
@@ -10,7 +10,7 @@ import { Component, OnInit, ViewChild, ViewContainerRef } from '@angular/core';
* Creates an embedded view of the contents
* (which means it'll be added to the parents html without a wrapping ds-item-edit-bitstream-drag-handle element)
*/
export class ItemEditBitstreamDragHandleComponent implements OnInit {
export class ItemEditBitstreamDragHandleComponent implements OnInit, OnDestroy {
/**
* The view on the drag-handle
*/
@@ -23,4 +23,8 @@ export class ItemEditBitstreamDragHandleComponent implements OnInit {
this.viewContainerRef.createEmbeddedView(this.handleView);
}
ngOnDestroy(): void {
this.viewContainerRef.clear();
}
}

View File

@@ -1,4 +1,4 @@
import { Component, Input, OnChanges, OnInit, SimpleChanges, ViewChild, ViewContainerRef } from '@angular/core';
import { Component, Input, OnChanges, OnDestroy, OnInit, SimpleChanges, ViewChild, ViewContainerRef } from '@angular/core';
import { Bitstream } from '../../../../core/shared/bitstream.model';
import cloneDeep from 'lodash/cloneDeep';
import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service';
@@ -21,7 +21,7 @@ import { getBitstreamDownloadRoute } from '../../../../app-routing-paths';
* Creates an embedded view of the contents
* (which means it'll be added to the parents html without a wrapping ds-item-edit-bitstream element)
*/
export class ItemEditBitstreamComponent implements OnChanges, OnInit {
export class ItemEditBitstreamComponent implements OnChanges, OnDestroy, OnInit {
/**
* The view on the bitstream
@@ -72,6 +72,10 @@ export class ItemEditBitstreamComponent implements OnChanges, OnInit {
this.viewContainerRef.createEmbeddedView(this.bitstreamView);
}
ngOnDestroy(): void {
this.viewContainerRef.clear();
}
/**
* Update the current bitstream and its format on changes
* @param changes

View File

@@ -37,8 +37,8 @@ describe('ItemPageFieldComponent', () => {
}
});
const buildTestEnvironment = async () => {
await TestBed.configureTestingModule({
beforeEach(waitForAsync(() => {
void TestBed.configureTestingModule({
imports: [
RouterTestingModule.withRoutes([]),
TranslateModule.forRoot({
@@ -65,19 +65,16 @@ describe('ItemPageFieldComponent', () => {
comp.fields = mockFields;
comp.label = mockLabel;
fixture.detectChanges();
};
it('should display display the correct metadata value', waitForAsync(async () => {
await buildTestEnvironment();
expect(fixture.nativeElement.innerHTML).toContain(mockValue);
}));
describe('when markdown is disabled in the environment config', () => {
it('should display display the correct metadata value', () => {
expect(fixture.nativeElement.innerHTML).toContain(mockValue);
});
beforeEach(waitForAsync(async () => {
describe('when markdown is disabled in the environment config', () => {
beforeEach( () => {
appConfig.markdown.enabled = false;
await buildTestEnvironment();
}));
});
describe('and markdown is disabled in this component', () => {
@@ -105,11 +102,9 @@ describe('ItemPageFieldComponent', () => {
});
describe('when markdown is enabled in the environment config', () => {
beforeEach(waitForAsync(async () => {
beforeEach(() => {
appConfig.markdown.enabled = true;
await buildTestEnvironment();
}));
});
describe('and markdown is disabled in this component', () => {
@@ -139,12 +134,13 @@ describe('ItemPageFieldComponent', () => {
describe('test rendering of configured browse links', () => {
beforeEach(() => {
appConfig.markdown.enabled = false;
comp.enableMarkdown = true;
fixture.detectChanges();
});
waitForAsync(() => {
it('should have a browse link', () => {
expect(fixture.debugElement.query(By.css('a.ds-browse-link')).nativeElement.innerHTML).toContain(mockValue);
});
it('should have a browse link', async () => {
expect(fixture.debugElement.query(By.css('a.ds-browse-link')).nativeElement.innerHTML).toContain(mockValue);
});
});
@@ -153,10 +149,9 @@ describe('ItemPageFieldComponent', () => {
comp.urlRegex = '^test';
fixture.detectChanges();
});
waitForAsync(() => {
it('should have a rendered (non-browse) link since the value matches ^test', () => {
expect(fixture.debugElement.query(By.css('a.ds-simple-metadata-link')).nativeElement.innerHTML).toContain(mockValue);
});
it('should have a rendered (non-browse) link since the value matches ^test', () => {
expect(fixture.debugElement.query(By.css('a.ds-simple-metadata-link')).nativeElement.innerHTML).toContain(mockValue);
});
});
@@ -165,14 +160,11 @@ describe('ItemPageFieldComponent', () => {
comp.urlRegex = '^nope';
fixture.detectChanges();
});
beforeEach(waitForAsync(() => {
it('should NOT have a rendered (non-browse) link since the value matches ^test', () => {
expect(fixture.debugElement.query(By.css('a.ds-simple-metadata-link'))).toBeNull();
});
}));
it('should NOT have a rendered (non-browse) link since the value matches ^test', () => {
expect(fixture.debugElement.query(By.css('a.ds-simple-metadata-link'))).toBeNull();
});
});
});
export function mockItemWithMetadataFieldsAndValue(fields: string[], value: string): Item {

View File

@@ -2,7 +2,7 @@ import { ChangeDetectionStrategy, Component, Inject, OnDestroy, OnInit, PLATFORM
import { ActivatedRoute, Router } from '@angular/router';
import { isPlatformServer } from '@angular/common';
import { Observable, combineLatest } from 'rxjs';
import { Observable, combineLatest, of } from 'rxjs';
import { map, switchMap, take } from 'rxjs/operators';
import { ItemDataService } from '../../core/data/item-data.service';
@@ -155,6 +155,8 @@ export class ItemPageComponent implements OnInit, OnDestroy {
switchMap((coarLdnEnabled: boolean) => {
if (coarLdnEnabled) {
return this.notifyInfoService.getCoarLdnLocalInboxUrls();
} else {
return of([]);
}
})
);

View File

@@ -1,4 +1,4 @@
<ds-themed-results-back-button *ngIf="showBackButton | async" [back]="back"></ds-themed-results-back-button>
<ds-themed-results-back-button *ngIf="showBackButton$ | async" [back]="back"></ds-themed-results-back-button>
<div class="row" *ngIf="iiifEnabled">
<div class="col-12">
<ds-mirador-viewer id="iiif-viewer"
@@ -86,7 +86,7 @@
<ds-item-page-collections [item]="object"></ds-item-page-collections>
<ds-item-page-uri-field [item]="object"
[fields]="['notify.relation.endorsedBy']"
[label]="'item.page.endorsment'">
[label]="'item.page.endorsement'">
</ds-item-page-uri-field>
<ds-item-page-uri-field [item]="object"
[fields]="['datacite.relation.isReviewedBy']"

View File

@@ -469,30 +469,31 @@ describe('ItemComponent', () => {
fixture.detectChanges();
}));
it('should hide back button',() => {
it('should hide back button', () => {
spyOn(mockRouteService, 'getPreviousUrl').and.returnValue(observableOf('/item'));
comp.showBackButton.subscribe((val) => {
comp.ngOnInit();
comp.showBackButton$.subscribe((val) => {
expect(val).toBeFalse();
});
});
it('should show back button for search', () => {
spyOn(mockRouteService, 'getPreviousUrl').and.returnValue(observableOf(searchUrl));
comp.ngOnInit();
comp.showBackButton.subscribe((val) => {
comp.showBackButton$.subscribe((val) => {
expect(val).toBeTrue();
});
});
it('should show back button for browse', () => {
spyOn(mockRouteService, 'getPreviousUrl').and.returnValue(observableOf(browseUrl));
comp.ngOnInit();
comp.showBackButton.subscribe((val) => {
comp.showBackButton$.subscribe((val) => {
expect(val).toBeTrue();
});
});
it('should show back button for recent submissions', () => {
spyOn(mockRouteService, 'getPreviousUrl').and.returnValue(observableOf(recentSubmissionsUrl));
comp.ngOnInit();
comp.showBackButton.subscribe((val) => {
comp.showBackButton$.subscribe((val) => {
expect(val).toBeTrue();
});
});

View File

@@ -5,7 +5,7 @@ import { getItemPageRoute } from '../../../item-page-routing-paths';
import { RouteService } from '../../../../core/services/route.service';
import { Observable } from 'rxjs';
import { getDSpaceQuery, isIiifEnabled, isIiifSearchEnabled } from './item-iiif-utils';
import { filter, map, take } from 'rxjs/operators';
import { map, take } from 'rxjs/operators';
import { Router } from '@angular/router';
@Component({
@@ -27,7 +27,7 @@ export class ItemComponent implements OnInit {
/**
* Used to show or hide the back to results button in the view.
*/
showBackButton: Observable<boolean>;
showBackButton$: Observable<boolean>;
/**
* Route to the item page
@@ -73,10 +73,9 @@ export class ItemComponent implements OnInit {
this.itemPageRoute = getItemPageRoute(this.object);
// hide/show the back button
this.showBackButton = this.routeService.getPreviousUrl().pipe(
filter(url => this.previousRoute.test(url)),
this.showBackButton$ = this.routeService.getPreviousUrl().pipe(
map((url: string) => this.previousRoute.test(url)),
take(1),
map(() => true)
);
// check to see if iiif viewer is required.
this.iiifEnabled = isIiifEnabled(this.object);

View File

@@ -1,4 +1,4 @@
<ds-themed-results-back-button *ngIf="showBackButton | async" [back]="back"></ds-themed-results-back-button>
<ds-themed-results-back-button *ngIf="showBackButton$ | async" [back]="back"></ds-themed-results-back-button>
<div class="row" *ngIf="iiifEnabled">
<div class="col-12">
<ds-mirador-viewer id="iiif-viewer"
@@ -72,7 +72,7 @@
<ds-item-page-collections [item]="object"></ds-item-page-collections>
<ds-item-page-uri-field [item]="object"
[fields]="['notify.relation.endorsedBy']"
[label]="'item.page.endorsment'">
[label]="'item.page.endorsement'">
</ds-item-page-uri-field>
<ds-item-page-uri-field [item]="object"
[fields]="['datacite.relation.isReviewedBy']"

View File

@@ -4,7 +4,7 @@ import {
} from '@angular/core/testing';
import { VarDirective } from '../../shared/utils/var.directive';
import { TranslateModule } from '@ngx-translate/core';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
import { Item } from '../../core/shared/item.model';
import { Version } from '../../core/shared/version.model';
import { VersionHistory } from '../../core/shared/version-history.model';
@@ -220,20 +220,21 @@ describe('ItemVersionsComponent', () => {
authorizationServiceSpy.isAuthorized.and.callFake(canDelete);
}));
it('should not disable the delete button', () => {
const deleteButtons = fixture.debugElement.queryAll(By.css(`.version-row-element-delete`));
deleteButtons.forEach((btn) => {
const deleteButtons: DebugElement[] = fixture.debugElement.queryAll(By.css('.version-row-element-delete'));
expect(deleteButtons.length).not.toBe(0);
deleteButtons.forEach((btn: DebugElement) => {
expect(btn.nativeElement.disabled).toBe(false);
});
});
it('should disable other buttons', () => {
const createButtons = fixture.debugElement.queryAll(By.css(`.version-row-element-create`));
createButtons.forEach((btn) => {
expect(btn.nativeElement.disabled).toBe(true);
});
const editButtons = fixture.debugElement.queryAll(By.css(`.version-row-element-create`));
editButtons.forEach((btn) => {
expect(btn.nativeElement.disabled).toBe(true);
});
it('should hide the create buttons', () => {
const createButtons: DebugElement[] = fixture.debugElement.queryAll(By.css('.version-row-element-create'));
expect(createButtons.length).toBe(0);
});
it('should hide the edit buttons', () => {
const editButtons: DebugElement[] = fixture.debugElement.queryAll(By.css('.version-row-element-edit'));
expect(editButtons.length).toBe(0);
});
});

View File

@@ -368,18 +368,40 @@ export class MenuResolver implements Resolve<boolean> {
icon: 'terminal',
index: 10
},
/* COAR Notify section */
{
id: 'coar_notify',
active: false,
visible: isSiteAdmin && isCoarNotifyEnabled,
model: {
type: MenuItemType.TEXT,
text: 'menu.section.coar_notify'
} as TextMenuItemModel,
icon: 'inbox',
index: 13
},
{
id: 'notify_dashboard',
active: false,
parentID: 'coar_notify',
visible: isSiteAdmin && isCoarNotifyEnabled,
model: {
type: MenuItemType.LINK,
text: 'menu.section.notify_dashboard',
link: '/admin/notify-dashboard'
} as LinkMenuItemModel,
},
/* LDN Services */
{
id: 'ldn_services',
active: false,
parentID: 'coar_notify',
visible: isSiteAdmin && isCoarNotifyEnabled,
model: {
type: MenuItemType.LINK,
text: 'menu.section.services',
link: '/admin/ldn/services'
} as LinkMenuItemModel,
icon: 'inbox',
index: 14
},
{
id: 'health',

View File

@@ -5,7 +5,6 @@ import { By } from '@angular/platform-browser';
import { Component, NO_ERRORS_SCHEMA } from '@angular/core';
import { of as observableOf } from 'rxjs';
import { CommonModule } from '@angular/common';
import { Item } from '../../core/shared/item.model';
import { buildPaginatedList } from '../../core/data/paginated-list.model';
import { PageInfo } from '../../core/shared/page-info.model';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
@@ -48,10 +47,10 @@ import { SharedModule } from '../shared.module';
import { BrowseByRoutingModule } from '../../browse-by/browse-by-routing.module';
import { AccessControlRoutingModule } from '../../access-control/access-control-routing.module';
@listableObjectComponent(BrowseEntry, ViewMode.ListElement, DEFAULT_CONTEXT, 'custom')
@listableObjectComponent(BrowseEntry, ViewMode.ListElement, DEFAULT_CONTEXT, 'dspace')
@Component({
// eslint-disable-next-line @angular-eslint/component-selector
selector: '',
selector: 'ds-browse-entry-list-element',
template: ''
})
class MockThemedBrowseEntryListElementComponent {
@@ -61,28 +60,6 @@ describe('BrowseByComponent', () => {
let comp: BrowseByComponent;
let fixture: ComponentFixture<BrowseByComponent>;
const mockItems = [
Object.assign(new Item(), {
id: 'fakeId-1',
metadata: [
{
key: 'dc.title',
value: 'First Fake Title'
}
]
}),
Object.assign(new Item(), {
id: 'fakeId-2',
metadata: [
{
key: 'dc.title',
value: 'Second Fake Title'
}
]
})
];
const mockItemsRD$ = createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), mockItems));
const groupDataService = jasmine.createSpyObj('groupsDataService', {
findListByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])),
getGroupRegistryRouterLink: '',
@@ -113,8 +90,8 @@ describe('BrowseByComponent', () => {
let themeService;
beforeEach(waitForAsync(() => {
themeService = getMockThemeService('dspace');
TestBed.configureTestingModule({
themeService = getMockThemeService('base');
void TestBed.configureTestingModule({
imports: [
BrowseByRoutingModule,
AccessControlRoutingModule,
@@ -200,40 +177,40 @@ describe('BrowseByComponent', () => {
});
describe('when theme is base', () => {
beforeEach(() => {
beforeEach(async () => {
themeService.getThemeName.and.returnValue('base');
themeService.getThemeName$.and.returnValue(observableOf('base'));
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
});
it('should use the base component to render browse entries', () => {
waitForAsync(() => {
const componentLoaders = fixture.debugElement.queryAll(By.directive(ListableObjectComponentLoaderComponent));
expect(componentLoaders.length).toEqual(browseEntries.length);
componentLoaders.forEach((componentLoader) => {
const browseEntry = componentLoader.query(By.css('ds-browse-entry-list-element'));
expect(browseEntry.componentInstance).toBeInstanceOf(BrowseEntryListElementComponent);
});
const componentLoaders = fixture.debugElement.queryAll(By.directive(ListableObjectComponentLoaderComponent));
expect(componentLoaders.length).toEqual(browseEntries.length);
componentLoaders.forEach((componentLoader) => {
const browseEntry = componentLoader.query(By.css('ds-browse-entry-list-element'));
expect(browseEntry.componentInstance).toBeInstanceOf(BrowseEntryListElementComponent);
});
});
});
describe('when theme is custom', () => {
beforeEach(() => {
themeService.getThemeName.and.returnValue('custom');
themeService.getThemeName$.and.returnValue(observableOf('custom'));
describe('when theme is dspace', () => {
beforeEach(async () => {
themeService.getThemeName.and.returnValue('dspace');
themeService.getThemeName$.and.returnValue(observableOf('dspace'));
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
});
it('should use the themed component to render browse entries', () => {
waitForAsync(() => {
const componentLoaders = fixture.debugElement.queryAll(By.directive(ListableObjectComponentLoaderComponent));
expect(componentLoaders.length).toEqual(browseEntries.length);
componentLoaders.forEach((componentLoader) => {
const browseEntry = componentLoader.query(By.css('ds-browse-entry-list-element'));
expect(browseEntry.componentInstance).toBeInstanceOf(MockThemedBrowseEntryListElementComponent);
});
});
});
});
});

View File

@@ -27,4 +27,5 @@ const DECLARATIONS = [
...DECLARATIONS,
]
})
export class SharedBrowseByModule { }
export class SharedBrowseByModule {
}

View File

@@ -24,9 +24,8 @@ import { Community } from '../../core/shared/community.model';
import { Collection } from '../../core/shared/collection.model';
import flatten from 'lodash/flatten';
import { DsoWithdrawnReinstateModalService } from './dso-withdrawn-reinstate-service/dso-withdrawn-reinstate-modal.service';
import { AuthService } from 'src/app/core/auth/auth.service';
import { AuthServiceMock } from '../mocks/auth.service.mock';
import { CorrectionTypeDataService } from 'src/app/core/submission/correctiontype-data.service';
import { createPaginatedList } from '../testing/utils.test';
describe('DSOEditMenuResolver', () => {
@@ -152,7 +151,7 @@ describe('DSOEditMenuResolver', () => {
});
correctionsDataService = jasmine.createSpyObj('correctionsDataService', {
findByItem: observableOf([])
findByItem: createSuccessfulRemoteDataObject$(createPaginatedList([])),
});
TestBed.configureTestingModule({
@@ -167,7 +166,6 @@ describe('DSOEditMenuResolver', () => {
{provide: TranslateService, useValue: translate},
{provide: NotificationsService, useValue: notificationsService},
{provide: DsoWithdrawnReinstateModalService, useValue: dsoWithdrawnReinstateModalService},
{provide: AuthService, useValue: new AuthServiceMock()},
{provide: CorrectionTypeDataService, useValue: correctionsDataService},
{
provide: NgbModal, useValue: {
@@ -367,7 +365,7 @@ describe('DSOEditMenuResolver', () => {
route = dsoRoute(testItem);
});
it('should return Item-specific entries', () => {
it('should return Item-specific entries', (done: DoneFn) => {
const result = resolver.getDsoMenus(testObject, route, state);
combineLatest(result).pipe(map(flatten)).subscribe((menu) => {
const orcidEntry = menu.find(entry => entry.id === 'orcid-dso');
@@ -388,18 +386,20 @@ describe('DSOEditMenuResolver', () => {
expect(claimEntry.active).toBeFalse();
expect(claimEntry.visible).toBeFalse();
expect(claimEntry.model.type).toEqual(MenuItemType.ONCLICK);
done();
});
});
it('should not return Community/Collection-specific entries', () => {
it('should not return Community/Collection-specific entries', (done: DoneFn) => {
const result = resolver.getDsoMenus(testObject, route, state);
combineLatest(result).pipe(map(flatten)).subscribe((menu) => {
const subscribeEntry = menu.find(entry => entry.id === 'subscribe');
expect(subscribeEntry).toBeFalsy();
done();
});
});
it('should return as third part the common list ', () => {
it('should return as third part the common list ', (done: DoneFn) => {
const result = resolver.getDsoMenus(testObject, route, state);
combineLatest(result).pipe(map(flatten)).subscribe((menu) => {
const editEntry = menu.find(entry => entry.id === 'edit-dso');
@@ -410,6 +410,7 @@ describe('DSOEditMenuResolver', () => {
expect((editEntry.model as LinkMenuItemModel).link).toEqual(
'/items/test-item-uuid/edit/metadata'
);
done();
});
});
});

View File

@@ -24,9 +24,6 @@ import { ResearcherProfileDataService } from '../../core/profile/researcher-prof
import { NotificationsService } from '../notifications/notifications.service';
import { TranslateService } from '@ngx-translate/core';
import { DsoWithdrawnReinstateModalService, REQUEST_REINSTATE, REQUEST_WITHDRAWN } from './dso-withdrawn-reinstate-service/dso-withdrawn-reinstate-modal.service';
import { AuthService } from '../../core/auth/auth.service';
import { FindListOptions } from '../../core/data/find-list-options.model';
import { RequestParam } from '../../core/cache/models/request-param.model';
import { CorrectionTypeDataService } from '../../core/submission/correctiontype-data.service';
import { SubscriptionModalComponent } from '../subscriptions/subscription-modal/subscription-modal.component';
import { Community } from '../../core/shared/community.model';
@@ -50,7 +47,6 @@ export class DSOEditMenuResolver implements Resolve<{ [key: string]: MenuSection
protected notificationsService: NotificationsService,
protected translate: TranslateService,
protected dsoWithdrawnReinstateModalService: DsoWithdrawnReinstateModalService,
private auth: AuthService,
private correctionTypeDataService: CorrectionTypeDataService
) {
}
@@ -133,9 +129,6 @@ export class DSOEditMenuResolver implements Resolve<{ [key: string]: MenuSection
*/
protected getItemMenu(dso): Observable<MenuSection[]> {
if (dso instanceof Item) {
const findListTopicOptions: FindListOptions = {
searchParams: [new RequestParam('target', dso.uuid)]
};
return combineLatest([
this.authorizationService.isAuthorized(FeatureID.CanCreateVersion, dso.self),
this.dsoVersioningModalService.isNewVersionButtonDisabled(dso),

View File

@@ -1,5 +1,5 @@
<a [routerLink]="(bitstreamPath$| async)?.routerLink" class="dont-break-out" [queryParams]="(bitstreamPath$| async)?.queryParams" [target]="isBlank ? '_blank': '_self'" [ngClass]="cssClasses">
<span *ngIf="!(canDownload$ |async)" [attr.aria-label]="'file-download-link.restricted' | translate" class="pr-1"><i class="fas fa-lock"></i></span>
<span role="img" *ngIf="!(canDownload$ |async)" [attr.aria-label]="'file-download-link.restricted' | translate" class="pr-1"><i class="fas fa-lock"></i></span>
<ng-container *ngTemplateOutlet="content"></ng-container>
</a>

View File

@@ -1,4 +1,4 @@
import { Component, Input } from '@angular/core';
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { VocabularyOptions } from '../../../core/submission/vocabularies/models/vocabulary-options.model';
import { VocabularyEntryDetail } from '../../../core/submission/vocabularies/models/vocabulary-entry-detail.model';
@@ -33,6 +33,12 @@ export class VocabularyTreeviewModalComponent {
*/
@Input() multiSelect = false;
/**
* An event fired when a vocabulary entry is selected.
* Event's payload equals to {@link VocabularyEntryDetail} selected.
*/
@Output() select: EventEmitter<VocabularyEntryDetail> = new EventEmitter<VocabularyEntryDetail>(null);
/**
* Initialize instance variables
*
@@ -46,6 +52,7 @@ export class VocabularyTreeviewModalComponent {
* Method called on entry select
*/
onSelect(item: VocabularyEntryDetail) {
this.select.emit(item);
this.activeModal.close(item);
}
}

View File

@@ -44,6 +44,10 @@ describe('hostWindowReducer', () => {
const action = new HostWindowResizeAction(1024, 768);
hostWindowReducer(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();
});
});

View File

@@ -180,6 +180,7 @@ describe('menusReducer', () => {
// 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 collapsed to false for the correct menu in response to the EXPAND_MENU action', () => {
@@ -201,6 +202,7 @@ describe('menusReducer', () => {
// 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 collapsed to false for the correct menu in response to the TOGGLE_MENU action when collapsed is true', () => {
@@ -231,6 +233,7 @@ describe('menusReducer', () => {
// 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 previewCollapsed to true for the correct menu in response to the COLLAPSE_MENU_PREVIEW action', () => {
@@ -252,6 +255,7 @@ describe('menusReducer', () => {
// 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 previewCollapsed to false for the correct menu in response to the EXPAND_MENU_PREVIEW action', () => {
@@ -273,6 +277,7 @@ describe('menusReducer', () => {
// 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 visible to true for the correct menu in response to the SHOW_MENU action', () => {
@@ -294,6 +299,7 @@ describe('menusReducer', () => {
// 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 previewCollapsed to false for the correct menu in response to the HIDE_MENU action', () => {
@@ -315,6 +321,7 @@ describe('menusReducer', () => {
// 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 reset the menu state to the initial state when performing the REINIT_MENUS action without affecting the previous state', () => {
@@ -358,6 +365,7 @@ describe('menusReducer', () => {
// 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 a section for the correct menu in response to the REMOVE_SECTION action', () => {
@@ -394,6 +402,10 @@ describe('menusReducer', () => {
const action = new ActivateMenuSectionAction(menuID, topSectionID);
menusReducer(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 set active to false for the correct menu section in response to the DEACTIVATE_SECTION action', () => {
@@ -412,6 +424,10 @@ describe('menusReducer', () => {
const action = new DeactivateMenuSectionAction(menuID, topSectionID);
menusReducer(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 set active to false for the correct menu in response to the TOGGLE_ACTIVE_SECTION action when active is true', () => {
@@ -441,6 +457,7 @@ describe('menusReducer', () => {
// 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 visible to true for the correct menu section in response to the SHOW_SECTION action', () => {
@@ -459,6 +476,10 @@ describe('menusReducer', () => {
const action = new ShowMenuSectionAction(menuID, topSectionID);
menusReducer(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 set visible to false for the correct menu section in response to the HIDE_SECTION action', () => {
@@ -477,5 +498,9 @@ describe('menusReducer', () => {
const action = new HideMenuSectionAction(menuID, topSectionID);
menusReducer(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();
});
});

View File

@@ -1114,7 +1114,10 @@ export const mockSubmissionState: SubmissionObjectState = Object.assign({}, {
isLoading: false,
isValid: false,
removePending: false
} as any
} as any,
'duplicates': {
potentialDuplicates: []
} as any,
},
isLoading: false,
savePending: false,

View File

@@ -0,0 +1,12 @@
<div role="button"
class="w-100 h-100 pt-4 pb-3 px-2 box-container"
[ngStyle]="{'background-color': boxConfig.color}"
[dsHoverClass]="'shadow-lg'"
(click)="onClick(boxConfig)"
[title]="boxConfig.description | translate"
>
<div [ngStyle]="{'color': boxConfig.textColor}" class="d-flex flex-column justify-content-center align-items-center">
<div class="mb-3 font-weight-bold box-counter">{{ boxConfig.count ?? 0 }}</div>
<div class="font-weight-bold d-flex justify-content-center w-100">{{ boxConfig.title | translate }}</div>
</div>
</div>

View File

@@ -0,0 +1,8 @@
.box-container {
min-width: max-content;
}
.box-counter {
font-size: calc(var(--bs-font-size-lg) * 1.5);
}

Some files were not shown because too many files have changed in this diff Show More