diff --git a/cypress.config.ts b/cypress.config.ts
index 91eeb9838b3..f6f7a040735 100644
--- a/cypress.config.ts
+++ b/cypress.config.ts
@@ -40,5 +40,6 @@ export default defineConfig({
// It can be overridden via the CYPRESS_BASE_URL environment variable
// (By default we set this to a value which should work in most development environments)
baseUrl: 'http://localhost:4000',
+ experimentalRunAllSpecs: true
},
});
diff --git a/cypress/e2e/submission.cy.ts b/cypress/e2e/submission.cy.ts
index ed10b2d13aa..914bf461903 100644
--- a/cypress/e2e/submission.cy.ts
+++ b/cypress/e2e/submission.cy.ts
@@ -1,8 +1,8 @@
-import { TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD, TEST_SUBMIT_COLLECTION_NAME, TEST_SUBMIT_COLLECTION_UUID } from 'cypress/support/e2e';
+import { TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD, TEST_SUBMIT_COLLECTION_NAME, TEST_SUBMIT_COLLECTION_UUID, TEST_ADMIN_USER, TEST_ADMIN_PASSWORD } from 'cypress/support/e2e';
describe('New Submission page', () => {
- // NOTE: We already test that new submissions can be started from MyDSpace in my-dspace.spec.ts
+ // NOTE: We already test that new Item submissions can be started from MyDSpace in my-dspace.spec.ts
it('should create a new submission when using /submit path & pass accessibility', () => {
// Test that calling /submit with collection & entityType will create a new submission
cy.visit('/submit?collection='.concat(TEST_SUBMIT_COLLECTION_UUID).concat('&entityType=none'));
diff --git a/cypress/plugins/index.ts b/cypress/plugins/index.ts
index ead38afb921..cc3dccba38e 100644
--- a/cypress/plugins/index.ts
+++ b/cypress/plugins/index.ts
@@ -1,5 +1,11 @@
const fs = require('fs');
+// These two global variables are used to store information about the REST API used
+// by these e2e tests. They are filled out prior to running any tests in the before()
+// method of e2e.ts. They can then be accessed by any tests via the getters below.
+let REST_BASE_URL: string;
+let REST_DOMAIN: string;
+
// Plugins enable you to tap into, modify, or extend the internal behavior of Cypress
// For more info, visit https://on.cypress.io/plugins-api
module.exports = (on, config) => {
@@ -30,6 +36,24 @@ module.exports = (on, config) => {
}
return null;
+ },
+ // Save value of REST Base URL, looked up before all tests.
+ // This allows other tests to use it easily via getRestBaseURL() below.
+ saveRestBaseURL(url: string) {
+ return (REST_BASE_URL = url);
+ },
+ // Retrieve currently saved value of REST Base URL
+ getRestBaseURL() {
+ return REST_BASE_URL ;
+ },
+ // Save value of REST Domain, looked up before all tests.
+ // This allows other tests to use it easily via getRestBaseDomain() below.
+ saveRestBaseDomain(domain: string) {
+ return (REST_DOMAIN = domain);
+ },
+ // Retrieve currently saved value of REST Domain
+ getRestBaseDomain() {
+ return REST_DOMAIN ;
}
});
};
diff --git a/src/app/admin/admin-ldn-services/admin-ldn-services-routing.module.ts b/src/app/admin/admin-ldn-services/admin-ldn-services-routing.module.ts
new file mode 100644
index 00000000000..c94083d3ba6
--- /dev/null
+++ b/src/app/admin/admin-ldn-services/admin-ldn-services-routing.module.ts
@@ -0,0 +1,47 @@
+import { NgModule } from '@angular/core';
+import { RouterModule, Routes } from '@angular/router';
+import { LdnServicesOverviewComponent } from './ldn-services-directory/ldn-services-directory.component';
+import { NavigationBreadcrumbResolver } from '../../core/breadcrumbs/navigation-breadcrumb.resolver';
+import { I18nBreadcrumbResolver } from '../../core/breadcrumbs/i18n-breadcrumb.resolver';
+import { LdnServiceFormComponent } from './ldn-service-form/ldn-service-form.component';
+
+
+const moduleRoutes: Routes = [
+ {
+ path: '',
+ pathMatch: 'full',
+ component: LdnServicesOverviewComponent,
+ resolve: {breadcrumb: I18nBreadcrumbResolver},
+ data: {title: 'ldn-registered-services.title', breadcrumbKey: 'ldn-registered-services.new'},
+ },
+ {
+ path: 'new',
+ resolve: {breadcrumb: NavigationBreadcrumbResolver},
+ component: LdnServiceFormComponent,
+ data: {title: 'ldn-register-new-service.title', breadcrumbKey: 'ldn-register-new-service'}
+ },
+ {
+ path: 'edit/:serviceId',
+ resolve: {breadcrumb: NavigationBreadcrumbResolver},
+ component: LdnServiceFormComponent,
+ data: {title: 'ldn-edit-service.title', breadcrumbKey: 'ldn-edit-service'}
+ },
+];
+
+
+@NgModule({
+ imports: [
+ RouterModule.forChild(moduleRoutes.map(route => {
+ return {...route, data: {
+ ...route.data,
+ relatedRoutes: moduleRoutes.filter(relatedRoute => relatedRoute.path !== route.path)
+ .map((relatedRoute) => {
+ return {path: relatedRoute.path, data: relatedRoute.data};
+ })
+ }};
+ }))
+ ]
+})
+export class AdminLdnServicesRoutingModule {
+
+}
diff --git a/src/app/admin/admin-ldn-services/admin-ldn-services.module.ts b/src/app/admin/admin-ldn-services/admin-ldn-services.module.ts
new file mode 100644
index 00000000000..45ec696cd30
--- /dev/null
+++ b/src/app/admin/admin-ldn-services/admin-ldn-services.module.ts
@@ -0,0 +1,25 @@
+import { NgModule } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { AdminLdnServicesRoutingModule } from './admin-ldn-services-routing.module';
+import { LdnServicesOverviewComponent } from './ldn-services-directory/ldn-services-directory.component';
+import { SharedModule } from '../../shared/shared.module';
+import { LdnServiceFormComponent } from './ldn-service-form/ldn-service-form.component';
+import { FormsModule } from '@angular/forms';
+import { LdnItemfiltersService } from './ldn-services-data/ldn-itemfilters-data.service';
+
+
+@NgModule({
+ imports: [
+ CommonModule,
+ SharedModule,
+ AdminLdnServicesRoutingModule,
+ FormsModule
+ ],
+ declarations: [
+ LdnServicesOverviewComponent,
+ LdnServiceFormComponent,
+ ],
+ providers: [LdnItemfiltersService]
+})
+export class AdminLdnServicesModule {
+}
diff --git a/src/app/admin/admin-ldn-services/ldn-service-form/ldn-service-form.component.html b/src/app/admin/admin-ldn-services/ldn-service-form/ldn-service-form.component.html
new file mode 100644
index 00000000000..0a7bc39fa31
--- /dev/null
+++ b/src/app/admin/admin-ldn-services/ldn-service-form/ldn-service-form.component.html
@@ -0,0 +1,315 @@
+
+
+
+
+
+
+ {{ 'service.overview.edit.body' | translate }}
+
+
+ {{ 'service.overview.create.body' | translate }}
+
+
+
+
+
+
diff --git a/src/app/admin/admin-ldn-services/ldn-service-form/ldn-service-form.component.scss b/src/app/admin/admin-ldn-services/ldn-service-form/ldn-service-form.component.scss
new file mode 100644
index 00000000000..afd5c80d1cb
--- /dev/null
+++ b/src/app/admin/admin-ldn-services/ldn-service-form/ldn-service-form.component.scss
@@ -0,0 +1,143 @@
+@import '../../../shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.scss';
+@import '../../../shared/form/form.component.scss';
+
+form {
+ font-size: 14px;
+ position: relative;
+}
+
+input,
+select {
+ max-width: 100%;
+ width: 100%;
+ padding: 8px;
+ font-size: 14px;
+}
+
+option:not(:first-child) {
+ font-weight: bold;
+}
+
+.trash-button {
+ width: 40px;
+ height: 40px;
+}
+
+textarea {
+ height: 200px;
+ resize: none;
+}
+
+.add-pattern-link {
+ color: #0048ff;
+ cursor: pointer;
+}
+
+.remove-pattern-link {
+ color: #e34949;
+ cursor: pointer;
+ margin-left: 10px;
+}
+
+.status-checkbox {
+ margin-top: 5px;
+}
+
+
+.invalid-field {
+ border: 1px solid red;
+ color: #000000;
+}
+
+.error-text {
+ color: red;
+ font-size: 0.8em;
+ margin-top: 5px;
+}
+
+.toggle-switch {
+ display: flex;
+ align-items: center;
+ opacity: 0.8;
+ position: relative;
+ width: 60px;
+ height: 30px;
+ background-color: #ccc;
+ border-radius: 15px;
+ cursor: pointer;
+ transition: background-color 0.3s;
+}
+
+.toggle-switch.checked {
+ background-color: #24cc9a;
+}
+
+.slider {
+ position: absolute;
+ width: 30px;
+ height: 30px;
+ border-radius: 50%;
+ background-color: #fff;
+ transition: transform 0.3s;
+}
+
+
+.toggle-switch .slider {
+ width: 22px;
+ height: 22px;
+ border-radius: 50%;
+ margin: 0 auto;
+}
+
+.toggle-switch.checked .slider {
+ transform: translateX(30px);
+}
+
+.toggle-switch-container {
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-start;
+ align-items: flex-end;
+ margin-top: 10px;
+}
+
+.small-text {
+ font-size: 0.7em;
+ color: #888;
+}
+
+.toggle-switch {
+ cursor: pointer;
+ margin-top: 3px;
+ margin-right: 3px
+}
+
+.label-box {
+ margin-left: 11px;
+}
+
+.label-box-2 {
+ margin-left: 14px;
+}
+
+.label-box-3 {
+ margin-left: 5px;
+}
+
+.submission-form-footer {
+ border-radius: var(--bs-card-border-radius);
+ bottom: 0;
+ background-color: var(--bs-gray-400);
+ padding: calc(var(--bs-spacer) / 2);
+ z-index: calc(var(--ds-submission-footer-z-index) + 1);
+}
+
+.marked-for-deletion {
+ background-color: lighten($red, 30%);
+}
+
+.dropdown-menu-top, .scrollable-dropdown-menu {
+ z-index: var(--ds-submission-footer-z-index);
+}
+
+
diff --git a/src/app/admin/admin-ldn-services/ldn-service-form/ldn-service-form.component.spec.ts b/src/app/admin/admin-ldn-services/ldn-service-form/ldn-service-form.component.spec.ts
new file mode 100644
index 00000000000..e16ff49b7e6
--- /dev/null
+++ b/src/app/admin/admin-ldn-services/ldn-service-form/ldn-service-form.component.spec.ts
@@ -0,0 +1,241 @@
+import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
+
+import {NgbDropdownModule, NgbModal} from '@ng-bootstrap/ng-bootstrap';
+import {LdnServiceFormComponent} from './ldn-service-form.component';
+import {ChangeDetectorRef, EventEmitter} from '@angular/core';
+import { FormArray, FormBuilder, FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
+import {ActivatedRoute, Router} from '@angular/router';
+import {TranslateModule, TranslateService} from '@ngx-translate/core';
+import {PaginationService} from 'ngx-pagination';
+import {NotificationsService} from '../../../shared/notifications/notifications.service';
+import {LdnItemfiltersService} from '../ldn-services-data/ldn-itemfilters-data.service';
+import {LdnServicesService} from '../ldn-services-data/ldn-services-data.service';
+import {RouterStub} from '../../../shared/testing/router.stub';
+import {MockActivatedRoute} from '../../../shared/mocks/active-router.mock';
+import {NotificationsServiceStub} from '../../../shared/testing/notifications-service.stub';
+import { of as observableOf, of } from 'rxjs';
+import {RouteService} from '../../../core/services/route.service';
+import {provideMockStore} from '@ngrx/store/testing';
+import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
+import { By } from '@angular/platform-browser';
+
+describe('LdnServiceFormEditComponent', () => {
+ let component: LdnServiceFormComponent;
+ let fixture: ComponentFixture;
+
+ let ldnServicesService: LdnServicesService;
+ let ldnItemfiltersService: any;
+ let cdRefStub: any;
+ let modalService: any;
+ let activatedRoute: MockActivatedRoute;
+
+ const testId = '1234';
+ const routeParams = {
+ serviceId: testId,
+ };
+ const routeUrlSegments = [{path: 'path'}];
+ const formMockValue = {
+ 'id': '',
+ 'name': 'name',
+ 'description': 'description',
+ 'url': 'www.test.com',
+ 'ldnUrl': 'https://test.com',
+ 'lowerIp': '127.0.0.1',
+ 'upperIp': '100.100.100.100',
+ 'score': 1,
+ 'inboundPattern': '',
+ 'constraintPattern': '',
+ 'enabled': '',
+ 'type': 'ldnservice',
+ 'notifyServiceInboundPatterns': [
+ {
+ 'pattern': '',
+ 'patternLabel': 'Select a pattern',
+ 'constraint': '',
+ 'automatic': false
+ }
+ ]
+ };
+
+
+ const translateServiceStub = {
+ get: () => of('translated-text'),
+ instant: () => 'translated-text',
+ onLangChange: new EventEmitter(),
+ onTranslationChange: new EventEmitter(),
+ onDefaultLangChange: new EventEmitter()
+ };
+
+ beforeEach(async () => {
+ ldnServicesService = jasmine.createSpyObj('ldnServicesService', {
+ create: observableOf(null),
+ update: observableOf(null),
+ findById: createSuccessfulRemoteDataObject$({}),
+ });
+
+ ldnItemfiltersService = {
+ findAll: () => of(['item1', 'item2']),
+ };
+ cdRefStub = Object.assign({
+ detectChanges: () => fixture.detectChanges()
+ });
+ modalService = {
+ open: () => {/*comment*/
+ }
+ };
+
+
+ activatedRoute = new MockActivatedRoute(routeParams, routeUrlSegments);
+
+ await TestBed.configureTestingModule({
+ imports: [ReactiveFormsModule, TranslateModule.forRoot(), NgbDropdownModule],
+ declarations: [LdnServiceFormComponent],
+ providers: [
+ {provide: LdnServicesService, useValue: ldnServicesService},
+ {provide: LdnItemfiltersService, useValue: ldnItemfiltersService},
+ {provide: Router, useValue: new RouterStub()},
+ {provide: ActivatedRoute, useValue: activatedRoute},
+ {provide: ChangeDetectorRef, useValue: cdRefStub},
+ {provide: NgbModal, useValue: modalService},
+ {provide: NotificationsService, useValue: new NotificationsServiceStub()},
+ {provide: TranslateService, useValue: translateServiceStub},
+ {provide: PaginationService, useValue: {}},
+ FormBuilder,
+ RouteService,
+ provideMockStore({}),
+ ]
+ })
+ .compileComponents();
+
+ fixture = TestBed.createComponent(LdnServiceFormComponent);
+ component = fixture.componentInstance;
+ spyOn(component, 'filterPatternObjectsAndAssignLabel').and.callFake((a) => a);
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ expect(component.formModel instanceof FormGroup).toBeTruthy();
+ });
+
+ it('should init properties correctly', fakeAsync(() => {
+ spyOn(component, 'fetchServiceData');
+ spyOn(component, 'setItemfilters');
+ component.ngOnInit();
+ tick(100);
+ expect((component as any).serviceId).toEqual(testId);
+ expect(component.isNewService).toBeFalsy();
+ expect(component.areControlsInitialized).toBeTruthy();
+ expect(component.formModel.controls.notifyServiceInboundPatterns).toBeDefined();
+ expect(component.fetchServiceData).toHaveBeenCalledWith(testId);
+ expect(component.setItemfilters).toHaveBeenCalled();
+ }));
+
+ it('should unsubscribe on destroy', () => {
+ spyOn((component as any).routeSubscription, 'unsubscribe');
+ component.ngOnDestroy();
+ expect((component as any).routeSubscription.unsubscribe).toHaveBeenCalled();
+ });
+
+ it('should handle create service with valid form', () => {
+ spyOn(component, 'fetchServiceData').and.callFake((a) => a);
+ component.formModel.addControl('notifyServiceInboundPatterns', (component as any).formBuilder.array([{pattern: 'patternValue'}]));
+ const nameInput = fixture.debugElement.query(By.css('#name'));
+ const descriptionInput = fixture.debugElement.query(By.css('#description'));
+ const urlInput = fixture.debugElement.query(By.css('#url'));
+ const scoreInput = fixture.debugElement.query(By.css('#score'));
+ const lowerIpInput = fixture.debugElement.query(By.css('#lowerIp'));
+ const upperIpInput = fixture.debugElement.query(By.css('#upperIp'));
+ const ldnUrlInput = fixture.debugElement.query(By.css('#ldnUrl'));
+ component.formModel.patchValue(formMockValue);
+
+ nameInput.nativeElement.value = 'testName';
+ descriptionInput.nativeElement.value = 'testDescription';
+ urlInput.nativeElement.value = 'tetsUrl.com';
+ ldnUrlInput.nativeElement.value = 'tetsLdnUrl.com';
+ scoreInput.nativeElement.value = 1;
+ lowerIpInput.nativeElement.value = '127.0.0.1';
+ upperIpInput.nativeElement.value = '127.0.0.1';
+
+ fixture.detectChanges();
+
+ expect(component.formModel.valid).toBeTruthy();
+ });
+
+ it('should handle create service with invalid form', () => {
+ const nameInput = fixture.debugElement.query(By.css('#name'));
+
+ nameInput.nativeElement.value = 'testName';
+ fixture.detectChanges();
+
+ expect(component.formModel.valid).toBeFalsy();
+ });
+
+ it('should not create service with invalid form', () => {
+ spyOn(component.formModel, 'markAllAsTouched');
+ spyOn(component, 'closeModal');
+ component.createService();
+
+ expect(component.formModel.markAllAsTouched).toHaveBeenCalled();
+ expect(component.closeModal).toHaveBeenCalled();
+ });
+
+ it('should create service with valid form', () => {
+ spyOn(component.formModel, 'markAllAsTouched');
+ spyOn(component, 'closeModal');
+ spyOn(component, 'checkPatterns').and.callFake(() => true);
+ component.formModel.addControl('notifyServiceInboundPatterns', (component as any).formBuilder.array([{pattern: 'patternValue'}]));
+ component.formModel.patchValue(formMockValue);
+ component.createService();
+
+ expect(component.formModel.markAllAsTouched).toHaveBeenCalled();
+ expect(component.closeModal).not.toHaveBeenCalled();
+ expect(ldnServicesService.create).toHaveBeenCalled();
+ });
+
+ it('should check patterns', () => {
+ const arrValid = new FormArray([
+ new FormGroup({
+ pattern: new FormControl('pattern')
+ }),
+ ]);
+
+ const arrInvalid = new FormArray([
+ new FormGroup({
+ pattern: new FormControl('')
+ }),
+ ]);
+
+ expect(component.checkPatterns(arrValid)).toBeTruthy();
+ expect(component.checkPatterns(arrInvalid)).toBeFalsy();
+ });
+
+ it('should fetch service data', () => {
+ component.fetchServiceData(testId);
+ expect(ldnServicesService.findById).toHaveBeenCalledWith(testId);
+ expect(component.filterPatternObjectsAndAssignLabel).toHaveBeenCalled();
+ expect((component as any).ldnService).toEqual({});
+ });
+
+ it('should generate patch operations', () => {
+ spyOn(component as any, 'createReplaceOperation');
+ spyOn(component as any, 'handlePatterns');
+ component.generatePatchOperations();
+ expect((component as any).createReplaceOperation).toHaveBeenCalledTimes(7);
+ expect((component as any).handlePatterns).toHaveBeenCalled();
+ });
+
+ it('should open modal on submit', () => {
+ spyOn(component, 'openConfirmModal');
+ component.onSubmit();
+ expect(component.openConfirmModal).toHaveBeenCalled();
+ });
+
+
+ it('should reset form and leave', () => {
+ spyOn(component as any, 'sendBack');
+
+ component.resetFormAndLeave();
+ expect((component as any).sendBack).toHaveBeenCalled();
+ });
+});
diff --git a/src/app/admin/admin-ldn-services/ldn-service-form/ldn-service-form.component.ts b/src/app/admin/admin-ldn-services/ldn-service-form/ldn-service-form.component.ts
new file mode 100644
index 00000000000..93f7911057c
--- /dev/null
+++ b/src/app/admin/admin-ldn-services/ldn-service-form/ldn-service-form.component.ts
@@ -0,0 +1,578 @@
+import {
+ ChangeDetectorRef,
+ Component,
+ OnDestroy,
+ OnInit,
+ TemplateRef,
+ ViewChild
+} from '@angular/core';
+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';
+import {notifyPatterns} from '../ldn-services-patterns/ldn-service-coar-patterns';
+import {animate, state, style, transition, trigger} from '@angular/animations';
+import {NgbModal} from '@ng-bootstrap/ng-bootstrap';
+import {NotificationsService} from '../../../shared/notifications/notifications.service';
+import {TranslateService} from '@ngx-translate/core';
+import {LdnService} from '../ldn-services-model/ldn-services.model';
+import {RemoteData} from 'src/app/core/data/remote-data';
+import {Operation} from 'fast-json-patch';
+import {getFirstCompletedRemoteData} from '../../../core/shared/operators';
+import {LdnItemfiltersService} from '../ldn-services-data/ldn-itemfilters-data.service';
+import {Itemfilter} from '../ldn-services-model/ldn-service-itemfilters';
+import {PaginatedList} from '../../../core/data/paginated-list.model';
+import {combineLatestWith, Observable, Subscription} from 'rxjs';
+import {PaginationService} from '../../../core/pagination/pagination.service';
+import {FindListOptions} from '../../../core/data/find-list-options.model';
+import {NotifyServicePattern} from '../ldn-services-model/ldn-service-patterns.model';
+import { IpV4Validator } from '../../../shared/utils/ipV4.validator';
+
+
+/**
+ * Component for editing LDN service through a form that allows to create or edit the properties of a service
+ */
+@Component({
+ selector: 'ds-ldn-service-form',
+ templateUrl: './ldn-service-form.component.html',
+ styleUrls: ['./ldn-service-form.component.scss'],
+ animations: [
+ trigger('toggleAnimation', [
+ state('true', style({})),
+ state('false', style({})),
+ transition('true <=> false', animate('300ms ease-in')),
+ ]),
+ ],
+})
+export class LdnServiceFormComponent implements OnInit, OnDestroy {
+ formModel: FormGroup;
+
+ @ViewChild('confirmModal', {static: true}) confirmModal: TemplateRef;
+ @ViewChild('resetFormModal', {static: true}) resetFormModal: TemplateRef;
+
+ public inboundPatterns: string[] = notifyPatterns;
+ public isNewService: boolean;
+ public areControlsInitialized: boolean;
+ public itemfiltersRD$: Observable>>;
+ public config: FindListOptions = Object.assign(new FindListOptions(), {
+ elementsPerPage: 20
+ });
+ public markedForDeletionInboundPattern: number[] = [];
+ public selectedInboundPatterns: string[];
+
+ protected serviceId: string;
+
+ private deletedInboundPatterns: number[] = [];
+ private modalRef: any;
+ private ldnService: LdnService;
+ private selectPatternDefaultLabeli18Key = 'ldn-service.form.label.placeholder.default-select';
+ private routeSubscription: Subscription;
+
+ constructor(
+ protected ldnServicesService: LdnServicesService,
+ private ldnItemfiltersService: LdnItemfiltersService,
+ private formBuilder: FormBuilder,
+ private router: Router,
+ private route: ActivatedRoute,
+ private cdRef: ChangeDetectorRef,
+ protected modalService: NgbModal,
+ private notificationService: NotificationsService,
+ private translateService: TranslateService,
+ protected paginationService: PaginationService
+ ) {
+
+ this.formModel = this.formBuilder.group({
+ id: [''],
+ name: ['', Validators.required],
+ description: [''],
+ url: ['', Validators.required],
+ ldnUrl: ['', Validators.required],
+ lowerIp: ['', [Validators.required, new IpV4Validator()]],
+ upperIp: ['', [Validators.required, new IpV4Validator()]],
+ score: ['', [Validators.required, Validators.pattern('^0*(\.[0-9]+)?$|^1(\.0+)?$')]], inboundPattern: [''],
+ constraintPattern: [''],
+ enabled: [''],
+ type: LDN_SERVICE.value,
+ });
+ }
+
+ ngOnInit(): void {
+ this.routeSubscription = this.route.params.pipe(
+ combineLatestWith(this.route.url)
+ ).subscribe(([params, segment]) => {
+ this.serviceId = params.serviceId;
+ this.isNewService = segment[0].path === 'new';
+ this.formModel.addControl('notifyServiceInboundPatterns', this.formBuilder.array([this.createInboundPatternFormGroup()]));
+ this.areControlsInitialized = true;
+ if (this.serviceId && !this.isNewService) {
+ this.fetchServiceData(this.serviceId);
+ }
+ });
+ this.setItemfilters();
+ }
+
+ ngOnDestroy(): void {
+ this.routeSubscription.unsubscribe();
+ }
+
+ /**
+ * Sets item filters using LDN item filters service
+ */
+ setItemfilters() {
+ this.itemfiltersRD$ = this.ldnItemfiltersService.findAll().pipe(
+ getFirstCompletedRemoteData());
+ }
+
+ /**
+ * Handles the creation of an LDN service by retrieving and validating form fields,
+ * and submitting the form data to the LDN services endpoint.
+ */
+ createService() {
+ this.formModel.markAllAsTouched();
+ const notifyServiceInboundPatterns = this.formModel.get('notifyServiceInboundPatterns') as FormArray;
+ const hasInboundPattern = notifyServiceInboundPatterns?.length > 0 ? this.checkPatterns(notifyServiceInboundPatterns) : false;
+
+ if (this.formModel.invalid) {
+ this.closeModal();
+ return;
+ }
+
+ if (!hasInboundPattern) {
+ this.notificationService.warning(this.translateService.get('ldn-service-notification.created.warning.title'));
+ this.closeModal();
+ return;
+ }
+
+
+ this.formModel.value.notifyServiceInboundPatterns = this.formModel.value.notifyServiceInboundPatterns.map((pattern: {
+ pattern: string;
+ patternLabel: string,
+ constraintFormatted: string;
+ }) => {
+ const {patternLabel, ...rest} = pattern;
+ delete rest.constraintFormatted;
+ return rest;
+ });
+
+ const values = {...this.formModel.value, enabled: true};
+
+ const ldnServiceData = this.ldnServicesService.create(values);
+
+ ldnServiceData.pipe(
+ getFirstCompletedRemoteData()
+ ).subscribe((rd: RemoteData) => {
+ if (rd.hasSucceeded) {
+ this.notificationService.success(this.translateService.get('ldn-service-notification.created.success.title'),
+ this.translateService.get('ldn-service-notification.created.success.body'));
+ 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();
+ }
+ });
+ }
+
+ /**
+ * Checks if at least one pattern in the specified form array has a value.
+ *
+ * @param {FormArray} formArray - The form array containing patterns to check.
+ * @returns {boolean} - True if at least one pattern has a value, otherwise false.
+ */
+ checkPatterns(formArray: FormArray): boolean {
+ for (let i = 0; i < formArray.length; i++) {
+ const pattern = formArray.at(i).get('pattern').value;
+ if (pattern) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Fetches LDN service data by ID and updates the form
+ * @param serviceId - The ID of the LDN service
+ */
+ fetchServiceData(serviceId: string): void {
+ this.ldnServicesService.findById(serviceId).pipe(
+ getFirstCompletedRemoteData()
+ ).subscribe(
+ (data: RemoteData) => {
+ if (data.hasSucceeded) {
+ this.ldnService = data.payload;
+ this.formModel.patchValue({
+ id: this.ldnService.id,
+ name: this.ldnService.name,
+ description: this.ldnService.description,
+ url: this.ldnService.url,
+ score: this.ldnService.score,
+ ldnUrl: this.ldnService.ldnUrl,
+ type: this.ldnService.type,
+ enabled: this.ldnService.enabled,
+ lowerIp: this.ldnService.lowerIp,
+ upperIp: this.ldnService.upperIp
+ });
+ this.filterPatternObjectsAndAssignLabel('notifyServiceInboundPatterns');
+ let notifyServiceInboundPatternsFormArray = this.formModel.get('notifyServiceInboundPatterns') as FormArray;
+ notifyServiceInboundPatternsFormArray.controls.forEach(
+ control => {
+ const controlFormGroup = control as FormGroup;
+ const controlConstraint = controlFormGroup.get('constraint').value;
+ controlFormGroup.patchValue({
+ constraintFormatted: controlConstraint ? this.translateService.instant((controlConstraint as string) + '.label') : ''
+ });
+ }
+ );
+ }
+ },
+ );
+ }
+
+ /**
+ * Filters pattern objects, initializes form groups, assigns labels, and adds them to the specified form array so the correct string is shown in the dropdown..
+ * @param formArrayName - The name of the form array to be populated
+ */
+ filterPatternObjectsAndAssignLabel(formArrayName: string) {
+ const PatternsArray = this.formModel.get(formArrayName) as FormArray;
+ PatternsArray.clear();
+
+ let servicesToUse = this.ldnService.notifyServiceInboundPatterns;
+
+ servicesToUse.forEach((patternObj: NotifyServicePattern) => {
+ let patternFormGroup;
+ patternFormGroup = this.initializeInboundPatternFormGroup();
+ const newPatternObjWithLabel = Object.assign(new NotifyServicePattern(), {
+ ...patternObj,
+ patternLabel: this.translateService.instant('ldn-service.form.pattern.' + patternObj?.pattern + '.label')
+ });
+ patternFormGroup.patchValue(newPatternObjWithLabel);
+
+ PatternsArray.push(patternFormGroup);
+ this.cdRef.detectChanges();
+ });
+ }
+
+ /**
+ * Generates an array of patch operations based on form changes
+ * @returns Array of patch operations
+ */
+ generatePatchOperations(): any[] {
+ const patchOperations: any[] = [];
+
+ this.createReplaceOperation(patchOperations, 'name', '/name');
+ this.createReplaceOperation(patchOperations, 'description', '/description');
+ this.createReplaceOperation(patchOperations, 'ldnUrl', '/ldnurl');
+ this.createReplaceOperation(patchOperations, 'url', '/url');
+ this.createReplaceOperation(patchOperations, 'score', '/score');
+ this.createReplaceOperation(patchOperations, 'lowerIp', '/lowerIp');
+ this.createReplaceOperation(patchOperations, 'upperIp', '/upperIp');
+
+ this.handlePatterns(patchOperations, 'notifyServiceInboundPatterns');
+ this.deletedInboundPatterns.forEach(index => {
+ const removeOperation: Operation = {
+ op: 'remove',
+ path: `notifyServiceInboundPatterns[${index}]`
+ };
+ patchOperations.push(removeOperation);
+ });
+
+ return patchOperations;
+ }
+
+ /**
+ * Submits the form by opening the confirmation modal
+ */
+ onSubmit() {
+ this.openConfirmModal(this.confirmModal);
+ }
+
+ /**
+ * Adds a new inbound pattern form group to the array of inbound patterns in the form
+ */
+ addInboundPattern() {
+ const notifyServiceInboundPatternsArray = this.formModel.get('notifyServiceInboundPatterns') as FormArray;
+ notifyServiceInboundPatternsArray.push(this.createInboundPatternFormGroup());
+ }
+
+ /**
+ * Selects an inbound pattern by updating its values based on the provided pattern value and index
+ * @param patternValue - The selected pattern value
+ * @param index - The index of the inbound pattern in the array
+ */
+ selectInboundPattern(patternValue: string, index: number): void {
+ const patternArray = (this.formModel.get('notifyServiceInboundPatterns') as FormArray);
+ patternArray.controls[index].patchValue({pattern: patternValue});
+ patternArray.controls[index].patchValue({patternLabel: this.translateService.instant('ldn-service.form.pattern.' + patternValue + '.label')});
+ }
+
+ /**
+ * Selects an inbound item filter by updating its value based on the provided filter value and index
+ * @param filterValue - The selected filter value
+ * @param index - The index of the inbound pattern in the array
+ */
+ selectInboundItemFilter(filterValue: string, index: number): void {
+ const filterArray = (this.formModel.get('notifyServiceInboundPatterns') as FormArray);
+ filterArray.controls[index].patchValue({
+ constraint: filterValue,
+ constraintFormatted: this.translateService.instant((filterValue !== '' ? filterValue : 'ldn.no-filter') + '.label')
+ });
+ filterArray.markAllAsTouched();
+ }
+
+ /**
+ * Toggles the automatic property of an inbound pattern at the specified index
+ * @param i - The index of the inbound pattern in the array
+ */
+ toggleAutomatic(i: number) {
+ const automaticControl = this.formModel.get(`notifyServiceInboundPatterns.${i}.automatic`);
+ if (automaticControl) {
+ automaticControl.markAsTouched();
+ automaticControl.setValue(!automaticControl.value);
+ }
+ }
+
+ /**
+ * Toggles the enabled status of the LDN service by sending a patch request
+ */
+ toggleEnabled() {
+ const newStatus = !this.formModel.get('enabled').value;
+
+ const patchOperation: Operation = {
+ op: 'replace',
+ path: '/enabled',
+ value: newStatus,
+ };
+
+ this.ldnServicesService.patch(this.ldnService, [patchOperation]).pipe(
+ getFirstCompletedRemoteData()
+ ).subscribe(
+ () => {
+ this.formModel.get('enabled').setValue(newStatus);
+ this.cdRef.detectChanges();
+ }
+ );
+ }
+
+ /**
+ * Closes the modal
+ */
+ closeModal() {
+ this.modalRef.close();
+ this.cdRef.detectChanges();
+ }
+
+ /**
+ * Opens a confirmation modal with the specified content
+ * @param content - The content to be displayed in the modal
+ */
+ openConfirmModal(content) {
+ this.modalRef = this.modalService.open(content);
+ }
+
+ /**
+ * Patches the LDN service by retrieving and sending patch operations geenrated in generatePatchOperations()
+ */
+ patchService() {
+ this.deleteMarkedInboundPatterns();
+
+ const patchOperations = this.generatePatchOperations();
+ this.formModel.markAllAsTouched();
+ // If the form is invalid, close the modal and return
+ if (this.formModel.invalid) {
+ this.closeModal();
+ return;
+ }
+
+ const notifyServiceInboundPatterns = this.formModel.get('notifyServiceInboundPatterns') as FormArray;
+ const deletedInboundPatternsLength = this.deletedInboundPatterns.length;
+ // If no inbound patterns are specified, close the modal and return
+ // notify the user that no patterns are specified
+ if (notifyServiceInboundPatterns.length === deletedInboundPatternsLength) {
+ this.notificationService.warning(this.translateService.get('ldn-service-notification.created.warning.title'));
+ this.deletedInboundPatterns = [];
+ this.closeModal();
+ return;
+ }
+
+ this.ldnServicesService.patch(this.ldnService, patchOperations).pipe(
+ getFirstCompletedRemoteData()
+ ).subscribe(
+ (rd: RemoteData) => {
+ if (rd.hasSucceeded) {
+ this.closeModal();
+ this.sendBack();
+ 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();
+ }
+ });
+ }
+
+ /**
+ * Resets the form and navigates back to the LDN services page
+ */
+ resetFormAndLeave() {
+ this.sendBack();
+ }
+
+ /**
+ * Marks the specified inbound pattern for deletion
+ * @param index - The index of the inbound pattern in the array
+ */
+ markForInboundPatternDeletion(index: number) {
+ if (!this.markedForDeletionInboundPattern.includes(index)) {
+ this.markedForDeletionInboundPattern.push(index);
+ }
+ }
+
+ /**
+ * Unmarks the specified inbound pattern for deletion
+ * @param index - The index of the inbound pattern in the array
+ */
+ unmarkForInboundPatternDeletion(index: number) {
+ const i = this.markedForDeletionInboundPattern.indexOf(index);
+ if (i !== -1) {
+ this.markedForDeletionInboundPattern.splice(i, 1);
+ }
+ }
+
+ /**
+ * Deletes marked inbound patterns from the form model
+ */
+ deleteMarkedInboundPatterns() {
+ this.markedForDeletionInboundPattern.sort((a, b) => b - a);
+ const patternsArray = this.formModel.get('notifyServiceInboundPatterns') as FormArray;
+
+ for (const index of this.markedForDeletionInboundPattern) {
+ if (index >= 0 && index < patternsArray.length) {
+ const patternGroup = patternsArray.at(index) as FormGroup;
+ const patternValue = patternGroup.value;
+ if (patternValue.isNew) {
+ patternsArray.removeAt(index);
+ } else {
+ this.deletedInboundPatterns.push(index);
+ }
+ }
+ }
+
+ this.markedForDeletionInboundPattern = [];
+ }
+
+ /**
+ * Creates a replace operation and adds it to the patch operations if the form control is dirty
+ * @param patchOperations - The array to store patch operations
+ * @param formControlName - The name of the form control
+ * @param path - The JSON Patch path for the operation
+ */
+ private createReplaceOperation(patchOperations: any[], formControlName: string, path: string): void {
+ if (this.formModel.get(formControlName).dirty) {
+ patchOperations.push({
+ op: 'replace',
+ path,
+ value: this.formModel.get(formControlName).value.toString(),
+ });
+ }
+ }
+
+ /**
+ * Handles patterns in the form array, checking if an add or replace operations is required
+ * @param patchOperations - The array to store patch operations
+ * @param formArrayName - The name of the form array
+ */
+ private handlePatterns(patchOperations: any[], formArrayName: string): void {
+ const patternsArray = this.formModel.get(formArrayName) as FormArray;
+
+ for (let i = 0; i < patternsArray.length; i++) {
+ const patternGroup = patternsArray.at(i) as FormGroup;
+
+ const patternValue = patternGroup.value;
+ delete patternValue.constraintFormatted;
+ if (patternGroup.touched && patternGroup.valid) {
+ delete patternValue?.patternLabel;
+ if (patternValue.isNew) {
+ delete patternValue.isNew;
+ const addOperation = {
+ op: 'add',
+ path: `${formArrayName}/-`,
+ value: patternValue,
+ };
+ patchOperations.push(addOperation);
+ } else {
+ const replaceOperation = {
+ op: 'replace',
+ path: `${formArrayName}[${i}]`,
+ value: patternValue,
+ };
+ patchOperations.push(replaceOperation);
+ }
+ }
+ }
+ }
+
+ /**
+ * Navigates back to the LDN services page
+ */
+ private sendBack() {
+ this.router.navigateByUrl('admin/ldn/services');
+ }
+
+ /**
+ * Creates a form group for inbound patterns
+ * @returns The form group for inbound patterns
+ */
+ private createInboundPatternFormGroup(): FormGroup {
+ const inBoundFormGroup = {
+ pattern: '',
+ patternLabel: this.translateService.instant(this.selectPatternDefaultLabeli18Key),
+ constraint: '',
+ constraintFormatted: '',
+ automatic: false,
+ isNew: true
+ };
+
+ if (this.isNewService) {
+ delete inBoundFormGroup.isNew;
+ }
+
+ return this.formBuilder.group(inBoundFormGroup);
+ }
+
+ /**
+ * Initializes an existing form group for inbound patterns
+ * @returns The initialized form group for inbound patterns
+ */
+ private initializeInboundPatternFormGroup(): FormGroup {
+ return this.formBuilder.group({
+ pattern: '',
+ patternLabel: '',
+ constraint: '',
+ constraintFormatted: '',
+ automatic: '',
+ });
+ }
+
+
+ /**
+ * set ldnUrl error in case of unprocessable entity and provided value
+ */
+ private setLdnUrlError(): void {
+ const control = this.formModel.controls.ldnUrl;
+ const controlErrors = control.errors || {};
+ control.setErrors({...controlErrors, ldnUrlAlreadyAssociated: true });
+ }
+}
diff --git a/src/app/admin/admin-ldn-services/ldn-service-serviceMock/ldnServicesRD$-mock.ts b/src/app/admin/admin-ldn-services/ldn-service-serviceMock/ldnServicesRD$-mock.ts
new file mode 100644
index 00000000000..d8534dde037
--- /dev/null
+++ b/src/app/admin/admin-ldn-services/ldn-service-serviceMock/ldnServicesRD$-mock.ts
@@ -0,0 +1,111 @@
+import {LdnService} from '../ldn-services-model/ldn-services.model';
+import {LDN_SERVICE} from '../ldn-services-model/ldn-service.resource-type';
+import {RemoteData} from '../../../core/data/remote-data';
+import {PaginatedList} from '../../../core/data/paginated-list.model';
+import {Observable, of} from 'rxjs';
+import {createSuccessfulRemoteDataObject$} from '../../../shared/remote-data.utils';
+
+export const mockLdnService: LdnService = {
+ uuid: '1',
+ enabled: false,
+ score: 0,
+ id: 1,
+ lowerIp: '192.0.2.146',
+ upperIp: '192.0.2.255',
+ name: 'Service Name',
+ description: 'Service Description',
+ url: 'Service URL',
+ ldnUrl: 'Service LDN URL',
+ notifyServiceInboundPatterns: [
+ {
+ pattern: 'patternA',
+ constraint: 'itemFilterA',
+ automatic: 'false',
+ },
+ {
+ pattern: 'patternB',
+ constraint: 'itemFilterB',
+ automatic: 'true',
+ },
+ ],
+ type: LDN_SERVICE,
+ _links: {
+ self: {
+ href: 'http://localhost/api/ldn/ldnservices/1'
+ },
+ },
+ get self(): string {
+ return '';
+ },
+};
+
+export const mockLdnServiceRD$ = createSuccessfulRemoteDataObject$(mockLdnService);
+
+
+export const mockLdnServices: LdnService[] = [{
+ uuid: '1',
+ enabled: false,
+ score: 0,
+ id: 1,
+ lowerIp: '192.0.2.146',
+ upperIp: '192.0.2.255',
+ name: 'Service Name',
+ description: 'Service Description',
+ url: 'Service URL',
+ ldnUrl: 'Service LDN URL',
+ notifyServiceInboundPatterns: [
+ {
+ pattern: 'patternA',
+ constraint: 'itemFilterA',
+ automatic: 'false',
+ },
+ {
+ pattern: 'patternB',
+ constraint: 'itemFilterB',
+ automatic: 'true',
+ },
+ ],
+ type: LDN_SERVICE,
+ _links: {
+ self: {
+ href: 'http://localhost/api/ldn/ldnservices/1'
+ },
+ },
+ get self(): string {
+ return '';
+ },
+}, {
+ uuid: '2',
+ enabled: false,
+ score: 0,
+ id: 2,
+ lowerIp: '192.0.2.146',
+ upperIp: '192.0.2.255',
+ name: 'Service Name',
+ description: 'Service Description',
+ url: 'Service URL',
+ ldnUrl: 'Service LDN URL',
+ notifyServiceInboundPatterns: [
+ {
+ pattern: 'patternA',
+ constraint: 'itemFilterA',
+ automatic: 'false',
+ },
+ {
+ pattern: 'patternB',
+ constraint: 'itemFilterB',
+ automatic: 'true',
+ },
+ ],
+ type: LDN_SERVICE,
+ _links: {
+ self: {
+ href: 'http://localhost/api/ldn/ldnservices/1'
+ },
+ },
+ get self(): string {
+ return '';
+ },
+}
+];
+export const mockLdnServicesRD$: Observable>> = of((mockLdnServices as unknown) as RemoteData>);
diff --git a/src/app/admin/admin-ldn-services/ldn-services-data/ldn-itemfilter-data.service.spec.ts b/src/app/admin/admin-ldn-services/ldn-services-data/ldn-itemfilter-data.service.spec.ts
new file mode 100644
index 00000000000..b5b08817271
--- /dev/null
+++ b/src/app/admin/admin-ldn-services/ldn-services-data/ldn-itemfilter-data.service.spec.ts
@@ -0,0 +1,89 @@
+import { TestScheduler } from 'rxjs/testing';
+import { LdnItemfiltersService } from './ldn-itemfilters-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 { RequestEntry } from '../../../core/data/request-entry.model';
+import { RemoteData } from '../../../core/data/remote-data';
+import { RequestEntryState } from '../../../core/data/request-entry-state.model';
+import { cold, getTestScheduler } from 'jasmine-marbles';
+import { RestResponse } from '../../../core/cache/response.models';
+import { of } from 'rxjs';
+import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
+import { FindAllData } from '../../../core/data/base/find-all-data';
+import { testFindAllDataImplementation } from '../../../core/data/base/find-all-data.spec';
+
+describe('LdnItemfiltersService test', () => {
+ let scheduler: TestScheduler;
+ let service: LdnItemfiltersService;
+ let requestService: RequestService;
+ let rdbService: RemoteDataBuildService;
+ let objectCache: ObjectCacheService;
+ let halService: HALEndpointService;
+ let notificationsService: NotificationsService;
+ let responseCacheEntry: RequestEntry;
+
+ const endpointURL = `https://rest.api/rest/api/ldn/itemfilters`;
+ const requestUUID = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a';
+
+ const remoteDataMocks = {
+ Success: new RemoteData(null, null, null, RequestEntryState.Success, null, null, 200),
+ };
+
+ function initTestService() {
+ return new LdnItemfiltersService(
+ requestService,
+ rdbService,
+ objectCache,
+ halService,
+ notificationsService,
+ );
+ }
+
+ beforeEach(() => {
+ scheduler = getTestScheduler();
+
+ objectCache = {} as ObjectCacheService;
+ notificationsService = {} as NotificationsService;
+ 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 })
+ });
+
+
+ service = initTestService();
+ });
+
+ describe('composition', () => {
+ const initFindAllService = () => new LdnItemfiltersService(null, null, null, null, null) as unknown as FindAllData;
+ testFindAllDataImplementation(initFindAllService);
+ });
+
+ describe('get endpoint', () => {
+ it('should retrieve correct endpoint', (done) => {
+ service.getEndpoint().subscribe(() => {
+ expect(halService.getEndpoint).toHaveBeenCalledWith('itemfilters');
+ done();
+ });
+ });
+ });
+
+});
diff --git a/src/app/admin/admin-ldn-services/ldn-services-data/ldn-itemfilters-data.service.ts b/src/app/admin/admin-ldn-services/ldn-services-data/ldn-itemfilters-data.service.ts
new file mode 100644
index 00000000000..15a7bcccdaa
--- /dev/null
+++ b/src/app/admin/admin-ldn-services/ldn-services-data/ldn-itemfilters-data.service.ts
@@ -0,0 +1,61 @@
+import {Injectable} from '@angular/core';
+import {dataService} from '../../../core/data/base/data-service.decorator';
+import {LDN_SERVICE_CONSTRAINT_FILTERS} from '../ldn-services-model/ldn-service.resource-type';
+import {IdentifiableDataService} from '../../../core/data/base/identifiable-data.service';
+import {FindAllData, FindAllDataImpl} from '../../../core/data/base/find-all-data';
+
+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 {FindListOptions} from '../../../core/data/find-list-options.model';
+import {FollowLinkConfig} from '../../../shared/utils/follow-link-config.model';
+import {Observable} from 'rxjs';
+import {RemoteData} from '../../../core/data/remote-data';
+import {Itemfilter} from '../ldn-services-model/ldn-service-itemfilters';
+import {PaginatedList} from '../../../core/data/paginated-list.model';
+
+
+/**
+ * A service responsible for fetching/sending data from/to the REST API on the itemfilters endpoint
+ */
+@Injectable()
+@dataService(LDN_SERVICE_CONSTRAINT_FILTERS)
+export class LdnItemfiltersService extends IdentifiableDataService implements FindAllData {
+ private findAllData: FindAllDataImpl;
+
+ constructor(
+ protected requestService: RequestService,
+ protected rdbService: RemoteDataBuildService,
+ protected objectCache: ObjectCacheService,
+ protected halService: HALEndpointService,
+ protected notificationsService: NotificationsService,
+ ) {
+ super('itemfilters', requestService, rdbService, objectCache, halService);
+
+ this.findAllData = new FindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
+ }
+
+ /**
+ * Gets the endpoint URL for the itemfilters.
+ *
+ * @returns {string} - The endpoint URL.
+ */
+ getEndpoint() {
+ return this.halService.getEndpoint(this.linkPath);
+ }
+
+ /**
+ * Finds all itemfilters based on the provided options and link configurations.
+ *
+ * @param {FindListOptions} options - The options for finding a list of itemfilters.
+ * @param {boolean} useCachedVersionIfAvailable - Whether to use the cached version if available.
+ * @param {boolean} reRequestOnStale - Whether to re-request the data if it's stale.
+ * @param {...FollowLinkConfig[]} linksToFollow - Configurations for following specific links.
+ * @returns {Observable>>} - An observable of remote data containing a paginated list of itemfilters.
+ */
+ findAll(options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> {
+ return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
+ }
+}
diff --git a/src/app/admin/admin-ldn-services/ldn-services-data/ldn-services-data.service.spec.ts b/src/app/admin/admin-ldn-services/ldn-services-data/ldn-services-data.service.spec.ts
new file mode 100644
index 00000000000..c661a034e4d
--- /dev/null
+++ b/src/app/admin/admin-ldn-services/ldn-services-data/ldn-services-data.service.spec.ts
@@ -0,0 +1,131 @@
+import { TestScheduler } from 'rxjs/testing';
+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 { RequestEntry } from '../../../core/data/request-entry.model';
+import { RemoteData } from '../../../core/data/remote-data';
+import { RequestEntryState } from '../../../core/data/request-entry-state.model';
+import { cold, getTestScheduler } from 'jasmine-marbles';
+import { RestResponse } from '../../../core/cache/response.models';
+import { of as observableOf } from 'rxjs';
+import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
+import { FindAllData } from '../../../core/data/base/find-all-data';
+import { testFindAllDataImplementation } from '../../../core/data/base/find-all-data.spec';
+import { LdnServicesService } from './ldn-services-data.service';
+import { testDeleteDataImplementation } from '../../../core/data/base/delete-data.spec';
+import { DeleteData } from '../../../core/data/base/delete-data';
+import { testSearchDataImplementation } from '../../../core/data/base/search-data.spec';
+import { SearchData } from '../../../core/data/base/search-data';
+import { testPatchDataImplementation } from '../../../core/data/base/patch-data.spec';
+import { PatchData } from '../../../core/data/base/patch-data';
+import { CreateData } from '../../../core/data/base/create-data';
+import { testCreateDataImplementation } from '../../../core/data/base/create-data.spec';
+import { FindListOptions } from '../../../core/data/find-list-options.model';
+import { RequestParam } from '../../../core/cache/models/request-param.model';
+import { mockLdnService } from '../ldn-service-serviceMock/ldnServicesRD$-mock';
+import { createPaginatedList } from '../../../shared/testing/utils.test';
+
+
+describe('LdnServicesService test', () => {
+ let scheduler: TestScheduler;
+ let service: LdnServicesService;
+ let requestService: RequestService;
+ let rdbService: RemoteDataBuildService;
+ let objectCache: ObjectCacheService;
+ let halService: HALEndpointService;
+ let notificationsService: NotificationsService;
+ let responseCacheEntry: RequestEntry;
+
+ const endpointURL = `https://rest.api/rest/api/ldn/ldnservices`;
+ const requestUUID = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a';
+
+ const remoteDataMocks = {
+ Success: new RemoteData(null, null, null, RequestEntryState.Success, null, null, 200),
+ };
+
+ function initTestService() {
+ return new LdnServicesService(
+ requestService,
+ rdbService,
+ objectCache,
+ halService,
+ notificationsService,
+ );
+ }
+
+ beforeEach(() => {
+ scheduler = getTestScheduler();
+
+ objectCache = {} as ObjectCacheService;
+ notificationsService = {} as NotificationsService;
+ responseCacheEntry = new RequestEntry();
+ responseCacheEntry.request = { href: 'https://rest.api/' } as any;
+ responseCacheEntry.response = new RestResponse(true, 200, 'Success');
+
+ requestService = jasmine.createSpyObj('requestService', {
+ generateRequestId: requestUUID,
+ send: true,
+ removeByHrefSubstring: {},
+ getByHref: observableOf(responseCacheEntry),
+ getByUUID: observableOf(responseCacheEntry),
+ });
+
+ halService = jasmine.createSpyObj('halService', {
+ getEndpoint: observableOf(endpointURL)
+ });
+
+ rdbService = jasmine.createSpyObj('rdbService', {
+ buildSingle: createSuccessfulRemoteDataObject$({}, 500),
+ buildFromRequestUUID: createSuccessfulRemoteDataObject$({}, 500),
+ buildList: cold('a', { a: remoteDataMocks.Success })
+ });
+
+
+ service = initTestService();
+ });
+
+ describe('composition', () => {
+ const initFindAllService = () => new LdnServicesService(null, null, null, null, null) as unknown as FindAllData;
+ const initDeleteService = () => new LdnServicesService(null, null, null, null, null) as unknown as DeleteData;
+ const initSearchService = () => new LdnServicesService(null, null, null, null, null) as unknown as SearchData;
+ const initPatchService = () => new LdnServicesService(null, null, null, null, null) as unknown as PatchData;
+ const initCreateService = () => new LdnServicesService(null, null, null, null, null) as unknown as CreateData;
+
+ testFindAllDataImplementation(initFindAllService);
+ testDeleteDataImplementation(initDeleteService);
+ testSearchDataImplementation(initSearchService);
+ testPatchDataImplementation(initPatchService);
+ testCreateDataImplementation(initCreateService);
+ });
+
+ describe('custom methods', () => {
+ it('should find service by inbound pattern', (done) => {
+ const params = [new RequestParam('pattern', 'testPattern')];
+ const findListOptions = Object.assign(new FindListOptions(), {}, {searchParams: params});
+ spyOn(service, 'searchBy').and.returnValue(observableOf(null));
+ spyOn((service as any).searchData, 'searchBy').and.returnValue(createSuccessfulRemoteDataObject$(createPaginatedList([mockLdnService])));
+
+ service.findByInboundPattern('testPattern').subscribe(() => {
+ expect(service.searchBy).toHaveBeenCalledWith('byInboundPattern', findListOptions, undefined, undefined );
+ done();
+ });
+ });
+
+ it('should invoke service', (done) => {
+ const constraints = [{void: true}];
+ const files = [new File([],'fileName')];
+ spyOn(service as any, 'getInvocationFormData');
+ spyOn(service, 'getBrowseEndpoint').and.returnValue(observableOf('testEndpoint'));
+ service.invoke('serviceName', 'serviceId', constraints, files).subscribe(result => {
+ expect((service as any).getInvocationFormData).toHaveBeenCalledWith(constraints, files);
+ expect(service.getBrowseEndpoint).toHaveBeenCalled();
+ expect(result).toBeInstanceOf(RemoteData);
+ done();
+ });
+
+ });
+ });
+
+});
diff --git a/src/app/admin/admin-ldn-services/ldn-services-data/ldn-services-data.service.ts b/src/app/admin/admin-ldn-services/ldn-services-data/ldn-services-data.service.ts
new file mode 100644
index 00000000000..d1541e6bd81
--- /dev/null
+++ b/src/app/admin/admin-ldn-services/ldn-services-data/ldn-services-data.service.ts
@@ -0,0 +1,217 @@
+import {Injectable} from '@angular/core';
+import {dataService} from '../../../core/data/base/data-service.decorator';
+import {LDN_SERVICE} from '../ldn-services-model/ldn-service.resource-type';
+import {IdentifiableDataService} from '../../../core/data/base/identifiable-data.service';
+import {FindAllData, FindAllDataImpl} from '../../../core/data/base/find-all-data';
+import {DeleteData, DeleteDataImpl} from '../../../core/data/base/delete-data';
+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 {FindListOptions} from '../../../core/data/find-list-options.model';
+import {FollowLinkConfig} from '../../../shared/utils/follow-link-config.model';
+import {Observable} from 'rxjs';
+import {RemoteData} from '../../../core/data/remote-data';
+import {PaginatedList} from '../../../core/data/paginated-list.model';
+import {NoContent} from '../../../core/shared/NoContent.model';
+import {map, take} from 'rxjs/operators';
+import {URLCombiner} from '../../../core/url-combiner/url-combiner';
+import {MultipartPostRequest} from '../../../core/data/request.models';
+import {RestRequest} from '../../../core/data/rest-request.model';
+
+
+import {LdnService} from '../ldn-services-model/ldn-services.model';
+
+import {PatchData, PatchDataImpl} from '../../../core/data/base/patch-data';
+import {ChangeAnalyzer} from '../../../core/data/change-analyzer';
+import {Operation} from 'fast-json-patch';
+import {RestRequestMethod} from '../../../core/data/rest-request-method';
+import {CreateData, CreateDataImpl} from '../../../core/data/base/create-data';
+import {LdnServiceConstrain} from '../ldn-services-model/ldn-service.constrain.model';
+import {SearchDataImpl} from '../../../core/data/base/search-data';
+import {RequestParam} from '../../../core/cache/models/request-param.model';
+
+/**
+ * Injectable service responsible for fetching/sending data from/to the REST API on the ldnservices endpoint.
+ *
+ * @export
+ * @class LdnServicesService
+ * @extends {IdentifiableDataService}
+ * @implements {FindAllData}
+ * @implements {DeleteData}
+ * @implements {PatchData}
+ * @implements {CreateData}
+ */
+@Injectable()
+@dataService(LDN_SERVICE)
+export class LdnServicesService extends IdentifiableDataService implements FindAllData, DeleteData, PatchData, CreateData {
+ createData: CreateDataImpl;
+ private findAllData: FindAllDataImpl;
+ private deleteData: DeleteDataImpl;
+ private patchData: PatchDataImpl;
+ private comparator: ChangeAnalyzer;
+ private searchData: SearchDataImpl;
+
+ private findByPatternEndpoint = 'byInboundPattern';
+
+ constructor(
+ protected requestService: RequestService,
+ protected rdbService: RemoteDataBuildService,
+ protected objectCache: ObjectCacheService,
+ protected halService: HALEndpointService,
+ protected notificationsService: NotificationsService,
+ ) {
+ super('ldnservices', requestService, rdbService, objectCache, halService);
+
+ this.findAllData = new FindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
+ this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
+ this.deleteData = new DeleteDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive, this.constructIdEndpoint);
+ this.patchData = new PatchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.comparator, this.responseMsToLive, this.constructIdEndpoint);
+ this.createData = new CreateDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive);
+ }
+
+ /**
+ * Creates an LDN service by sending a POST request to the REST API.
+ *
+ * @param {LdnService} object - The LDN service object to be created.
+ * @param params Array with additional params to combine with query string
+ * @returns {Observable>} - Observable containing the result of the creation operation.
+ */
+ create(object: LdnService, ...params: RequestParam[]): Observable> {
+ return this.createData.create(object, ...params);
+ }
+
+ /**
+ * Updates an LDN service by applying a set of operations through a PATCH request to the REST API.
+ *
+ * @param {LdnService} object - The LDN service object to be updated.
+ * @param {Operation[]} operations - The patch operations to be applied.
+ * @returns {Observable>} - Observable containing the result of the update operation.
+ */
+ patch(object: LdnService, operations: Operation[]): Observable> {
+ return this.patchData.patch(object, operations);
+ }
+
+ /**
+ * Updates an LDN service by sending a PUT request to the REST API.
+ *
+ * @param {LdnService} object - The LDN service object to be updated.
+ * @returns {Observable>} - Observable containing the result of the update operation.
+ */
+ update(object: LdnService): Observable> {
+ return this.patchData.update(object);
+ }
+
+ /**
+ * Commits pending updates by sending a PATCH request to the REST API.
+ *
+ * @param {RestRequestMethod} [method] - The HTTP method to be used for the request.
+ */
+ commitUpdates(method?: RestRequestMethod): void {
+ return this.patchData.commitUpdates(method);
+ }
+
+ /**
+ * Creates a patch representing the changes made to the LDN service in the cache.
+ *
+ * @param {LdnService} object - The LDN service object for which to create the patch.
+ * @returns {Observable} - Observable containing the patch operations.
+ */
+ createPatchFromCache(object: LdnService): Observable {
+ return this.patchData.createPatchFromCache(object);
+ }
+
+ /**
+ * Retrieves all LDN services from the REST API based on the provided options.
+ *
+ * @param {FindListOptions} [options] - The options to be applied to the request.
+ * @param {boolean} [useCachedVersionIfAvailable] - Flag indicating whether to use cached data if available.
+ * @param {boolean} [reRequestOnStale] - Flag indicating whether to re-request data if it's stale.
+ * @param {...FollowLinkConfig[]} linksToFollow - Optional links to follow during the request.
+ * @returns {Observable>>} - Observable containing the result of the request.
+ */
+ findAll(options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> {
+ return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
+ }
+
+ /**
+ * Retrieves LDN services based on the inbound pattern from the REST API.
+ *
+ * @param {string} pattern - The inbound pattern to be used in the search.
+ * @param {FindListOptions} [options] - The options to be applied to the request.
+ * @param {boolean} [useCachedVersionIfAvailable] - Flag indicating whether to use cached data if available.
+ * @param {boolean} [reRequestOnStale] - Flag indicating whether to re-request data if it's stale.
+ * @param {...FollowLinkConfig[]} linksToFollow - Optional links to follow during the request.
+ * @returns {Observable>>} - Observable containing the result of the request.
+ */
+ findByInboundPattern(pattern: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> {
+ const params = [new RequestParam('pattern', pattern)];
+ const findListOptions = Object.assign(new FindListOptions(), options, {searchParams: params});
+ return this.searchBy(this.findByPatternEndpoint, findListOptions, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
+ }
+
+ /**
+ * Deletes an LDN service by sending a DELETE request to the REST API.
+ *
+ * @param {string} objectId - The ID of the LDN service to be deleted.
+ * @param {string[]} [copyVirtualMetadata] - Optional virtual metadata to be copied during the deletion.
+ * @returns {Observable>} - Observable containing the result of the deletion operation.
+ */
+ public delete(objectId: string, copyVirtualMetadata?: string[]): Observable> {
+ return this.deleteData.delete(objectId, copyVirtualMetadata);
+ }
+
+ /**
+ * Deletes an LDN service by its HATEOAS link.
+ *
+ * @param {string} href - The HATEOAS link of the LDN service to be deleted.
+ * @param {string[]} [copyVirtualMetadata] - Optional virtual metadata to be copied during the deletion.
+ * @returns {Observable>} - Observable containing the result of the deletion operation.
+ */
+ public deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable> {
+ return this.deleteData.deleteByHref(href, copyVirtualMetadata);
+ }
+
+
+ /**
+ * Make a new FindListRequest with given search method
+ *
+ * @param searchMethod The search method for the object
+ * @param options The [[FindListOptions]] object
+ * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
+ * no valid cached version. Defaults to true
+ * @param reRequestOnStale Whether or not the request should automatically be re-
+ * requested after the response becomes stale
+ * @param linksToFollow List of {@link FollowLinkConfig} that indicate which
+ * {@link HALLink}s should be automatically resolved
+ * @return {Observable>}
+ * Return an observable that emits response from the server
+ */
+ public searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> {
+ return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
+ }
+
+ public invoke(serviceName: string, serviceId: string, parameters: LdnServiceConstrain[], files: File[]): Observable> {
+ const requestId = this.requestService.generateRequestId();
+ this.getBrowseEndpoint().pipe(
+ take(1),
+ map((endpoint: string) => new URLCombiner(endpoint, serviceName, 'processes', serviceId).toString()),
+ map((endpoint: string) => {
+ const body = this.getInvocationFormData(parameters, files);
+ return new MultipartPostRequest(requestId, endpoint, body);
+ })
+ ).subscribe((request: RestRequest) => this.requestService.send(request));
+
+ return this.rdbService.buildFromRequestUUID(requestId);
+ }
+
+ private getInvocationFormData(constrain: LdnServiceConstrain[], files: File[]): FormData {
+ const form: FormData = new FormData();
+ form.set('properties', JSON.stringify(constrain));
+ files.forEach((file: File) => {
+ form.append('file', file);
+ });
+ return form;
+ }
+}
diff --git a/src/app/admin/admin-ldn-services/ldn-services-directory/ldn-services-directory.component.html b/src/app/admin/admin-ldn-services/ldn-services-directory/ldn-services-directory.component.html
new file mode 100644
index 00000000000..0aaa39bda21
--- /dev/null
+++ b/src/app/admin/admin-ldn-services/ldn-services-directory/ldn-services-directory.component.html
@@ -0,0 +1,99 @@
+
+
+
{{ 'ldn-registered-services.title' | translate }}
+
+
+ {{ 'process.overview.new' | translate }}
+
+
0"
+ [collectionSize]="(ldnServicesRD$ | async)?.payload?.totalElements"
+ [hideGear]="true"
+ [hidePagerWhenSinglePage]="true"
+ [pageInfoState]="(ldnServicesRD$ | async)?.payload"
+ [paginationOptions]="pageConfig">
+
+
+
+
+ {{ 'service.overview.table.name' | translate }}
+ {{ 'service.overview.table.description' | translate }}
+ {{ 'service.overview.table.status' | translate }}
+ {{ 'service.overview.table.actions' | translate }}
+
+
+
+
+ {{ ldnService.name }}
+
+
+
+
+ {{ ldnService.description }}
+
+
+
+
+
+
+ {{ ldnService.enabled ? ('ldn-service.overview.table.enabled' | translate) : ('ldn-service.overview.table.disabled' | translate) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ 'service.overview.delete.body' | translate }}
+
+
+ {{ 'service.detail.delete.cancel' | translate }}
+ {{ 'service.overview.delete' | translate }}
+
+
+
+
+
+
diff --git a/src/app/admin/admin-ldn-services/ldn-services-directory/ldn-services-directory.component.scss b/src/app/admin/admin-ldn-services/ldn-services-directory/ldn-services-directory.component.scss
new file mode 100644
index 00000000000..07377d63d5a
--- /dev/null
+++ b/src/app/admin/admin-ldn-services/ldn-services-directory/ldn-services-directory.component.scss
@@ -0,0 +1,29 @@
+.status-indicator {
+ padding: 2.5px 25px 2.5px 25px;
+ border-radius: 4px;
+ cursor: pointer;
+ transition: background-color 0.5s;
+}
+
+.status-enabled {
+ background-color: #daf7a6;
+ color: #4f5359;
+ font-size: 85%;
+ font-weight: bold;
+
+}
+
+.status-enabled:hover {
+ background-color: #faa0a0;
+}
+
+.status-disabled {
+ background-color: #faa0a0;
+ color: #4f5359;
+ font-size: 85%;
+ font-weight: bold;
+}
+
+.status-disabled:hover {
+ background-color: #daf7a6;
+}
diff --git a/src/app/admin/admin-ldn-services/ldn-services-directory/ldn-services-directory.component.spec.ts b/src/app/admin/admin-ldn-services/ldn-services-directory/ldn-services-directory.component.spec.ts
new file mode 100644
index 00000000000..ddb7a9fbb99
--- /dev/null
+++ b/src/app/admin/admin-ldn-services/ldn-services-directory/ldn-services-directory.component.spec.ts
@@ -0,0 +1,163 @@
+import {ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing';
+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';
+import {NgbModal} from '@ng-bootstrap/ng-bootstrap';
+import {LdnServicesService} from '../ldn-services-data/ldn-services-data.service';
+import {PaginationService} from '../../../core/pagination/pagination.service';
+import {PaginationServiceStub} from '../../../shared/testing/pagination-service.stub';
+import {of} from 'rxjs';
+import {LdnService} from '../ldn-services-model/ldn-services.model';
+import {PaginatedList} from '../../../core/data/paginated-list.model';
+import {RemoteData} from '../../../core/data/remote-data';
+import {LdnServicesOverviewComponent} from './ldn-services-directory.component';
+import {createSuccessfulRemoteDataObject$} from '../../../shared/remote-data.utils';
+import {createPaginatedList} from '../../../shared/testing/utils.test';
+
+describe('LdnServicesOverviewComponent', () => {
+ let component: LdnServicesOverviewComponent;
+ let fixture: ComponentFixture;
+ let ldnServicesService;
+ let paginationService;
+ let modalService: NgbModal;
+
+ const translateServiceStub = {
+ get: () => of('translated-text'),
+ onLangChange: new EventEmitter(),
+ onTranslationChange: new EventEmitter(),
+ onDefaultLangChange: new EventEmitter()
+ };
+
+ beforeEach(async () => {
+ paginationService = new PaginationServiceStub();
+ ldnServicesService = jasmine.createSpyObj('ldnServicesService', {
+ 'findAll': createSuccessfulRemoteDataObject$({}),
+ 'delete': createSuccessfulRemoteDataObject$({}),
+ 'patch': createSuccessfulRemoteDataObject$({}),
+ });
+ await TestBed.configureTestingModule({
+ imports: [TranslateModule.forRoot()],
+ declarations: [LdnServicesOverviewComponent],
+ providers: [
+ {
+ provide: LdnServicesService,
+ useValue: ldnServicesService
+ },
+ {provide: PaginationService, useValue: paginationService},
+ {
+ provide: NgbModal, useValue: {
+ open: () => { /*comment*/
+ }
+ }
+ },
+ {provide: ChangeDetectorRef, useValue: {}},
+ {provide: NotificationsService, useValue: new NotificationsServiceStub()},
+ {provide: TranslateService, useValue: translateServiceStub},
+ ],
+ schemas: [NO_ERRORS_SCHEMA]
+ }).compileComponents();
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(LdnServicesOverviewComponent);
+ component = fixture.componentInstance;
+ ldnServicesService = TestBed.inject(LdnServicesService);
+ paginationService = TestBed.inject(PaginationService);
+ modalService = TestBed.inject(NgbModal);
+ component.modalRef = jasmine.createSpyObj({close: null});
+ component.isProcessingSub = jasmine.createSpyObj({unsubscribe: null});
+ component.ldnServicesRD$ = of({} as RemoteData>);
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe('ngOnInit', () => {
+ it('should call setLdnServices', fakeAsync(() => {
+ spyOn(component, 'setLdnServices').and.callThrough();
+ component.ngOnInit();
+ tick();
+ expect(component.setLdnServices).toHaveBeenCalled();
+ }));
+
+ it('should set ldnServicesRD$ with mock data', fakeAsync(() => {
+ spyOn(component, 'setLdnServices').and.callThrough();
+ const testData: LdnService[] = Object.assign([new LdnService()], [
+ {id: 1, name: 'Service 1', description: 'Description 1', enabled: true},
+ {id: 2, name: 'Service 2', description: 'Description 2', enabled: false},
+ {id: 3, name: 'Service 3', description: 'Description 3', enabled: true}]);
+
+ const mockLdnServicesRD = createPaginatedList(testData);
+ component.ldnServicesRD$ = createSuccessfulRemoteDataObject$(mockLdnServicesRD);
+ fixture.detectChanges();
+
+ const tableRows = fixture.debugElement.nativeElement.querySelectorAll('tbody tr');
+ expect(tableRows.length).toBe(testData.length);
+ const firstRowContent = tableRows[0].textContent;
+ expect(firstRowContent).toContain('Service 1');
+ expect(firstRowContent).toContain('Description 1');
+ }));
+ });
+
+ describe('ngOnDestroy', () => {
+ it('should call paginationService.clearPagination and unsubscribe', () => {
+ // spyOn(paginationService, 'clearPagination');
+ // spyOn(component.isProcessingSub, 'unsubscribe');
+ component.ngOnDestroy();
+ expect(paginationService.clearPagination).toHaveBeenCalledWith(component.pageConfig.id);
+ expect(component.isProcessingSub.unsubscribe).toHaveBeenCalled();
+ });
+ });
+
+ describe('openDeleteModal', () => {
+ it('should open delete modal', () => {
+ spyOn(modalService, 'open');
+ component.openDeleteModal(component.deleteModal);
+ expect(modalService.open).toHaveBeenCalledWith(component.deleteModal);
+ });
+ });
+
+ describe('closeModal', () => {
+ it('should close modal and detect changes', () => {
+ // spyOn(component.modalRef, 'close');
+ spyOn(component.cdRef, 'detectChanges');
+ component.closeModal();
+ expect(component.modalRef.close).toHaveBeenCalled();
+ expect(component.cdRef.detectChanges).toHaveBeenCalled();
+ });
+ });
+
+ describe('deleteSelected', () => {
+ it('should delete selected service and update data', fakeAsync(() => {
+ const serviceId = '123';
+ const mockRemoteData = { /* just an empty object to retrieve as as RemoteData> */};
+ spyOn(component, 'setLdnServices').and.callThrough();
+ const deleteSpy = ldnServicesService.delete.and.returnValue(of(mockRemoteData as RemoteData>));
+ component.selectedServiceId = serviceId;
+ component.deleteSelected(serviceId, ldnServicesService);
+ tick();
+ expect(deleteSpy).toHaveBeenCalledWith(serviceId);
+ }));
+ });
+
+ describe('selectServiceToDelete', () => {
+ it('should set service to delete', fakeAsync(() => {
+ spyOn(component, 'openDeleteModal');
+ const serviceId = 123;
+ component.selectServiceToDelete(serviceId);
+ expect(component.selectedServiceId).toEqual(serviceId);
+ expect(component.openDeleteModal).toHaveBeenCalled();
+ }));
+ });
+
+ describe('toggleStatus', () => {
+ it('should toggle status', (() => {
+ component.toggleStatus({enabled: false}, ldnServicesService);
+ expect(ldnServicesService.patch).toHaveBeenCalled();
+ }));
+ });
+
+});
diff --git a/src/app/admin/admin-ldn-services/ldn-services-directory/ldn-services-directory.component.ts b/src/app/admin/admin-ldn-services/ldn-services-directory/ldn-services-directory.component.ts
new file mode 100644
index 00000000000..b36d102cb0a
--- /dev/null
+++ b/src/app/admin/admin-ldn-services/ldn-services-directory/ldn-services-directory.component.ts
@@ -0,0 +1,176 @@
+import {
+ ChangeDetectionStrategy,
+ ChangeDetectorRef,
+ Component,
+ OnDestroy,
+ OnInit,
+ TemplateRef,
+ ViewChild
+} from '@angular/core';
+import {Observable, Subscription} from 'rxjs';
+import {RemoteData} from '../../../core/data/remote-data';
+import {PaginatedList} from '../../../core/data/paginated-list.model';
+import {FindListOptions} from '../../../core/data/find-list-options.model';
+import {LdnService} from '../ldn-services-model/ldn-services.model';
+import {PaginationComponentOptions} from '../../../shared/pagination/pagination-component-options.model';
+import {map, switchMap} from 'rxjs/operators';
+import {LdnServicesService} from 'src/app/admin/admin-ldn-services/ldn-services-data/ldn-services-data.service';
+import {PaginationService} from 'src/app/core/pagination/pagination.service';
+import {NgbModal} from '@ng-bootstrap/ng-bootstrap';
+import {hasValue} from '../../../shared/empty.util';
+import {Operation} from 'fast-json-patch';
+import {getFirstCompletedRemoteData} from '../../../core/shared/operators';
+import {NotificationsService} from '../../../shared/notifications/notifications.service';
+import {TranslateService} from '@ngx-translate/core';
+
+
+/**
+ * The `LdnServicesOverviewComponent` is a component that provides an overview of LDN (Linked Data Notifications) services.
+ * It displays a paginated list of LDN services, allows users to edit and delete services,
+ * toggle the status of each service directly form the page and allows for creation of new services redirecting the user on the creation/edit form
+ */
+@Component({
+ selector: 'ds-ldn-services-directory',
+ templateUrl: './ldn-services-directory.component.html',
+ styleUrls: ['./ldn-services-directory.component.scss'],
+ changeDetection: ChangeDetectionStrategy.Default
+})
+export class LdnServicesOverviewComponent implements OnInit, OnDestroy {
+
+ selectedServiceId: string | number | null = null;
+ servicesData: any[] = [];
+ @ViewChild('deleteModal', {static: true}) deleteModal: TemplateRef;
+ ldnServicesRD$: Observable>>;
+ config: FindListOptions = Object.assign(new FindListOptions(), {
+ elementsPerPage: 10
+ });
+ pageConfig: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), {
+ id: 'po',
+ pageSize: 10
+ });
+ isProcessingSub: Subscription;
+ modalRef: any;
+
+
+ constructor(
+ protected ldnServicesService: LdnServicesService,
+ protected paginationService: PaginationService,
+ protected modalService: NgbModal,
+ public cdRef: ChangeDetectorRef,
+ private notificationService: NotificationsService,
+ private translateService: TranslateService,
+ ) {
+ }
+
+ ngOnInit(): void {
+ this.setLdnServices();
+ }
+
+ /**
+ * Sets up the LDN services by fetching and observing the paginated list of services.
+ */
+ setLdnServices() {
+ this.ldnServicesRD$ = this.paginationService.getFindListOptions(this.pageConfig.id, this.config).pipe(
+ switchMap((config) => this.ldnServicesService.findAll(config, false, false).pipe(
+ getFirstCompletedRemoteData()
+ ))
+ );
+ }
+
+ ngOnDestroy(): void {
+ this.paginationService.clearPagination(this.pageConfig.id);
+ if (hasValue(this.isProcessingSub)) {
+ this.isProcessingSub.unsubscribe();
+ }
+ }
+
+ /**
+ * Opens the delete confirmation modal.
+ *
+ * @param {any} content - The content of the modal.
+ */
+ openDeleteModal(content) {
+ this.modalRef = this.modalService.open(content);
+ }
+
+ /**
+ * Closes the currently open modal and triggers change detection.
+ */
+ closeModal() {
+ this.modalRef.close();
+ this.cdRef.detectChanges();
+ }
+
+ /**
+ * Sets the selected LDN service ID for deletion and opens the delete confirmation modal.
+ *
+ * @param {number} serviceId - The ID of the service to be deleted.
+ */
+ selectServiceToDelete(serviceId: number) {
+ this.selectedServiceId = serviceId;
+ this.openDeleteModal(this.deleteModal);
+ }
+
+ /**
+ * Deletes the selected LDN service.
+ *
+ * @param {string} serviceId - The ID of the service to be deleted.
+ * @param {LdnServicesService} ldnServicesService - The service for managing LDN services.
+ */
+ deleteSelected(serviceId: string, ldnServicesService: LdnServicesService): void {
+ if (this.selectedServiceId !== null) {
+ ldnServicesService.delete(serviceId).pipe(getFirstCompletedRemoteData()).subscribe((rd: RemoteData) => {
+ if (rd.hasSucceeded) {
+ this.servicesData = this.servicesData.filter(service => service.id !== serviceId);
+ this.ldnServicesRD$ = this.ldnServicesRD$.pipe(
+ map((remoteData: RemoteData>) => {
+ if (remoteData.hasSucceeded) {
+ remoteData.payload.page = remoteData.payload.page.filter(service => service.id.toString() !== serviceId);
+ }
+ return remoteData;
+ })
+ );
+ this.cdRef.detectChanges();
+ this.closeModal();
+ this.notificationService.success(this.translateService.get('ldn-service-delete.notification.success.title'),
+ this.translateService.get('ldn-service-delete.notification.success.content'));
+ } else {
+ this.notificationService.error(this.translateService.get('ldn-service-delete.notification.error.title'),
+ this.translateService.get('ldn-service-delete.notification.error.content'));
+ this.cdRef.detectChanges();
+ }
+ });
+ }
+ }
+
+ /**
+ * Toggles the status (enabled/disabled) of an LDN service.
+ *
+ * @param {any} ldnService - The LDN service object.
+ * @param {LdnServicesService} ldnServicesService - The service for managing LDN services.
+ */
+ toggleStatus(ldnService: any, ldnServicesService: LdnServicesService): void {
+ const newStatus = !ldnService.enabled;
+ const originalStatus = ldnService.enabled;
+
+ const patchOperation: Operation = {
+ op: 'replace',
+ path: '/enabled',
+ value: newStatus,
+ };
+
+ ldnServicesService.patch(ldnService, [patchOperation]).pipe(getFirstCompletedRemoteData()).subscribe(
+ (rd: RemoteData) => {
+ if (rd.hasSucceeded) {
+ ldnService.enabled = newStatus;
+ this.notificationService.success(this.translateService.get('ldn-enable-service.notification.success.title'),
+ this.translateService.get('ldn-enable-service.notification.success.content'));
+ } else {
+ ldnService.enabled = originalStatus;
+ this.notificationService.error(this.translateService.get('ldn-enable-service.notification.error.title'),
+ this.translateService.get('ldn-enable-service.notification.error.content'));
+ }
+ }
+ );
+ }
+}
diff --git a/src/app/admin/admin-ldn-services/ldn-services-model/ldn-service-itemfilters.ts b/src/app/admin/admin-ldn-services/ldn-services-model/ldn-service-itemfilters.ts
new file mode 100644
index 00000000000..55b7ad8b982
--- /dev/null
+++ b/src/app/admin/admin-ldn-services/ldn-services-model/ldn-service-itemfilters.ts
@@ -0,0 +1,31 @@
+import {autoserialize, deserialize, inheritSerialization} from 'cerialize';
+import {LDN_SERVICE_CONSTRAINT_FILTER} from './ldn-service.resource-type';
+import {CacheableObject} from '../../../core/cache/cacheable-object.model';
+import {typedObject} from '../../../core/cache/builders/build-decorators';
+import {excludeFromEquals} from '../../../core/utilities/equals.decorators';
+import {ResourceType} from '../../../core/shared/resource-type';
+
+/** A single filter value and its properties. */
+@typedObject
+@inheritSerialization(CacheableObject)
+export class Itemfilter extends CacheableObject {
+ static type = LDN_SERVICE_CONSTRAINT_FILTER;
+
+ @excludeFromEquals
+ @autoserialize
+ type: ResourceType;
+
+ @autoserialize
+ id: string;
+
+ @deserialize
+ _links: {
+ self: {
+ href: string;
+ };
+ };
+
+ get self(): string {
+ return this._links.self.href;
+ }
+}
diff --git a/src/app/admin/admin-ldn-services/ldn-services-model/ldn-service-patterns.model.ts b/src/app/admin/admin-ldn-services/ldn-services-model/ldn-service-patterns.model.ts
new file mode 100644
index 00000000000..295426ba878
--- /dev/null
+++ b/src/app/admin/admin-ldn-services/ldn-services-model/ldn-service-patterns.model.ts
@@ -0,0 +1,13 @@
+import {autoserialize} from 'cerialize';
+
+/**
+ * A single notify service pattern and his properties
+ */
+export class NotifyServicePattern {
+ @autoserialize
+ pattern: string;
+ @autoserialize
+ constraint: string;
+ @autoserialize
+ automatic: string;
+}
diff --git a/src/app/admin/admin-ldn-services/ldn-services-model/ldn-service-status.model.ts b/src/app/admin/admin-ldn-services/ldn-services-model/ldn-service-status.model.ts
new file mode 100644
index 00000000000..040e4d37b8a
--- /dev/null
+++ b/src/app/admin/admin-ldn-services/ldn-services-model/ldn-service-status.model.ts
@@ -0,0 +1,8 @@
+/**
+ * List of services statuses
+ */
+export enum LdnServiceStatus {
+ UNKOWN,
+ DISABLED,
+ ENABLED,
+}
diff --git a/src/app/admin/admin-ldn-services/ldn-services-model/ldn-service.constrain.model.ts b/src/app/admin/admin-ldn-services/ldn-services-model/ldn-service.constrain.model.ts
new file mode 100644
index 00000000000..5121e47f69d
--- /dev/null
+++ b/src/app/admin/admin-ldn-services/ldn-services-model/ldn-service.constrain.model.ts
@@ -0,0 +1,3 @@
+export class LdnServiceConstrain {
+ void: any;
+}
diff --git a/src/app/admin/admin-ldn-services/ldn-services-model/ldn-service.resource-type.ts b/src/app/admin/admin-ldn-services/ldn-services-model/ldn-service.resource-type.ts
new file mode 100644
index 00000000000..4fb510c032e
--- /dev/null
+++ b/src/app/admin/admin-ldn-services/ldn-services-model/ldn-service.resource-type.ts
@@ -0,0 +1,12 @@
+/**
+ * The resource type for Ldn-Services
+ *
+ * Needs to be in a separate file to prevent circular
+ * dependencies in webpack.
+ */
+import {ResourceType} from '../../../core/shared/resource-type';
+
+export const LDN_SERVICE = new ResourceType('ldnservice');
+export const LDN_SERVICE_CONSTRAINT_FILTERS = new ResourceType('itemfilters');
+
+export const LDN_SERVICE_CONSTRAINT_FILTER = new ResourceType('itemfilter');
diff --git a/src/app/admin/admin-ldn-services/ldn-services-model/ldn-services.model.ts b/src/app/admin/admin-ldn-services/ldn-services-model/ldn-services.model.ts
new file mode 100644
index 00000000000..9e803fbc013
--- /dev/null
+++ b/src/app/admin/admin-ldn-services/ldn-services-model/ldn-services.model.ts
@@ -0,0 +1,72 @@
+import {ResourceType} from '../../../core/shared/resource-type';
+import {CacheableObject} from '../../../core/cache/cacheable-object.model';
+import {autoserialize, deserialize, deserializeAs, inheritSerialization} from 'cerialize';
+import {LDN_SERVICE} from './ldn-service.resource-type';
+import {excludeFromEquals} from '../../../core/utilities/equals.decorators';
+import {typedObject} from '../../../core/cache/builders/build-decorators';
+import {NotifyServicePattern} from './ldn-service-patterns.model';
+
+
+/**
+ * LDN Services bounded to each selected pattern, relation set in service creation
+ */
+
+export interface LdnServiceByPattern {
+ allowsMultipleRequests: boolean;
+ services: LdnService[];
+}
+
+/** An LdnService and its properties. */
+@typedObject
+@inheritSerialization(CacheableObject)
+export class LdnService extends CacheableObject {
+ static type = LDN_SERVICE;
+
+ @excludeFromEquals
+ @autoserialize
+ type: ResourceType;
+
+ @autoserialize
+ id: number;
+
+ @deserializeAs('id')
+ uuid: string;
+
+ @autoserialize
+ name: string;
+
+ @autoserialize
+ description: string;
+
+ @autoserialize
+ url: string;
+
+ @autoserialize
+ score: number;
+
+ @autoserialize
+ enabled: boolean;
+
+ @autoserialize
+ ldnUrl: string;
+
+ @autoserialize
+ lowerIp: string;
+
+ @autoserialize
+ upperIp: string;
+
+ @autoserialize
+ notifyServiceInboundPatterns?: NotifyServicePattern[];
+
+ @deserialize
+ _links: {
+ self: {
+ href: string;
+ };
+ };
+
+ get self(): string {
+ return this._links.self.href;
+ }
+}
diff --git a/src/app/admin/admin-ldn-services/ldn-services-model/service-constrain-type.model.ts b/src/app/admin/admin-ldn-services/ldn-services-model/service-constrain-type.model.ts
new file mode 100644
index 00000000000..c734503d951
--- /dev/null
+++ b/src/app/admin/admin-ldn-services/ldn-services-model/service-constrain-type.model.ts
@@ -0,0 +1,10 @@
+/**
+ * List of parameter types used for scripts
+ */
+export enum LdnServiceConstrainType {
+ STRING = 'String',
+ DATE = 'date',
+ BOOLEAN = 'boolean',
+ FILE = 'InputStream',
+ OUTPUT = 'OutputStream'
+}
diff --git a/src/app/admin/admin-ldn-services/ldn-services-patterns/ldn-service-coar-patterns.ts b/src/app/admin/admin-ldn-services/ldn-services-patterns/ldn-service-coar-patterns.ts
new file mode 100644
index 00000000000..faa7dc82d7f
--- /dev/null
+++ b/src/app/admin/admin-ldn-services/ldn-services-patterns/ldn-service-coar-patterns.ts
@@ -0,0 +1,16 @@
+/**
+ * All available patterns for LDN service creation.
+ * They are used to populate a dropdown in the LDN service form creation
+ */
+
+export const notifyPatterns = [
+
+ 'request-endorsement',
+
+ 'request-ingest',
+
+ 'request-review',
+
+];
+
+
diff --git a/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page-resolver.service.ts b/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page-resolver.service.ts
new file mode 100644
index 00000000000..f524cd56c20
--- /dev/null
+++ b/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page-resolver.service.ts
@@ -0,0 +1,32 @@
+import { Injectable } from '@angular/core';
+import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router';
+
+/**
+ * Interface for the route parameters.
+ */
+export interface NotificationsSuggestionTargetsPageParams {
+ pageId?: string;
+ pageSize?: number;
+ currentPage?: number;
+}
+
+/**
+ * This class represents a resolver that retrieve the route data before the route is activated.
+ */
+@Injectable()
+export class NotificationsSuggestionTargetsPageResolver implements Resolve {
+
+ /**
+ * Method for resolving the parameters in the current route.
+ * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot
+ * @param {RouterStateSnapshot} state The current RouterStateSnapshot
+ * @returns AdminNotificationsSuggestionTargetsPageParams Emits the route parameters
+ */
+ resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): NotificationsSuggestionTargetsPageParams {
+ return {
+ pageId: route.queryParams.pageId,
+ pageSize: parseInt(route.queryParams.pageSize, 10),
+ currentPage: parseInt(route.queryParams.page, 10)
+ };
+ }
+}
diff --git a/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.html b/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.html
new file mode 100644
index 00000000000..b04e7132f17
--- /dev/null
+++ b/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.html
@@ -0,0 +1 @@
+
diff --git a/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.spec.ts b/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.spec.ts
new file mode 100644
index 00000000000..cec8ddc00b8
--- /dev/null
+++ b/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.spec.ts
@@ -0,0 +1,40 @@
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { NO_ERRORS_SCHEMA } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { TranslateModule } from '@ngx-translate/core';
+import {
+ NotificationsSuggestionTargetsPageComponent
+} from '../../../quality-assurance-notifications-pages/notifications-suggestion-targets-page/notifications-suggestion-targets-page.component';
+
+describe('NotificationsSuggestionTargetsPageComponent', () => {
+ let component: NotificationsSuggestionTargetsPageComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async(() => {
+ TestBed.configureTestingModule({
+ imports: [
+ CommonModule,
+ TranslateModule.forRoot()
+ ],
+ declarations: [
+ NotificationsSuggestionTargetsPageComponent
+ ],
+ providers: [
+ NotificationsSuggestionTargetsPageComponent
+ ],
+ schemas: [NO_ERRORS_SCHEMA]
+ })
+ .compileComponents();
+ }));
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(NotificationsSuggestionTargetsPageComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.ts b/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.ts
new file mode 100644
index 00000000000..e16d7e51e4d
--- /dev/null
+++ b/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.ts
@@ -0,0 +1,9 @@
+import { Component } from '@angular/core';
+
+@Component({
+ selector: 'ds-admin-notifications-publication-claim-page',
+ templateUrl: './admin-notifications-publication-claim-page.component.html'
+})
+export class AdminNotificationsPublicationClaimPageComponent {
+
+}
diff --git a/src/app/admin/admin-notifications/admin-notifications-routing-paths.ts b/src/app/admin/admin-notifications/admin-notifications-routing-paths.ts
new file mode 100644
index 00000000000..9fcabedd647
--- /dev/null
+++ b/src/app/admin/admin-notifications/admin-notifications-routing-paths.ts
@@ -0,0 +1,7 @@
+
+export const QUALITY_ASSURANCE_EDIT_PATH = 'quality-assurance';
+export const PUBLICATION_CLAIMS_PATH = 'publication-claim';
+
+export function getQualityAssuranceEditRoute() {
+ return `/${QUALITY_ASSURANCE_EDIT_PATH}`;
+}
diff --git a/src/app/admin/admin-notifications/admin-notifications-routing.module.ts b/src/app/admin/admin-notifications/admin-notifications-routing.module.ts
new file mode 100644
index 00000000000..c82c91233ee
--- /dev/null
+++ b/src/app/admin/admin-notifications/admin-notifications-routing.module.ts
@@ -0,0 +1,140 @@
+import { NgModule } from '@angular/core';
+import { RouterModule } from '@angular/router';
+
+import { AuthenticatedGuard } from '../../core/auth/authenticated.guard';
+import { I18nBreadcrumbResolver } from '../../core/breadcrumbs/i18n-breadcrumb.resolver';
+import { I18nBreadcrumbsService } from '../../core/breadcrumbs/i18n-breadcrumbs.service';
+import { PUBLICATION_CLAIMS_PATH } from './admin-notifications-routing-paths';
+import { AdminNotificationsPublicationClaimPageComponent } from './admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component';
+import { QUALITY_ASSURANCE_EDIT_PATH } from './admin-notifications-routing-paths';
+import {
+ SiteAdministratorGuard
+} from '../../core/data/feature-authorization/feature-authorization-guard/site-administrator.guard';
+import { QualityAssuranceBreadcrumbResolver } from '../../core/breadcrumbs/quality-assurance-breadcrumb.resolver';
+import { QualityAssuranceBreadcrumbService } from '../../core/breadcrumbs/quality-assurance-breadcrumb.service';
+import {
+ QualityAssuranceEventsPageResolver
+} from '../../quality-assurance-notifications-pages/quality-assurance-events-page/quality-assurance-events-page.resolver';
+import {
+ AdminNotificationsPublicationClaimPageResolver
+} from '../../quality-assurance-notifications-pages/notifications-suggestion-targets-page/notifications-suggestion-targets-page-resolver.service';
+import {
+ QualityAssuranceTopicsPageComponent
+} from '../../quality-assurance-notifications-pages/quality-assurance-topics-page/quality-assurance-topics-page.component';
+import {
+ QualityAssuranceTopicsPageResolver
+} from '../../quality-assurance-notifications-pages/quality-assurance-topics-page/quality-assurance-topics-page-resolver.service';
+import {
+ QualityAssuranceSourcePageComponent
+} from '../../quality-assurance-notifications-pages/quality-assurance-source-page-component/quality-assurance-source-page.component';
+import {
+ QualityAssuranceSourcePageResolver
+} from '../../quality-assurance-notifications-pages/quality-assurance-source-page-component/quality-assurance-source-page-resolver.service';
+import {
+ SourceDataResolver
+} from '../../quality-assurance-notifications-pages/quality-assurance-source-page-component/quality-assurance-source-data.resolver';
+import {
+ QualityAssuranceEventsPageComponent
+} from '../../quality-assurance-notifications-pages/quality-assurance-events-page/quality-assurance-events-page.component';
+
+
+@NgModule({
+ imports: [
+ RouterModule.forChild([
+ {
+ canActivate: [ AuthenticatedGuard ],
+ path: `${PUBLICATION_CLAIMS_PATH}`,
+ component: AdminNotificationsPublicationClaimPageComponent,
+ pathMatch: 'full',
+ resolve: {
+ breadcrumb: I18nBreadcrumbResolver,
+ suggestionTargetParams: AdminNotificationsPublicationClaimPageResolver
+ },
+ data: {
+ title: 'admin.notifications.publicationclaim.page.title',
+ breadcrumbKey: 'admin.notifications.publicationclaim',
+ showBreadcrumbsFluid: false
+ }
+ },
+ {
+ canActivate: [ AuthenticatedGuard ],
+ path: `${QUALITY_ASSURANCE_EDIT_PATH}/:sourceId`,
+ component: QualityAssuranceTopicsPageComponent,
+ pathMatch: 'full',
+ resolve: {
+ breadcrumb: QualityAssuranceBreadcrumbResolver,
+ openaireQualityAssuranceTopicsParams: QualityAssuranceTopicsPageResolver
+ },
+ data: {
+ title: 'admin.quality-assurance.page.title',
+ breadcrumbKey: 'admin.quality-assurance',
+ showBreadcrumbsFluid: false
+ }
+ },
+ {
+ canActivate: [ AuthenticatedGuard ],
+ path: `${QUALITY_ASSURANCE_EDIT_PATH}/:sourceId/target/:targetId`,
+ component: QualityAssuranceTopicsPageComponent,
+ pathMatch: 'full',
+ resolve: {
+ breadcrumb: I18nBreadcrumbResolver,
+ openaireQualityAssuranceTopicsParams: QualityAssuranceTopicsPageResolver
+ },
+ data: {
+ title: 'admin.quality-assurance.page.title',
+ breadcrumbKey: 'admin.quality-assurance',
+ showBreadcrumbsFluid: false
+ }
+ },
+ {
+ canActivate: [ SiteAdministratorGuard ],
+ path: `${QUALITY_ASSURANCE_EDIT_PATH}`,
+ component: QualityAssuranceSourcePageComponent,
+ pathMatch: 'full',
+ resolve: {
+ breadcrumb: I18nBreadcrumbResolver,
+ openaireQualityAssuranceSourceParams: QualityAssuranceSourcePageResolver,
+ sourceData: SourceDataResolver
+ },
+ data: {
+ title: 'admin.notifications.source.breadcrumbs',
+ breadcrumbKey: 'admin.notifications.source',
+ showBreadcrumbsFluid: false
+ }
+ },
+ {
+ canActivate: [ AuthenticatedGuard ],
+ path: `${QUALITY_ASSURANCE_EDIT_PATH}/:sourceId/:topicId`,
+ component: QualityAssuranceEventsPageComponent,
+ pathMatch: 'full',
+ resolve: {
+ breadcrumb: QualityAssuranceBreadcrumbResolver,
+ openaireQualityAssuranceEventsParams: QualityAssuranceEventsPageResolver
+ },
+ data: {
+ title: 'admin.notifications.event.page.title',
+ breadcrumbKey: 'admin.notifications.event',
+ showBreadcrumbsFluid: false
+ }
+ }
+ ])
+ ],
+ providers: [
+ I18nBreadcrumbResolver,
+ I18nBreadcrumbsService,
+ AdminNotificationsPublicationClaimPageResolver,
+ SourceDataResolver,
+ QualityAssuranceSourcePageResolver,
+ QualityAssuranceTopicsPageResolver,
+ QualityAssuranceEventsPageResolver,
+ QualityAssuranceSourcePageResolver,
+ QualityAssuranceBreadcrumbResolver,
+ QualityAssuranceBreadcrumbService
+ ]
+})
+/**
+ * Routing module for the Notifications section of the admin sidebar
+ */
+export class AdminNotificationsRoutingModule {
+
+}
diff --git a/src/app/admin/admin-notifications/admin-notifications.module.ts b/src/app/admin/admin-notifications/admin-notifications.module.ts
new file mode 100644
index 00000000000..ea670d222c0
--- /dev/null
+++ b/src/app/admin/admin-notifications/admin-notifications.module.ts
@@ -0,0 +1,33 @@
+import { CommonModule } from '@angular/common';
+import { NgModule } from '@angular/core';
+import { CoreModule } from '../../core/core.module';
+import { SharedModule } from '../../shared/shared.module';
+import { AdminNotificationsRoutingModule } from './admin-notifications-routing.module';
+import { AdminNotificationsPublicationClaimPageComponent } from './admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component';
+import { NotificationsModule } from '../../notifications/notifications.module';
+
+
+
+
+
+
+
+@NgModule({
+ imports: [
+ CommonModule,
+ SharedModule,
+ CoreModule.forRoot(),
+ AdminNotificationsRoutingModule,
+ NotificationsModule
+ ],
+ declarations: [
+ AdminNotificationsPublicationClaimPageComponent,
+ ],
+ entryComponents: []
+})
+/**
+ * This module handles all components related to the notifications pages
+ */
+export class AdminNotificationsModule {
+
+}
diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-dashboard-routing.module.ts b/src/app/admin/admin-notify-dashboard/admin-notify-dashboard-routing.module.ts
new file mode 100644
index 00000000000..6fb3b469777
--- /dev/null
+++ b/src/app/admin/admin-notify-dashboard/admin-notify-dashboard-routing.module.ts
@@ -0,0 +1,64 @@
+import { NgModule } from '@angular/core';
+import { RouterModule } from '@angular/router';
+import { I18nBreadcrumbResolver } from '../../core/breadcrumbs/i18n-breadcrumb.resolver';
+import { AdminNotifyDashboardComponent } from './admin-notify-dashboard.component';
+import {
+ SiteAdministratorGuard
+} from '../../core/data/feature-authorization/feature-authorization-guard/site-administrator.guard';
+import {
+ AdminNotifyIncomingComponent
+} from './admin-notify-logs/admin-notify-incoming/admin-notify-incoming.component';
+import {
+ AdminNotifyOutgoingComponent
+} from './admin-notify-logs/admin-notify-outgoing/admin-notify-outgoing.component';
+import { NotifyInfoGuard } from '../../core/coar-notify/notify-info/notify-info.guard';
+
+@NgModule({
+ imports: [
+ RouterModule.forChild([
+ {
+ canActivate: [SiteAdministratorGuard, NotifyInfoGuard],
+ path: '',
+ resolve: {
+ breadcrumb: I18nBreadcrumbResolver,
+ },
+ component: AdminNotifyDashboardComponent,
+ pathMatch: 'full',
+ data: {
+ title: 'admin.notify.dashboard.page.title',
+ breadcrumbKey: 'admin.notify.dashboard',
+ },
+ },
+ {
+ path: 'inbound',
+ resolve: {
+ breadcrumb: I18nBreadcrumbResolver,
+ },
+ component: AdminNotifyIncomingComponent,
+ canActivate: [SiteAdministratorGuard, NotifyInfoGuard],
+ data: {
+ title: 'admin.notify.dashboard.page.title',
+ breadcrumbKey: 'admin.notify.dashboard',
+ },
+ },
+ {
+ path: 'outbound',
+ resolve: {
+ breadcrumb: I18nBreadcrumbResolver,
+ },
+ component: AdminNotifyOutgoingComponent,
+ canActivate: [SiteAdministratorGuard, NotifyInfoGuard],
+ data: {
+ title: 'admin.notify.dashboard.page.title',
+ breadcrumbKey: 'admin.notify.dashboard',
+ },
+ }
+ ])
+ ],
+})
+/**
+ * Routing module for the Notifications section of the admin sidebar
+ */
+export class AdminNotifyDashboardRoutingModule {
+
+}
diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-dashboard.component.html b/src/app/admin/admin-notify-dashboard/admin-notify-dashboard.component.html
new file mode 100644
index 00000000000..3adb7e857b6
--- /dev/null
+++ b/src/app/admin/admin-notify-dashboard/admin-notify-dashboard.component.html
@@ -0,0 +1,23 @@
+
+
+
+
{{'admin-notify-dashboard.title'| translate}}
+
{{'admin-notify-dashboard.description' | translate}}
+
+
+
+
diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-dashboard.component.spec.ts b/src/app/admin/admin-notify-dashboard/admin-notify-dashboard.component.spec.ts
new file mode 100644
index 00000000000..74007055b48
--- /dev/null
+++ b/src/app/admin/admin-notify-dashboard/admin-notify-dashboard.component.spec.ts
@@ -0,0 +1,57 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { AdminNotifyDashboardComponent } from './admin-notify-dashboard.component';
+import { TranslateModule } from '@ngx-translate/core';
+import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
+import { SearchService } from '../../core/shared/search/search.service';
+import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
+import { buildPaginatedList } from '../../core/data/paginated-list.model';
+import { AdminNotifySearchResult } from './models/admin-notify-message-search-result.model';
+import { AdminNotifyMessage } from './models/admin-notify-message.model';
+
+describe('AdminNotifyDashboardComponent', () => {
+ let component: AdminNotifyDashboardComponent;
+ let fixture: ComponentFixture;
+
+ let item1;
+ let item2;
+ let item3;
+ let searchResult1;
+ let searchResult2;
+ let searchResult3;
+ let results;
+
+ const mockBoxes = [
+ { title: 'admin-notify-dashboard.received-ldn', boxes: [ undefined, undefined, undefined, undefined, undefined ] },
+ { title: 'admin-notify-dashboard.generated-ldn', boxes: [ undefined, undefined, undefined, undefined, undefined ] }
+ ];
+
+ beforeEach(async () => {
+ item1 = Object.assign(new AdminNotifyMessage(), { uuid: 'e1c51c69-896d-42dc-8221-1d5f2ad5516e' });
+ item2 = Object.assign(new AdminNotifyMessage(), { uuid: 'c8279647-1acc-41ae-b036-951d5f65649b' });
+ item3 = Object.assign(new AdminNotifyMessage(), { uuid: 'c3bcbff5-ec0c-4831-8e4c-94b9c933ccac' });
+ searchResult1 = Object.assign(new AdminNotifySearchResult(), { indexableObject: item1 });
+ searchResult2 = Object.assign(new AdminNotifySearchResult(), { indexableObject: item2 });
+ searchResult3 = Object.assign(new AdminNotifySearchResult(), { indexableObject: item3 });
+ results = buildPaginatedList(undefined, [searchResult1, searchResult2, searchResult3]);
+
+ await TestBed.configureTestingModule({
+ imports: [TranslateModule.forRoot(), NgbNavModule],
+ declarations: [ AdminNotifyDashboardComponent ],
+ providers: [{ provide: SearchService, useValue: { search: () => createSuccessfulRemoteDataObject$(results)}}]
+ })
+ .compileComponents();
+
+ fixture = TestBed.createComponent(AdminNotifyDashboardComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', (done) => {
+ component.notifyMetricsRows$.subscribe(boxes => {
+ expect(boxes).toEqual(mockBoxes);
+ done();
+ });
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-dashboard.component.ts b/src/app/admin/admin-notify-dashboard/admin-notify-dashboard.component.ts
new file mode 100644
index 00000000000..9aa738b29bd
--- /dev/null
+++ b/src/app/admin/admin-notify-dashboard/admin-notify-dashboard.component.ts
@@ -0,0 +1,95 @@
+import { Component, OnInit } from '@angular/core';
+import { SearchService } from '../../core/shared/search/search.service';
+import { environment } from '../../../environments/environment';
+import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model';
+import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
+import { forkJoin, Observable } from 'rxjs';
+import { getFirstCompletedRemoteData } from '../../core/shared/operators';
+import { map } from 'rxjs/operators';
+import { SearchObjects } from '../../shared/search/models/search-objects.model';
+import { AdminNotifyMetricsBox, AdminNotifyMetricsRow } from './admin-notify-metrics/admin-notify-metrics.model';
+import { DSpaceObject } from '../../core/shared/dspace-object.model';
+import { SEARCH_CONFIG_SERVICE } from '../../my-dspace-page/my-dspace-page.component';
+import { SearchConfigurationService } from '../../core/shared/search/search-configuration.service';
+
+
+@Component({
+ selector: 'ds-admin-notify-dashboard',
+ templateUrl: './admin-notify-dashboard.component.html',
+ providers: [
+ {
+ provide: SEARCH_CONFIG_SERVICE,
+ useClass: SearchConfigurationService
+ }
+ ]
+})
+
+/**
+ * Component used for visual representation and search of LDN messages for Admins
+ */
+export class AdminNotifyDashboardComponent implements OnInit{
+
+ public notifyMetricsRows$: Observable;
+
+ private metricsConfig = environment.notifyMetrics;
+ private singleResultOptions = Object.assign(new PaginationComponentOptions(), {
+ id: 'single-result-options',
+ pageSize: 1
+ });
+
+ constructor(private searchService: SearchService) {
+ }
+
+ ngOnInit() {
+ const mertricsRowsConfigurations = this.metricsConfig
+ .map(row => row.boxes)
+ .map(boxes => boxes.map(box => box.config).filter(config => !!config));
+ const flatConfigurations = [].concat(...mertricsRowsConfigurations.map((config) => config));
+ const searchConfigurations = flatConfigurations
+ .map(config => Object.assign(new PaginatedSearchOptions({}),
+ { configuration: config, pagination: this.singleResultOptions }
+ ));
+
+ this.notifyMetricsRows$ = forkJoin(searchConfigurations.map(config => this.searchService.search(config)
+ .pipe(
+ getFirstCompletedRemoteData(),
+ map(response => this.mapSearchObjectsToMetricsBox(response.payload)),
+ )
+ )
+ ).pipe(
+ map(metricBoxes => this.mapUpdatedBoxesToMetricsRows(metricBoxes))
+ );
+ }
+
+ /**
+ * Function to map received SearchObjects to notify boxes config
+ *
+ * @param searchObject The object to map
+ * @private
+ */
+ private mapSearchObjectsToMetricsBox(searchObject: SearchObjects): AdminNotifyMetricsBox {
+ const count = searchObject.pageInfo.totalElements;
+ const objectConfig = searchObject.configuration;
+ const metricsBoxes = [].concat(...this.metricsConfig.map((config) => config.boxes));
+
+ return {
+ ...metricsBoxes.find(box => box.config === objectConfig),
+ count
+ };
+ }
+
+ /**
+ * Function to map updated boxes with count to each row of the configuration
+ *
+ * @param boxesWithCount The object to map
+ * @private
+ */
+ private mapUpdatedBoxesToMetricsRows(boxesWithCount: AdminNotifyMetricsBox[]): AdminNotifyMetricsRow[] {
+ return this.metricsConfig.map(row => {
+ return {
+ ...row,
+ boxes: row.boxes.map(rowBox => boxesWithCount.find(boxWithCount => boxWithCount.config === rowBox.config))
+ };
+ });
+ }
+}
diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-dashboard.module.ts b/src/app/admin/admin-notify-dashboard/admin-notify-dashboard.module.ts
new file mode 100644
index 00000000000..0598fc33043
--- /dev/null
+++ b/src/app/admin/admin-notify-dashboard/admin-notify-dashboard.module.ts
@@ -0,0 +1,56 @@
+import { NgModule } from '@angular/core';
+import { CommonModule, DatePipe } from '@angular/common';
+import { RouterModule } from '@angular/router';
+import { AdminNotifyDashboardComponent } from './admin-notify-dashboard.component';
+import { AdminNotifyDashboardRoutingModule } from './admin-notify-dashboard-routing.module';
+import { AdminNotifyMetricsComponent } from './admin-notify-metrics/admin-notify-metrics.component';
+import { AdminNotifyIncomingComponent } from './admin-notify-logs/admin-notify-incoming/admin-notify-incoming.component';
+import { SharedModule } from '../../shared/shared.module';
+import { SearchModule } from '../../shared/search/search.module';
+import { SearchPageModule } from '../../search-page/search-page.module';
+import {
+ AdminNotifyOutgoingComponent
+} from './admin-notify-logs/admin-notify-outgoing/admin-notify-outgoing.component';
+import { AdminNotifyDetailModalComponent } from './admin-notify-detail-modal/admin-notify-detail-modal.component';
+import {
+ AdminNotifySearchResultComponent
+} from './admin-notify-search-result/admin-notify-search-result.component';
+import { AdminNotifyMessagesService } from './services/admin-notify-messages.service';
+import { AdminNotifyLogsResultComponent } from './admin-notify-logs/admin-notify-logs-result/admin-notify-logs-result.component';
+
+
+const ENTRY_COMPONENTS = [
+ AdminNotifySearchResultComponent
+];
+@NgModule({
+ imports: [
+ CommonModule,
+ RouterModule,
+ SharedModule,
+ AdminNotifyDashboardRoutingModule,
+ SearchModule,
+ SearchPageModule
+ ],
+ providers: [
+ AdminNotifyMessagesService,
+ DatePipe
+ ],
+ declarations: [
+ ...ENTRY_COMPONENTS,
+ AdminNotifyDashboardComponent,
+ AdminNotifyMetricsComponent,
+ AdminNotifyIncomingComponent,
+ AdminNotifyOutgoingComponent,
+ AdminNotifyDetailModalComponent,
+ AdminNotifySearchResultComponent,
+ AdminNotifyLogsResultComponent
+ ]
+})
+export class AdminNotifyDashboardModule {
+ static withEntryComponents() {
+ return {
+ ngModule: AdminNotifyDashboardModule,
+ providers: ENTRY_COMPONENTS.map((component) => ({provide: component}))
+ };
+ }
+}
diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-detail-modal/admin-notify-detail-modal.component.html b/src/app/admin/admin-notify-dashboard/admin-notify-detail-modal/admin-notify-detail-modal.component.html
new file mode 100644
index 00000000000..52d93cbb627
--- /dev/null
+++ b/src/app/admin/admin-notify-dashboard/admin-notify-detail-modal/admin-notify-detail-modal.component.html
@@ -0,0 +1,22 @@
+
+
+
+
+
{{ key + '.notify-detail-modal' | translate}}
+
{{'notify-detail-modal.' + notifyMessage[key] | translate: {default: notifyMessage[key] ?? "n/a" } }}
+
+
+
+
+
+ {{'notify-message-modal.show-message' | translate}}
+
+
+
+
+
diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-detail-modal/admin-notify-detail-modal.component.spec.ts b/src/app/admin/admin-notify-dashboard/admin-notify-detail-modal/admin-notify-detail-modal.component.spec.ts
new file mode 100644
index 00000000000..0ddf449e5c7
--- /dev/null
+++ b/src/app/admin/admin-notify-dashboard/admin-notify-detail-modal/admin-notify-detail-modal.component.spec.ts
@@ -0,0 +1,36 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { AdminNotifyDetailModalComponent } from './admin-notify-detail-modal.component';
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+import { TranslateModule } from '@ngx-translate/core';
+
+describe('AdminNotifyDetailModalComponent', () => {
+ let component: AdminNotifyDetailModalComponent;
+ let fixture: ComponentFixture;
+ const modalStub = jasmine.createSpyObj('modalStub', ['close']);
+
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [TranslateModule.forRoot()],
+ declarations: [ AdminNotifyDetailModalComponent ],
+ providers: [{ provide: NgbActiveModal, useValue: modalStub }]
+ })
+ .compileComponents();
+
+ fixture = TestBed.createComponent(AdminNotifyDetailModalComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should close', () => {
+ spyOn(component.response, 'emit');
+ component.closeModal();
+ expect(modalStub.close).toHaveBeenCalled();
+ expect(component.response.emit).toHaveBeenCalledWith(true);
+ });
+});
diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-detail-modal/admin-notify-detail-modal.component.ts b/src/app/admin/admin-notify-dashboard/admin-notify-detail-modal/admin-notify-detail-modal.component.ts
new file mode 100644
index 00000000000..54b14be64c6
--- /dev/null
+++ b/src/app/admin/admin-notify-dashboard/admin-notify-detail-modal/admin-notify-detail-modal.component.ts
@@ -0,0 +1,49 @@
+import { Component, EventEmitter, Input, Output } from '@angular/core';
+import { AdminNotifyMessage } from '../models/admin-notify-message.model';
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+import { TranslateService } from '@ngx-translate/core';
+import { MissingTranslationHelper } from '../../../shared/translate/missing-translation.helper';
+import { fadeIn } from '../../../shared/animations/fade';
+
+@Component({
+ selector: 'ds-admin-notify-detail-modal',
+ templateUrl: './admin-notify-detail-modal.component.html',
+ animations: [
+ fadeIn
+ ]
+})
+/**
+ * Component for detailed view of LDN messages displayed in search result in AdminNotifyDashboardComponent
+ */
+
+export class AdminNotifyDetailModalComponent {
+ @Input() notifyMessage: AdminNotifyMessage;
+ @Input() notifyMessageKeys: string[];
+
+ /**
+ * An event fired when the modal is closed
+ */
+ @Output()
+ response = new EventEmitter();
+
+ public isCoarMessageVisible = false;
+
+
+ constructor(protected activeModal: NgbActiveModal,
+ public translationsService: TranslateService) {
+ this.translationsService.missingTranslationHandler = new MissingTranslationHelper();
+ }
+
+
+ /**
+ * Close the modal and set the response to true so RootComponent knows the modal was closed
+ */
+ closeModal() {
+ this.activeModal.close();
+ this.response.emit(true);
+ }
+
+ toggleCoarMessage() {
+ this.isCoarMessageVisible = !this.isCoarMessageVisible;
+ }
+}
diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-incoming/admin-notify-incoming.component.html b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-incoming/admin-notify-incoming.component.html
new file mode 100644
index 00000000000..4c957ca6300
--- /dev/null
+++ b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-incoming/admin-notify-incoming.component.html
@@ -0,0 +1,23 @@
+
+
+
+
{{'admin-notify-dashboard.title'| translate}}
+
+
+
+
diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-incoming/admin-notify-incoming.component.spec.ts b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-incoming/admin-notify-incoming.component.spec.ts
new file mode 100644
index 00000000000..f7388c5210d
--- /dev/null
+++ b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-incoming/admin-notify-incoming.component.spec.ts
@@ -0,0 +1,58 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { AdminNotifyIncomingComponent } from './admin-notify-incoming.component';
+import { TranslateModule } from '@ngx-translate/core';
+import { ActivatedRoute } from '@angular/router';
+import { MockActivatedRoute } from '../../../../shared/mocks/active-router.mock';
+import { provideMockStore } from '@ngrx/store/testing';
+import { HALEndpointService } from '../../../../core/shared/hal-endpoint.service';
+import { SEARCH_CONFIG_SERVICE } from '../../../../my-dspace-page/my-dspace-page.component';
+import { RouteService } from '../../../../core/services/route.service';
+import { routeServiceStub } from '../../../../shared/testing/route-service.stub';
+import { RequestService } from '../../../../core/data/request.service';
+import { RemoteDataBuildService } from '../../../../core/cache/builders/remote-data-build.service';
+import { getMockRemoteDataBuildService } from '../../../../shared/mocks/remote-data-build.service.mock';
+import { SearchConfigurationService } from '../../../../core/shared/search/search-configuration.service';
+
+describe('AdminNotifyIncomingComponent', () => {
+ let component: AdminNotifyIncomingComponent;
+ let fixture: ComponentFixture;
+ let halService: HALEndpointService;
+ let requestService: RequestService;
+ let rdbService: RemoteDataBuildService;
+
+
+
+ beforeEach(async () => {
+ rdbService = getMockRemoteDataBuildService();
+ halService = jasmine.createSpyObj('halService', {
+ 'getRootHref': '/api'
+ });
+ requestService = jasmine.createSpyObj('requestService', {
+ 'generateRequestId': 'client/1234',
+ 'send': '',
+ });
+ await TestBed.configureTestingModule({
+ imports: [TranslateModule.forRoot()],
+ declarations: [ AdminNotifyIncomingComponent ],
+ providers: [
+ { provide: SEARCH_CONFIG_SERVICE, useValue: SearchConfigurationService },
+ { provide: RouteService, useValue: routeServiceStub },
+ { provide: ActivatedRoute, useValue: new MockActivatedRoute() },
+ { provide: HALEndpointService, useValue: halService },
+ { provide: RequestService, useValue: requestService },
+ { provide: RemoteDataBuildService, useValue: rdbService },
+ provideMockStore({}),
+ ]
+ })
+ .compileComponents();
+
+ fixture = TestBed.createComponent(AdminNotifyIncomingComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-incoming/admin-notify-incoming.component.ts b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-incoming/admin-notify-incoming.component.ts
new file mode 100644
index 00000000000..b259d9a13cd
--- /dev/null
+++ b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-incoming/admin-notify-incoming.component.ts
@@ -0,0 +1,19 @@
+import { Component, Inject } from '@angular/core';
+import { SEARCH_CONFIG_SERVICE } from '../../../../my-dspace-page/my-dspace-page.component';
+import { SearchConfigurationService } from '../../../../core/shared/search/search-configuration.service';
+
+
+@Component({
+ selector: 'ds-admin-notify-incoming',
+ templateUrl: './admin-notify-incoming.component.html',
+ providers: [
+ {
+ provide: SEARCH_CONFIG_SERVICE,
+ useClass: SearchConfigurationService
+ }
+ ]
+})
+export class AdminNotifyIncomingComponent {
+ constructor(@Inject(SEARCH_CONFIG_SERVICE) public searchConfigService: SearchConfigurationService) {
+ }
+}
diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-logs-result/admin-notify-logs-result.component.html b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-logs-result/admin-notify-logs-result.component.html
new file mode 100644
index 00000000000..c26c2682e59
--- /dev/null
+++ b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-logs-result/admin-notify-logs-result.component.html
@@ -0,0 +1,25 @@
+
+
+
{{((isInbound$ | async) ? 'admin.notify.dashboard.inbound' : 'admin.notify.dashboard.outbound') | translate}}
+
+
+
+ {{ 'admin-notify-logs.' + (selectedSearchConfig$ | async) | translate}}
+ ×
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-logs-result/admin-notify-logs-result.component.spec.ts b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-logs-result/admin-notify-logs-result.component.spec.ts
new file mode 100644
index 00000000000..e59a52198d6
--- /dev/null
+++ b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-logs-result/admin-notify-logs-result.component.spec.ts
@@ -0,0 +1,49 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { AdminNotifyLogsResultComponent } from './admin-notify-logs-result.component';
+import { ActivatedRoute, Router } from '@angular/router';
+import { MockActivatedRoute } from '../../../../shared/mocks/active-router.mock';
+import { provideMockStore } from '@ngrx/store/testing';
+import { HALEndpointService } from '../../../../core/shared/hal-endpoint.service';
+import { ObjectCacheService } from '../../../../core/cache/object-cache.service';
+import { RequestService } from '../../../../core/data/request.service';
+import { RemoteDataBuildService } from '../../../../core/cache/builders/remote-data-build.service';
+import { TranslateModule } from '@ngx-translate/core';
+import { RouterStub } from '../../../../shared/testing/router.stub';
+import { RouteService } from '../../../../core/services/route.service';
+import { routeServiceStub } from '../../../../shared/testing/route-service.stub';
+
+describe('AdminNotifyLogsResultComponent', () => {
+ let component: AdminNotifyLogsResultComponent;
+ let fixture: ComponentFixture;
+ let objectCache: ObjectCacheService;
+ let requestService: RequestService;
+ let halService: HALEndpointService;
+ let rdbService: RemoteDataBuildService;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [TranslateModule.forRoot()],
+ declarations: [ AdminNotifyLogsResultComponent ],
+ providers: [
+ { provide: RouteService, useValue: routeServiceStub },
+ { provide: Router, useValue: new RouterStub() },
+ { provide: ActivatedRoute, useValue: new MockActivatedRoute() },
+ { provide: HALEndpointService, useValue: halService },
+ { provide: ObjectCacheService, useValue: objectCache },
+ { provide: RequestService, useValue: requestService },
+ { provide: RemoteDataBuildService, useValue: rdbService },
+ provideMockStore({}),
+ ]
+ })
+ .compileComponents();
+
+ fixture = TestBed.createComponent(AdminNotifyLogsResultComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-logs-result/admin-notify-logs-result.component.ts b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-logs-result/admin-notify-logs-result.component.ts
new file mode 100644
index 00000000000..4f0407ce887
--- /dev/null
+++ b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-logs-result/admin-notify-logs-result.component.ts
@@ -0,0 +1,79 @@
+import {
+ ChangeDetectorRef,
+ Component,
+ Inject,
+ Input,
+ OnInit
+} from '@angular/core';
+import { SEARCH_CONFIG_SERVICE } from '../../../../my-dspace-page/my-dspace-page.component';
+import { Context } from '../../../../core/shared/context.model';
+import { SearchConfigurationService } from '../../../../core/shared/search/search-configuration.service';
+import { Observable } from 'rxjs';
+import { ActivatedRoute, ActivatedRouteSnapshot, Router } from '@angular/router';
+import { ViewMode } from '../../../../core/shared/view-mode.model';
+import { map } from 'rxjs/operators';
+
+@Component({
+ selector: 'ds-admin-notify-logs-result',
+ templateUrl: './admin-notify-logs-result.component.html',
+ providers: [
+ {
+ provide: SEARCH_CONFIG_SERVICE,
+ useClass: SearchConfigurationService
+ }
+ ]
+})
+
+/**
+ * Component for visualization of search page and related results for the logs of the Notify dashboard
+ */
+
+export class AdminNotifyLogsResultComponent implements OnInit {
+
+ @Input()
+ defaultConfiguration: string;
+
+
+ public selectedSearchConfig$: Observable;
+ public isInbound$: Observable;
+
+ protected readonly context = Context.CoarNotify;
+
+ constructor(@Inject(SEARCH_CONFIG_SERVICE) public searchConfigService: SearchConfigurationService,
+ private router: Router,
+ private route: ActivatedRoute,
+ protected cdRef: ChangeDetectorRef) {
+ }
+
+ ngOnInit() {
+ this.selectedSearchConfig$ = this.searchConfigService.getCurrentConfiguration(this.defaultConfiguration);
+ this.isInbound$ = this.selectedSearchConfig$.pipe(
+ map(config => config.startsWith('NOTIFY.incoming'))
+ );
+ }
+
+ /**
+ * Reset route state to default configuration
+ */
+ public resetDefaultConfiguration() {
+ //Idle navigation to trigger rendering of result on same page
+ this.router.navigateByUrl('/', { skipLocationChange: true }).then(() => {
+ this.router.navigate([this.getResolvedUrl(this.route.snapshot)], {
+ queryParams: {
+ configuration: this.defaultConfiguration,
+ view: ViewMode.Table,
+ },
+ });
+ });
+ }
+
+ /**
+ * Get resolved url from route
+ *
+ * @param route url path
+ * @returns url path
+ */
+ private getResolvedUrl(route: ActivatedRouteSnapshot): string {
+ return route.pathFromRoot.map(v => v.url.map(segment => segment.toString()).join('/')).join('/');
+ }
+}
diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-outgoing/admin-notify-outgoing.component.html b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-outgoing/admin-notify-outgoing.component.html
new file mode 100644
index 00000000000..e9bc1d10b20
--- /dev/null
+++ b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-outgoing/admin-notify-outgoing.component.html
@@ -0,0 +1,24 @@
+
+
+
+
{{'admin-notify-dashboard.title'| translate}}
+
+
+
+
diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-outgoing/admin-notify-outgoing.component.spec.ts b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-outgoing/admin-notify-outgoing.component.spec.ts
new file mode 100644
index 00000000000..a8af9a7fd65
--- /dev/null
+++ b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-outgoing/admin-notify-outgoing.component.spec.ts
@@ -0,0 +1,57 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { AdminNotifyOutgoingComponent } from './admin-notify-outgoing.component';
+import { TranslateModule } from '@ngx-translate/core';
+import { ActivatedRoute } from '@angular/router';
+import { MockActivatedRoute } from '../../../../shared/mocks/active-router.mock';
+import { provideMockStore } from '@ngrx/store/testing';
+import { HALEndpointService } from '../../../../core/shared/hal-endpoint.service';
+import { SEARCH_CONFIG_SERVICE } from '../../../../my-dspace-page/my-dspace-page.component';
+import { RouteService } from '../../../../core/services/route.service';
+import { routeServiceStub } from '../../../../shared/testing/route-service.stub';
+import { RequestService } from '../../../../core/data/request.service';
+import { getMockRemoteDataBuildService } from '../../../../shared/mocks/remote-data-build.service.mock';
+import { RemoteDataBuildService } from '../../../../core/cache/builders/remote-data-build.service';
+import { SearchConfigurationService } from '../../../../core/shared/search/search-configuration.service';
+
+describe('AdminNotifyOutgoingComponent', () => {
+ let component: AdminNotifyOutgoingComponent;
+ let fixture: ComponentFixture;
+ let halService: HALEndpointService;
+ let requestService: RequestService;
+ let rdbService: RemoteDataBuildService;
+
+
+ beforeEach(async () => {
+ rdbService = getMockRemoteDataBuildService();
+ requestService = jasmine.createSpyObj('requestService', {
+ 'generateRequestId': 'client/1234',
+ 'send': '',
+ });
+ halService = jasmine.createSpyObj('halService', {
+ 'getRootHref': '/api'
+ });
+ await TestBed.configureTestingModule({
+ imports: [TranslateModule.forRoot()],
+ declarations: [ AdminNotifyOutgoingComponent ],
+ providers: [
+ { provide: SEARCH_CONFIG_SERVICE, useValue: SearchConfigurationService },
+ { provide: RouteService, useValue: routeServiceStub },
+ { provide: ActivatedRoute, useValue: new MockActivatedRoute() },
+ { provide: HALEndpointService, useValue: halService },
+ { provide: RequestService, useValue: requestService },
+ { provide: RemoteDataBuildService, useValue: rdbService },
+ provideMockStore({}),
+ ]
+ })
+ .compileComponents();
+
+ fixture = TestBed.createComponent(AdminNotifyOutgoingComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-outgoing/admin-notify-outgoing.component.ts b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-outgoing/admin-notify-outgoing.component.ts
new file mode 100644
index 00000000000..a37ddc3bd6e
--- /dev/null
+++ b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-outgoing/admin-notify-outgoing.component.ts
@@ -0,0 +1,19 @@
+import { Component, Inject } from '@angular/core';
+import { SEARCH_CONFIG_SERVICE } from '../../../../my-dspace-page/my-dspace-page.component';
+import { SearchConfigurationService } from '../../../../core/shared/search/search-configuration.service';
+
+
+@Component({
+ selector: 'ds-admin-notify-outgoing',
+ templateUrl: './admin-notify-outgoing.component.html',
+ providers: [
+ {
+ provide: SEARCH_CONFIG_SERVICE,
+ useClass: SearchConfigurationService
+ }
+ ]
+})
+export class AdminNotifyOutgoingComponent {
+ constructor(@Inject(SEARCH_CONFIG_SERVICE) public searchConfigService: SearchConfigurationService) {
+ }
+}
diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.component.html b/src/app/admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.component.html
new file mode 100644
index 00000000000..3257bdd5ba9
--- /dev/null
+++ b/src/app/admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.component.html
@@ -0,0 +1,9 @@
+
+
{{ row.title | translate }}
+
+
+
diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.component.spec.ts b/src/app/admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.component.spec.ts
new file mode 100644
index 00000000000..57f21a4ef31
--- /dev/null
+++ b/src/app/admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.component.spec.ts
@@ -0,0 +1,71 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { AdminNotifyMetricsComponent } from './admin-notify-metrics.component';
+import { TranslateModule } from '@ngx-translate/core';
+import { Router } from '@angular/router';
+import { ViewMode } from '../../../core/shared/view-mode.model';
+import { RouterStub } from '../../../shared/testing/router.stub';
+
+describe('AdminNotifyMetricsComponent', () => {
+ let component: AdminNotifyMetricsComponent;
+ let fixture: ComponentFixture;
+ let router: RouterStub;
+
+ beforeEach(async () => {
+ router = Object.assign(new RouterStub(),
+ {url : '/notify-dashboard'}
+ );
+
+
+ await TestBed.configureTestingModule({
+ imports: [TranslateModule.forRoot()],
+ declarations: [ AdminNotifyMetricsComponent ],
+ providers: [{provide: Router, useValue: router}]
+ })
+ .compileComponents();
+
+
+
+ fixture = TestBed.createComponent(AdminNotifyMetricsComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+ it('should navigate to correct url based on config', () => {
+ const searchConfig = 'test.involvedItems';
+ const incomingConfig = 'NOTIFY.incoming.test';
+ const outgoingConfig = 'NOTIFY.outgoing.test';
+ const adminPath = '/admin/search';
+ const routeExtras = {
+ queryParams: {
+ configuration: searchConfig,
+ view: ViewMode.ListElement
+ },
+ };
+
+ const routeExtrasTable = {
+ queryParams: {
+ configuration: incomingConfig,
+ view: ViewMode.Table
+ },
+ };
+
+ const routeExtrasTableOutgoing = {
+ queryParams: {
+ configuration: outgoingConfig,
+ view: ViewMode.Table
+ },
+ };
+ component.navigateToSelectedSearchConfig(searchConfig);
+ expect(router.navigate).toHaveBeenCalledWith([adminPath], routeExtras);
+
+ component.navigateToSelectedSearchConfig(incomingConfig);
+ expect(router.navigate).toHaveBeenCalledWith(['/notify-dashboard/inbound'], routeExtrasTable);
+
+ component.navigateToSelectedSearchConfig(outgoingConfig);
+ expect(router.navigate).toHaveBeenCalledWith(['/notify-dashboard/outbound'], routeExtrasTableOutgoing);
+ });
+});
diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.component.ts b/src/app/admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.component.ts
new file mode 100644
index 00000000000..8822e2bd1e8
--- /dev/null
+++ b/src/app/admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.component.ts
@@ -0,0 +1,53 @@
+import { Component, Input } from '@angular/core';
+import { AdminNotifyMetricsRow } from './admin-notify-metrics.model';
+import { Router } from '@angular/router';
+import { ViewMode } from '../../../core/shared/view-mode.model';
+
+@Component({
+ selector: 'ds-admin-notify-metrics',
+ templateUrl: './admin-notify-metrics.component.html',
+})
+/**
+ * Component used to display the number of notification for each configured box in the notifyMetrics section
+ */
+
+export class AdminNotifyMetricsComponent {
+
+ @Input()
+ boxesConfig: AdminNotifyMetricsRow[];
+
+ private incomingConfiguration = 'NOTIFY.incoming';
+ private involvedItemsSuffix = 'involvedItems';
+ private inboundPath = '/inbound';
+ private outboundPath = '/outbound';
+ private adminSearchPath = '/admin/search';
+
+ constructor(private router: Router) {
+ }
+
+
+ public navigateToSelectedSearchConfig(searchConfig: string) {
+ const isRelatedItemsConfig = searchConfig.endsWith(this.involvedItemsSuffix);
+
+ if (isRelatedItemsConfig) {
+ this.router.navigate([this.adminSearchPath], {
+ queryParams: {
+ configuration: searchConfig,
+ view: ViewMode.ListElement
+ },
+ });
+
+ return;
+ }
+
+ const isIncomingConfig = searchConfig.startsWith(this.incomingConfiguration);
+ const selectedPath = isIncomingConfig ? this.inboundPath : this.outboundPath;
+
+ this.router.navigate([`${this.router.url}${selectedPath}`], {
+ queryParams: {
+ configuration: searchConfig,
+ view: ViewMode.Table
+ },
+ });
+ }
+}
diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.model.ts b/src/app/admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.model.ts
new file mode 100644
index 00000000000..83b931c8668
--- /dev/null
+++ b/src/app/admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.model.ts
@@ -0,0 +1,19 @@
+/**
+ * The properties for each Box to be displayed in rows in the AdminNotifyMetricsComponent
+ */
+
+export interface AdminNotifyMetricsBox {
+ color: string;
+ textColor?: string;
+ title: string;
+ description: string;
+ config: string;
+ count?: number;
+}
+/**
+ * The properties for each Row containing a list of AdminNotifyMetricsBox to be displayed in the AdminNotifyMetricsComponent
+ */
+export interface AdminNotifyMetricsRow {
+ title: string;
+ boxes: AdminNotifyMetricsBox[]
+}
diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-search-result/admin-notify-search-result.component.html b/src/app/admin/admin-notify-dashboard/admin-notify-search-result/admin-notify-search-result.component.html
new file mode 100644
index 00000000000..af540b094e9
--- /dev/null
+++ b/src/app/admin/admin-notify-dashboard/admin-notify-search-result/admin-notify-search-result.component.html
@@ -0,0 +1,51 @@
+
+
+
+
+ {{ 'notify-message-result.timestamp' | translate}}
+ {{'notify-message-result.repositoryItem' | translate}}
+ {{ 'notify-message-result.ldnService' | translate}}
+ {{ 'notify-message-result.type' | translate }}
+ {{ 'notify-message-result.status' | translate }}
+ {{ 'notify-message-result.action' | translate }}
+
+
+
+
+
+ {{ message.queueLastStartTime | date:"YYYY/MM/d hh:mm:ss" }}
+ n/a
+
+
+
+
+ {{ message.relatedItem }}
+
+
+ n/a
+
+
+ {{ message.ldnService }}
+ n/a
+
+
+ {{ message.activityStreamType }}
+
+
+ {{ 'notify-detail-modal.' + message.queueStatusLabel | translate }}
+
+
+
+ {{ 'notify-message-result.detail' | translate }}
+
+ {{ 'notify-message-result.reprocess' | translate }}
+
+
+
+
+
+
+
diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-search-result/admin-notify-search-result.component.spec.ts b/src/app/admin/admin-notify-dashboard/admin-notify-search-result/admin-notify-search-result.component.spec.ts
new file mode 100644
index 00000000000..08f60a8f5c4
--- /dev/null
+++ b/src/app/admin/admin-notify-dashboard/admin-notify-search-result/admin-notify-search-result.component.spec.ts
@@ -0,0 +1,182 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { AdminNotifySearchResultComponent } from './admin-notify-search-result.component';
+import { AdminNotifyMessagesService } from '../services/admin-notify-messages.service';
+import { ObjectCacheService } from '../../../core/cache/object-cache.service';
+import { RequestService } from '../../../core/data/request.service';
+import { HALEndpointService } from '../../../core/shared/hal-endpoint.service';
+import { RemoteDataBuildService } from '../../../core/cache/builders/remote-data-build.service';
+import { cold } from 'jasmine-marbles';
+import { ConfigurationProperty } from '../../../core/shared/configuration-property.model';
+import { RouteService } from '../../../core/services/route.service';
+import { routeServiceStub } from '../../../shared/testing/route-service.stub';
+import { ActivatedRoute } from '@angular/router';
+import { RouterStub } from '../../../shared/testing/router.stub';
+import { TranslateModule } from '@ngx-translate/core';
+import { of as observableOf, of } from 'rxjs';
+import { AdminNotifyMessage } from '../models/admin-notify-message.model';
+import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
+import { AdminNotifyDetailModalComponent } from '../admin-notify-detail-modal/admin-notify-detail-modal.component';
+import { SearchConfigurationService } from '../../../core/shared/search/search-configuration.service';
+import { SEARCH_CONFIG_SERVICE } from '../../../my-dspace-page/my-dspace-page.component';
+import { DatePipe } from '@angular/common';
+import { NO_ERRORS_SCHEMA } from '@angular/core';
+
+
+export const mockAdminNotifyMessages = [
+ {
+ 'type': 'message',
+ 'id': 'urn:uuid:5fb3af44-d4f8-4226-9475-2d09c2d8d9e0',
+ 'coarNotifyType': 'coar-notify:ReviewAction',
+ 'activityStreamType': 'TentativeReject',
+ 'inReplyTo': 'urn:uuid:f7289ad5-0955-4c86-834c-fb54a736778b',
+ 'object': null,
+ 'context': '24d50450-9ff0-485f-82d4-fba1be42f3f9',
+ 'queueAttempts': 1,
+ 'queueLastStartTime': '2023-11-24T14:44:00.064+00:00',
+ 'origin': 12,
+ 'target': null,
+ 'queueStatusLabel': 'notify-queue-status.processed',
+ 'queueTimeout': '2023-11-24T15:44:00.064+00:00',
+ 'queueStatus': 3,
+ '_links': {
+ 'self': {
+ 'href': 'http://localhost:8080/server/api/ldn/messages/urn:uuid:5fb3af44-d4f8-4226-9475-2d09c2d8d9e0'
+ }
+ },
+ 'thumbnail': 'test',
+ 'item': {},
+ 'accessStatus': {},
+ 'ldnService': 'NOTIFY inbox - Automatic service',
+ 'relatedItem': 'test coar 2 demo',
+ 'message': '{"@context":["https://www.w3.org/ns/activitystreams","https://purl.org/coar/notify"],"id":"urn:uuid:668f26e0-2e8d-4118-b0d2-ee713523bc45","type":["Reject","coar-notify:IngestAction"],"actor":{"id":"https://generic-service.com","type":["Service"],"name":"Generic Service"},"context":{"id":"https://dspace-coar.4science.cloud/handle/123456789/28","type":["Document"],"ietf:cite-as":"https://doi.org/10.4598/12123488"},"object":{"id":"https://dspace-coar.4science.cloud/handle/123456789/28","type":["Offer"]},"origin":{"id":"https://generic-service.com/system","type":["Service"],"inbox":"https://notify-inbox.info/inbox7"},"target":{"id":"https://some-organisation.org","type":["Organization"],"inbox":"https://dspace-coar.4science.cloud/server/ldn/inbox"},"inReplyTo":"urn:uuid:d9b4010a-f128-4815-abb2-83707a2ee9cf"}'
+ },
+ {
+ 'type': 'message',
+ 'id': 'urn:uuid:544c8777-e826-4810-a625-3e394cc3660d',
+ 'coarNotifyType': 'coar-notify:IngestAction',
+ 'activityStreamType': 'Announce',
+ 'inReplyTo': 'urn:uuid:b2ad72d6-6ea9-464f-b385-29a78417f6b8',
+ 'object': null,
+ 'context': 'e657437a-0ee2-437d-916a-bba8c57bf40b',
+ 'queueAttempts': 1,
+ 'queueLastStartTime': null,
+ 'origin': 12,
+ 'target': null,
+ 'queueStatusLabel': 'notify-queue-status.unmapped_action',
+ 'queueTimeout': '2023-11-24T14:15:34.945+00:00',
+ 'queueStatus': 6,
+ '_links': {
+ 'self': {
+ 'href': 'http://localhost:8080/server/api/ldn/messages/urn:uuid:544c8777-e826-4810-a625-3e394cc3660d'
+ }
+ },
+ 'thumbnail': {},
+ 'item': {},
+ 'accessStatus': {},
+ 'ldnService': 'NOTIFY inbox - Automatic service',
+ 'relatedItem': 'test coar demo',
+ 'message': '{"@context":["https://www.w3.org/ns/activitystreams","https://purl.org/coar/notify"],"id":"urn:uuid:668f26e0-2e8d-4118-b0d2-ee713523bc45","type":["Reject","coar-notify:IngestAction"],"actor":{"id":"https://generic-service.com","type":["Service"],"name":"Generic Service"},"context":{"id":"https://dspace-coar.4science.cloud/handle/123456789/28","type":["Document"],"ietf:cite-as":"https://doi.org/10.4598/12123488"},"object":{"id":"https://dspace-coar.4science.cloud/handle/123456789/28","type":["Offer"]},"origin":{"id":"https://generic-service.com/system","type":["Service"],"inbox":"https://notify-inbox.info/inbox7"},"target":{"id":"https://some-organisation.org","type":["Organization"],"inbox":"https://dspace-coar.4science.cloud/server/ldn/inbox"},"inReplyTo":"urn:uuid:d9b4010a-f128-4815-abb2-83707a2ee9cf"}'
+ }
+] as unknown as AdminNotifyMessage[];
+describe('AdminNotifySearchResultComponent', () => {
+ let component: AdminNotifySearchResultComponent;
+ let fixture: ComponentFixture;
+ let objectCache: ObjectCacheService;
+ let requestService: RequestService;
+ let halService: HALEndpointService;
+ let rdbService: RemoteDataBuildService;
+ let adminNotifyMessageService: AdminNotifyMessagesService;
+ let searchConfigService: SearchConfigurationService;
+ let modalService: NgbModal;
+ const requestUUID = '34cfed7c-f597-49ef-9cbe-ea351f0023c2';
+ const testObject = {
+ uuid: 'test-property',
+ name: 'test-property',
+ values: ['value-1', 'value-2']
+ } as ConfigurationProperty;
+
+ beforeEach(async () => {
+ halService = jasmine.createSpyObj('halService', {
+ getEndpoint: cold('a', { a: '' })
+ });
+ adminNotifyMessageService = jasmine.createSpyObj('adminNotifyMessageService', {
+ getDetailedMessages: of(mockAdminNotifyMessages),
+ reprocessMessage: of(mockAdminNotifyMessages),
+ });
+ requestService = jasmine.createSpyObj('requestService', {
+ generateRequestId: requestUUID,
+ send: true
+ });
+ rdbService = jasmine.createSpyObj('rdbService', {
+ buildSingle: cold('a', {
+ a: {
+ payload: testObject
+ }
+ })
+ });
+
+ searchConfigService = jasmine.createSpyObj('searchConfigService', {
+ getCurrentConfiguration: of('NOTIFY.outgoing')
+ });
+ objectCache = {} as ObjectCacheService;
+
+
+ await TestBed.configureTestingModule({
+ imports: [TranslateModule.forRoot()],
+ declarations: [ AdminNotifySearchResultComponent, AdminNotifyDetailModalComponent ],
+ providers: [
+ { provide: AdminNotifyMessagesService, useValue: adminNotifyMessageService },
+ { provide: RouteService, useValue: routeServiceStub },
+ { provide: ActivatedRoute, useValue: new RouterStub() },
+ { provide: HALEndpointService, useValue: halService },
+ { provide: ObjectCacheService, useValue: objectCache },
+ { provide: RequestService, useValue: requestService },
+ { provide: RemoteDataBuildService, useValue: rdbService },
+ { provide: SEARCH_CONFIG_SERVICE, useValue: searchConfigService },
+ DatePipe
+ ],
+ schemas: [NO_ERRORS_SCHEMA]
+ })
+ .compileComponents();
+
+ fixture = TestBed.createComponent(AdminNotifySearchResultComponent);
+ component = fixture.componentInstance;
+ component.searchConfigService = searchConfigService;
+ modalService = (component as any).modalService;
+ spyOn(modalService, 'open').and.returnValue(Object.assign({ componentInstance: Object.assign({ response: observableOf(true) }) }));
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ expect(component.isInbound).toBeFalsy();
+ });
+
+ it('should open modal', () => {
+ component.openDetailModal(mockAdminNotifyMessages[0]);
+ expect(modalService.open).toHaveBeenCalledWith(AdminNotifyDetailModalComponent);
+ });
+
+ it('should map messages', (done) => {
+ component.messagesSubject$.subscribe((messages) => {
+ expect(messages).toEqual(mockAdminNotifyMessages);
+ done();
+ });
+ });
+
+ it('should reprocess message', (done) => {
+ component.reprocessMessage(mockAdminNotifyMessages[0]);
+ component.messagesSubject$.subscribe((messages) => {
+ expect(messages).toEqual(mockAdminNotifyMessages);
+ done();
+ });
+ });
+
+ it('should unsubscribe on destroy', () => {
+ (component as any).subs = [of(null).subscribe()];
+
+ spyOn((component as any).subs[0], 'unsubscribe');
+ component.ngOnDestroy();
+ expect((component as any).subs[0].unsubscribe).toHaveBeenCalled();
+ });
+});
diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-search-result/admin-notify-search-result.component.ts b/src/app/admin/admin-notify-dashboard/admin-notify-search-result/admin-notify-search-result.component.ts
new file mode 100644
index 00000000000..11e80209862
--- /dev/null
+++ b/src/app/admin/admin-notify-dashboard/admin-notify-search-result/admin-notify-search-result.component.ts
@@ -0,0 +1,157 @@
+import { Component, Inject, OnDestroy, OnInit } from '@angular/core';
+import { AdminNotifySearchResult } from '../models/admin-notify-message-search-result.model';
+import { ViewMode } from '../../../core/shared/view-mode.model';
+import { Context } from '../../../core/shared/context.model';
+import { AdminNotifyMessage } from '../models/admin-notify-message.model';
+import {
+ tabulatableObjectsComponent
+} from '../../../shared/object-collection/shared/tabulatable-objects/tabulatable-objects.decorator';
+import {
+ TabulatableResultListElementsComponent
+} from '../../../shared/object-list/search-result-list-element/tabulatable-search-result/tabulatable-result-list-elements.component';
+import { PaginatedList } from '../../../core/data/paginated-list.model';
+import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
+import { AdminNotifyDetailModalComponent } from '../admin-notify-detail-modal/admin-notify-detail-modal.component';
+import { BehaviorSubject, Subscription } from 'rxjs';
+import { AdminNotifyMessagesService } from '../services/admin-notify-messages.service';
+import { SearchConfigurationService } from '../../../core/shared/search/search-configuration.service';
+import { SEARCH_CONFIG_SERVICE } from '../../../my-dspace-page/my-dspace-page.component';
+import { DatePipe } from '@angular/common';
+
+@tabulatableObjectsComponent(PaginatedList, ViewMode.Table, Context.CoarNotify)
+@Component({
+ selector: 'ds-admin-notify-search-result',
+ templateUrl: './admin-notify-search-result.component.html',
+ providers: [
+ {
+ provide: SEARCH_CONFIG_SERVICE,
+ useClass: SearchConfigurationService
+ }
+ ]
+})
+/**
+ * Component for visualization in table format of the search results related to the AdminNotifyDashboardComponent
+ */
+
+
+export class AdminNotifySearchResultComponent extends TabulatableResultListElementsComponent, AdminNotifySearchResult> implements OnInit, OnDestroy{
+ public messagesSubject$: BehaviorSubject = new BehaviorSubject([]);
+ public reprocessStatus = 'QUEUE_STATUS_QUEUED_FOR_RETRY';
+ //we check on one type of config to render specific table headers
+ public isInbound: boolean;
+
+ /**
+ * Statuses for which we display the reprocess button
+ */
+ public validStatusesForReprocess = [
+ 'QUEUE_STATUS_UNTRUSTED',
+ 'QUEUE_STATUS_UNTRUSTED_IP',
+ 'QUEUE_STATUS_FAILED',
+ 'QUEUE_STATUS_UNMAPPED_ACTION'
+ ];
+
+
+ /**
+ * Array to track all subscriptions and unsubscribe them onDestroy
+ * @type {Array}
+ */
+ private subs: Subscription[] = [];
+
+ /**
+ * Keys to be formatted as date
+ * @private
+ */
+
+ private dateTypeKeys: string[] = ['queueLastStartTime', 'queueTimeout'];
+
+ /**
+ * Keys to be not shown in detail
+ * @private
+ */
+ private messageKeys: string[] = [
+ 'type',
+ 'id',
+ 'coarNotifyType',
+ 'activityStreamType',
+ 'inReplyTo',
+ 'queueAttempts',
+ 'queueLastStartTime',
+ 'queueStatusLabel',
+ 'queueTimeout'
+ ];
+
+ /**
+ * The format for the date values
+ * @private
+ */
+ private dateFormat = 'YYYY/MM/d hh:mm:ss';
+
+ constructor(private modalService: NgbModal,
+ private adminNotifyMessagesService: AdminNotifyMessagesService,
+ private datePipe: DatePipe,
+ @Inject(SEARCH_CONFIG_SERVICE) public searchConfigService: SearchConfigurationService) {
+ super();
+ }
+
+ /**
+ * Map messages on init for readable representation
+ */
+ ngOnInit() {
+ this.mapDetailsToMessages();
+ this.subs.push(this.searchConfigService.getCurrentConfiguration('')
+ .subscribe(configuration => {
+ this.isInbound = configuration.startsWith('NOTIFY.incoming');
+ })
+ );
+ }
+
+ ngOnDestroy() {
+ this.subs.forEach(sub => sub.unsubscribe());
+ }
+
+ /**
+ * Open modal for details visualization
+ * @param notifyMessage the message to be displayed
+ */
+ openDetailModal(notifyMessage: AdminNotifyMessage) {
+ const modalRef = this.modalService.open(AdminNotifyDetailModalComponent);
+ const messageToOpen = {...notifyMessage};
+
+ this.messageKeys.forEach(key => {
+ if (this.dateTypeKeys.includes(key)) {
+ messageToOpen[key] = this.datePipe.transform(messageToOpen[key], this.dateFormat);
+ }
+ });
+ // format COAR message for technical visualization
+ messageToOpen.message = JSON.stringify(JSON.parse(notifyMessage.message), null, 2);
+
+ modalRef.componentInstance.notifyMessage = messageToOpen;
+ modalRef.componentInstance.notifyMessageKeys = this.messageKeys;
+ }
+
+ /**
+ * Reprocess message in status QUEUE_STATUS_QUEUED_FOR_RETRY and update results
+ * @param message the message to be reprocessed
+ */
+ reprocessMessage(message: AdminNotifyMessage) {
+ this.subs.push(
+ this.adminNotifyMessagesService.reprocessMessage(message, this.messagesSubject$)
+ .subscribe(response => {
+ this.messagesSubject$.next(response);
+ }
+ )
+ );
+ }
+
+
+ /**
+ * Map readable results to messages
+ * @private
+ */
+ private mapDetailsToMessages() {
+ this.subs.push(this.adminNotifyMessagesService.getDetailedMessages(this.objects?.page.map(pageResult => pageResult.indexableObject))
+ .subscribe(response => {
+ this.messagesSubject$.next(response);
+ }));
+ }
+}
diff --git a/src/app/admin/admin-notify-dashboard/models/admin-notify-message-search-result.model.ts b/src/app/admin/admin-notify-dashboard/models/admin-notify-message-search-result.model.ts
new file mode 100644
index 00000000000..236a564f203
--- /dev/null
+++ b/src/app/admin/admin-notify-dashboard/models/admin-notify-message-search-result.model.ts
@@ -0,0 +1,8 @@
+import { AdminNotifyMessage } from './admin-notify-message.model';
+import { searchResultFor } from '../../../shared/search/search-result-element-decorator';
+import { SearchResult } from '../../../shared/search/models/search-result.model';
+
+
+@searchResultFor(AdminNotifyMessage)
+export class AdminNotifySearchResult extends SearchResult {
+}
diff --git a/src/app/admin/admin-notify-dashboard/models/admin-notify-message.model.ts b/src/app/admin/admin-notify-dashboard/models/admin-notify-message.model.ts
new file mode 100644
index 00000000000..cca26f0fb62
--- /dev/null
+++ b/src/app/admin/admin-notify-dashboard/models/admin-notify-message.model.ts
@@ -0,0 +1,165 @@
+import { autoserialize, deserialize, inheritSerialization } from 'cerialize';
+import { typedObject } from '../../../core/cache/builders/build-decorators';
+import { ADMIN_NOTIFY_MESSAGE } from './admin-notify-message.resource-type';
+import { excludeFromEquals } from '../../../core/utilities/equals.decorators';
+import { DSpaceObject } from '../../../core/shared/dspace-object.model';
+import { GenericConstructor } from '../../../core/shared/generic-constructor';
+import { ListableObject } from '../../../shared/object-collection/shared/listable-object.model';
+import { Observable } from 'rxjs';
+/**
+ * A message that includes admin notify info
+ */
+@typedObject
+@inheritSerialization(DSpaceObject)
+export class AdminNotifyMessage extends DSpaceObject {
+ static type = ADMIN_NOTIFY_MESSAGE;
+
+ /**
+ * The type of the resource
+ */
+ @excludeFromEquals
+ type = ADMIN_NOTIFY_MESSAGE;
+
+ /**
+ * The id of the message
+ */
+ @autoserialize
+ id: string;
+
+ /**
+ * The id of the notification
+ */
+ @autoserialize
+ notificationId: string;
+
+ /**
+ * The type of the notification
+ */
+ @autoserialize
+ notificationType: string;
+
+ /**
+ * The type of the notification
+ */
+ @autoserialize
+ coarNotifyType: string;
+
+ /**
+ * The type of the activity
+ */
+ @autoserialize
+ activityStreamType: string;
+
+ /**
+ * The object the message reply to
+ */
+ @autoserialize
+ inReplyTo: string;
+
+ /**
+ * The object the message relates to
+ */
+ @autoserialize
+ object: string;
+
+ /**
+ * The name of the related item
+ */
+ @autoserialize
+ relatedItem: string;
+
+ /**
+ * The name of the related ldn service
+ */
+ @autoserialize
+ ldnService: string;
+
+ /**
+ * The context of the message
+ */
+ @autoserialize
+ context: string;
+
+ /**
+ * The related COAR message
+ */
+ @autoserialize
+ message: string;
+
+ /**
+ * The attempts of the queue
+ */
+ @autoserialize
+ queueAttempts: number;
+
+ /**
+ * Timestamp of the last queue attempt
+ */
+ @autoserialize
+ queueLastStartTime: string;
+
+ /**
+ * The type of the activity stream
+ */
+ @autoserialize
+ origin: number | string;
+
+ /**
+ * The type of the activity stream
+ */
+ @autoserialize
+ target: number | string;
+
+ /**
+ * The label for the status of the queue
+ */
+ @autoserialize
+ queueStatusLabel: string;
+
+ /**
+ * The timeout of the queue
+ */
+ @autoserialize
+ queueTimeout: string;
+
+ /**
+ * The status of the queue
+ */
+ @autoserialize
+ queueStatus: number;
+
+ /**
+ * Thumbnail link used when browsing items with showThumbs config enabled.
+ */
+ @autoserialize
+ thumbnail: string;
+
+ /**
+ * The observable pointing to the item itself
+ */
+ @autoserialize
+ item: Observable;
+
+ /**
+ * The observable pointing to the access status of the item
+ */
+ @autoserialize
+ accessStatus: Observable;
+
+
+
+ @deserialize
+ _links: {
+ self: {
+ href: string;
+ };
+ };
+
+ get self(): string {
+ return this._links.self.href;
+ }
+
+ getRenderTypes(): (string | GenericConstructor)[] {
+ return [this.constructor as GenericConstructor];
+ }
+}
diff --git a/src/app/admin/admin-notify-dashboard/models/admin-notify-message.resource-type.ts b/src/app/admin/admin-notify-dashboard/models/admin-notify-message.resource-type.ts
new file mode 100644
index 00000000000..994146adb3d
--- /dev/null
+++ b/src/app/admin/admin-notify-dashboard/models/admin-notify-message.resource-type.ts
@@ -0,0 +1,9 @@
+import { ResourceType } from '../../../core/shared/resource-type';
+
+/**
+ * The resource type for AdminNotifyMessage
+ *
+ * Needs to be in a separate file to prevent circular
+ * dependencies in webpack.
+ */
+export const ADMIN_NOTIFY_MESSAGE = new ResourceType('message');
diff --git a/src/app/admin/admin-notify-dashboard/services/admin-notify-messages.service.spec.ts b/src/app/admin/admin-notify-dashboard/services/admin-notify-messages.service.spec.ts
new file mode 100644
index 00000000000..975950a33de
--- /dev/null
+++ b/src/app/admin/admin-notify-dashboard/services/admin-notify-messages.service.spec.ts
@@ -0,0 +1,114 @@
+import { cold } from 'jasmine-marbles';
+import { AdminNotifyMessagesService } from './admin-notify-messages.service';
+import { RequestService } from '../../../core/data/request.service';
+import { RemoteDataBuildService } from '../../../core/cache/builders/remote-data-build.service';
+import { ObjectCacheService } from '../../../core/cache/object-cache.service';
+import { HALEndpointService } from '../../../core/shared/hal-endpoint.service';
+import { NotificationsService } from '../../../shared/notifications/notifications.service';
+import { LdnServicesService } from '../../admin-ldn-services/ldn-services-data/ldn-services-data.service';
+import { ItemDataService } from '../../../core/data/item-data.service';
+import { RequestEntry } from '../../../core/data/request-entry.model';
+import { RestResponse } from '../../../core/cache/response.models';
+import { BehaviorSubject, of } from 'rxjs';
+import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
+import { RemoteData } from '../../../core/data/remote-data';
+import { RequestEntryState } from '../../../core/data/request-entry-state.model';
+import {
+ mockAdminNotifyMessages} from '../admin-notify-search-result/admin-notify-search-result.component.spec';
+import { take } from 'rxjs/operators';
+import { deepClone } from 'fast-json-patch';
+import { AdminNotifyMessage } from '../models/admin-notify-message.model';
+
+describe('AdminNotifyMessagesService test', () => {
+ let service: AdminNotifyMessagesService;
+ let requestService: RequestService;
+ let rdbService: RemoteDataBuildService;
+ let objectCache: ObjectCacheService;
+ let halService: HALEndpointService;
+ let notificationsService: NotificationsService;
+ let ldnServicesService: LdnServicesService;
+ let itemDataService: ItemDataService;
+ let responseCacheEntry: RequestEntry;
+ let mockMessages: AdminNotifyMessage[];
+
+ const endpointURL = `https://rest.api/rest/api/ldn/messages`;
+ const requestUUID = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a';
+ const remoteDataMocks = {
+ Success: new RemoteData(null, null, null, RequestEntryState.Success, null, null, 200),
+ };
+ const testLdnServiceName = 'testLdnService';
+ const testRelatedItemName = 'testRelatedItem';
+
+ function initTestService() {
+ return new AdminNotifyMessagesService(
+ requestService,
+ rdbService,
+ objectCache,
+ halService,
+ notificationsService,
+ ldnServicesService,
+ itemDataService
+ );
+ }
+
+ beforeEach(() => {
+ mockMessages = deepClone(mockAdminNotifyMessages);
+ objectCache = {} as ObjectCacheService;
+ notificationsService = {} as NotificationsService;
+ responseCacheEntry = new RequestEntry();
+ responseCacheEntry.request = { href: endpointURL } as any;
+ responseCacheEntry.response = new RestResponse(true, 200, 'Success');
+
+
+ requestService = jasmine.createSpyObj('requestService', {
+ generateRequestId: requestUUID,
+ send: true,
+ removeByHrefSubstring: {},
+ getByHref: of(responseCacheEntry),
+ getByUUID: of(responseCacheEntry),
+ });
+
+ halService = jasmine.createSpyObj('halService', {
+ getEndpoint: of(endpointURL)
+ });
+
+ rdbService = jasmine.createSpyObj('rdbService', {
+ buildSingle: createSuccessfulRemoteDataObject$({}, 500),
+ buildList: cold('a', { a: remoteDataMocks.Success }),
+ buildFromRequestUUID: createSuccessfulRemoteDataObject$(mockMessages)
+ });
+
+ ldnServicesService = jasmine.createSpyObj('ldnServicesService', {
+ findById: createSuccessfulRemoteDataObject$({name: testLdnServiceName}),
+ });
+
+ itemDataService = jasmine.createSpyObj('itemDataService', {
+ findById: createSuccessfulRemoteDataObject$({name: testRelatedItemName}),
+ });
+
+ service = initTestService();
+ });
+
+ describe('Admin Notify service', () => {
+ it('should be defined', () => {
+ expect(service).toBeDefined();
+ });
+
+ it('should get details for messages', (done) => {
+ service.getDetailedMessages(mockMessages).pipe(take(1)).subscribe((detailedMessages) => {
+ expect(detailedMessages[0].ldnService).toEqual(testLdnServiceName);
+ expect(detailedMessages[0].relatedItem).toEqual(testRelatedItemName);
+ done();
+ });
+ });
+
+ it('should reprocess message', (done) => {
+ const behaviorSubject = new BehaviorSubject(mockMessages);
+ service.reprocessMessage(mockMessages[0], behaviorSubject).pipe(take(1)).subscribe((reprocessedMessages) => {
+ expect(reprocessedMessages.length).toEqual(2);
+ expect(reprocessedMessages).toEqual(mockMessages);
+ done();
+ });
+ });
+ });
+});
diff --git a/src/app/admin/admin-notify-dashboard/services/admin-notify-messages.service.ts b/src/app/admin/admin-notify-dashboard/services/admin-notify-messages.service.ts
new file mode 100644
index 00000000000..ee78957abeb
--- /dev/null
+++ b/src/app/admin/admin-notify-dashboard/services/admin-notify-messages.service.ts
@@ -0,0 +1,100 @@
+import {Injectable} from '@angular/core';
+import {dataService} from '../../../core/data/base/data-service.decorator';
+import {IdentifiableDataService} from '../../../core/data/base/identifiable-data.service';
+import {RequestService} from '../../../core/data/request.service';
+import {RemoteDataBuildService} from '../../../core/cache/builders/remote-data-build.service';
+import {ObjectCacheService} from '../../../core/cache/object-cache.service';
+import {HALEndpointService} from '../../../core/shared/hal-endpoint.service';
+import {NotificationsService} from '../../../shared/notifications/notifications.service';
+import { BehaviorSubject, from, Observable, of, scan } from 'rxjs';
+import { ADMIN_NOTIFY_MESSAGE } from '../models/admin-notify-message.resource-type';
+import { AdminNotifyMessage } from '../models/admin-notify-message.model';
+import { map, mergeMap, switchMap, tap } from 'rxjs/operators';
+import { getAllSucceededRemoteDataPayload, getFirstCompletedRemoteData } from '../../../core/shared/operators';
+import { LdnServicesService } from '../../admin-ldn-services/ldn-services-data/ldn-services-data.service';
+import { ItemDataService } from '../../../core/data/item-data.service';
+import { PostRequest } from '../../../core/data/request.models';
+import { RestRequest } from '../../../core/data/rest-request.model';
+
+/**
+ * Injectable service responsible for fetching/sending data from/to the REST API on the messages endpoint.
+ *
+ * @export
+ * @class AdminNotifyMessagesService
+ * @extends {IdentifiableDataService}
+ */
+@Injectable()
+@dataService(ADMIN_NOTIFY_MESSAGE)
+export class AdminNotifyMessagesService extends IdentifiableDataService {
+
+ protected reprocessEndpoint = 'enqueueretry';
+
+ constructor(
+ protected requestService: RequestService,
+ protected rdbService: RemoteDataBuildService,
+ protected objectCache: ObjectCacheService,
+ protected halService: HALEndpointService,
+ protected notificationsService: NotificationsService,
+ private ldnServicesService: LdnServicesService,
+ private itemDataService: ItemDataService,
+ ) {
+ super('messages', requestService, rdbService, objectCache, halService);
+ }
+
+
+ /**
+ * Add detailed information to each message
+ * @param messages the messages to which add detailded info
+ */
+ public getDetailedMessages(messages: AdminNotifyMessage[]): Observable {
+ return from(messages).pipe(
+ mergeMap(message =>
+ message.target || message.origin ? this.ldnServicesService.findById((message.target || message.origin).toString()).pipe(
+ getAllSucceededRemoteDataPayload(),
+ map(detail => ({...message, ldnService: detail.name}))
+ ) : of(message),
+ ),
+ mergeMap(message =>
+ message.object || message.context ? this.itemDataService.findById(message.object || message.context).pipe(
+ getAllSucceededRemoteDataPayload(),
+ map(detail => ({...message, relatedItem: detail.name}))
+ ) : of(message),
+ ),
+ scan((acc: any, value: any) => [...acc, value], []),
+ );
+ }
+
+ /**
+ * Reprocess message in status QUEUE_STATUS_QUEUED_FOR_RETRY and update results
+ * @param message the message to reprocess
+ * @param messageSubject the current visualised messages source
+ */
+ public reprocessMessage(message: AdminNotifyMessage, messageSubject: BehaviorSubject): Observable {
+ const requestId = this.requestService.generateRequestId();
+
+ return this.halService.getEndpoint(this.reprocessEndpoint).pipe(
+ map(endpoint => endpoint.replace('{id}', message.id)),
+ map((endpointURL: string) => new PostRequest(requestId, endpointURL)),
+ tap(request => this.requestService.send(request)),
+ switchMap((request: RestRequest) => this.rdbService.buildFromRequestUUID(request.uuid)),
+ getFirstCompletedRemoteData(),
+ getAllSucceededRemoteDataPayload(),
+ mergeMap(reprocessedMessage => this.getDetailedMessages([reprocessedMessage])),
+ ).pipe(
+ mergeMap((newMessages) => messageSubject.pipe(
+ map(messages => {
+ const detailedReprocessedMessage = newMessages[0];
+ const messageToUpdate = messages.find(currentMessage => currentMessage.id === message.id);
+ const indexOfMessageToUpdate = messages.indexOf(messageToUpdate);
+ detailedReprocessedMessage.target = message.target;
+ detailedReprocessedMessage.object = message.object;
+ detailedReprocessedMessage.origin = message.origin;
+ detailedReprocessedMessage.context = message.context;
+ messages[indexOfMessageToUpdate] = detailedReprocessedMessage;
+
+ return messages;
+ })
+ )),
+ );
+ }
+}
diff --git a/src/app/admin/admin-reports/admin-reports-routing.module.ts b/src/app/admin/admin-reports/admin-reports-routing.module.ts
new file mode 100644
index 00000000000..9022429502f
--- /dev/null
+++ b/src/app/admin/admin-reports/admin-reports-routing.module.ts
@@ -0,0 +1,37 @@
+import { FilteredCollectionsComponent } from './filtered-collections/filtered-collections.component';
+import { FilteredItemsComponent } from './filtered-items/filtered-items.component';
+import { RouterModule } from '@angular/router';
+import { NgModule } from '@angular/core';
+import { I18nBreadcrumbResolver } from '../../core/breadcrumbs/i18n-breadcrumb.resolver';
+
+@NgModule({
+ imports: [
+ RouterModule.forChild([
+ {
+ path: 'collections',
+ resolve: { breadcrumb: I18nBreadcrumbResolver },
+ data: {title: 'admin.reports.collections.title', breadcrumbKey: 'admin.reports.collections'},
+ children: [
+ {
+ path: '',
+ component: FilteredCollectionsComponent
+ }
+ ]
+ },
+ {
+ path: 'queries',
+ resolve: { breadcrumb: I18nBreadcrumbResolver },
+ data: {title: 'admin.reports.items.title', breadcrumbKey: 'admin.reports.items'},
+ children: [
+ {
+ path: '',
+ component: FilteredItemsComponent
+ }
+ ]
+ }
+ ])
+ ]
+})
+export class AdminReportsRoutingModule {
+
+}
diff --git a/src/app/admin/admin-reports/admin-reports.module.ts b/src/app/admin/admin-reports/admin-reports.module.ts
new file mode 100644
index 00000000000..70dfba8a072
--- /dev/null
+++ b/src/app/admin/admin-reports/admin-reports.module.ts
@@ -0,0 +1,28 @@
+import { NgModule } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FilteredCollectionsComponent } from './filtered-collections/filtered-collections.component';
+import { RouterModule } from '@angular/router';
+import { SharedModule } from '../../shared/shared.module';
+import { FormModule } from '../../shared/form/form.module';
+import { FilteredItemsComponent } from './filtered-items/filtered-items.component';
+import { AdminReportsRoutingModule } from './admin-reports-routing.module';
+import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap';
+import { FiltersComponent } from './filters-section/filters-section.component';
+
+@NgModule({
+ imports: [
+ CommonModule,
+ SharedModule,
+ RouterModule,
+ FormModule,
+ AdminReportsRoutingModule,
+ NgbAccordionModule
+ ],
+ declarations: [
+ FilteredCollectionsComponent,
+ FilteredItemsComponent,
+ FiltersComponent
+ ]
+})
+export class AdminReportsModule {
+}
diff --git a/src/app/admin/admin-reports/filtered-collections/filtered-collection.model.ts b/src/app/admin/admin-reports/filtered-collections/filtered-collection.model.ts
new file mode 100644
index 00000000000..a48b1e02fa8
--- /dev/null
+++ b/src/app/admin/admin-reports/filtered-collections/filtered-collection.model.ts
@@ -0,0 +1,36 @@
+export class FilteredCollection {
+
+ public label: string;
+ public handle: string;
+ public communityLabel: string;
+ public communityHandle: string;
+ public nbTotalItems: number;
+ public values = {};
+ public allFiltersValue: number;
+
+ public clear() {
+ this.label = '';
+ this.handle = '';
+ this.communityLabel = '';
+ this.communityHandle = '';
+ this.nbTotalItems = 0;
+ this.values = {};
+ this.allFiltersValue = 0;
+ }
+
+ public deserialize(object: any) {
+ this.clear();
+ this.label = object.label;
+ this.handle = object.handle;
+ this.communityLabel = object.community_label;
+ this.communityHandle = object.community_handle;
+ this.nbTotalItems = object.nb_total_items;
+ let valuesPerFilter = object.values;
+ for (let filter in valuesPerFilter) {
+ if (valuesPerFilter.hasOwnProperty(filter)) {
+ this.values[filter] = valuesPerFilter[filter];
+ }
+ }
+ this.allFiltersValue = object.all_filters_value;
+ }
+}
diff --git a/src/app/admin/admin-reports/filtered-collections/filtered-collections.component.html b/src/app/admin/admin-reports/filtered-collections/filtered-collections.component.html
new file mode 100644
index 00000000000..5199a115a6e
--- /dev/null
+++ b/src/app/admin/admin-reports/filtered-collections/filtered-collections.component.html
@@ -0,0 +1,64 @@
+
diff --git a/src/app/admin/admin-reports/filtered-collections/filtered-collections.component.scss b/src/app/admin/admin-reports/filtered-collections/filtered-collections.component.scss
new file mode 100644
index 00000000000..73ce5275e5b
--- /dev/null
+++ b/src/app/admin/admin-reports/filtered-collections/filtered-collections.component.scss
@@ -0,0 +1,3 @@
+.num {
+ text-align: center;
+}
diff --git a/src/app/admin/admin-reports/filtered-collections/filtered-collections.component.spec.ts b/src/app/admin/admin-reports/filtered-collections/filtered-collections.component.spec.ts
new file mode 100644
index 00000000000..fe5dc612cad
--- /dev/null
+++ b/src/app/admin/admin-reports/filtered-collections/filtered-collections.component.spec.ts
@@ -0,0 +1,83 @@
+import { waitForAsync, ComponentFixture, TestBed} from '@angular/core/testing';
+import { NO_ERRORS_SCHEMA } from '@angular/core';
+import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
+import { TranslateLoaderMock } from 'src/app/shared/mocks/translate-loader.mock';
+import { FormBuilder } from '@angular/forms';
+import { FilteredCollectionsComponent } from './filtered-collections.component';
+import { DspaceRestService } from 'src/app/core/dspace-rest/dspace-rest.service';
+import { NgbAccordion, NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap';
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { of as observableOf } from 'rxjs';
+import { RawRestResponse } from 'src/app/core/dspace-rest/raw-rest-response.model';
+
+describe('FiltersComponent', () => {
+ let component: FilteredCollectionsComponent;
+ let fixture: ComponentFixture;
+ let formBuilder: FormBuilder;
+
+ const expected = {
+ payload: {
+ collections: [],
+ summary: {
+ label: 'Test'
+ }
+ },
+ statusCode: 200,
+ statusText: 'OK'
+ } as RawRestResponse;
+
+ beforeEach(waitForAsync(() => {
+ TestBed.configureTestingModule({
+ declarations: [FilteredCollectionsComponent],
+ imports: [
+ NgbAccordionModule,
+ TranslateModule.forRoot({
+ loader: {
+ provide: TranslateLoader,
+ useClass: TranslateLoaderMock
+ }
+ }),
+ HttpClientTestingModule
+ ],
+ providers: [
+ FormBuilder,
+ DspaceRestService
+ ],
+ schemas: [NO_ERRORS_SCHEMA]
+ });
+ }));
+
+ beforeEach(waitForAsync(() => {
+ formBuilder = TestBed.inject(FormBuilder);
+
+ fixture = TestBed.createComponent(FilteredCollectionsComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ }));
+
+ it('should create the component', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should be displaying the filters panel initially', () => {
+ let accordion: NgbAccordion = component.accordionComponent;
+ expect(accordion.isExpanded('filters')).toBeTrue();
+ });
+
+ describe('toggle', () => {
+ beforeEach(() => {
+ spyOn(component, 'getFilteredCollections').and.returnValue(observableOf(expected));
+ spyOn(component.results, 'deserialize');
+ spyOn(component.accordionComponent, 'expand').and.callThrough();
+ component.submit();
+ fixture.detectChanges();
+ });
+
+ it('should be displaying the collections panel after submitting', waitForAsync(() => {
+ fixture.whenStable().then(() => {
+ expect(component.accordionComponent.expand).toHaveBeenCalledWith('collections');
+ expect(component.accordionComponent.isExpanded('collections')).toBeTrue();
+ });
+ }));
+ });
+});
diff --git a/src/app/admin/admin-reports/filtered-collections/filtered-collections.component.ts b/src/app/admin/admin-reports/filtered-collections/filtered-collections.component.ts
new file mode 100644
index 00000000000..23fde052789
--- /dev/null
+++ b/src/app/admin/admin-reports/filtered-collections/filtered-collections.component.ts
@@ -0,0 +1,70 @@
+import { Component, ViewChild } from '@angular/core';
+import { FormBuilder, FormGroup } from '@angular/forms';
+import { NgbAccordion } from '@ng-bootstrap/ng-bootstrap';
+import { Observable } from 'rxjs';
+import { RestRequestMethod } from 'src/app/core/data/rest-request-method';
+import { DspaceRestService } from 'src/app/core/dspace-rest/dspace-rest.service';
+import { RawRestResponse } from 'src/app/core/dspace-rest/raw-rest-response.model';
+import { environment } from 'src/environments/environment';
+import { FiltersComponent } from '../filters-section/filters-section.component';
+import { FilteredCollections } from './filtered-collections.model';
+
+/**
+ * Component representing the Filtered Collections content report
+ */
+@Component({
+ selector: 'ds-report-filtered-collections',
+ templateUrl: './filtered-collections.component.html',
+ styleUrls: ['./filtered-collections.component.scss']
+})
+export class FilteredCollectionsComponent {
+
+ queryForm: FormGroup;
+ results: FilteredCollections = new FilteredCollections();
+ @ViewChild('acc') accordionComponent: NgbAccordion;
+
+ constructor(
+ private formBuilder: FormBuilder,
+ private restService: DspaceRestService) {}
+
+ ngOnInit() {
+ this.queryForm = this.formBuilder.group({
+ filters: FiltersComponent.formGroup(this.formBuilder)
+ });
+ }
+
+ filtersFormGroup(): FormGroup {
+ return this.queryForm.get('filters') as FormGroup;
+ }
+
+ getGroup(filterId: string): string {
+ return FiltersComponent.getGroup(filterId).id;
+ }
+
+ submit() {
+ this
+ .getFilteredCollections()
+ .subscribe(
+ response => {
+ this.results.deserialize(response.payload);
+ this.accordionComponent.expand('collections');
+ }
+ );
+ }
+
+ getFilteredCollections(): Observable {
+ let params = this.toQueryString();
+ if (params.length > 0) {
+ params = `?${params}`;
+ }
+ let scheme = environment.rest.ssl ? 'https' : 'http';
+ let urlRestApp = `${scheme}://${environment.rest.host}:${environment.rest.port}${environment.rest.nameSpace}`;
+ return this.restService.request(RestRequestMethod.GET, `${urlRestApp}/api/contentreport/filteredcollections${params}`);
+ }
+
+ private toQueryString(): string {
+ let params = FiltersComponent.toQueryString(this.queryForm.value.filters);
+ return params;
+ }
+
+}
diff --git a/src/app/admin/admin-reports/filtered-collections/filtered-collections.model.ts b/src/app/admin/admin-reports/filtered-collections/filtered-collections.model.ts
new file mode 100644
index 00000000000..6ea5a2fc80a
--- /dev/null
+++ b/src/app/admin/admin-reports/filtered-collections/filtered-collections.model.ts
@@ -0,0 +1,26 @@
+import { FilteredCollection } from './filtered-collection.model';
+
+export class FilteredCollections {
+
+ public collections: Array = [];
+ public summary: FilteredCollection = new FilteredCollection();
+
+ public clear() {
+ this.collections.splice(0, this.collections.length);
+ this.summary.clear();
+ }
+
+ public deserialize(object: any) {
+ this.clear();
+ let summary = object.summary;
+ this.summary.deserialize(summary);
+ let collections = object.collections;
+ for (let i = 0; i < collections.length; i++) {
+ let collection = collections[i];
+ let coll = new FilteredCollection();
+ coll.deserialize(collection);
+ this.collections.push(coll);
+ }
+ }
+
+}
diff --git a/src/app/admin/admin-reports/filtered-items/filtered-items-model.ts b/src/app/admin/admin-reports/filtered-items/filtered-items-model.ts
new file mode 100644
index 00000000000..2c384fe39cf
--- /dev/null
+++ b/src/app/admin/admin-reports/filtered-items/filtered-items-model.ts
@@ -0,0 +1,23 @@
+import { Item } from 'src/app/core/shared/item.model';
+
+export class FilteredItems {
+
+ public items: Item[] = [];
+ public itemCount: number;
+
+ public clear() {
+ this.items.splice(0, this.items.length);
+ }
+
+ public deserialize(object: any, offset: number = 0) {
+ this.clear();
+ this.itemCount = object.itemCount;
+ let items = object.items;
+ for (let i = 0; i < items.length; i++) {
+ let item = items[i];
+ item.index = this.items.length + offset + 1;
+ this.items.push(item);
+ }
+ }
+
+}
diff --git a/src/app/admin/admin-reports/filtered-items/filtered-items.component.html b/src/app/admin/admin-reports/filtered-items/filtered-items.component.html
new file mode 100644
index 00000000000..4b6679bdbca
--- /dev/null
+++ b/src/app/admin/admin-reports/filtered-items/filtered-items.component.html
@@ -0,0 +1,175 @@
+
diff --git a/src/app/admin/admin-reports/filtered-items/filtered-items.component.scss b/src/app/admin/admin-reports/filtered-items/filtered-items.component.scss
new file mode 100644
index 00000000000..73ce5275e5b
--- /dev/null
+++ b/src/app/admin/admin-reports/filtered-items/filtered-items.component.scss
@@ -0,0 +1,3 @@
+.num {
+ text-align: center;
+}
diff --git a/src/app/admin/admin-reports/filtered-items/filtered-items.component.ts b/src/app/admin/admin-reports/filtered-items/filtered-items.component.ts
new file mode 100644
index 00000000000..7fbf90565dc
--- /dev/null
+++ b/src/app/admin/admin-reports/filtered-items/filtered-items.component.ts
@@ -0,0 +1,336 @@
+import { Component, ViewChild } from '@angular/core';
+import { FormArray, FormBuilder, FormControl, FormGroup } from '@angular/forms';
+import { NgbAccordion } from '@ng-bootstrap/ng-bootstrap';
+import { TranslateService } from '@ngx-translate/core';
+import { map, Observable } from 'rxjs';
+import { CollectionDataService } from 'src/app/core/data/collection-data.service';
+import { CommunityDataService } from 'src/app/core/data/community-data.service';
+import { MetadataFieldDataService } from 'src/app/core/data/metadata-field-data.service';
+import { MetadataSchemaDataService } from 'src/app/core/data/metadata-schema-data.service';
+import { RestRequestMethod } from 'src/app/core/data/rest-request-method';
+import { DspaceRestService } from 'src/app/core/dspace-rest/dspace-rest.service';
+import { RawRestResponse } from 'src/app/core/dspace-rest/raw-rest-response.model';
+import { MetadataField } from 'src/app/core/metadata/metadata-field.model';
+import { MetadataSchema } from 'src/app/core/metadata/metadata-schema.model';
+import { Collection } from 'src/app/core/shared/collection.model';
+import { Community } from 'src/app/core/shared/community.model';
+import { Item } from 'src/app/core/shared/item.model';
+import { getFirstSucceededRemoteListPayload } from 'src/app/core/shared/operators';
+import { isEmpty } from 'src/app/shared/empty.util';
+import { environment } from 'src/environments/environment';
+import { FiltersComponent } from '../filters-section/filters-section.component';
+import { FilteredItems } from './filtered-items-model';
+import { OptionVO } from './option-vo.model';
+import { PresetQuery } from './preset-query.model';
+import { QueryPredicate } from './query-predicate.model';
+
+/**
+ * Component representing the Filtered Items content report.
+ */
+@Component({
+ selector: 'ds-report-filtered-items',
+ templateUrl: './filtered-items.component.html',
+ styleUrls: ['./filtered-items.component.scss']
+})
+export class FilteredItemsComponent {
+
+ collections: OptionVO[];
+ presetQueries: PresetQuery[];
+ metadataFields: OptionVO[];
+ metadataFieldsWithAny: OptionVO[];
+ predicates: OptionVO[];
+ pageLimits: OptionVO[];
+
+ queryForm: FormGroup;
+ currentPage = 0;
+ results: FilteredItems = new FilteredItems();
+ results$: Observable- ;
+ @ViewChild('acc') accordionComponent: NgbAccordion;
+
+ constructor(
+ private communityService: CommunityDataService,
+ private collectionService: CollectionDataService,
+ private metadataSchemaService: MetadataSchemaDataService,
+ private metadataFieldService: MetadataFieldDataService,
+ private translateService: TranslateService,
+ private formBuilder: FormBuilder,
+ private restService: DspaceRestService) {}
+
+ ngOnInit() {
+ this.loadCollections();
+ this.loadPresetQueries();
+ this.loadMetadataFields();
+ this.loadPredicates();
+ this.loadPageLimits();
+
+ let formQueryPredicates: FormGroup[] = [
+ new QueryPredicate().toFormGroup(this.formBuilder)
+ ];
+
+ this.queryForm = this.formBuilder.group({
+ collections: this.formBuilder.control([''], []),
+ presetQuery: this.formBuilder.control('new', []),
+ queryPredicates: this.formBuilder.array(formQueryPredicates),
+ pageLimit: this.formBuilder.control('10', []),
+ filters: FiltersComponent.formGroup(this.formBuilder),
+ additionalFields: this.formBuilder.control([], [])
+ });
+ }
+
+ loadCollections(): void {
+ this.collections = [];
+ let wholeRepo$ = this.translateService.stream('admin.reports.items.wholeRepo');
+ this.collections.push(OptionVO.collectionLoc('', wholeRepo$));
+
+ this.communityService.findAll({ elementsPerPage: 10000, currentPage: 1 }).pipe(
+ getFirstSucceededRemoteListPayload()
+ ).subscribe(
+ (communitiesRest: Community[]) => {
+ communitiesRest.forEach(community => {
+ let commVO = OptionVO.collection(community.uuid, community.name, true);
+ this.collections.push(commVO);
+
+ this.collectionService.findByParent(community.uuid, { elementsPerPage: 10000, currentPage: 1 }).pipe(
+ getFirstSucceededRemoteListPayload()
+ ).subscribe(
+ (collectionsRest: Collection[]) => {
+ collectionsRest.filter(collection => collection.firstMetadataValue('dspace.entity.type') === 'Publication')
+ .forEach(collection => {
+ let collVO = OptionVO.collection(collection.uuid, '–' + collection.name);
+ this.collections.push(collVO);
+ });
+ }
+ );
+ });
+ }
+ );
+ }
+
+ loadPresetQueries(): void {
+ this.presetQueries = [
+ PresetQuery.of('new', 'admin.reports.items.preset.new', []),
+ PresetQuery.of('q1', 'admin.reports.items.preset.hasNoTitle', [
+ QueryPredicate.of('dc.title', QueryPredicate.DOES_NOT_EXIST)
+ ]),
+ PresetQuery.of('q2', 'admin.reports.items.preset.hasNoIdentifierUri', [
+ QueryPredicate.of('dc.identifier.uri', QueryPredicate.DOES_NOT_EXIST)
+ ]),
+ PresetQuery.of('q3', 'admin.reports.items.preset.hasCompoundSubject', [
+ QueryPredicate.of('dc.subject.*', QueryPredicate.LIKE, '%;%')
+ ]),
+ PresetQuery.of('q4', 'admin.reports.items.preset.hasCompoundAuthor', [
+ QueryPredicate.of('dc.contributor.author', QueryPredicate.LIKE, '% and %')
+ ]),
+ PresetQuery.of('q5', 'admin.reports.items.preset.hasCompoundCreator', [
+ QueryPredicate.of('dc.creator', QueryPredicate.LIKE, '% and %')
+ ]),
+ PresetQuery.of('q6', 'admin.reports.items.preset.hasUrlInDescription', [
+ QueryPredicate.of('dc.description', QueryPredicate.MATCHES, '^.*(http://|https://|mailto:).*$')
+ ]),
+ PresetQuery.of('q7', 'admin.reports.items.preset.hasFullTextInProvenance', [
+ QueryPredicate.of('dc.description.provenance', QueryPredicate.MATCHES, '^.*No\. of bitstreams(.|\r|\n|\r\n)*\.(PDF|pdf|DOC|doc|PPT|ppt|DOCX|docx|PPTX|pptx).*$')
+ ]),
+ PresetQuery.of('q8', 'admin.reports.items.preset.hasNonFullTextInProvenance', [
+ QueryPredicate.of('dc.description.provenance', QueryPredicate.DOES_NOT_MATCH, '^.*No\. of bitstreams(.|\r|\n|\r\n)*\.(PDF|pdf|DOC|doc|PPT|ppt|DOCX|docx|PPTX|pptx).*$')
+ ]),
+ PresetQuery.of('q9', 'admin.reports.items.preset.hasEmptyMetadata', [
+ QueryPredicate.of('*', QueryPredicate.MATCHES, '^\s*$')
+ ]),
+ PresetQuery.of('q10', 'admin.reports.items.preset.hasUnbreakingDataInDescription', [
+ QueryPredicate.of('dc.description.*', QueryPredicate.MATCHES, '^.*[^\s]{50,}.*$')
+ ]),
+ PresetQuery.of('q12', 'admin.reports.items.preset.hasXmlEntityInMetadata', [
+ QueryPredicate.of('*', QueryPredicate.MATCHES, '^.*.*$')
+ ]),
+ PresetQuery.of('q13', 'admin.reports.items.preset.hasNonAsciiCharInMetadata', [
+ QueryPredicate.of('*', QueryPredicate.MATCHES, '^.*[^[:ascii:]].*$')
+ ])
+ ];
+ }
+
+ loadMetadataFields(): void {
+ this.metadataFields = [];
+ this.metadataFieldsWithAny = [];
+ let anyField$ = this.translateService.stream('admin.reports.items.anyField');
+ this.metadataFieldsWithAny.push(OptionVO.itemLoc('*', anyField$));
+ this.metadataSchemaService.findAll({ elementsPerPage: 10000, currentPage: 1 }).pipe(
+ getFirstSucceededRemoteListPayload()
+ ).subscribe(
+ (schemasRest: MetadataSchema[]) => {
+ schemasRest.forEach(schema => {
+ this.metadataFieldService.findBySchema(schema, { elementsPerPage: 10000, currentPage: 1 }).pipe(
+ getFirstSucceededRemoteListPayload()
+ ).subscribe(
+ (fieldsRest: MetadataField[]) => {
+ fieldsRest.forEach(field => {
+ let fieldName = schema.prefix + '.' + field.toString();
+ let fieldVO = OptionVO.item(fieldName, fieldName);
+ this.metadataFields.push(fieldVO);
+ this.metadataFieldsWithAny.push(fieldVO);
+ if (isEmpty(field.qualifier)) {
+ fieldName = schema.prefix + '.' + field.element + '.*';
+ fieldVO = OptionVO.item(fieldName, fieldName);
+ this.metadataFieldsWithAny.push(fieldVO);
+ }
+ });
+ }
+ );
+ });
+ }
+ );
+ }
+
+ loadPredicates(): void {
+ this.predicates = [
+ OptionVO.item(QueryPredicate.EXISTS, 'admin.reports.items.predicate.exists'),
+ OptionVO.item(QueryPredicate.DOES_NOT_EXIST, 'admin.reports.items.predicate.doesNotExist'),
+ OptionVO.item(QueryPredicate.EQUALS, 'admin.reports.items.predicate.equals'),
+ OptionVO.item(QueryPredicate.DOES_NOT_EQUAL, 'admin.reports.items.predicate.doesNotEqual'),
+ OptionVO.item(QueryPredicate.LIKE, 'admin.reports.items.predicate.like'),
+ OptionVO.item(QueryPredicate.NOT_LIKE, 'admin.reports.items.predicate.notLike'),
+ OptionVO.item(QueryPredicate.CONTAINS, 'admin.reports.items.predicate.contains'),
+ OptionVO.item(QueryPredicate.DOES_NOT_CONTAIN, 'admin.reports.items.predicate.doesNotContain'),
+ OptionVO.item(QueryPredicate.MATCHES, 'admin.reports.items.predicate.matches'),
+ OptionVO.item(QueryPredicate.DOES_NOT_MATCH, 'admin.reports.items.predicate.doesNotMatch')
+ ];
+ }
+
+ loadPageLimits(): void {
+ this.pageLimits = [
+ OptionVO.item('10', '10'),
+ OptionVO.item('25', '25'),
+ OptionVO.item('50', '50'),
+ OptionVO.item('100', '100')
+ ];
+ }
+
+ queryPredicatesArray(): FormArray {
+ return (this.queryForm.get('queryPredicates') as FormArray);
+ }
+
+ addQueryPredicate(newItem: FormGroup = new QueryPredicate().toFormGroup(this.formBuilder)) {
+ this.queryPredicatesArray().push(newItem);
+ }
+
+ deleteQueryPredicateDisabled(): boolean {
+ return this.queryPredicatesArray().length < 2;
+ }
+
+ deleteQueryPredicate(index: number, nbToDelete: number = 1) {
+ if (index > -1) {
+ this.queryPredicatesArray().removeAt(index);
+ }
+ }
+
+ setPresetQuery() {
+ let queryField = this.queryForm.controls.presetQuery as FormControl;
+ let value = queryField.value;
+ let query = this.presetQueries.find(q => q.id === value);
+ if (query !== undefined) {
+ this.queryPredicatesArray().clear();
+ query.predicates
+ .map(qp => qp.toFormGroup(this.formBuilder))
+ .forEach(qp => this.addQueryPredicate(qp));
+ if (query.predicates.length === 0) {
+ this.addQueryPredicate(new QueryPredicate().toFormGroup(this.formBuilder));
+ }
+ }
+ }
+
+ filtersFormGroup(): FormGroup {
+ return this.queryForm.get('filters') as FormGroup;
+ }
+
+ private pageSize() {
+ let form = this.queryForm.value;
+ return form.pageLimit;
+ }
+
+ canNavigatePrevious(): boolean {
+ return this.currentPage > 0;
+ }
+
+ prevPage() {
+ if (this.canNavigatePrevious()) {
+ this.currentPage--;
+ this.resubmit();
+ }
+ }
+
+ pageCount(): number {
+ let total = this.results.itemCount || 0;
+ return Math.ceil(total / this.pageSize());
+ }
+
+ canNavigateNext(): boolean {
+ return this.currentPage + 1 < this.pageCount();
+ }
+
+ nextPage() {
+ if (this.canNavigateNext()) {
+ this.currentPage++;
+ this.resubmit();
+ }
+ }
+
+ submit() {
+ this.accordionComponent.expand('itemResults');
+ this.currentPage = 0;
+ this.resubmit();
+ }
+
+ resubmit() {
+ this.results$ = this
+ .getFilteredItems()
+ .pipe(
+ map(response => {
+ let offset = this.currentPage * this.pageSize();
+ this.results.deserialize(response.payload, offset);
+ return this.results.items;
+ })
+ );
+ }
+
+ getFilteredItems(): Observable
{
+ let params = this.toQueryString();
+ if (params.length > 0) {
+ params = `?${params}`;
+ }
+ let scheme = environment.rest.ssl ? 'https' : 'http';
+ let urlRestApp = `${scheme}://${environment.rest.host}:${environment.rest.port}${environment.rest.nameSpace}`;
+ return this.restService.request(RestRequestMethod.GET, `${urlRestApp}/api/contentreport/filtereditems${params}`);
+ }
+
+ private toQueryString(): string {
+ let params = `pageNumber=${this.currentPage}&pageLimit=${this.pageSize()}`;
+
+ let colls = this.queryForm.value.collections;
+ for (let i = 0; i < colls.length; i++) {
+ params += `&collections=${colls[i]}`;
+ }
+
+ let preds = this.queryForm.value.queryPredicates;
+ for (let i = 0; i < preds.length; i++) {
+ const field = preds[i].field;
+ const op = preds[i].operator;
+ const value = preds[i].value;
+ params += `&queryPredicates=${field}:${op}`;
+ if (value) {
+ params += `:${value}`;
+ }
+ }
+
+ let filters = FiltersComponent.toQueryString(this.queryForm.value.filters);
+ if (filters.length > 0) {
+ params += `&${filters}`;
+ }
+
+ let addFlds = this.queryForm.value.additionalFields;
+ for (let i = 0; i < addFlds.length; i++) {
+ params += `&additionalFields=${addFlds[i]}`;
+ }
+
+ return params;
+ }
+
+}
diff --git a/src/app/admin/admin-reports/filtered-items/option-vo.model.ts b/src/app/admin/admin-reports/filtered-items/option-vo.model.ts
new file mode 100644
index 00000000000..0aee34d070f
--- /dev/null
+++ b/src/app/admin/admin-reports/filtered-items/option-vo.model.ts
@@ -0,0 +1,50 @@
+import { Observable } from 'rxjs';
+
+/**
+ * Component representing an option in each selectable list of values
+ * used in the Filtered Items report query interface
+ */
+export class OptionVO {
+
+ id: string;
+ name$: Observable;
+ disabled = false;
+
+ static collection(id: string, name: string, disabled: boolean = false): OptionVO {
+ let opt = new OptionVO();
+ opt.id = id;
+ opt.name$ = OptionVO.toObservable(name);
+ opt.disabled = disabled;
+ return opt;
+ }
+
+ static collectionLoc(id: string, name$: Observable, disabled: boolean = false): OptionVO {
+ let opt = new OptionVO();
+ opt.id = id;
+ opt.name$ = name$;
+ opt.disabled = disabled;
+ return opt;
+ }
+
+ static item(id: string, name: string): OptionVO {
+ let opt = new OptionVO();
+ opt.id = id;
+ opt.name$ = OptionVO.toObservable(name);
+ return opt;
+ }
+
+ static itemLoc(id: string, name$: Observable): OptionVO {
+ let opt = new OptionVO();
+ opt.id = id;
+ opt.name$ = name$;
+ return opt;
+ }
+
+ private static toObservable(value: T): Observable {
+ return new Observable(subscriber => {
+ subscriber.next(value);
+ subscriber.complete();
+ });
+
+ }
+}
diff --git a/src/app/admin/admin-reports/filtered-items/preset-query.model.ts b/src/app/admin/admin-reports/filtered-items/preset-query.model.ts
new file mode 100644
index 00000000000..73522f02cf1
--- /dev/null
+++ b/src/app/admin/admin-reports/filtered-items/preset-query.model.ts
@@ -0,0 +1,17 @@
+import { QueryPredicate } from './query-predicate.model';
+
+export class PresetQuery {
+
+ id: string;
+ label: string;
+ predicates: QueryPredicate[];
+
+ static of(id: string, label: string, predicates: QueryPredicate[]) {
+ let query = new PresetQuery();
+ query.id = id;
+ query.label = label;
+ query.predicates = predicates;
+ return query;
+ }
+
+}
diff --git a/src/app/admin/admin-reports/filtered-items/query-predicate.model.ts b/src/app/admin/admin-reports/filtered-items/query-predicate.model.ts
new file mode 100644
index 00000000000..c5f323ed2c8
--- /dev/null
+++ b/src/app/admin/admin-reports/filtered-items/query-predicate.model.ts
@@ -0,0 +1,36 @@
+import { FormBuilder, FormControl, FormGroup } from '@angular/forms';
+
+export class QueryPredicate {
+
+ static EXISTS = 'exists';
+ static DOES_NOT_EXIST = 'doesnt_exist';
+ static EQUALS = 'equals';
+ static DOES_NOT_EQUAL = 'not_equals';
+ static LIKE = 'like';
+ static NOT_LIKE = 'not_like';
+ static CONTAINS = 'contains';
+ static DOES_NOT_CONTAIN = 'doesnt_contain';
+ static MATCHES = 'matches';
+ static DOES_NOT_MATCH = 'doesnt_match';
+
+ field = '*';
+ operator: string;
+ value: string;
+
+ static of(field: string, operator: string, value: string = '') {
+ let pred = new QueryPredicate();
+ pred.field = field;
+ pred.operator = operator;
+ pred.value = value;
+ return pred;
+ }
+
+ toFormGroup(formBuilder: FormBuilder): FormGroup {
+ return formBuilder.group({
+ field: new FormControl(this.field),
+ operator: new FormControl(this.operator),
+ value: new FormControl(this.value)
+ });
+ }
+
+}
diff --git a/src/app/admin/admin-reports/filters-section/filter-group.model.ts b/src/app/admin/admin-reports/filters-section/filter-group.model.ts
new file mode 100644
index 00000000000..975b43a9860
--- /dev/null
+++ b/src/app/admin/admin-reports/filters-section/filter-group.model.ts
@@ -0,0 +1,19 @@
+import { Filter } from './filter.model';
+
+export class FilterGroup {
+
+ id: string;
+ key: string;
+
+ constructor(id: string, public filters: Filter[]) {
+ this.id = id;
+ this.key = 'admin.reports.commons.filters.' + id;
+ filters.forEach(filter => {
+ filter.key = this.key + '.' + filter.id;
+ if (filter.hasTooltip) {
+ filter.tooltipKey = filter.key + '.tooltip';
+ }
+ });
+ }
+
+}
diff --git a/src/app/admin/admin-reports/filters-section/filter.model.ts b/src/app/admin/admin-reports/filters-section/filter.model.ts
new file mode 100644
index 00000000000..63eeb114cde
--- /dev/null
+++ b/src/app/admin/admin-reports/filters-section/filter.model.ts
@@ -0,0 +1,8 @@
+export class Filter {
+
+ key: string;
+ tooltipKey: string;
+
+ constructor(public id: string, public hasTooltip: boolean = false) {}
+
+}
diff --git a/src/app/admin/admin-reports/filters-section/filters-section.component.html b/src/app/admin/admin-reports/filters-section/filters-section.component.html
new file mode 100644
index 00000000000..1e7856f09cb
--- /dev/null
+++ b/src/app/admin/admin-reports/filters-section/filters-section.component.html
@@ -0,0 +1,19 @@
+
+
+
+
+ {{"admin.reports.commons.filters.select_all" | translate}}
+
+ {{"admin.reports.commons.filters.deselect_all" | translate}}
+
+
+
+
+
+ {{group.key | translate}}
+
+
+ {{filter.key | translate}}
+
+
+
diff --git a/src/app/admin/admin-reports/filters-section/filters-section.component.spec.ts b/src/app/admin/admin-reports/filters-section/filters-section.component.spec.ts
new file mode 100644
index 00000000000..94f2753ec09
--- /dev/null
+++ b/src/app/admin/admin-reports/filters-section/filters-section.component.spec.ts
@@ -0,0 +1,101 @@
+import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing';
+import { NO_ERRORS_SCHEMA } from '@angular/core';
+import { FiltersComponent } from './filters-section.component';
+import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
+import { TranslateLoaderMock } from 'src/app/shared/mocks/translate-loader.mock';
+import { FormBuilder } from '@angular/forms';
+
+describe('FiltersComponent', () => {
+ let component: FiltersComponent;
+ let fixture: ComponentFixture;
+ let formBuilder: FormBuilder;
+
+ beforeEach(waitForAsync(() => {
+ TestBed.configureTestingModule({
+ declarations: [FiltersComponent],
+ imports: [
+ TranslateModule.forRoot({
+ loader: {
+ provide: TranslateLoader,
+ useClass: TranslateLoaderMock
+ }
+ })
+ ],
+ providers: [
+ FormBuilder
+ ],
+ schemas: [NO_ERRORS_SCHEMA]
+ });
+ }));
+
+ beforeEach(waitForAsync(() => {
+ formBuilder = TestBed.inject(FormBuilder);
+
+ fixture = TestBed.createComponent(FiltersComponent);
+ component = fixture.componentInstance;
+ component.filtersForm = FiltersComponent.formGroup(formBuilder);
+ fixture.detectChanges();
+ }));
+
+ const isOneSelected = (values: {}): boolean => {
+ let oneSelected = false;
+ let allFilters = FiltersComponent.FILTERS;
+ for (let i = 0; !oneSelected && i < allFilters.length; i++) {
+ let group = allFilters[i];
+ for (let j = 0; j < group.filters.length; j++) {
+ let filter = group.filters[j];
+ oneSelected = oneSelected || values[filter.id];
+ }
+ }
+ return oneSelected;
+ };
+
+ const isAllSelected = (values: {}): boolean => {
+ let allSelected = true;
+ let allFilters = FiltersComponent.FILTERS;
+ for (let i = 0; allSelected && i < allFilters.length; i++) {
+ let group = allFilters[i];
+ for (let j = 0; j < group.filters.length; j++) {
+ let filter = group.filters[j];
+ allSelected = allSelected && values[filter.id];
+ }
+ }
+ return allSelected;
+ };
+
+ it('should create the component', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should select all checkboxes', () => {
+ // By default, nothing is selected, so at least one item is not selected.
+ let values = component.filtersForm.value;
+ let allSelected: boolean = isAllSelected(values);
+ expect(allSelected).toBeFalse();
+
+ // Now we select everything...
+ component.selectAll();
+
+ // We must retrieve the form values again since selectAll() injects a new dictionary.
+ values = component.filtersForm.value;
+ allSelected = isAllSelected(values);
+ expect(allSelected).toBeTrue();
+ });
+
+ it('should deselect all checkboxes', () => {
+ // Since nothing is selected by default, we select at least an item
+ // so that deselectAll() actually deselects something.
+ let values = component.filtersForm.value;
+ values.is_item = true;
+ let oneSelected: boolean = isOneSelected(values);
+ expect(oneSelected).toBeTrue();
+
+ // Now we deselect everything...
+ component.deselectAll();
+
+ // We must retrieve the form values again since deselectAll() injects a new dictionary.
+ values = component.filtersForm.value;
+ oneSelected = isOneSelected(values);
+ expect(oneSelected).toBeFalse();
+ });
+});
diff --git a/src/app/admin/admin-reports/filters-section/filters-section.component.ts b/src/app/admin/admin-reports/filters-section/filters-section.component.ts
new file mode 100644
index 00000000000..94372ebab7d
--- /dev/null
+++ b/src/app/admin/admin-reports/filters-section/filters-section.component.ts
@@ -0,0 +1,148 @@
+import { Component, Input } from '@angular/core';
+import { FormBuilder, FormControl, FormGroup } from '@angular/forms';
+import { FilterGroup } from './filter-group.model';
+import { Filter } from './filter.model';
+
+/**
+ * Component representing the Query Filters section used in both
+ * Filtered Collections and Filtered Items content reports
+ */
+@Component({
+ selector: 'ds-filters',
+ templateUrl: './filters-section.component.html'
+})
+export class FiltersComponent {
+
+ static FILTERS = [
+ new FilterGroup('property', [
+ new Filter('is_item'),
+ new Filter('is_withdrawn'),
+ new Filter('is_not_withdrawn'),
+ new Filter('is_discoverable'),
+ new Filter('is_not_discoverable')
+ ]),
+ new FilterGroup('bitstream', [
+ new Filter('has_multiple_originals'),
+ new Filter('has_no_originals'),
+ new Filter('has_one_original')
+ ]),
+ new FilterGroup('bitstream_mime', [
+ new Filter('has_doc_original'),
+ new Filter('has_image_original'),
+ new Filter('has_unsupp_type'),
+ new Filter('has_mixed_original'),
+ new Filter('has_pdf_original'),
+ new Filter('has_jpg_original'),
+ new Filter('has_small_pdf'),
+ new Filter('has_large_pdf'),
+ new Filter('has_doc_without_text')
+ ]),
+ new FilterGroup('mime', [
+ new Filter('has_only_supp_image_type'),
+ new Filter('has_unsupp_image_type'),
+ new Filter('has_only_supp_doc_type'),
+ new Filter('has_unsupp_doc_type')
+ ]),
+ new FilterGroup('bundle', [
+ new Filter('has_unsupported_bundle'),
+ new Filter('has_small_thumbnail'),
+ new Filter('has_original_without_thumbnail'),
+ new Filter('has_invalid_thumbnail_name'),
+ new Filter('has_non_generated_thumb'),
+ new Filter('no_license'),
+ new Filter('has_license_documentation')
+ ]),
+ new FilterGroup('permission', [
+ new Filter('has_restricted_original', true),
+ new Filter('has_restricted_thumbnail', true),
+ new Filter('has_restricted_metadata', true)
+ ])
+ ];
+
+ @Input() filtersForm: FormGroup;
+
+ static formGroup(formBuilder: FormBuilder): FormGroup {
+ let fields = {};
+ let allFilters = FiltersComponent.FILTERS;
+ for (let i = 0; i < allFilters.length; i++) {
+ let group = allFilters[i];
+ for (let j = 0; j < group.filters.length; j++) {
+ let filter = group.filters[j];
+ fields[filter.id] = new FormControl(false);
+ }
+ }
+ return formBuilder.group(fields);
+ }
+
+ static getFilter(filterId: string): Filter {
+ let allFilters = FiltersComponent.FILTERS;
+ for (let i = 0; i < allFilters.length; i++) {
+ let group = allFilters[i];
+ for (let j = 0; j < group.filters.length; j++) {
+ let filter = group.filters[j];
+ if (filter.id === filterId) {
+ return filter;
+ }
+ }
+ }
+ return undefined;
+ }
+
+ static getGroup(filterId: string): FilterGroup {
+ let allFilters = FiltersComponent.FILTERS;
+ for (let i = 0; i < allFilters.length; i++) {
+ let group = allFilters[i];
+ for (let j = 0; j < group.filters.length; j++) {
+ let filter = group.filters[j];
+ if (filter.id === filterId) {
+ return group;
+ }
+ }
+ }
+ return undefined;
+ }
+
+ static toQueryString(filters: Object): string {
+ let params = '';
+ let first = true;
+ for (const key in filters) {
+ if (filters[key]) {
+ if (first) {
+ first = false;
+ } else {
+ params += '&';
+ }
+ params += `filters=${key}`;
+ }
+ }
+ return params;
+ }
+
+ allFilters(): FilterGroup[] {
+ return FiltersComponent.FILTERS;
+ }
+
+ private setAllFilters(value: boolean) {
+ // I don't know why, but patchValue() with individual controls doesn't work.
+ // I therefore use setValue() with the whole set, which mercifully works...
+ let fields = {};
+ let allFilters = FiltersComponent.FILTERS;
+ for (let i = 0; i < allFilters.length; i++) {
+ let group = allFilters[i];
+ for (let j = 0; j < group.filters.length; j++) {
+ let filter = group.filters[j];
+ fields[filter.id] = value;
+ }
+ }
+ this.filtersForm.setValue(fields);
+ }
+
+ selectAll(): void {
+ this.setAllFilters(true);
+ }
+
+ deselectAll(): void {
+ this.setAllFilters(false);
+ }
+
+}
diff --git a/src/app/admin/admin-routing-paths.ts b/src/app/admin/admin-routing-paths.ts
index 3168ea93c92..3c45081d704 100644
--- a/src/app/admin/admin-routing-paths.ts
+++ b/src/app/admin/admin-routing-paths.ts
@@ -1,8 +1,30 @@
import { URLCombiner } from '../core/url-combiner/url-combiner';
import { getAdminModuleRoute } from '../app-routing-paths';
+import { getQualityAssuranceEditRoute } from './admin-notifications/admin-notifications-routing-paths';
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() {
return new URLCombiner(getAdminModuleRoute(), REGISTRIES_MODULE_PATH).toString();
}
+
+export function getLdnServicesModuleRoute() {
+ return new URLCombiner(getAdminModuleRoute(), LDN_PATH).toString();
+}
+
+export function getNotificationsModuleRoute() {
+ return new URLCombiner(getAdminModuleRoute(), NOTIFICATIONS_MODULE_PATH).toString();
+}
+
+export function getNotificatioQualityAssuranceRoute() {
+ return new URLCombiner(`/${NOTIFICATIONS_MODULE_PATH}`, getQualityAssuranceEditRoute()).toString();
+}
+
+export function getReportsModuleRoute() {
+ return new URLCombiner(getAdminModuleRoute(), REPORTS_MODULE_PATH).toString();
+}
diff --git a/src/app/admin/admin-routing.module.ts b/src/app/admin/admin-routing.module.ts
index 8e4f13b1641..14f241342fa 100644
--- a/src/app/admin/admin-routing.module.ts
+++ b/src/app/admin/admin-routing.module.ts
@@ -6,12 +6,21 @@ import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.reso
import { AdminWorkflowPageComponent } from './admin-workflow-page/admin-workflow-page.component';
import { I18nBreadcrumbsService } from '../core/breadcrumbs/i18n-breadcrumbs.service';
import { AdminCurationTasksComponent } from './admin-curation-tasks/admin-curation-tasks.component';
-import { REGISTRIES_MODULE_PATH } from './admin-routing-paths';
+import {
+ LDN_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';
@NgModule({
imports: [
RouterModule.forChild([
+ {
+ path: NOTIFICATIONS_MODULE_PATH,
+ loadChildren: () => import('./admin-notifications/admin-notifications.module')
+ .then((m) => m.AdminNotificationsModule),
+ },
{
path: REGISTRIES_MODULE_PATH,
loadChildren: () => import('./admin-registries/admin-registries.module')
@@ -51,7 +60,28 @@ import { BatchImportPageComponent } from './admin-import-batch-page/batch-import
path: 'system-wide-alert',
resolve: { breadcrumb: I18nBreadcrumbResolver },
loadChildren: () => import('../system-wide-alert/system-wide-alert.module').then((m) => m.SystemWideAlertModule),
- data: {title: 'admin.system-wide-alert.title', breadcrumbKey: 'admin.system-wide-alert'}
+ data: {title: 'admin.system-wide-alert.title', breadcrumbKey: 'admin.system-wide-alert'},
+ },
+ {
+ path: LDN_PATH,
+ children: [
+ { path: '', pathMatch: 'full', redirectTo: 'services' },
+ {
+ path: 'services',
+ loadChildren: () => import('./admin-ldn-services/admin-ldn-services.module')
+ .then((m) => m.AdminLdnServicesModule),
+ }
+ ],
+ },
+ {
+ path: REPORTS_MODULE_PATH,
+ 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),
},
])
],
diff --git a/src/app/admin/admin.module.ts b/src/app/admin/admin.module.ts
index 3dc0036854e..e2a2f3194ba 100644
--- a/src/app/admin/admin.module.ts
+++ b/src/app/admin/admin.module.ts
@@ -10,6 +10,7 @@ import { AdminSearchModule } from './admin-search-page/admin-search.module';
import { AdminSidebarSectionComponent } from './admin-sidebar/admin-sidebar-section/admin-sidebar-section.component';
import { ExpandableAdminSidebarSectionComponent } from './admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component';
import { BatchImportPageComponent } from './admin-import-batch-page/batch-import-page.component';
+import { AdminReportsModule } from './admin-reports/admin-reports.module';
import { UiSwitchModule } from 'ngx-ui-switch';
import { UploadModule } from '../shared/upload/upload.module';
@@ -24,6 +25,7 @@ const ENTRY_COMPONENTS = [
imports: [
AdminRoutingModule,
AdminRegistriesModule,
+ AdminReportsModule,
AccessControlModule,
AdminSearchModule.withEntryComponents(),
AdminWorkflowModuleModule.withEntryComponents(),
diff --git a/src/app/app-routing-paths.ts b/src/app/app-routing-paths.ts
index fe2837c6e3f..63b11ec80ec 100644
--- a/src/app/app-routing-paths.ts
+++ b/src/app/app-routing-paths.ts
@@ -31,6 +31,7 @@ export function getBitstreamRequestACopyRoute(item, bitstream): { routerLink: st
}
};
}
+export const COAR_NOTIFY_SUPPORT = 'coar-notify-support';
export const HOME_PAGE_PATH = 'admin';
@@ -132,3 +133,10 @@ export const SUBSCRIPTIONS_MODULE_PATH = 'subscriptions';
export function getSubscriptionsModuleRoute() {
return `/${SUBSCRIPTIONS_MODULE_PATH}`;
}
+
+export const EDIT_ITEM_PATH = 'edit-items';
+export function getEditItemPageRoute() {
+ return `/${EDIT_ITEM_PATH}`;
+}
+export const CORRECTION_TYPE_PATH = 'corrections';
+
diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts
index deb68f1ea92..b72f3751618 100644
--- a/src/app/app-routing.module.ts
+++ b/src/app/app-routing.module.ts
@@ -38,8 +38,10 @@ import {
ThemedPageInternalServerErrorComponent
} from './page-internal-server-error/themed-page-internal-server-error.component';
import { ServerCheckGuard } from './core/server-check/server-check.guard';
+import { SUGGESTION_MODULE_PATH } from './suggestions-page/suggestions-page-routing-paths';
import { MenuResolver } from './menu.resolver';
import { ThemedPageErrorComponent } from './page-error/themed-page-error.component';
+import { NOTIFICATIONS_MODULE_PATH } from './admin/admin-routing-paths';
@NgModule({
imports: [
@@ -156,6 +158,18 @@ import { ThemedPageErrorComponent } from './page-error/themed-page-error.compone
.then((m) => m.AdminModule),
canActivate: [SiteAdministratorGuard, EndUserAgreementCurrentUserGuard]
},
+ {
+ path: NOTIFICATIONS_MODULE_PATH,
+ loadChildren: () => import('./admin/admin-notifications/admin-notifications.module')
+ .then((m) => m.AdminNotificationsModule),
+ canActivate: [AuthenticatedGuard, EndUserAgreementCurrentUserGuard]
+ },
+ {
+ path: NOTIFICATIONS_MODULE_PATH,
+ loadChildren: () => import('./quality-assurance-notifications-pages/notifications-pages.module')
+ .then((m) => m.NotificationsPageModule),
+ canActivate: [AuthenticatedGuard, EndUserAgreementCurrentUserGuard]
+ },
{
path: 'login',
loadChildren: () => import('./login-page/login-page.module')
@@ -202,6 +216,11 @@ import { ThemedPageErrorComponent } from './page-error/themed-page-error.compone
.then((m) => m.ProcessPageModule),
canActivate: [AuthenticatedGuard, EndUserAgreementCurrentUserGuard]
},
+ { path: SUGGESTION_MODULE_PATH,
+ loadChildren: () => import('./suggestions-page/suggestions-page.module')
+ .then((m) => m.SuggestionsPageModule),
+ canActivate: [AuthenticatedGuard, EndUserAgreementCurrentUserGuard]
+ },
{
path: INFO_MODULE_PATH,
loadChildren: () => import('./info/info.module').then((m) => m.InfoModule)
diff --git a/src/app/browse-by/browse-by-metadata/browse-by-metadata.component.html b/src/app/browse-by/browse-by-metadata/browse-by-metadata.component.html
new file mode 100644
index 00000000000..52ef06206f0
--- /dev/null
+++ b/src/app/browse-by/browse-by-metadata/browse-by-metadata.component.html
@@ -0,0 +1,21 @@
+
diff --git a/src/app/community-list-page/community-list/community-list.component.html b/src/app/community-list-page/community-list/community-list.component.html
index de67607bb4b..52960e37150 100644
--- a/src/app/community-list-page/community-list/community-list.component.html
+++ b/src/app/community-list-page/community-list/community-list.component.html
@@ -4,7 +4,7 @@
-
+
diff --git a/src/app/core/breadcrumbs/navigation-breadcrumb.resolver.spec.ts b/src/app/core/breadcrumbs/navigation-breadcrumb.resolver.spec.ts
new file mode 100644
index 00000000000..81b3b8ad2ae
--- /dev/null
+++ b/src/app/core/breadcrumbs/navigation-breadcrumb.resolver.spec.ts
@@ -0,0 +1,52 @@
+import { NavigationBreadcrumbResolver } from './navigation-breadcrumb.resolver';
+
+describe('NavigationBreadcrumbResolver', () => {
+ describe('resolve', () => {
+ let resolver: NavigationBreadcrumbResolver;
+ let NavigationBreadcrumbService: any;
+ let i18nKey: string;
+ let relatedI18nKey: string;
+ let route: any;
+ let expectedPath;
+ let state;
+ beforeEach(() => {
+ i18nKey = 'example.key';
+ relatedI18nKey = 'related.key';
+ route = {
+ data: {
+ breadcrumbKey: i18nKey,
+ relatedRoutes: [
+ {
+ path: '',
+ data: {breadcrumbKey: relatedI18nKey},
+ }
+ ]
+ },
+ routeConfig: {
+ path: 'example'
+ },
+ parent: {
+ routeConfig: {
+ path: ''
+ },
+ url: [{
+ path: 'base'
+ }]
+ } as any
+ };
+
+ state = {
+ url: '/base/example'
+ };
+ expectedPath = '/base/example:/base';
+ NavigationBreadcrumbService = {};
+ resolver = new NavigationBreadcrumbResolver(NavigationBreadcrumbService);
+ });
+
+ it('should resolve the breadcrumb config', () => {
+ const resolvedConfig = resolver.resolve(route, state);
+ const expectedConfig = { provider: NavigationBreadcrumbService, key: `${i18nKey}:${relatedI18nKey}`, url: expectedPath };
+ expect(resolvedConfig).toEqual(expectedConfig);
+ });
+ });
+});
diff --git a/src/app/core/breadcrumbs/navigation-breadcrumb.resolver.ts b/src/app/core/breadcrumbs/navigation-breadcrumb.resolver.ts
new file mode 100644
index 00000000000..18ebfc395b7
--- /dev/null
+++ b/src/app/core/breadcrumbs/navigation-breadcrumb.resolver.ts
@@ -0,0 +1,52 @@
+import { BreadcrumbConfig } from '../../breadcrumbs/breadcrumb/breadcrumb-config.model';
+import { Injectable } from '@angular/core';
+import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router';
+import { NavigationBreadcrumbsService } from './navigation-breadcrumb.service';
+
+/**
+ * The class that resolves a BreadcrumbConfig object with an i18n key string for a route and related parents
+ */
+@Injectable({
+ providedIn: 'root'
+})
+export class NavigationBreadcrumbResolver implements Resolve
> {
+
+ private parentRoutes: ActivatedRouteSnapshot[] = [];
+ constructor(protected breadcrumbService: NavigationBreadcrumbsService) {
+ }
+
+ /**
+ * Method to collect all parent routes snapshot from current route snapshot
+ * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot
+ */
+ private getParentRoutes(route: ActivatedRouteSnapshot): void {
+ if (route.parent) {
+ this.parentRoutes.push(route.parent);
+ this.getParentRoutes(route.parent);
+ }
+ }
+ /**
+ * Method for resolving an I18n breadcrumb configuration object
+ * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot
+ * @param {RouterStateSnapshot} state The current RouterStateSnapshot
+ * @returns BreadcrumbConfig object
+ */
+ resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): BreadcrumbConfig {
+ this.getParentRoutes(route);
+ const relatedRoutes = route.data.relatedRoutes;
+ const parentPaths = this.parentRoutes.map(parent => parent.routeConfig?.path);
+ const relatedParentRoutes = relatedRoutes.filter(relatedRoute => parentPaths.includes(relatedRoute.path));
+ const baseUrlSegmentPath = route.parent.url[route.parent.url.length - 1].path;
+ const baseUrl = state.url.substring(0, state.url.lastIndexOf(baseUrlSegmentPath) + baseUrlSegmentPath.length);
+
+
+ const combinedParentBreadcrumbKeys = relatedParentRoutes.reduce((previous, current) => {
+ return `${previous}:${current.data.breadcrumbKey}`;
+ }, route.data.breadcrumbKey);
+ const combinedUrls = relatedParentRoutes.reduce((previous, current) => {
+ return `${previous}:${baseUrl}${current.path}`;
+ }, state.url);
+
+ return {provider: this.breadcrumbService, key: combinedParentBreadcrumbKeys, url: combinedUrls};
+ }
+}
diff --git a/src/app/core/breadcrumbs/navigation-breadcrumb.service.ts b/src/app/core/breadcrumbs/navigation-breadcrumb.service.ts
new file mode 100644
index 00000000000..beebeed94e1
--- /dev/null
+++ b/src/app/core/breadcrumbs/navigation-breadcrumb.service.ts
@@ -0,0 +1,30 @@
+import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model';
+import { BreadcrumbsProviderService } from './breadcrumbsProviderService';
+import { Observable, of as observableOf } from 'rxjs';
+import { Injectable } from '@angular/core';
+
+/**
+ * The postfix for i18n breadcrumbs
+ */
+export const BREADCRUMB_MESSAGE_POSTFIX = '.breadcrumbs';
+
+/**
+ * Service to calculate i18n breadcrumbs for a single part of the route
+ */
+@Injectable({
+ providedIn: 'root'
+})
+export class NavigationBreadcrumbsService implements BreadcrumbsProviderService {
+
+ /**
+ * Method to calculate the breadcrumbs
+ * @param key The key used to resolve the breadcrumb
+ * @param url The url to use as a link for this breadcrumb
+ */
+ getBreadcrumbs(key: string, url: string): Observable {
+ const keys = key.split(':');
+ const urls = url.split(':');
+ const breadcrumbs = keys.map((currentKey, index) => new Breadcrumb(currentKey + BREADCRUMB_MESSAGE_POSTFIX, urls[index] ));
+ return observableOf(breadcrumbs.reverse());
+ }
+}
diff --git a/src/app/core/breadcrumbs/navigation-breadcrumbs.service.spec.ts b/src/app/core/breadcrumbs/navigation-breadcrumbs.service.spec.ts
new file mode 100644
index 00000000000..98e20e285d9
--- /dev/null
+++ b/src/app/core/breadcrumbs/navigation-breadcrumbs.service.spec.ts
@@ -0,0 +1,43 @@
+import { TestBed, waitForAsync } from '@angular/core/testing';
+import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model';
+import { getTestScheduler } from 'jasmine-marbles';
+import { BREADCRUMB_MESSAGE_POSTFIX } from './i18n-breadcrumbs.service';
+import { NavigationBreadcrumbsService } from './navigation-breadcrumb.service';
+
+describe('NavigationBreadcrumbsService', () => {
+ let service: NavigationBreadcrumbsService;
+ let exampleString;
+ let exampleURL;
+ let childrenString;
+ let childrenUrl;
+ let parentString;
+ let parentUrl;
+
+ function init() {
+ exampleString = 'example.string:parent.string';
+ exampleURL = 'example.com:parent.com';
+ childrenString = 'example.string';
+ childrenUrl = 'example.com';
+ parentString = 'parent.string';
+ parentUrl = 'parent.com';
+ }
+
+ beforeEach(waitForAsync(() => {
+ init();
+ TestBed.configureTestingModule({}).compileComponents();
+ }));
+
+ beforeEach(() => {
+ service = new NavigationBreadcrumbsService();
+ });
+
+ describe('getBreadcrumbs', () => {
+ it('should return an array of breadcrumbs based on strings by adding the postfix', () => {
+ const breadcrumbs = service.getBreadcrumbs(exampleString, exampleURL);
+ getTestScheduler().expectObservable(breadcrumbs).toBe('(a|)', { a: [
+ new Breadcrumb(childrenString + BREADCRUMB_MESSAGE_POSTFIX, childrenUrl),
+ new Breadcrumb(parentString + BREADCRUMB_MESSAGE_POSTFIX, parentUrl),
+ ].reverse() });
+ });
+ });
+});
diff --git a/src/app/core/breadcrumbs/publication-claim-breadcrumb.resolver.spec.ts b/src/app/core/breadcrumbs/publication-claim-breadcrumb.resolver.spec.ts
new file mode 100644
index 00000000000..b6f41424693
--- /dev/null
+++ b/src/app/core/breadcrumbs/publication-claim-breadcrumb.resolver.spec.ts
@@ -0,0 +1,31 @@
+import { PublicationClaimBreadcrumbResolver } from './publication-claim-breadcrumb.resolver';
+
+describe('PublicationClaimBreadcrumbResolver', () => {
+ describe('resolve', () => {
+ let resolver: PublicationClaimBreadcrumbResolver;
+ let publicationClaimBreadcrumbService: any;
+ const fullPath = '/test/publication-claim/openaire:6bee076d-4f2a-4555-a475-04a267769b2a';
+ const expectedKey = '6bee076d-4f2a-4555-a475-04a267769b2a';
+ const expectedId = 'openaire:6bee076d-4f2a-4555-a475-04a267769b2a';
+ let route;
+
+ beforeEach(() => {
+ route = {
+ paramMap: {
+ get: function (param) {
+ return this[param];
+ },
+ targetId: expectedId,
+ }
+ };
+ publicationClaimBreadcrumbService = {};
+ resolver = new PublicationClaimBreadcrumbResolver(publicationClaimBreadcrumbService);
+ });
+
+ it('should resolve the breadcrumb config', () => {
+ const resolvedConfig = resolver.resolve(route as any, {url: fullPath } as any);
+ const expectedConfig = { provider: publicationClaimBreadcrumbService, key: expectedKey };
+ expect(resolvedConfig).toEqual(expectedConfig);
+ });
+ });
+});
diff --git a/src/app/core/breadcrumbs/publication-claim-breadcrumb.resolver.ts b/src/app/core/breadcrumbs/publication-claim-breadcrumb.resolver.ts
new file mode 100644
index 00000000000..713500d6a73
--- /dev/null
+++ b/src/app/core/breadcrumbs/publication-claim-breadcrumb.resolver.ts
@@ -0,0 +1,24 @@
+import { Injectable } from '@angular/core';
+import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router';
+import {BreadcrumbConfig} from '../../breadcrumbs/breadcrumb/breadcrumb-config.model';
+import { PublicationClaimBreadcrumbService } from './publication-claim-breadcrumb.service';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class PublicationClaimBreadcrumbResolver implements Resolve> {
+ constructor(protected breadcrumbService: PublicationClaimBreadcrumbService) {
+ }
+
+ /**
+ * Method that resolve Publication Claim item into a breadcrumb
+ * The parameter are retrieved by the url since part of the Publication Claim route config
+ * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot
+ * @param {RouterStateSnapshot} state The current RouterStateSnapshot
+ * @returns BreadcrumbConfig object
+ */
+ resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): BreadcrumbConfig {
+ const targetId = route.paramMap.get('targetId').split(':')[1];
+ return { provider: this.breadcrumbService, key: targetId };
+ }
+}
diff --git a/src/app/core/breadcrumbs/publication-claim-breadcrumb.service.spec.ts b/src/app/core/breadcrumbs/publication-claim-breadcrumb.service.spec.ts
new file mode 100644
index 00000000000..11062210bb3
--- /dev/null
+++ b/src/app/core/breadcrumbs/publication-claim-breadcrumb.service.spec.ts
@@ -0,0 +1,51 @@
+import { TestBed, waitForAsync } from '@angular/core/testing';
+import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model';
+import { getTestScheduler } from 'jasmine-marbles';
+import { PublicationClaimBreadcrumbService } from './publication-claim-breadcrumb.service';
+import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
+import { of } from 'rxjs';
+
+describe('PublicationClaimBreadcrumbService', () => {
+ let service: PublicationClaimBreadcrumbService;
+ let dsoNameService: any = {
+ getName: (str) => str
+ };
+ let translateService: any = {
+ instant: (str) => str,
+ };
+
+ let dataService: any = {
+ findById: (str) => createSuccessfulRemoteDataObject$(str),
+ };
+
+ let authorizationService: any = {
+ isAuthorized: (str) => of(true),
+ };
+
+ let exampleKey;
+
+ const ADMIN_PUBLICATION_CLAIMS_PATH = 'admin/notifications/publication-claim';
+ const ADMIN_PUBLICATION_CLAIMS_BREADCRUMB_KEY = 'admin.notifications.publicationclaim.page.title';
+
+ function init() {
+ exampleKey = 'suggestion.suggestionFor.breadcrumb';
+ }
+
+ beforeEach(waitForAsync(() => {
+ init();
+ TestBed.configureTestingModule({}).compileComponents();
+ }));
+
+ beforeEach(() => {
+ service = new PublicationClaimBreadcrumbService(dataService,dsoNameService,translateService, authorizationService);
+ });
+
+ describe('getBreadcrumbs', () => {
+ it('should return a breadcrumb based on a string', () => {
+ const breadcrumbs = service.getBreadcrumbs(exampleKey);
+ getTestScheduler().expectObservable(breadcrumbs).toBe('(a|)', { a: [new Breadcrumb(ADMIN_PUBLICATION_CLAIMS_BREADCRUMB_KEY, ADMIN_PUBLICATION_CLAIMS_PATH),
+ new Breadcrumb(exampleKey, undefined)]
+ });
+ });
+ });
+});
diff --git a/src/app/core/breadcrumbs/publication-claim-breadcrumb.service.ts b/src/app/core/breadcrumbs/publication-claim-breadcrumb.service.ts
new file mode 100644
index 00000000000..1a87fd7de60
--- /dev/null
+++ b/src/app/core/breadcrumbs/publication-claim-breadcrumb.service.ts
@@ -0,0 +1,46 @@
+import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model';
+import { BreadcrumbsProviderService } from './breadcrumbsProviderService';
+import { combineLatest, Observable } from 'rxjs';
+import { Injectable } from '@angular/core';
+import { ItemDataService } from '../data/item-data.service';
+import { getFirstCompletedRemoteData } from '../shared/operators';
+import { map } from 'rxjs/operators';
+import { DSONameService } from './dso-name.service';
+import { TranslateService } from '@ngx-translate/core';
+import { AuthorizationDataService } from '../data/feature-authorization/authorization-data.service';
+import { FeatureID } from '../data/feature-authorization/feature-id';
+
+
+
+/**
+ * Service to calculate Publication claims breadcrumbs
+ */
+@Injectable({
+ providedIn: 'root'
+})
+export class PublicationClaimBreadcrumbService implements BreadcrumbsProviderService {
+ private ADMIN_PUBLICATION_CLAIMS_PATH = 'admin/notifications/publication-claim';
+ private ADMIN_PUBLICATION_CLAIMS_BREADCRUMB_KEY = 'admin.notifications.publicationclaim.page.title';
+
+ constructor(private dataService: ItemDataService,
+ private dsoNameService: DSONameService,
+ private tranlsateService: TranslateService,
+ protected authorizationService: AuthorizationDataService) {
+ }
+
+
+ /**
+ * Method to calculate the breadcrumbs
+ * @param key The key used to resolve the breadcrumb
+ */
+ getBreadcrumbs(key: string): Observable {
+ return combineLatest([this.dataService.findById(key).pipe(getFirstCompletedRemoteData()),this.authorizationService.isAuthorized(FeatureID.AdministratorOf)]).pipe(
+ map(([item, isAdmin]) => {
+ const itemName = this.dsoNameService.getName(item.payload);
+ return isAdmin ? [new Breadcrumb(this.tranlsateService.instant(this.ADMIN_PUBLICATION_CLAIMS_BREADCRUMB_KEY), this.ADMIN_PUBLICATION_CLAIMS_PATH),
+ new Breadcrumb(this.tranlsateService.instant('suggestion.suggestionFor.breadcrumb', {name: itemName}), undefined)] :
+ [new Breadcrumb(this.tranlsateService.instant('suggestion.suggestionFor.breadcrumb', {name: itemName}), undefined)];
+ })
+ );
+ }
+}
diff --git a/src/app/core/breadcrumbs/quality-assurance-breadcrumb.resolver.spec.ts b/src/app/core/breadcrumbs/quality-assurance-breadcrumb.resolver.spec.ts
new file mode 100644
index 00000000000..3544af62e7a
--- /dev/null
+++ b/src/app/core/breadcrumbs/quality-assurance-breadcrumb.resolver.spec.ts
@@ -0,0 +1,31 @@
+import {QualityAssuranceBreadcrumbResolver} from './quality-assurance-breadcrumb.resolver';
+
+describe('QualityAssuranceBreadcrumbResolver', () => {
+ describe('resolve', () => {
+ let resolver: QualityAssuranceBreadcrumbResolver;
+ let qualityAssuranceBreadcrumbService: any;
+ let route: any;
+ const fullPath = '/test/quality-assurance/';
+ const expectedKey = 'testSourceId:testTopicId';
+
+ beforeEach(() => {
+ route = {
+ paramMap: {
+ get: function (param) {
+ return this[param];
+ },
+ sourceId: 'testSourceId',
+ topicId: 'testTopicId'
+ }
+ };
+ qualityAssuranceBreadcrumbService = {};
+ resolver = new QualityAssuranceBreadcrumbResolver(qualityAssuranceBreadcrumbService);
+ });
+
+ it('should resolve the breadcrumb config', () => {
+ const resolvedConfig = resolver.resolve(route as any, {url: fullPath + 'testSourceId'} as any);
+ const expectedConfig = { provider: qualityAssuranceBreadcrumbService, key: expectedKey, url: fullPath };
+ expect(resolvedConfig).toEqual(expectedConfig);
+ });
+ });
+});
diff --git a/src/app/core/breadcrumbs/quality-assurance-breadcrumb.resolver.ts b/src/app/core/breadcrumbs/quality-assurance-breadcrumb.resolver.ts
new file mode 100644
index 00000000000..6eb351ab1ab
--- /dev/null
+++ b/src/app/core/breadcrumbs/quality-assurance-breadcrumb.resolver.ts
@@ -0,0 +1,32 @@
+import { Injectable } from '@angular/core';
+import {QualityAssuranceBreadcrumbService} from './quality-assurance-breadcrumb.service';
+import {ActivatedRouteSnapshot, Resolve, RouterStateSnapshot} from '@angular/router';
+import {BreadcrumbConfig} from '../../breadcrumbs/breadcrumb/breadcrumb-config.model';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class QualityAssuranceBreadcrumbResolver implements Resolve> {
+ constructor(protected breadcrumbService: QualityAssuranceBreadcrumbService) {}
+
+ /**
+ * Method that resolve QA item into a breadcrumb
+ * The parameter are retrieved by the url since part of the QA route config
+ * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot
+ * @param {RouterStateSnapshot} state The current RouterStateSnapshot
+ * @returns BreadcrumbConfig object
+ */
+ resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): BreadcrumbConfig {
+ const sourceId = route.paramMap.get('sourceId');
+ const topicId = route.paramMap.get('topicId');
+ let key = sourceId;
+
+ if (topicId) {
+ key += `:${topicId}`;
+ }
+ const fullPath = state.url;
+ const url = fullPath.substr(0, fullPath.indexOf(sourceId));
+
+ return { provider: this.breadcrumbService, key, url };
+ }
+}
diff --git a/src/app/core/breadcrumbs/quality-assurance-breadcrumb.service.spec.ts b/src/app/core/breadcrumbs/quality-assurance-breadcrumb.service.spec.ts
new file mode 100644
index 00000000000..cefa1d2f6fb
--- /dev/null
+++ b/src/app/core/breadcrumbs/quality-assurance-breadcrumb.service.spec.ts
@@ -0,0 +1,39 @@
+import { TestBed, waitForAsync } from '@angular/core/testing';
+import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model';
+import { getTestScheduler } from 'jasmine-marbles';
+import {QualityAssuranceBreadcrumbService} from './quality-assurance-breadcrumb.service';
+
+describe('QualityAssuranceBreadcrumbService', () => {
+ let service: QualityAssuranceBreadcrumbService;
+ let translateService: any = {
+ instant: (str) => str,
+ };
+
+ let exampleString;
+ let exampleURL;
+ let exampleQaKey;
+
+ function init() {
+ exampleString = 'sourceId';
+ exampleURL = '/test/quality-assurance/';
+ exampleQaKey = 'admin.quality-assurance.breadcrumbs';
+ }
+
+ beforeEach(waitForAsync(() => {
+ init();
+ TestBed.configureTestingModule({}).compileComponents();
+ }));
+
+ beforeEach(() => {
+ service = new QualityAssuranceBreadcrumbService(translateService);
+ });
+
+ describe('getBreadcrumbs', () => {
+ it('should return a breadcrumb based on a string', () => {
+ const breadcrumbs = service.getBreadcrumbs(exampleString, exampleURL);
+ getTestScheduler().expectObservable(breadcrumbs).toBe('(a|)', { a: [new Breadcrumb(exampleQaKey, exampleURL),
+ new Breadcrumb(exampleString, exampleURL + exampleString)]
+ });
+ });
+ });
+});
diff --git a/src/app/core/breadcrumbs/quality-assurance-breadcrumb.service.ts b/src/app/core/breadcrumbs/quality-assurance-breadcrumb.service.ts
new file mode 100644
index 00000000000..a0299705a40
--- /dev/null
+++ b/src/app/core/breadcrumbs/quality-assurance-breadcrumb.service.ts
@@ -0,0 +1,45 @@
+import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model';
+import { BreadcrumbsProviderService } from './breadcrumbsProviderService';
+import { Observable, of as observableOf } from 'rxjs';
+import { Injectable } from '@angular/core';
+import { TranslateService } from '@ngx-translate/core';
+
+
+
+/**
+ * Service to calculate QA breadcrumbs for a single part of the route
+ */
+@Injectable({
+ providedIn: 'root'
+})
+export class QualityAssuranceBreadcrumbService implements BreadcrumbsProviderService {
+
+ private QUALITY_ASSURANCE_BREADCRUMB_KEY = 'admin.quality-assurance.breadcrumbs';
+ constructor(
+ private translationService: TranslateService,
+ ) {
+
+ }
+
+
+ /**
+ * Method to calculate the breadcrumbs
+ * @param key The key used to resolve the breadcrumb
+ * @param url The url to use as a link for this breadcrumb
+ */
+ getBreadcrumbs(key: string, url: string): Observable {
+ const args = key.split(':');
+ const sourceId = args[0];
+ const topicId = args.length > 2 ? args[args.length - 1] : args[1];
+
+ if (topicId) {
+ return observableOf( [new Breadcrumb(this.translationService.instant(this.QUALITY_ASSURANCE_BREADCRUMB_KEY), url),
+ new Breadcrumb(sourceId, `${url}${sourceId}`),
+ new Breadcrumb(topicId, undefined)]);
+ } else {
+ return observableOf([new Breadcrumb(this.translationService.instant(this.QUALITY_ASSURANCE_BREADCRUMB_KEY), url),
+ new Breadcrumb(sourceId, `${url}${sourceId}`)]);
+ }
+
+ }
+}
diff --git a/src/app/core/cache/builders/build-decorators.ts b/src/app/core/cache/builders/build-decorators.ts
index 9e5ebaed854..779ffc2d1ed 100644
--- a/src/app/core/cache/builders/build-decorators.ts
+++ b/src/app/core/cache/builders/build-decorators.ts
@@ -5,11 +5,16 @@ import { HALResource } from '../../shared/hal-resource.model';
import { ResourceType } from '../../shared/resource-type';
import { getResourceTypeValueFor } from '../object-cache.reducer';
import { InjectionToken } from '@angular/core';
+import { CacheableObject } from '../cacheable-object.model';
import { TypedObject } from '../typed-object.model';
+export const DATA_SERVICE_FACTORY = new InjectionToken<(resourceType: ResourceType) => GenericConstructor>('getDataServiceFor', {
+ providedIn: 'root',
+ factory: () => getDataServiceFor
+});
export const LINK_DEFINITION_FACTORY = new InjectionToken<(source: GenericConstructor, linkName: keyof T['_links']) => LinkDefinition>('getLinkDefinition', {
providedIn: 'root',
- factory: () => getLinkDefinition,
+ factory: () => getLinkDefinition
});
export const LINK_DEFINITION_MAP_FACTORY = new InjectionToken<(source: GenericConstructor) => Map>>('getLinkDefinitions', {
providedIn: 'root',
@@ -20,6 +25,7 @@ const resolvedLinkKey = Symbol('resolvedLink');
const resolvedLinkMap = new Map();
const typeMap = new Map();
+const dataServiceMap = new Map();
const linkMap = new Map();
/**
@@ -38,6 +44,39 @@ export function getClassForType(type: string | ResourceType) {
return typeMap.get(getResourceTypeValueFor(type));
}
+/**
+ * A class decorator to indicate that this class is a dataservice
+ * for a given resource type.
+ *
+ * "dataservice" in this context means that it has findByHref and
+ * findAllByHref methods.
+ *
+ * @param resourceType the resource type the class is a dataservice for
+ */
+export function dataService(resourceType: ResourceType): any {
+ return (target: any) => {
+ if (hasNoValue(resourceType)) {
+ throw new Error(`Invalid @dataService annotation on ${target}, resourceType needs to be defined`);
+ }
+ const existingDataservice = dataServiceMap.get(resourceType.value);
+
+ if (hasValue(existingDataservice)) {
+ throw new Error(`Multiple dataservices for ${resourceType.value}: ${existingDataservice} and ${target}`);
+ }
+
+ dataServiceMap.set(resourceType.value, target);
+ };
+}
+
+/**
+ * Return the dataservice matching the given resource type
+ *
+ * @param resourceType the resource type you want the matching dataservice for
+ */
+export function getDataServiceFor(resourceType: ResourceType) {
+ return dataServiceMap.get(resourceType.value);
+}
+
/**
* A class to represent the data that can be set by the @link decorator
*/
@@ -65,7 +104,7 @@ export const link = (
resourceType: ResourceType,
isList = false,
linkName?: keyof T['_links'],
- ) => {
+) => {
return (target: T, propertyName: string) => {
let targetMap = linkMap.get(target.constructor);
diff --git a/src/app/core/cache/builders/remote-data-build.service.ts b/src/app/core/cache/builders/remote-data-build.service.ts
index 075bf3ca0ca..5b5e362406c 100644
--- a/src/app/core/cache/builders/remote-data-build.service.ts
+++ b/src/app/core/cache/builders/remote-data-build.service.ts
@@ -161,6 +161,7 @@ export class RemoteDataBuildService {
} else {
// in case the elements of the paginated list were already filled in, because they're UnCacheableObjects
paginatedList.page = paginatedList.page
+ .filter((obj: any) => obj != null)
.map((obj: any) => this.plainObjectToInstance(obj))
.map((obj: any) =>
this.linkService.resolveLinks(obj, ...pageLink.linksToFollow)
diff --git a/src/app/core/coar-notify/notify-info/notify-info.component.html b/src/app/core/coar-notify/notify-info/notify-info.component.html
new file mode 100644
index 00000000000..3370f83d03c
--- /dev/null
+++ b/src/app/core/coar-notify/notify-info/notify-info.component.html
@@ -0,0 +1,18 @@
+
+
+
{{ 'coar-notify-support.title' | translate }}
+
+
+
{{ 'coar-notify-support.title' | translate }}
+
+
+
{{ 'coar-notify-support.ldn-inbox.title' | translate }}
+
+
+
{{ 'coar-notify-support.message-moderation.title' | translate }}
+
+ {{ 'coar-notify-support.message-moderation.content' | translate }}
+ {{ 'coar-notify-support.message-moderation.feedback-form' | translate }}
+
+
+
diff --git a/src/app/core/coar-notify/notify-info/notify-info.component.spec.ts b/src/app/core/coar-notify/notify-info/notify-info.component.spec.ts
new file mode 100644
index 00000000000..881e1b67fb2
--- /dev/null
+++ b/src/app/core/coar-notify/notify-info/notify-info.component.spec.ts
@@ -0,0 +1,37 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { NotifyInfoComponent } from './notify-info.component';
+import { NotifyInfoService } from './notify-info.service';
+import { TranslateModule } from '@ngx-translate/core';
+import { of } from 'rxjs';
+
+describe('NotifyInfoComponent', () => {
+ let component: NotifyInfoComponent;
+ let fixture: ComponentFixture;
+ let notifyInfoServiceSpy: any;
+
+ beforeEach(async () => {
+ notifyInfoServiceSpy = jasmine.createSpyObj('NotifyInfoService', ['getCoarLdnLocalInboxUrls']);
+
+ await TestBed.configureTestingModule({
+ imports: [TranslateModule.forRoot()],
+ declarations: [ NotifyInfoComponent ],
+ providers: [
+ { provide: NotifyInfoService, useValue: notifyInfoServiceSpy }
+ ]
+ })
+ .compileComponents();
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(NotifyInfoComponent);
+ component = fixture.componentInstance;
+ component.coarRestApiUrl = of([]);
+ spyOn(component, 'generateCoarRestApiLinksHTML').and.returnValue(of(''));
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/app/core/coar-notify/notify-info/notify-info.component.ts b/src/app/core/coar-notify/notify-info/notify-info.component.ts
new file mode 100644
index 00000000000..f1ce210c0ed
--- /dev/null
+++ b/src/app/core/coar-notify/notify-info/notify-info.component.ts
@@ -0,0 +1,38 @@
+import { Component, OnInit } from '@angular/core';
+import { NotifyInfoService } from './notify-info.service';
+import { Observable, map, of } from 'rxjs';
+
+@Component({
+ selector: 'ds-notify-info',
+ templateUrl: './notify-info.component.html'
+})
+/**
+ * Component for displaying COAR notification information.
+ */
+export class NotifyInfoComponent implements OnInit {
+ /**
+ * Observable containing the COAR REST INBOX API URLs.
+ */
+ coarRestApiUrl: Observable = of([]);
+
+ constructor(private notifyInfoService: NotifyInfoService) {}
+
+ ngOnInit() {
+ this.coarRestApiUrl = this.notifyInfoService.getCoarLdnLocalInboxUrls();
+ }
+
+ /**
+ * Generates HTML code for COAR REST API links.
+ * @returns An Observable that emits the generated HTML code.
+ */
+ generateCoarRestApiLinksHTML() {
+ return this.coarRestApiUrl.pipe(
+ // transform the data into HTML
+ map((urls) => {
+ return urls.map(url => `
+ ${url}
+ `).join(',');
+ })
+ );
+ }
+}
diff --git a/src/app/core/coar-notify/notify-info/notify-info.guard.spec.ts b/src/app/core/coar-notify/notify-info/notify-info.guard.spec.ts
new file mode 100644
index 00000000000..81ac0db8d81
--- /dev/null
+++ b/src/app/core/coar-notify/notify-info/notify-info.guard.spec.ts
@@ -0,0 +1,49 @@
+import { TestBed } from '@angular/core/testing';
+
+import { NotifyInfoGuard } from './notify-info.guard';
+import { Router } from '@angular/router';
+import { NotifyInfoService } from './notify-info.service';
+import { of } from 'rxjs';
+
+describe('NotifyInfoGuard', () => {
+ let guard: NotifyInfoGuard;
+ let notifyInfoServiceSpy: any;
+ let router: any;
+
+ beforeEach(() => {
+ notifyInfoServiceSpy = jasmine.createSpyObj('NotifyInfoService', ['isCoarConfigEnabled']);
+ router = jasmine.createSpyObj('Router', ['parseUrl']);
+ TestBed.configureTestingModule({
+ providers: [
+ NotifyInfoGuard,
+ { provide: NotifyInfoService, useValue: notifyInfoServiceSpy},
+ { provide: Router, useValue: router}
+ ]
+ });
+ guard = TestBed.inject(NotifyInfoGuard);
+ });
+
+ it('should be created', () => {
+ expect(guard).toBeTruthy();
+ });
+
+ it('should return true if COAR config is enabled', (done) => {
+ notifyInfoServiceSpy.isCoarConfigEnabled.and.returnValue(of(true));
+
+ guard.canActivate(null, null).subscribe((result) => {
+ expect(result).toBe(true);
+ done();
+ });
+ });
+
+ it('should call parseUrl method of Router if COAR config is not enabled', (done) => {
+ notifyInfoServiceSpy.isCoarConfigEnabled.and.returnValue(of(false));
+ router.parseUrl.and.returnValue(of('/404'));
+
+ guard.canActivate(null, null).subscribe(() => {
+ expect(router.parseUrl).toHaveBeenCalledWith('/404');
+ done();
+ });
+ });
+
+});
diff --git a/src/app/core/coar-notify/notify-info/notify-info.guard.ts b/src/app/core/coar-notify/notify-info/notify-info.guard.ts
new file mode 100644
index 00000000000..7af08216184
--- /dev/null
+++ b/src/app/core/coar-notify/notify-info/notify-info.guard.ts
@@ -0,0 +1,30 @@
+import { Injectable } from '@angular/core';
+import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, UrlTree } from '@angular/router';
+import { Observable } from 'rxjs';
+import { NotifyInfoService } from './notify-info.service';
+import { map } from 'rxjs/operators';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class NotifyInfoGuard implements CanActivate {
+ constructor(
+ private notifyInfoService: NotifyInfoService,
+ private router: Router
+ ) {}
+
+ canActivate(
+ route: ActivatedRouteSnapshot,
+ state: RouterStateSnapshot
+ ): Observable {
+ return this.notifyInfoService.isCoarConfigEnabled().pipe(
+ map(coarLdnEnabled => {
+ if (coarLdnEnabled) {
+ return true;
+ } else {
+ return this.router.parseUrl('/404');
+ }
+ })
+ );
+ }
+}
diff --git a/src/app/core/coar-notify/notify-info/notify-info.service.spec.ts b/src/app/core/coar-notify/notify-info/notify-info.service.spec.ts
new file mode 100644
index 00000000000..a3cc360a969
--- /dev/null
+++ b/src/app/core/coar-notify/notify-info/notify-info.service.spec.ts
@@ -0,0 +1,56 @@
+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';
+
+describe('NotifyInfoService', () => {
+ let service: NotifyInfoService;
+ let configurationDataService: any;
+ let authorizationDataService: any;
+ beforeEach(() => {
+ authorizationDataService = {
+ isAuthorized: jasmine.createSpy('isAuthorized').and.returnValue(of(true)),
+ };
+ configurationDataService = {
+ findByPropertyName: jasmine.createSpy('findByPropertyName').and.returnValue(of({})),
+ };
+ TestBed.configureTestingModule({
+ providers: [
+ NotifyInfoService,
+ { provide: ConfigurationDataService, useValue: configurationDataService },
+ { provide: AuthorizationDataService, useValue: authorizationDataService }
+ ]
+ });
+ service = TestBed.inject(NotifyInfoService);
+ authorizationDataService = TestBed.inject(AuthorizationDataService);
+ configurationDataService = TestBed.inject(ConfigurationDataService);
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('should retrieve and map coar configuration', () => {
+ const mockResponse = { payload: { values: ['true'] } };
+ (configurationDataService.findByPropertyName as jasmine.Spy).and.returnValue(of(mockResponse));
+
+ service.isCoarConfigEnabled().subscribe((result) => {
+ expect(result).toBe(true);
+ });
+ });
+
+ it('should retrieve and map LDN local inbox URLs', () => {
+ const mockResponse = { values: ['inbox1', 'inbox2'] };
+ (configurationDataService.findByPropertyName as jasmine.Spy).and.returnValue(of(mockResponse));
+
+ service.getCoarLdnLocalInboxUrls().subscribe((result) => {
+ expect(result).toEqual(['inbox1', 'inbox2']);
+ });
+ });
+
+ it('should return the inbox relation link', () => {
+ expect(service.getInboxRelationLink()).toBe('http://www.w3.org/ns/ldp#inbox');
+ });
+});
diff --git a/src/app/core/coar-notify/notify-info/notify-info.service.ts b/src/app/core/coar-notify/notify-info/notify-info.service.ts
new file mode 100644
index 00000000000..a15c64237ce
--- /dev/null
+++ b/src/app/core/coar-notify/notify-info/notify-info.service.ts
@@ -0,0 +1,52 @@
+import { Injectable } from '@angular/core';
+import { getFirstSucceededRemoteData, getRemoteDataPayload } from '../../shared/operators';
+import { ConfigurationDataService } from '../../data/configuration-data.service';
+import { map, Observable } from 'rxjs';
+import { ConfigurationProperty } from '../../shared/configuration-property.model';
+import { AuthorizationDataService } from '../../data/feature-authorization/authorization-data.service';
+import { FeatureID } from '../../data/feature-authorization/feature-id';
+
+/**
+ * Service to check COAR availability and LDN services information for the COAR Notify functionalities
+ */
+@Injectable({
+ providedIn: 'root'
+})
+export class NotifyInfoService {
+
+ /**
+ * The relation link for the inbox
+ */
+ private _inboxRelationLink = 'http://www.w3.org/ns/ldp#inbox';
+
+ constructor(
+ private configService: ConfigurationDataService,
+ protected authorizationService: AuthorizationDataService,
+ ) {}
+
+ isCoarConfigEnabled(): Observable {
+ return this.authorizationService.isAuthorized(FeatureID.CoarNotifyEnabled);
+ }
+
+ /**
+ * Get the url of the local inbox from the REST configuration
+ * @returns the url of the local inbox
+ */
+ getCoarLdnLocalInboxUrls(): Observable {
+ return this.configService.findByPropertyName('ldn.notify.inbox').pipe(
+ getFirstSucceededRemoteData(),
+ getRemoteDataPayload(),
+ map((response: ConfigurationProperty) => {
+ return response.values;
+ })
+ );
+ }
+
+ /**
+ * Method to get the relation link for the inbox
+ * @returns the relation link for the inbox
+ */
+ getInboxRelationLink(): string {
+ return this._inboxRelationLink;
+ }
+}
diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts
index dbca773375a..28e0d3e6e39 100644
--- a/src/app/core/core.module.ts
+++ b/src/app/core/core.module.ts
@@ -157,6 +157,9 @@ import { SequenceService } from './shared/sequence.service';
import { CoreState } from './core-state.model';
import { GroupDataService } from './eperson/group-data.service';
import { SubmissionAccessesModel } from './config/models/config-submission-accesses.model';
+import { QualityAssuranceTopicObject } from './notifications/qa/models/quality-assurance-topic.model';
+import { QualityAssuranceEventObject } from './notifications/qa/models/quality-assurance-event.model';
+import { QualityAssuranceSourceObject } from './notifications/qa/models/quality-assurance-source.model';
import { RatingAdvancedWorkflowInfo } from './tasks/models/rating-advanced-workflow-info.model';
import { AdvancedWorkflowInfo } from './tasks/models/advanced-workflow-info.model';
import { SelectReviewerAdvancedWorkflowInfo } from './tasks/models/select-reviewer-advanced-workflow-info.model';
@@ -182,6 +185,20 @@ import { FlatBrowseDefinition } from './shared/flat-browse-definition.model';
import { ValueListBrowseDefinition } from './shared/value-list-browse-definition.model';
import { NonHierarchicalBrowseDefinition } from './shared/non-hierarchical-browse-definition';
import { BulkAccessConditionOptions } from './config/models/bulk-access-condition-options.model';
+import { CorrectionTypeDataService } from './submission/correctiontype-data.service';
+import { LdnServicesService } from '../admin/admin-ldn-services/ldn-services-data/ldn-services-data.service';
+import { LdnItemfiltersService } from '../admin/admin-ldn-services/ldn-services-data/ldn-itemfilters-data.service';
+import {
+ CoarNotifyConfigDataService
+} from '../submission/sections/section-coar-notify/coar-notify-config-data.service';
+import { NotifyRequestsStatusDataService } from './data/notify-services-status-data.service';
+import { SuggestionTarget } from './notifications/models/suggestion-target.model';
+import { SuggestionSource } from './notifications/models/suggestion-source.model';
+import { NotifyRequestsStatus } from '../item-page/simple/notify-requests-status/notify-requests-status.model';
+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';
/**
* When not in production, endpoint responses can be mocked for testing purposes
@@ -304,7 +321,12 @@ const PROVIDERS = [
OrcidAuthService,
OrcidQueueDataService,
OrcidHistoryDataService,
- SupervisionOrderDataService
+ SupervisionOrderDataService,
+ CorrectionTypeDataService,
+ LdnServicesService,
+ LdnItemfiltersService,
+ CoarNotifyConfigDataService,
+ NotifyRequestsStatusDataService
];
/**
@@ -369,9 +391,12 @@ export const models =
ShortLivedToken,
Registration,
UsageReport,
+ QualityAssuranceTopicObject,
+ QualityAssuranceEventObject,
Root,
SearchConfig,
SubmissionAccessesModel,
+ QualityAssuranceSourceObject,
AccessStatusObject,
ResearcherProfile,
OrcidQueue,
@@ -380,7 +405,14 @@ export const models =
IdentifierData,
Subscription,
ItemRequest,
- BulkAccessConditionOptions
+ BulkAccessConditionOptions,
+ SuggestionTarget,
+ SuggestionSource,
+ LdnService,
+ Itemfilter,
+ SubmissionCoarNotifyConfig,
+ NotifyRequestsStatus,
+ AdminNotifyMessage
];
@NgModule({
diff --git a/src/app/core/data/collection-data.service.spec.ts b/src/app/core/data/collection-data.service.spec.ts
index 65f8b3ab2cd..c1a7ac64c26 100644
--- a/src/app/core/data/collection-data.service.spec.ts
+++ b/src/app/core/data/collection-data.service.spec.ts
@@ -24,6 +24,7 @@ import { testFindAllDataImplementation } from './base/find-all-data.spec';
import { testSearchDataImplementation } from './base/search-data.spec';
import { testPatchDataImplementation } from './base/patch-data.spec';
import { testDeleteDataImplementation } from './base/delete-data.spec';
+import { ObjectCacheServiceStub } from '../../shared/testing/object-cache-service.stub';
const url = 'fake-url';
const collectionId = 'fake-collection-id';
@@ -35,7 +36,7 @@ describe('CollectionDataService', () => {
let translate: TranslateService;
let notificationsService: any;
let rdbService: RemoteDataBuildService;
- let objectCache: ObjectCacheService;
+ let objectCache: ObjectCacheServiceStub;
let halService: any;
const mockCollection1: Collection = Object.assign(new Collection(), {
@@ -205,14 +206,12 @@ describe('CollectionDataService', () => {
buildFromRequestUUID: buildResponse$,
buildSingle: buildResponse$
});
- objectCache = jasmine.createSpyObj('objectCache', {
- remove: jasmine.createSpy('remove')
- });
+ objectCache = new ObjectCacheServiceStub();
halService = new HALEndpointServiceStub(url);
notificationsService = new NotificationsServiceStub();
translate = getMockTranslateService();
- service = new CollectionDataService(requestService, rdbService, objectCache, halService, null, notificationsService, null, null, translate);
+ service = new CollectionDataService(requestService, rdbService, objectCache as ObjectCacheService, halService, null, notificationsService, null, null, translate);
}
});
diff --git a/src/app/core/data/feature-authorization/feature-id.ts b/src/app/core/data/feature-authorization/feature-id.ts
index 8fef45a9532..3bc3d6c1f08 100644
--- a/src/app/core/data/feature-authorization/feature-id.ts
+++ b/src/app/core/data/feature-authorization/feature-id.ts
@@ -34,4 +34,6 @@ export enum FeatureID {
CanEditItem = 'canEditItem',
CanRegisterDOI = 'canRegisterDOI',
CanSubscribe = 'canSubscribeDso',
+ CoarNotifyEnabled = 'coarNotifyEnabled',
+ CanSeeQA = 'canSeeQA',
}
diff --git a/src/app/core/data/notify-services-status-data.service.spec.ts b/src/app/core/data/notify-services-status-data.service.spec.ts
new file mode 100644
index 00000000000..ade6ae41568
--- /dev/null
+++ b/src/app/core/data/notify-services-status-data.service.spec.ts
@@ -0,0 +1,81 @@
+import { NotifyRequestsStatusDataService } from './notify-services-status-data.service';
+import { ObjectCacheService } from '../cache/object-cache.service';
+import { TestScheduler } from 'rxjs/testing';
+import { RequestService } from './request.service';
+import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
+import { HALEndpointService } from '../shared/hal-endpoint.service';
+import { RequestEntry } from './request-entry.model';
+import { RemoteData } from './remote-data';
+import { RequestEntryState } from './request-entry-state.model';
+import { cold, getTestScheduler } from 'jasmine-marbles';
+import { RestResponse } from '../cache/response.models';
+import { of } from 'rxjs';
+import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
+import { NotifyRequestsStatus } from '../../item-page/simple/notify-requests-status/notify-requests-status.model';
+
+describe('NotifyRequestsStatusDataService test', () => {
+ let scheduler: TestScheduler;
+ let service: NotifyRequestsStatusDataService;
+ let requestService: RequestService;
+ let rdbService: RemoteDataBuildService;
+ let objectCache: ObjectCacheService;
+ let halService: HALEndpointService;
+ let responseCacheEntry: RequestEntry;
+
+ const endpointURL = `https://rest.api/rest/api/suggestiontargets`;
+ const requestUUID = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a';
+
+ const remoteDataMocks = {
+ Success: new RemoteData(null, null, null, RequestEntryState.Success, null, null, 200),
+ };
+
+ function initTestService() {
+ return new NotifyRequestsStatusDataService(
+ requestService,
+ rdbService,
+ objectCache,
+ halService,
+ );
+ }
+
+ beforeEach(() => {
+ scheduler = getTestScheduler();
+
+ objectCache = {} as ObjectCacheService;
+ responseCacheEntry = new RequestEntry();
+ responseCacheEntry.request = { href: 'https://rest.api/' } as any;
+ responseCacheEntry.response = new RestResponse(true, 200, 'Success');
+
+ requestService = jasmine.createSpyObj('requestService', {
+ generateRequestId: requestUUID,
+ send: true,
+ removeByHrefSubstring: {},
+ getByHref: of(responseCacheEntry),
+ getByUUID: of(responseCacheEntry),
+ });
+
+ halService = jasmine.createSpyObj('halService', {
+ getEndpoint: of(endpointURL)
+ });
+
+ rdbService = jasmine.createSpyObj('rdbService', {
+ buildSingle: createSuccessfulRemoteDataObject$({}, 500),
+ buildList: cold('a', { a: remoteDataMocks.Success }),
+ buildFromHref: createSuccessfulRemoteDataObject$({test: 'test'})
+ });
+
+
+ service = initTestService();
+ });
+
+ describe('getNotifyRequestsStatus', () => {
+ it('should get notify status', (done) => {
+ service.getNotifyRequestsStatus(requestUUID).subscribe((status) => {
+ expect(halService.getEndpoint).toHaveBeenCalled();
+ expect(requestService.generateRequestId).toHaveBeenCalled();
+ expect(status).toEqual(createSuccessfulRemoteDataObject({test: 'test'} as unknown as NotifyRequestsStatus));
+ done();
+ });
+ });
+ });
+});
diff --git a/src/app/core/data/notify-services-status-data.service.ts b/src/app/core/data/notify-services-status-data.service.ts
new file mode 100644
index 00000000000..84fe4e9d815
--- /dev/null
+++ b/src/app/core/data/notify-services-status-data.service.ts
@@ -0,0 +1,45 @@
+import { Injectable } from '@angular/core';
+import { RequestService } from './request.service';
+import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
+import { ObjectCacheService } from '../cache/object-cache.service';
+import { HALEndpointService } from '../shared/hal-endpoint.service';
+import { IdentifiableDataService } from './base/identifiable-data.service';
+import { dataService } from './base/data-service.decorator';
+import { NotifyRequestsStatus } from '../../item-page/simple/notify-requests-status/notify-requests-status.model';
+import { NOTIFYREQUEST} from '../../item-page/simple/notify-requests-status/notify-requests-status.resource-type';
+import { Observable, map, take } from 'rxjs';
+import { RemoteData } from './remote-data';
+import { GetRequest } from './request.models';
+
+
+@Injectable()
+@dataService(NOTIFYREQUEST)
+export class NotifyRequestsStatusDataService extends IdentifiableDataService {
+
+ constructor(
+ protected requestService: RequestService,
+ protected rdbService: RemoteDataBuildService,
+ protected objectCache: ObjectCacheService,
+ protected halService: HALEndpointService,
+ ) {
+ super('notifyrequests', requestService, rdbService, objectCache, halService);
+ }
+
+ /**
+ * Retrieves the status of notify requests for a specific item.
+ * @param itemUuid The UUID of the item.
+ * @returns An Observable that emits the remote data containing the notify requests status.
+ */
+ getNotifyRequestsStatus(itemUuid: string): Observable> {
+ const href$ = this.getEndpoint().pipe(
+ map((url: string) => url + '/' + itemUuid ),
+ );
+
+ href$.pipe(take(1)).subscribe((url: string) => {
+ const request = new GetRequest(this.requestService.generateRequestId(), url);
+ this.requestService.send(request, true);
+ });
+
+ return this.rdbService.buildFromHref(href$);
+ }
+}
diff --git a/src/app/core/data/update-data.service.spec.ts b/src/app/core/data/update-data.service.spec.ts
new file mode 100644
index 00000000000..426fa87eb6d
--- /dev/null
+++ b/src/app/core/data/update-data.service.spec.ts
@@ -0,0 +1,144 @@
+import { of as observableOf } from 'rxjs';
+import { TestScheduler } from 'rxjs/testing';
+import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
+import { ObjectCacheService } from '../cache/object-cache.service';
+import { HALEndpointService } from '../shared/hal-endpoint.service';
+import { RequestService } from './request.service';
+import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
+import { HrefOnlyDataService } from './href-only-data.service';
+import { getMockHrefOnlyDataService } from '../../shared/mocks/href-only-data.service.mock';
+import { RestResponse } from '../cache/response.models';
+import { cold, getTestScheduler, hot } from 'jasmine-marbles';
+import { Item } from '../shared/item.model';
+import { Version } from '../shared/version.model';
+import { VersionHistory } from '../shared/version-history.model';
+import { RequestEntry } from './request-entry.model';
+import { testPatchDataImplementation } from './base/patch-data.spec';
+import { UpdateDataServiceImpl } from './update-data.service';
+import { testSearchDataImplementation } from './base/search-data.spec';
+import { testDeleteDataImplementation } from './base/delete-data.spec';
+import { testCreateDataImplementation } from './base/create-data.spec';
+import { testFindAllDataImplementation } from './base/find-all-data.spec';
+import { testPutDataImplementation } from './base/put-data.spec';
+import { NotificationsService } from '../../shared/notifications/notifications.service';
+
+describe('VersionDataService test', () => {
+ let scheduler: TestScheduler;
+ let service: UpdateDataServiceImpl;
+ let requestService: RequestService;
+ let rdbService: RemoteDataBuildService;
+ let objectCache: ObjectCacheService;
+ let halService: HALEndpointService;
+ let hrefOnlyDataService: HrefOnlyDataService;
+ let responseCacheEntry: RequestEntry;
+
+ const notificationsService = {} as NotificationsService;
+
+ const item = Object.assign(new Item(), {
+ id: '1234-1234',
+ uuid: '1234-1234',
+ bundles: observableOf({}),
+ metadata: {
+ 'dc.title': [
+ {
+ language: 'en_US',
+ value: 'This is just another title'
+ }
+ ],
+ 'dc.type': [
+ {
+ language: null,
+ value: 'Article'
+ }
+ ],
+ 'dc.contributor.author': [
+ {
+ language: 'en_US',
+ value: 'Smith, Donald'
+ }
+ ],
+ 'dc.date.issued': [
+ {
+ language: null,
+ value: '2015-06-26'
+ }
+ ]
+ }
+ });
+
+ const versionHistory = Object.assign(new VersionHistory(), {
+ id: '1',
+ draftVersion: true,
+ });
+
+ const mockVersion: Version = Object.assign(new Version(), {
+ item: createSuccessfulRemoteDataObject$(item),
+ versionhistory: createSuccessfulRemoteDataObject$(versionHistory),
+ version: 1,
+ });
+ const mockVersionRD = createSuccessfulRemoteDataObject(mockVersion);
+
+ const endpointURL = `https://rest.api/rest/api/versioning/versions`;
+ const requestUUID = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a';
+
+ objectCache = {} as ObjectCacheService;
+ const comparatorEntry = {} as any;
+ function initTestService() {
+ hrefOnlyDataService = getMockHrefOnlyDataService();
+ return new UpdateDataServiceImpl(
+ 'testLinkPath',
+ requestService,
+ rdbService,
+ objectCache,
+ halService,
+ notificationsService,
+ comparatorEntry,
+ 10 * 1000
+ );
+ }
+
+ beforeEach(() => {
+
+ scheduler = getTestScheduler();
+
+ halService = jasmine.createSpyObj('halService', {
+ getEndpoint: cold('a', { a: endpointURL })
+ });
+ responseCacheEntry = new RequestEntry();
+ responseCacheEntry.request = { href: 'https://rest.api/' } as any;
+ responseCacheEntry.response = new RestResponse(true, 200, 'Success');
+
+ requestService = jasmine.createSpyObj('requestService', {
+ generateRequestId: requestUUID,
+ send: true,
+ removeByHrefSubstring: {},
+ getByHref: observableOf(responseCacheEntry),
+ getByUUID: observableOf(responseCacheEntry),
+ });
+ rdbService = jasmine.createSpyObj('rdbService', {
+ buildSingle: hot('(a|)', {
+ a: mockVersionRD
+ })
+ });
+
+ service = initTestService();
+
+ spyOn((service as any), 'findById').and.callThrough();
+ });
+
+ afterEach(() => {
+ service = null;
+ });
+
+ describe('composition', () => {
+ const initService = () => new UpdateDataServiceImpl(null, null, null, null, null, null, null, null);
+
+ testPatchDataImplementation(initService);
+ testSearchDataImplementation(initService);
+ testDeleteDataImplementation(initService);
+ testCreateDataImplementation(initService);
+ testFindAllDataImplementation(initService);
+ testPutDataImplementation(initService);
+ });
+
+});
diff --git a/src/app/core/data/update-data.service.ts b/src/app/core/data/update-data.service.ts
index 9f707a82da9..664e2dcabc9 100644
--- a/src/app/core/data/update-data.service.ts
+++ b/src/app/core/data/update-data.service.ts
@@ -1,13 +1,315 @@
-import { Observable } from 'rxjs';
+import { Operation } from 'fast-json-patch';
+import { AsyncSubject, from as observableFrom, Observable } from 'rxjs';
+import {
+ find,
+ map,
+ mergeMap,
+ switchMap,
+ take,
+ toArray
+} from 'rxjs/operators';
+import { hasValue } from '../../shared/empty.util';
+import { NotificationsService } from '../../shared/notifications/notifications.service';
+import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
+import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
+import { RequestParam } from '../cache/models/request-param.model';
+import { ObjectCacheEntry } from '../cache/object-cache.reducer';
+import { ObjectCacheService } from '../cache/object-cache.service';
+import { HALEndpointService } from '../shared/hal-endpoint.service';
+import { ChangeAnalyzer } from './change-analyzer';
+import { PaginatedList } from './paginated-list.model';
import { RemoteData } from './remote-data';
+import {
+ DeleteByIDRequest,
+ PostRequest
+} from './request.models';
+import { RequestService } from './request.service';
import { RestRequestMethod } from './rest-request-method';
-import { Operation } from 'fast-json-patch';
+import { NoContent } from '../shared/NoContent.model';
+import { CacheableObject } from '../cache/cacheable-object.model';
+import { FindListOptions } from './find-list-options.model';
+import { FindAllData, FindAllDataImpl } from './base/find-all-data';
+import { SearchData, SearchDataImpl } from './base/search-data';
+import { CreateData, CreateDataImpl } from './base/create-data';
+import { PatchData, PatchDataImpl } from './base/patch-data';
+import { IdentifiableDataService } from './base/identifiable-data.service';
+import { PutData, PutDataImpl } from './base/put-data';
+import { DeleteData, DeleteDataImpl } from './base/delete-data';
+
/**
- * Represents a data service to update a given object
+ * Interface to list the methods used by the injected service in components
*/
export interface UpdateDataService {
patch(dso: T, operations: Operation[]): Observable>;
update(object: T): Observable>;
- commitUpdates(method?: RestRequestMethod);
+ commitUpdates(method?: RestRequestMethod): void;
+}
+
+
+/**
+ * Specific functionalities that not all services would need.
+ * Goal of the class is to update remote objects, handling custom methods that don't belong to BaseDataService
+ * The class implements also the following common interfaces
+ *
+ * findAllData: FindAllData;
+ * searchData: SearchData;
+ * createData: CreateData;
+ * patchData: PatchData;
+ * putData: PutData;
+ * deleteData: DeleteData;
+ *
+ * Custom methods are:
+ *
+ * deleteOnRelated - delete all related objects to the given one
+ * postOnRelated - post all the related objects to the given one
+ * invalidate - invalidate the DSpaceObject making all requests as stale
+ * invalidateByHref - invalidate the href making all requests as stale
+ */
+
+export class UpdateDataServiceImpl extends IdentifiableDataService implements FindAllData, SearchData, CreateData, PatchData, PutData, DeleteData {
+ private findAllData: FindAllDataImpl;
+ private searchData: SearchDataImpl;
+ private createData: CreateDataImpl;
+ private patchData: PatchDataImpl;
+ private putData: PutDataImpl;
+ private deleteData: DeleteDataImpl;
+
+
+ constructor(
+ protected linkPath: string,
+ protected requestService: RequestService,
+ protected rdbService: RemoteDataBuildService,
+ protected objectCache: ObjectCacheService,
+ protected halService: HALEndpointService,
+ protected notificationsService: NotificationsService,
+ protected comparator: ChangeAnalyzer,
+ protected responseMsToLive: number,
+ ) {
+ super(linkPath, requestService, rdbService, objectCache, halService, responseMsToLive);
+ this.findAllData = new FindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
+ this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
+ this.createData = new CreateDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService ,this.responseMsToLive);
+ this.patchData = new PatchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, comparator ,this.responseMsToLive, this.constructIdEndpoint);
+ this.putData = new PutDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
+ this.deleteData = new DeleteDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService ,this.responseMsToLive, this.constructIdEndpoint);
+ }
+
+
+ /**
+ * Create the HREF with given options object
+ *
+ * @param options The [[FindListOptions]] object
+ * @param linkPath The link path for the object
+ * @return {Observable}
+ * Return an observable that emits created HREF
+ * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
+ */
+ public getFindAllHref(options: FindListOptions = {}, linkPath?: string, ...linksToFollow: FollowLinkConfig[]): Observable {
+ return this.findAllData.getFindAllHref(options, linkPath, ...linksToFollow);
+ }
+
+ /**
+ * Create the HREF for a specific object's search method with given options object
+ *
+ * @param searchMethod The search method for the object
+ * @param options The [[FindListOptions]] object
+ * @return {Observable}
+ * Return an observable that emits created HREF
+ * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
+ */
+ public getSearchByHref(searchMethod: string, options: FindListOptions = {}, ...linksToFollow: FollowLinkConfig[]): Observable {
+ return this.searchData.getSearchByHref(searchMethod, options, ...linksToFollow);
+ }
+
+ /**
+ * Returns {@link RemoteData} of all object with a list of {@link FollowLinkConfig}, to indicate which embedded
+ * info should be added to the objects
+ *
+ * @param options Find list options object
+ * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
+ * no valid cached version. Defaults to true
+ * @param reRequestOnStale Whether or not the request should automatically be re-
+ * requested after the response becomes stale
+ * @param linksToFollow List of {@link FollowLinkConfig} that indicate which
+ * {@link HALLink}s should be automatically resolved
+ * @return {Observable>>}
+ * Return an observable that emits object list
+ */
+ findAll(options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> {
+ return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
+ }
+
+ /**
+ * Make a new FindListRequest with given search method
+ *
+ * @param searchMethod The search method for the object
+ * @param options The [[FindListOptions]] object
+ * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
+ * no valid cached version. Defaults to true
+ * @param reRequestOnStale Whether or not the request should automatically be re-
+ * requested after the response becomes stale
+ * @param linksToFollow List of {@link FollowLinkConfig} that indicate which
+ * {@link HALLink}s should be automatically resolved
+ * @return {Observable>}
+ * Return an observable that emits response from the server
+ */
+ searchBy(searchMethod: string, options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> {
+ return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
+ }
+
+ /**
+ * Send a patch request for a specified object
+ * @param {T} object The object to send a patch request for
+ * @param {Operation[]} operations The patch operations to be performed
+ */
+ patch(object: T, operations: Operation[]): Observable> {
+ return this.patchData.patch(object, operations);
+ }
+
+ createPatchFromCache(object: T): Observable {
+ return this.patchData.createPatchFromCache(object);
+ }
+
+ /**
+ * Send a PUT request for the specified object
+ *
+ * @param object The object to send a put request for.
+ */
+ put(object: T): Observable> {
+ return this.putData.put(object);
+ }
+
+ /**
+ * Add a new patch to the object cache
+ * The patch is derived from the differences between the given object and its version in the object cache
+ * @param {DSpaceObject} object The given object
+ */
+ update(object: T): Observable> {
+ return this.patchData.update(object);
+ }
+
+ /**
+ * Create a new DSpaceObject on the server, and store the response
+ * in the object cache
+ *
+ * @param {CacheableObject} object
+ * The object to create
+ * @param {RequestParam[]} params
+ * Array with additional params to combine with query string
+ */
+ create(object: T, ...params: RequestParam[]): Observable> {
+ return this.createData.create(object, ...params);
+ }
+ /**
+ * Perform a post on an endpoint related item with ID. Ex.: endpoint//related?item=
+ * @param itemId The item id
+ * @param relatedItemId The related item Id
+ * @param body The optional POST body
+ * @return the RestResponse as an Observable
+ */
+ public postOnRelated(itemId: string, relatedItemId: string, body?: any) {
+ const requestId = this.requestService.generateRequestId();
+ const hrefObs = this.getIDHrefObs(itemId);
+
+ hrefObs.pipe(
+ take(1)
+ ).subscribe((href: string) => {
+ const request = new PostRequest(requestId, href + '/related?item=' + relatedItemId, body);
+ if (hasValue(this.responseMsToLive)) {
+ request.responseMsToLive = this.responseMsToLive;
+ }
+ this.requestService.send(request);
+ });
+
+ return this.rdbService.buildFromRequestUUID(requestId);
+ }
+
+ /**
+ * Perform a delete on an endpoint related item. Ex.: endpoint//related
+ * @param itemId The item id
+ * @return the RestResponse as an Observable
+ */
+ public deleteOnRelated(itemId: string): Observable> {
+ const requestId = this.requestService.generateRequestId();
+ const hrefObs = this.getIDHrefObs(itemId);
+
+ hrefObs.pipe(
+ find((href: string) => hasValue(href)),
+ map((href: string) => {
+ const request = new DeleteByIDRequest(requestId, href + '/related', itemId);
+ if (hasValue(this.responseMsToLive)) {
+ request.responseMsToLive = this.responseMsToLive;
+ }
+ this.requestService.send(request);
+ })
+ ).subscribe();
+
+ return this.rdbService.buildFromRequestUUID(requestId);
+ }
+
+ /*
+ * Invalidate an existing DSpaceObject by marking all requests it is included in as stale
+ * @param objectId The id of the object to be invalidated
+ * @return An Observable that will emit `true` once all requests are stale
+ */
+ invalidate(objectId: string): Observable {
+ return this.getIDHrefObs(objectId).pipe(
+ switchMap((href: string) => this.invalidateByHref(href))
+ );
+ }
+
+ /**
+ * Invalidate an existing DSpaceObject by marking all requests it is included in as stale
+ * @param href The self link of the object to be invalidated
+ * @return An Observable that will emit `true` once all requests are stale
+ */
+ invalidateByHref(href: string): Observable {
+ const done$ = new AsyncSubject();
+
+ this.objectCache.getByHref(href).pipe(
+ switchMap((oce: ObjectCacheEntry) => observableFrom(oce.requestUUIDs).pipe(
+ mergeMap((requestUUID: string) => this.requestService.setStaleByUUID(requestUUID)),
+ toArray(),
+ )),
+ ).subscribe(() => {
+ done$.next(true);
+ done$.complete();
+ });
+
+ return done$;
+ }
+
+ /**
+ * Delete an existing DSpace Object on the server
+ * @param objectId The id of the object to be removed
+ * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual
+ * metadata should be saved as real metadata
+ * @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode,
+ * errorMessage, timeCompleted, etc
+ */
+ delete(objectId: string, copyVirtualMetadata?: string[]): Observable> {
+ return this.deleteData.delete(objectId, copyVirtualMetadata);
+ }
+
+ /**
+ * Delete an existing DSpace Object on the server
+ * @param href The self link of the object to be removed
+ * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual
+ * metadata should be saved as real metadata
+ * @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode,
+ * errorMessage, timeCompleted, etc
+ * Only emits once all request related to the DSO has been invalidated.
+ */
+ deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable> {
+ return this.deleteData.deleteByHref(href, copyVirtualMetadata);
+ }
+
+ /**
+ * Commit current object changes to the server
+ * @param method The RestRequestMethod for which de server sync buffer should be committed
+ */
+ commitUpdates(method?: RestRequestMethod) {
+ this.patchData.commitUpdates(method);
+ }
}
diff --git a/src/app/core/json-patch/builder/json-patch-operations-builder.ts b/src/app/core/json-patch/builder/json-patch-operations-builder.ts
index f5a584fd3d0..0982e3ed538 100644
--- a/src/app/core/json-patch/builder/json-patch-operations-builder.ts
+++ b/src/app/core/json-patch/builder/json-patch-operations-builder.ts
@@ -1,5 +1,6 @@
import { Store } from '@ngrx/store';
import {
+ FlushPatchOperationAction,
NewPatchAddOperationAction,
NewPatchMoveOperationAction,
NewPatchRemoveOperationAction,
@@ -99,6 +100,20 @@ export class JsonPatchOperationsBuilder {
path.path));
}
+ /**
+ * Dispatches a new FlushPatchOperationAction
+ *
+ * @param path
+ * a JsonPatchOperationPathObject representing path
+ */
+ flushOperation(path: JsonPatchOperationPathObject) {
+ this.store.dispatch(
+ new FlushPatchOperationAction(
+ path.rootElement,
+ path.subRootElement,
+ path.path));
+ }
+
protected prepareValue(value: any, plain: boolean, first: boolean) {
let operationValue: any = null;
if (hasValue(value)) {
diff --git a/src/app/core/json-patch/json-patch-operations.actions.ts b/src/app/core/json-patch/json-patch-operations.actions.ts
index 6fea7a58fb9..81cce174ec9 100644
--- a/src/app/core/json-patch/json-patch-operations.actions.ts
+++ b/src/app/core/json-patch/json-patch-operations.actions.ts
@@ -20,6 +20,7 @@ export const JsonPatchOperationsActionTypes = {
COMMIT_JSON_PATCH_OPERATIONS: type('dspace/core/patch/COMMIT_JSON_PATCH_OPERATIONS'),
ROLLBACK_JSON_PATCH_OPERATIONS: type('dspace/core/patch/ROLLBACK_JSON_PATCH_OPERATIONS'),
FLUSH_JSON_PATCH_OPERATIONS: type('dspace/core/patch/FLUSH_JSON_PATCH_OPERATIONS'),
+ FLUSH_JSON_PATCH_OPERATION: type('dspace/core/patch/FLUSH_JSON_PATCH_OPERATION'),
START_TRANSACTION_JSON_PATCH_OPERATIONS: type('dspace/core/patch/START_TRANSACTION_JSON_PATCH_OPERATIONS'),
DELETE_PENDING_JSON_PATCH_OPERATIONS: type('dspace/core/patch/DELETE_PENDING_JSON_PATCH_OPERATIONS'),
};
@@ -120,6 +121,32 @@ export class FlushPatchOperationsAction implements Action {
}
}
+
+/**
+ * An ngrx action to flush a single operation of the JSON Patch operations
+ */
+export class FlushPatchOperationAction implements Action {
+ type = JsonPatchOperationsActionTypes.FLUSH_JSON_PATCH_OPERATION;
+ payload: {
+ resourceType: string;
+ resourceId: string;
+ path: string
+ };
+
+ /**
+ * Create a new FlushPatchOperationsAction
+ *
+ * @param resourceType
+ * the resource's type
+ * @param resourceId
+ * the resource's ID
+ * @param path
+ * the path of the operation
+ */
+ constructor(resourceType: string, resourceId: string, path: string) {
+ this.payload = { resourceType, resourceId, path };
+ }
+}
/**
* An ngrx action to Add new HTTP/PATCH ADD operations to state
*/
@@ -284,4 +311,5 @@ export type PatchOperationsActions
| NewPatchReplaceOperationAction
| RollbacktPatchOperationsAction
| StartTransactionPatchOperationsAction
- | DeletePendingJsonPatchOperationsAction;
+ | DeletePendingJsonPatchOperationsAction
+ | FlushPatchOperationAction;
diff --git a/src/app/core/json-patch/json-patch-operations.reducer.ts b/src/app/core/json-patch/json-patch-operations.reducer.ts
index 5e00027edb9..8eefacc518a 100644
--- a/src/app/core/json-patch/json-patch-operations.reducer.ts
+++ b/src/app/core/json-patch/json-patch-operations.reducer.ts
@@ -12,7 +12,7 @@ import {
CommitPatchOperationsAction,
StartTransactionPatchOperationsAction,
RollbacktPatchOperationsAction,
- DeletePendingJsonPatchOperationsAction
+ DeletePendingJsonPatchOperationsAction, FlushPatchOperationAction
} from './json-patch-operations.actions';
import { JsonPatchOperationModel, JsonPatchOperationType } from './json-patch.model';
@@ -71,7 +71,7 @@ export function jsonPatchOperationsReducer(state = initialState, action: PatchOp
}
case JsonPatchOperationsActionTypes.FLUSH_JSON_PATCH_OPERATIONS: {
- return flushOperation(state, action as FlushPatchOperationsAction);
+ return flushOperations(state, action as FlushPatchOperationsAction);
}
case JsonPatchOperationsActionTypes.NEW_JSON_PATCH_ADD_OPERATION: {
@@ -106,6 +106,10 @@ export function jsonPatchOperationsReducer(state = initialState, action: PatchOp
return deletePendingOperations(state, action as DeletePendingJsonPatchOperationsAction);
}
+ case JsonPatchOperationsActionTypes.FLUSH_JSON_PATCH_OPERATION: {
+ return flushOperation(state, action as FlushPatchOperationAction);
+ }
+
default: {
return state;
}
@@ -197,6 +201,39 @@ function deletePendingOperations(state: JsonPatchOperationsState, action: Delete
return null;
}
+/**
+ * Flush one operation from JsonPatchOperationsState.
+ *
+ * @param state
+ * the current state
+ * @param action
+ * an FlushPatchOperationsAction
+ * @return JsonPatchOperationsState
+ * the new state.
+ */
+function flushOperation(state: JsonPatchOperationsState, action: FlushPatchOperationAction): JsonPatchOperationsState {
+ const payload = action.payload;
+ if (state[payload.resourceType] && state[payload.resourceType].children) {
+ const body = state[payload.resourceType].children[payload.resourceId].body;
+ const operation = body.filter(operations => operations.operation.path === payload.path)[0];
+ const operationIndex = body.indexOf(operation);
+ const newBody = [...body];
+ newBody.splice(operationIndex, 1);
+
+ return Object.assign({}, state, {
+ [action.payload.resourceType]: Object.assign({}, {
+ children: {
+ [action.payload.resourceId]: {
+ body: newBody,
+ }
+ },
+ })
+ });
+ } else {
+ return state;
+ }
+}
+
/**
* Add new JSON patch operation list.
*
@@ -273,7 +310,7 @@ function hasValidBody(state: JsonPatchOperationsState, resourceType: any, resour
* @return SubmissionObjectState
* the new state, with the section new validity status.
*/
-function flushOperation(state: JsonPatchOperationsState, action: FlushPatchOperationsAction): JsonPatchOperationsState {
+function flushOperations(state: JsonPatchOperationsState, action: FlushPatchOperationsAction): JsonPatchOperationsState {
if (hasValue(state[ action.payload.resourceType ])) {
let newChildren;
if (isNotUndefined(action.payload.resourceId)) {
@@ -351,7 +388,28 @@ function addOperationToList(body: JsonPatchOperationObject[], actionType, target
newBody.push(makeOperationEntry({ op: JsonPatchOperationType.move, from: fromPath, path: targetPath }));
break;
}
- return newBody;
+ return dedupeOperationEntries(newBody);
+}
+
+/**
+ * Dedupe operation entries by op and path. This prevents processing unnecessary patches in a single PATCH request.
+ *
+ * @param body JSON patch operation object entries
+ * @returns deduped JSON patch operation object entries
+ */
+function dedupeOperationEntries(body: JsonPatchOperationObject[]): JsonPatchOperationObject[] {
+ const ops = new Map();
+ for (let i = body.length - 1; i >= 0; i--) {
+ const patch = body[i].operation;
+ const key = `${patch.op}-${patch.path}`;
+ if (!ops.has(key)) {
+ ops.set(key, patch);
+ } else {
+ body.splice(i, 1);
+ }
+ }
+
+ return body;
}
function makeOperationEntry(operation) {
diff --git a/src/app/core/notifications/models/suggestion-objects.resource-type.ts b/src/app/core/notifications/models/suggestion-objects.resource-type.ts
new file mode 100644
index 00000000000..8f83d86376b
--- /dev/null
+++ b/src/app/core/notifications/models/suggestion-objects.resource-type.ts
@@ -0,0 +1,9 @@
+import { ResourceType } from '../../shared/resource-type';
+
+/**
+ * The resource type for the Suggestion object
+ *
+ * Needs to be in a separate file to prevent circular
+ * dependencies in webpack.
+ */
+export const SUGGESTION = new ResourceType('suggestion');
diff --git a/src/app/core/notifications/models/suggestion-source-object.resource-type.ts b/src/app/core/notifications/models/suggestion-source-object.resource-type.ts
new file mode 100644
index 00000000000..e319ed5109b
--- /dev/null
+++ b/src/app/core/notifications/models/suggestion-source-object.resource-type.ts
@@ -0,0 +1,9 @@
+import { ResourceType } from '../../shared/resource-type';
+
+/**
+ * The resource type for the Suggestion Source object
+ *
+ * Needs to be in a separate file to prevent circular
+ * dependencies in webpack.
+ */
+export const SUGGESTION_SOURCE = new ResourceType('suggestionsource');
diff --git a/src/app/core/notifications/models/suggestion-source.model.ts b/src/app/core/notifications/models/suggestion-source.model.ts
new file mode 100644
index 00000000000..12d9d7e9d84
--- /dev/null
+++ b/src/app/core/notifications/models/suggestion-source.model.ts
@@ -0,0 +1,47 @@
+import { autoserialize, deserialize } from 'cerialize';
+
+import { SUGGESTION_SOURCE } from './suggestion-source-object.resource-type';
+import { excludeFromEquals } from '../../utilities/equals.decorators';
+import { ResourceType } from '../../shared/resource-type';
+import { HALLink } from '../../shared/hal-link.model';
+import { typedObject } from '../../cache/builders/build-decorators';
+import {CacheableObject} from '../../cache/cacheable-object.model';
+
+/**
+ * The interface representing the Suggestion Source model
+ */
+@typedObject
+export class SuggestionSource implements CacheableObject {
+ /**
+ * A string representing the kind of object, e.g. community, item, …
+ */
+ static type = SUGGESTION_SOURCE;
+
+ /**
+ * The Suggestion Target id
+ */
+ @autoserialize
+ id: string;
+
+ /**
+ * The total number of suggestions provided by Suggestion Target for
+ */
+ @autoserialize
+ total: number;
+
+ /**
+ * The type of this ConfigObject
+ */
+ @excludeFromEquals
+ @autoserialize
+ type: ResourceType;
+
+ /**
+ * The links to all related resources returned by the rest api.
+ */
+ @deserialize
+ _links: {
+ self: HALLink,
+ suggestiontargets: HALLink
+ };
+}
diff --git a/src/app/core/notifications/models/suggestion-target-object.resource-type.ts b/src/app/core/notifications/models/suggestion-target-object.resource-type.ts
new file mode 100644
index 00000000000..81b1b5c2619
--- /dev/null
+++ b/src/app/core/notifications/models/suggestion-target-object.resource-type.ts
@@ -0,0 +1,9 @@
+import { ResourceType } from '../../shared/resource-type';
+
+/**
+ * The resource type for the Suggestion Target object
+ *
+ * Needs to be in a separate file to prevent circular
+ * dependencies in webpack.
+ */
+export const SUGGESTION_TARGET = new ResourceType('suggestiontarget');
diff --git a/src/app/core/notifications/models/suggestion-target.model.ts b/src/app/core/notifications/models/suggestion-target.model.ts
new file mode 100644
index 00000000000..99d9a8628ad
--- /dev/null
+++ b/src/app/core/notifications/models/suggestion-target.model.ts
@@ -0,0 +1,61 @@
+import { autoserialize, deserialize } from 'cerialize';
+
+
+import { CacheableObject } from '../../cache/cacheable-object.model';
+import { SUGGESTION_TARGET } from './suggestion-target-object.resource-type';
+import { excludeFromEquals } from '../../utilities/equals.decorators';
+import { ResourceType } from '../../shared/resource-type';
+import { HALLink } from '../../shared/hal-link.model';
+import { typedObject } from '../../cache/builders/build-decorators';
+
+/**
+ * The interface representing the Suggestion Target model
+ */
+@typedObject
+export class SuggestionTarget implements CacheableObject {
+ /**
+ * A string representing the kind of object, e.g. community, item, …
+ */
+ static type = SUGGESTION_TARGET;
+
+ /**
+ * The Suggestion Target id
+ */
+ @autoserialize
+ id: string;
+
+ /**
+ * The Suggestion Target name to display
+ */
+ @autoserialize
+ display: string;
+
+ /**
+ * The Suggestion Target source to display
+ */
+ @autoserialize
+ source: string;
+
+ /**
+ * The total number of suggestions provided by Suggestion Target for
+ */
+ @autoserialize
+ total: number;
+
+ /**
+ * The type of this ConfigObject
+ */
+ @excludeFromEquals
+ @autoserialize
+ type: ResourceType;
+
+ /**
+ * The links to all related resources returned by the rest api.
+ */
+ @deserialize
+ _links: {
+ self: HALLink,
+ suggestions: HALLink,
+ target: HALLink
+ };
+}
diff --git a/src/app/core/notifications/models/suggestion.model.ts b/src/app/core/notifications/models/suggestion.model.ts
new file mode 100644
index 00000000000..ad58b1cfe55
--- /dev/null
+++ b/src/app/core/notifications/models/suggestion.model.ts
@@ -0,0 +1,88 @@
+import { autoserialize, autoserializeAs, deserialize } from 'cerialize';
+
+import { SUGGESTION } from './suggestion-objects.resource-type';
+import { excludeFromEquals } from '../../utilities/equals.decorators';
+import { ResourceType } from '../../shared/resource-type';
+import { HALLink } from '../../shared/hal-link.model';
+import { typedObject } from '../../cache/builders/build-decorators';
+import { MetadataMap, MetadataMapSerializer } from '../../shared/metadata.models';
+import {CacheableObject} from '../../cache/cacheable-object.model';
+
+/**
+ * The interface representing Suggestion Evidences such as scores (authorScore, datescore)
+ */
+export interface SuggestionEvidences {
+ [sectionId: string]: {
+ score: string;
+ notes: string
+ };
+}
+/**
+ * The interface representing the Suggestion Source model
+ */
+@typedObject
+export class Suggestion implements CacheableObject {
+ /**
+ * A string representing the kind of object, e.g. community, item, …
+ */
+ static type = SUGGESTION;
+
+ /**
+ * The Suggestion id
+ */
+ @autoserialize
+ id: string;
+
+ /**
+ * The Suggestion name to display
+ */
+ @autoserialize
+ display: string;
+
+ /**
+ * The Suggestion source to display
+ */
+ @autoserialize
+ source: string;
+
+ /**
+ * The Suggestion external source uri
+ */
+ @autoserialize
+ externalSourceUri: string;
+
+ /**
+ * The Total Score of the suggestion
+ */
+ @autoserialize
+ score: string;
+
+ /**
+ * The total number of suggestions provided by Suggestion Target for
+ */
+ @autoserialize
+ evidences: SuggestionEvidences;
+
+ /**
+ * All metadata of this suggestion object
+ */
+ @excludeFromEquals
+ @autoserializeAs(MetadataMapSerializer)
+ metadata: MetadataMap;
+
+ /**
+ * The type of this ConfigObject
+ */
+ @excludeFromEquals
+ @autoserialize
+ type: ResourceType;
+
+ /**
+ * The links to all related resources returned by the rest api.
+ */
+ @deserialize
+ _links: {
+ self: HALLink,
+ target: HALLink
+ };
+}
diff --git a/src/app/core/notifications/qa/events/quality-assurance-event-data.service.spec.ts b/src/app/core/notifications/qa/events/quality-assurance-event-data.service.spec.ts
new file mode 100644
index 00000000000..6ab60ef2de7
--- /dev/null
+++ b/src/app/core/notifications/qa/events/quality-assurance-event-data.service.spec.ts
@@ -0,0 +1,248 @@
+import { HttpClient } from '@angular/common/http';
+
+import { TestScheduler } from 'rxjs/testing';
+import { of as observableOf } from 'rxjs';
+import { cold, getTestScheduler } from 'jasmine-marbles';
+
+import { RequestService } from '../../../data/request.service';
+import { buildPaginatedList } from '../../../data/paginated-list.model';
+import { RemoteDataBuildService } from '../../../cache/builders/remote-data-build.service';
+import { ObjectCacheService } from '../../../cache/object-cache.service';
+import { RestResponse } from '../../../cache/response.models';
+import { PageInfo } from '../../../shared/page-info.model';
+import { HALEndpointService } from '../../../shared/hal-endpoint.service';
+import { NotificationsService } from '../../../../shared/notifications/notifications.service';
+import { createSuccessfulRemoteDataObject } from '../../../../shared/remote-data.utils';
+import { QualityAssuranceEventDataService } from './quality-assurance-event-data.service';
+import {
+ qualityAssuranceEventObjectMissingPid,
+ qualityAssuranceEventObjectMissingPid2,
+ qualityAssuranceEventObjectMissingProjectFound
+} from '../../../../shared/mocks/notifications.mock';
+import { ReplaceOperation } from 'fast-json-patch';
+import { RequestEntry } from '../../../data/request-entry.model';
+import { FindListOptions } from '../../../data/find-list-options.model';
+import { ObjectCacheServiceStub } from '../../../../shared/testing/object-cache-service.stub';
+
+describe('QualityAssuranceEventDataService', () => {
+ let scheduler: TestScheduler;
+ let service: QualityAssuranceEventDataService;
+ let serviceASAny: any;
+ let responseCacheEntry: RequestEntry;
+ let responseCacheEntryB: RequestEntry;
+ let responseCacheEntryC: RequestEntry;
+ let requestService: RequestService;
+ let rdbService: RemoteDataBuildService;
+ let objectCache: ObjectCacheServiceStub;
+ let halService: HALEndpointService;
+ let notificationsService: NotificationsService;
+ let http: HttpClient;
+ let comparator: any;
+
+ const endpointURL = 'https://rest.api/rest/api/integration/qualityassurancetopics';
+ const requestUUID = '8b3c913a-5a4b-438b-9181-be1a5b4a1c8a';
+ const topic = 'ENRICH!MORE!PID';
+
+ const pageInfo = new PageInfo();
+ const array = [qualityAssuranceEventObjectMissingPid, qualityAssuranceEventObjectMissingPid2];
+ const paginatedList = buildPaginatedList(pageInfo, array);
+ const qaEventObjectRD = createSuccessfulRemoteDataObject(qualityAssuranceEventObjectMissingPid);
+ const qaEventObjectMissingProjectRD = createSuccessfulRemoteDataObject(qualityAssuranceEventObjectMissingProjectFound);
+ const paginatedListRD = createSuccessfulRemoteDataObject(paginatedList);
+
+ const status = 'ACCEPTED';
+ const operation: ReplaceOperation[] = [
+ {
+ path: '/status',
+ op: 'replace',
+ value: status
+ }
+ ];
+
+ beforeEach(() => {
+ scheduler = getTestScheduler();
+
+ 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: jasmine.createSpy('getByHref'),
+ getByUUID: jasmine.createSpy('getByUUID')
+ });
+
+ responseCacheEntryB = new RequestEntry();
+ responseCacheEntryB.request = { href: 'https://rest.api/' } as any;
+ responseCacheEntryB.response = new RestResponse(true, 201, 'Created');
+
+ responseCacheEntryC = new RequestEntry();
+ responseCacheEntryC.request = { href: 'https://rest.api/' } as any;
+ responseCacheEntryC.response = new RestResponse(true, 204, 'No Content');
+
+ rdbService = jasmine.createSpyObj('rdbService', {
+ buildSingle: cold('(a)', {
+ a: qaEventObjectRD
+ }),
+ buildList: cold('(a)', {
+ a: paginatedListRD
+ }),
+ buildFromRequestUUID: jasmine.createSpy('buildFromRequestUUID'),
+ buildFromRequestUUIDAndAwait: jasmine.createSpy('buildFromRequestUUIDAndAwait')
+ });
+
+ objectCache = new ObjectCacheServiceStub();
+ halService = jasmine.createSpyObj('halService', {
+ getEndpoint: cold('a|', { a: endpointURL })
+ });
+
+ notificationsService = {} as NotificationsService;
+ http = {} as HttpClient;
+ comparator = {} as any;
+
+ service = new QualityAssuranceEventDataService(
+ requestService,
+ rdbService,
+ objectCache as ObjectCacheService,
+ halService,
+ notificationsService,
+ comparator
+ );
+
+ serviceASAny = service;
+
+ spyOn(serviceASAny.searchData, 'searchBy').and.callThrough();
+ spyOn(serviceASAny, 'findById').and.callThrough();
+ spyOn(serviceASAny.patchData, 'patch').and.callThrough();
+ spyOn(serviceASAny, 'postOnRelated').and.callThrough();
+ spyOn(serviceASAny, 'deleteOnRelated').and.callThrough();
+ });
+
+ describe('getEventsByTopic', () => {
+ beforeEach(() => {
+ serviceASAny.requestService.getByHref.and.returnValue(observableOf(responseCacheEntry));
+ serviceASAny.requestService.getByUUID.and.returnValue(observableOf(responseCacheEntry));
+ serviceASAny.rdbService.buildFromRequestUUID.and.returnValue(observableOf(qaEventObjectRD));
+ });
+
+ it('should proxy the call to searchData.searchBy', () => {
+ const options: FindListOptions = {
+ searchParams: [
+ {
+ fieldName: 'topic',
+ fieldValue: topic
+ }
+ ]
+ };
+ service.getEventsByTopic(topic);
+ expect(serviceASAny.searchData.searchBy).toHaveBeenCalledWith('findByTopic', options, true, true);
+ });
+
+ it('should return a RemoteData> for the object with the given Topic', () => {
+ const result = service.getEventsByTopic(topic);
+ const expected = cold('(a)', {
+ a: paginatedListRD
+ });
+ expect(result).toBeObservable(expected);
+ });
+ });
+
+ describe('getEvent', () => {
+ beforeEach(() => {
+ serviceASAny.requestService.getByHref.and.returnValue(observableOf(responseCacheEntry));
+ serviceASAny.requestService.getByUUID.and.returnValue(observableOf(responseCacheEntry));
+ serviceASAny.rdbService.buildFromRequestUUID.and.returnValue(observableOf(qaEventObjectRD));
+ });
+
+ it('should call findById', () => {
+ service.getEvent(qualityAssuranceEventObjectMissingPid.id).subscribe(
+ (res) => {
+ expect(serviceASAny.findById).toHaveBeenCalledWith(qualityAssuranceEventObjectMissingPid.id, true, true);
+ }
+ );
+ });
+
+ it('should return a RemoteData for the object with the given URL', () => {
+ const result = service.getEvent(qualityAssuranceEventObjectMissingPid.id);
+ const expected = cold('(a)', {
+ a: qaEventObjectRD
+ });
+ expect(result).toBeObservable(expected);
+ });
+ });
+
+ describe('patchEvent', () => {
+ beforeEach(() => {
+ serviceASAny.requestService.getByHref.and.returnValue(observableOf(responseCacheEntry));
+ serviceASAny.requestService.getByUUID.and.returnValue(observableOf(responseCacheEntry));
+ serviceASAny.rdbService.buildFromRequestUUID.and.returnValue(observableOf(qaEventObjectRD));
+ serviceASAny.rdbService.buildFromRequestUUIDAndAwait.and.returnValue(observableOf(qaEventObjectRD));
+ });
+
+ it('should proxy the call to patchData.patch', () => {
+ service.patchEvent(status, qualityAssuranceEventObjectMissingPid).subscribe(
+ (res) => {
+ expect(serviceASAny.patchData.patch).toHaveBeenCalledWith(qualityAssuranceEventObjectMissingPid, operation);
+ }
+ );
+ });
+
+ it('should return a RemoteData with HTTP 200', () => {
+ const result = service.patchEvent(status, qualityAssuranceEventObjectMissingPid);
+ const expected = cold('(a|)', {
+ a: createSuccessfulRemoteDataObject(qualityAssuranceEventObjectMissingPid)
+ });
+ expect(result).toBeObservable(expected);
+ });
+ });
+
+ describe('boundProject', () => {
+ beforeEach(() => {
+ serviceASAny.requestService.getByHref.and.returnValue(observableOf(responseCacheEntryB));
+ serviceASAny.requestService.getByUUID.and.returnValue(observableOf(responseCacheEntryB));
+ serviceASAny.rdbService.buildFromRequestUUID.and.returnValue(observableOf(qaEventObjectMissingProjectRD));
+ });
+
+ it('should call postOnRelated', () => {
+ service.boundProject(qualityAssuranceEventObjectMissingProjectFound.id, requestUUID).subscribe(
+ (res) => {
+ expect(serviceASAny.postOnRelated).toHaveBeenCalledWith(qualityAssuranceEventObjectMissingProjectFound.id, requestUUID);
+ }
+ );
+ });
+
+ it('should return a RestResponse with HTTP 201', () => {
+ const result = service.boundProject(qualityAssuranceEventObjectMissingProjectFound.id, requestUUID);
+ const expected = cold('(a|)', {
+ a: createSuccessfulRemoteDataObject(qualityAssuranceEventObjectMissingProjectFound)
+ });
+ expect(result).toBeObservable(expected);
+ });
+ });
+
+ describe('removeProject', () => {
+ beforeEach(() => {
+ serviceASAny.requestService.getByHref.and.returnValue(observableOf(responseCacheEntryC));
+ serviceASAny.requestService.getByUUID.and.returnValue(observableOf(responseCacheEntryC));
+ serviceASAny.rdbService.buildFromRequestUUID.and.returnValue(observableOf(createSuccessfulRemoteDataObject({})));
+ });
+
+ it('should call deleteOnRelated', () => {
+ service.removeProject(qualityAssuranceEventObjectMissingProjectFound.id).subscribe(
+ (res) => {
+ expect(serviceASAny.deleteOnRelated).toHaveBeenCalledWith(qualityAssuranceEventObjectMissingProjectFound.id);
+ }
+ );
+ });
+
+ it('should return a RestResponse with HTTP 204', () => {
+ const result = service.removeProject(qualityAssuranceEventObjectMissingProjectFound.id);
+ const expected = cold('(a|)', {
+ a: createSuccessfulRemoteDataObject({})
+ });
+ expect(result).toBeObservable(expected);
+ });
+ });
+
+});
diff --git a/src/app/core/notifications/qa/events/quality-assurance-event-data.service.ts b/src/app/core/notifications/qa/events/quality-assurance-event-data.service.ts
new file mode 100644
index 00000000000..e266ace080b
--- /dev/null
+++ b/src/app/core/notifications/qa/events/quality-assurance-event-data.service.ts
@@ -0,0 +1,252 @@
+import { Injectable } from '@angular/core';
+
+import { Observable } from 'rxjs';
+import { find, switchMap, take } from 'rxjs/operators';
+import { ReplaceOperation } from 'fast-json-patch';
+
+import { HALEndpointService } from '../../../shared/hal-endpoint.service';
+import { NotificationsService } from '../../../../shared/notifications/notifications.service';
+import { RemoteDataBuildService } from '../../../cache/builders/remote-data-build.service';
+import { ObjectCacheService } from '../../../cache/object-cache.service';
+import { dataService } from '../../../data/base/data-service.decorator';
+import { RequestService } from '../../../data/request.service';
+import { RemoteData } from '../../../data/remote-data';
+import { QualityAssuranceEventObject } from '../models/quality-assurance-event.model';
+import { QUALITY_ASSURANCE_EVENT_OBJECT } from '../models/quality-assurance-event-object.resource-type';
+import { FollowLinkConfig } from '../../../../shared/utils/follow-link-config.model';
+import { PaginatedList } from '../../../data/paginated-list.model';
+import { NoContent } from '../../../shared/NoContent.model';
+import { FindListOptions } from '../../../data/find-list-options.model';
+import { IdentifiableDataService } from '../../../data/base/identifiable-data.service';
+import { CreateData, CreateDataImpl } from '../../../data/base/create-data';
+import { PatchData, PatchDataImpl } from '../../../data/base/patch-data';
+import { DeleteData, DeleteDataImpl } from '../../../data/base/delete-data';
+import { SearchData, SearchDataImpl } from '../../../data/base/search-data';
+import { DefaultChangeAnalyzer } from '../../../data/default-change-analyzer.service';
+import { hasValue } from '../../../../shared/empty.util';
+import { DeleteByIDRequest, PostRequest } from '../../../data/request.models';
+import { HttpHeaders, HttpParams } from '@angular/common/http';
+import { HttpOptions } from '../../../dspace-rest/dspace-rest.service';
+import {
+ QualityAssuranceEventData
+} from '../../../../notifications/qa/project-entry-import-modal/project-entry-import-modal.component';
+
+/**
+ * The service handling all Quality Assurance topic REST requests.
+ */
+@Injectable()
+@dataService(QUALITY_ASSURANCE_EVENT_OBJECT)
+export class QualityAssuranceEventDataService extends IdentifiableDataService {
+
+ private createData: CreateData;
+ private searchData: SearchData;
+ private patchData: PatchData;
+ private deleteData: DeleteData;
+
+ /**
+ * Initialize service variables
+ * @param {RequestService} requestService
+ * @param {RemoteDataBuildService} rdbService
+ * @param {ObjectCacheService} objectCache
+ * @param {HALEndpointService} halService
+ * @param {NotificationsService} notificationsService
+ * @param {DefaultChangeAnalyzer} comparator
+ */
+ constructor(
+ protected requestService: RequestService,
+ protected rdbService: RemoteDataBuildService,
+ protected objectCache: ObjectCacheService,
+ protected halService: HALEndpointService,
+ protected notificationsService: NotificationsService,
+ protected comparator: DefaultChangeAnalyzer
+ ) {
+ super('qualityassuranceevents', requestService, rdbService, objectCache, halService);
+ this.createData = new CreateDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive);
+ this.deleteData = new DeleteDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive, this.constructIdEndpoint);
+ this.patchData = new PatchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, comparator, this.responseMsToLive, this.constructIdEndpoint);
+ this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
+ }
+
+ /**
+ * Return the list of Quality Assurance events by topic.
+ *
+ * @param topic
+ * The Quality Assurance topic
+ * @param options
+ * Find list options object.
+ * @param linksToFollow
+ * List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved.
+ * @return Observable>>
+ * The list of Quality Assurance events.
+ */
+ public getEventsByTopic(topic: string, options: FindListOptions = {}, ...linksToFollow: FollowLinkConfig[]): Observable>> {
+ options.searchParams = [
+ {
+ fieldName: 'topic',
+ fieldValue: topic
+ }
+ ];
+ return this.searchData.searchBy('findByTopic', options, true, true, ...linksToFollow);
+ }
+
+ /**
+ * Service for retrieving Quality Assurance events by topic and target.
+ * @param options (Optional) The search options to use when retrieving the events.
+ * @param linksToFollow (Optional) The links to follow when retrieving the events.
+ * @returns An observable of the remote data containing the paginated list of Quality Assurance events.
+ */
+ public searchEventsByTopic(options: FindListOptions = {}, ...linksToFollow: FollowLinkConfig[]): Observable>> {
+ return this.searchData.searchBy('findByTopic', options, true, true, ...linksToFollow);
+ }
+
+ /**
+ * Clear findByTopic requests from cache
+ */
+ public clearFindByTopicRequests() {
+ this.requestService.setStaleByHrefSubstring('findByTopic');
+ }
+
+ /**
+ * Return a single Quality Assurance event.
+ *
+ * @param id
+ * The Quality Assurance event id
+ * @param linksToFollow
+ * List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
+ * @return Observable>
+ * The Quality Assurance event.
+ */
+ public getEvent(id: string, ...linksToFollow: FollowLinkConfig[]): Observable> {
+ return this.findById(id, true, true, ...linksToFollow);
+ }
+
+ /**
+ * Save the new status of a Quality Assurance event.
+ *
+ * @param status
+ * The new status
+ * @param dso QualityAssuranceEventObject
+ * The event item
+ * @param reason
+ * The optional reason (not used for now; for future implementation)
+ * @return Observable
+ * The REST response.
+ */
+ public patchEvent(status, dso, reason?: string): Observable> {
+ const operation: ReplaceOperation[] = [
+ {
+ path: '/status',
+ op: 'replace',
+ value: status
+ }
+ ];
+ return this.patchData.patch(dso, operation);
+ }
+
+ /**
+ * Bound a project to a Quality Assurance event publication.
+ *
+ * @param itemId
+ * The Id of the Quality Assurance event
+ * @param projectId
+ * The project Id to bound
+ * @return Observable
+ * The REST response.
+ */
+ public boundProject(itemId: string, projectId: string): Observable> {
+ return this.postOnRelated(itemId, projectId);
+ }
+
+ /**
+ * Remove a project from a Quality Assurance event publication.
+ *
+ * @param itemId
+ * The Id of the Quality Assurance event
+ * @return Observable
+ * The REST response.
+ */
+ public removeProject(itemId: string): Observable> {
+ return this.deleteOnRelated(itemId);
+ }
+
+ /**
+ * Perform a delete operation on an endpoint related item. Ex.: endpoint//related
+ * @param objectId The item id
+ * @return the RestResponse as an Observable
+ */
+ private deleteOnRelated(objectId: string): Observable> {
+ const requestId = this.requestService.generateRequestId();
+
+ const hrefObs = this.getIDHrefObs(objectId);
+
+ hrefObs.pipe(
+ find((href: string) => hasValue(href)),
+ ).subscribe((href: string) => {
+ const request = new DeleteByIDRequest(requestId, href + '/related', objectId);
+ if (hasValue(this.responseMsToLive)) {
+ request.responseMsToLive = this.responseMsToLive;
+ }
+ this.requestService.send(request);
+ });
+
+ return this.rdbService.buildFromRequestUUID(requestId);
+ }
+
+ /**
+ * Perform a post on an endpoint related item with ID. Ex.: endpoint//related?item=
+ * @param objectId The item id
+ * @param relatedItemId The related item Id
+ * @param body The optional POST body
+ * @return the RestResponse as an Observable
+ */
+ private postOnRelated(objectId: string, relatedItemId: string, body?: any) {
+ const requestId = this.requestService.generateRequestId();
+ const hrefObs = this.getIDHrefObs(objectId);
+
+ hrefObs.pipe(
+ take(1)
+ ).subscribe((href: string) => {
+ const request = new PostRequest(requestId, href + '/related?item=' + relatedItemId, body);
+ if (hasValue(this.responseMsToLive)) {
+ request.responseMsToLive = this.responseMsToLive;
+ }
+ this.requestService.send(request);
+ });
+
+ return this.rdbService.buildFromRequestUUID(requestId);
+ }
+
+ /**
+ * Perform a post on an endpoint related to correction type
+ * @param data the data to post
+ * @returns the RestResponse as an Observable
+ */
+ postData(target: string, correctionType: string, related: string, reason: string): Observable> {
+ const requestId = this.requestService.generateRequestId();
+ const href$ = this.getBrowseEndpoint();
+
+ return href$.pipe(
+ switchMap((href: string) => {
+ const options: HttpOptions = Object.create({});
+ let headers = new HttpHeaders();
+ headers = headers.append('Content-Type', 'application/json');
+ options.headers = headers;
+ let params = new HttpParams();
+ params = params.append('target', target)
+ .append('correctionType', correctionType);
+ options.params = params;
+ const request = new PostRequest(requestId, href, {'reason': reason} , options);
+ if (hasValue(this.responseMsToLive)) {
+ request.responseMsToLive = this.responseMsToLive;
+ }
+ this.requestService.send(request);
+ return this.rdbService.buildFromRequestUUID(requestId);
+ })
+ );
+ }
+
+ public deleteQAEvent(qaEvent: QualityAssuranceEventData): Observable> {
+ return this.deleteData.delete(qaEvent.id);
+ }
+
+}
diff --git a/src/app/core/notifications/qa/models/quality-assurance-event-object.resource-type.ts b/src/app/core/notifications/qa/models/quality-assurance-event-object.resource-type.ts
new file mode 100644
index 00000000000..84aff6ba2cf
--- /dev/null
+++ b/src/app/core/notifications/qa/models/quality-assurance-event-object.resource-type.ts
@@ -0,0 +1,9 @@
+import { ResourceType } from '../../../shared/resource-type';
+
+/**
+ * The resource type for the Quality Assurance event
+ *
+ * Needs to be in a separate file to prevent circular
+ * dependencies in webpack.
+ */
+export const QUALITY_ASSURANCE_EVENT_OBJECT = new ResourceType('qualityassuranceevent');
diff --git a/src/app/core/notifications/qa/models/quality-assurance-event.model.ts b/src/app/core/notifications/qa/models/quality-assurance-event.model.ts
new file mode 100644
index 00000000000..1d66e5bb28a
--- /dev/null
+++ b/src/app/core/notifications/qa/models/quality-assurance-event.model.ts
@@ -0,0 +1,173 @@
+/* eslint-disable max-classes-per-file */
+import { Observable } from 'rxjs';
+import { autoserialize, autoserializeAs, deserialize } from 'cerialize';
+import { QUALITY_ASSURANCE_EVENT_OBJECT } from './quality-assurance-event-object.resource-type';
+import { excludeFromEquals } from '../../../utilities/equals.decorators';
+import { ResourceType } from '../../../shared/resource-type';
+import { HALLink } from '../../../shared/hal-link.model';
+import { Item } from '../../../shared/item.model';
+import { ITEM } from '../../../shared/item.resource-type';
+import { link, typedObject } from '../../../cache/builders/build-decorators';
+import { RemoteData } from '../../../data/remote-data';
+import {CacheableObject} from '../../../cache/cacheable-object.model';
+
+/**
+ * The interface representing the Quality Assurance event message
+ */
+// eslint-disable-next-line @typescript-eslint/no-empty-interface
+export interface QualityAssuranceEventMessageObject {
+
+}
+
+/**
+ * The interface representing the Quality Assurance event message
+ */
+export interface SourceQualityAssuranceEventMessageObject {
+ /**
+ * The type of 'value'
+ */
+ type: string;
+
+ reason: string;
+
+ /**
+ * The value suggested by Notifications
+ */
+ value: string;
+
+ /**
+ * The abstract suggested by Notifications
+ */
+ abstract: string;
+
+ /**
+ * The project acronym suggested by Notifications
+ */
+ acronym: string;
+
+ /**
+ * The project code suggested by Notifications
+ */
+ code: string;
+
+ /**
+ * The project funder suggested by Notifications
+ */
+ funder: string;
+
+ /**
+ * The project program suggested by Notifications
+ */
+ fundingProgram?: string;
+
+ /**
+ * The project jurisdiction suggested by Notifications
+ */
+ jurisdiction: string;
+
+ /**
+ * The project title suggested by Notifications
+ */
+ title: string;
+
+ /**
+ * The Source ID.
+ */
+ sourceId: string;
+
+ /**
+ * The PID href.
+ */
+ pidHref: string;
+
+}
+
+/**
+ * The interface representing the Quality Assurance event model
+ */
+@typedObject
+export class QualityAssuranceEventObject implements CacheableObject {
+ /**
+ * A string representing the kind of object, e.g. community, item, …
+ */
+ static type = QUALITY_ASSURANCE_EVENT_OBJECT;
+
+ /**
+ * The Quality Assurance event uuid inside DSpace
+ */
+ @autoserialize
+ id: string;
+
+ /**
+ * The universally unique identifier of this Quality Assurance event
+ */
+ @autoserializeAs(String, 'id')
+ uuid: string;
+
+ /**
+ * The Quality Assurance event original id (ex.: the source archive OAI-PMH identifier)
+ */
+ @autoserialize
+ originalId: string;
+
+ /**
+ * The title of the article to which the suggestion refers
+ */
+ @autoserialize
+ title: string;
+
+ /**
+ * Reliability of the suggestion (of the data inside 'message')
+ */
+ @autoserialize
+ trust: number;
+
+ /**
+ * The timestamp Quality Assurance event was saved in DSpace
+ */
+ @autoserialize
+ eventDate: string;
+
+ /**
+ * The Quality Assurance event status (ACCEPTED, REJECTED, DISCARDED, PENDING)
+ */
+ @autoserialize
+ status: string;
+
+ /**
+ * The suggestion data. Data may vary depending on the source
+ */
+ @autoserialize
+ message: SourceQualityAssuranceEventMessageObject;
+
+ /**
+ * The type of this ConfigObject
+ */
+ @excludeFromEquals
+ @autoserialize
+ type: ResourceType;
+
+ /**
+ * The links to all related resources returned by the rest api.
+ */
+ @deserialize
+ _links: {
+ self: HALLink,
+ target: HALLink,
+ related: HALLink
+ };
+
+ /**
+ * The related publication DSpace item
+ * Will be undefined unless the {@item HALLink} has been resolved.
+ */
+ @link(ITEM)
+ target?: Observable>;
+
+ /**
+ * The related project for this Event
+ * Will be undefined unless the {@related HALLink} has been resolved.
+ */
+ @link(ITEM)
+ related?: Observable>;
+}
diff --git a/src/app/core/notifications/qa/models/quality-assurance-source-object.resource-type.ts b/src/app/core/notifications/qa/models/quality-assurance-source-object.resource-type.ts
new file mode 100644
index 00000000000..b4f64b24d14
--- /dev/null
+++ b/src/app/core/notifications/qa/models/quality-assurance-source-object.resource-type.ts
@@ -0,0 +1,9 @@
+import { ResourceType } from '../../../shared/resource-type';
+
+/**
+ * The resource type for the Quality Assurance source
+ *
+ * Needs to be in a separate file to prevent circular
+ * dependencies in webpack.
+ */
+export const QUALITY_ASSURANCE_SOURCE_OBJECT = new ResourceType('qualityassurancesource');
diff --git a/src/app/core/notifications/qa/models/quality-assurance-source.model.ts b/src/app/core/notifications/qa/models/quality-assurance-source.model.ts
new file mode 100644
index 00000000000..f59467384ff
--- /dev/null
+++ b/src/app/core/notifications/qa/models/quality-assurance-source.model.ts
@@ -0,0 +1,52 @@
+import { autoserialize, deserialize } from 'cerialize';
+
+import { excludeFromEquals } from '../../../utilities/equals.decorators';
+import { ResourceType } from '../../../shared/resource-type';
+import { HALLink } from '../../../shared/hal-link.model';
+import { typedObject } from '../../../cache/builders/build-decorators';
+import { QUALITY_ASSURANCE_SOURCE_OBJECT } from './quality-assurance-source-object.resource-type';
+import {CacheableObject} from '../../../cache/cacheable-object.model';
+
+/**
+ * The interface representing the Quality Assurance source model
+ */
+@typedObject
+export class QualityAssuranceSourceObject implements CacheableObject {
+ /**
+ * A string representing the kind of object, e.g. community, item, …
+ */
+ static type = QUALITY_ASSURANCE_SOURCE_OBJECT;
+
+ /**
+ * The Quality Assurance source id
+ */
+ @autoserialize
+ id: string;
+
+ /**
+ * The date of the last udate from Notifications
+ */
+ @autoserialize
+ lastEvent: string;
+
+ /**
+ * The total number of suggestions provided by Notifications for this source
+ */
+ @autoserialize
+ totalEvents: number;
+
+ /**
+ * The type of this ConfigObject
+ */
+ @excludeFromEquals
+ @autoserialize
+ type: ResourceType;
+
+ /**
+ * The links to all related resources returned by the rest api.
+ */
+ @deserialize
+ _links: {
+ self: HALLink,
+ };
+}
diff --git a/src/app/core/notifications/qa/models/quality-assurance-topic-object.resource-type.ts b/src/app/core/notifications/qa/models/quality-assurance-topic-object.resource-type.ts
new file mode 100644
index 00000000000..e9fc57a307c
--- /dev/null
+++ b/src/app/core/notifications/qa/models/quality-assurance-topic-object.resource-type.ts
@@ -0,0 +1,9 @@
+import { ResourceType } from '../../../shared/resource-type';
+
+/**
+ * The resource type for the Quality Assurance topic
+ *
+ * Needs to be in a separate file to prevent circular
+ * dependencies in webpack.
+ */
+export const QUALITY_ASSURANCE_TOPIC_OBJECT = new ResourceType('qualityassurancetopic');
diff --git a/src/app/core/notifications/qa/models/quality-assurance-topic.model.ts b/src/app/core/notifications/qa/models/quality-assurance-topic.model.ts
new file mode 100644
index 00000000000..529980e5f7c
--- /dev/null
+++ b/src/app/core/notifications/qa/models/quality-assurance-topic.model.ts
@@ -0,0 +1,58 @@
+import { autoserialize, deserialize } from 'cerialize';
+
+import { QUALITY_ASSURANCE_TOPIC_OBJECT } from './quality-assurance-topic-object.resource-type';
+import { excludeFromEquals } from '../../../utilities/equals.decorators';
+import { ResourceType } from '../../../shared/resource-type';
+import { HALLink } from '../../../shared/hal-link.model';
+import { typedObject } from '../../../cache/builders/build-decorators';
+import {CacheableObject} from '../../../cache/cacheable-object.model';
+
+/**
+ * The interface representing the Quality Assurance topic model
+ */
+@typedObject
+export class QualityAssuranceTopicObject implements CacheableObject {
+ /**
+ * A string representing the kind of object, e.g. community, item, …
+ */
+ static type = QUALITY_ASSURANCE_TOPIC_OBJECT;
+
+ /**
+ * The Quality Assurance topic id
+ */
+ @autoserialize
+ id: string;
+
+ /**
+ * The Quality Assurance topic name to display
+ */
+ @autoserialize
+ name: string;
+
+ /**
+ * The date of the last udate from Notifications
+ */
+ @autoserialize
+ lastEvent: string;
+
+ /**
+ * The total number of suggestions provided by Notifications for this topic
+ */
+ @autoserialize
+ totalEvents: number;
+
+ /**
+ * The type of this ConfigObject
+ */
+ @excludeFromEquals
+ @autoserialize
+ type: ResourceType;
+
+ /**
+ * The links to all related resources returned by the rest api.
+ */
+ @deserialize
+ _links: {
+ self: HALLink,
+ };
+}
diff --git a/src/app/core/notifications/qa/source/quality-assurance-source-data.service.spec.ts b/src/app/core/notifications/qa/source/quality-assurance-source-data.service.spec.ts
new file mode 100644
index 00000000000..105303d1f9b
--- /dev/null
+++ b/src/app/core/notifications/qa/source/quality-assurance-source-data.service.spec.ts
@@ -0,0 +1,126 @@
+import { HttpClient } from '@angular/common/http';
+
+import { TestScheduler } from 'rxjs/testing';
+import { of as observableOf } from 'rxjs';
+import { cold, getTestScheduler } from 'jasmine-marbles';
+
+import { RequestService } from '../../../data/request.service';
+import { buildPaginatedList } from '../../../data/paginated-list.model';
+import { RemoteDataBuildService } from '../../../cache/builders/remote-data-build.service';
+import { ObjectCacheService } from '../../../cache/object-cache.service';
+import { RestResponse } from '../../../cache/response.models';
+import { PageInfo } from '../../../shared/page-info.model';
+import { HALEndpointService } from '../../../shared/hal-endpoint.service';
+import { NotificationsService } from '../../../../shared/notifications/notifications.service';
+import { createSuccessfulRemoteDataObject } from '../../../../shared/remote-data.utils';
+import {
+ qualityAssuranceSourceObjectMoreAbstract,
+ qualityAssuranceSourceObjectMorePid
+} from '../../../../shared/mocks/notifications.mock';
+import { RequestEntry } from '../../../data/request-entry.model';
+import { QualityAssuranceSourceDataService } from './quality-assurance-source-data.service';
+import { ObjectCacheServiceStub } from '../../../../shared/testing/object-cache-service.stub';
+
+describe('QualityAssuranceSourceDataService', () => {
+ let scheduler: TestScheduler;
+ let service: QualityAssuranceSourceDataService;
+ let responseCacheEntry: RequestEntry;
+ let requestService: RequestService;
+ let rdbService: RemoteDataBuildService;
+ let objectCache: ObjectCacheServiceStub;
+ let halService: HALEndpointService;
+ let notificationsService: NotificationsService;
+ let http: HttpClient;
+ let comparator: any;
+
+ const endpointURL = 'https://rest.api/rest/api/integration/qualityassurancesources';
+ const requestUUID = '8b3c913a-5a4b-438b-9181-be1a5b4a1c8a';
+
+ const pageInfo = new PageInfo();
+ const array = [qualityAssuranceSourceObjectMorePid, qualityAssuranceSourceObjectMoreAbstract];
+ const paginatedList = buildPaginatedList(pageInfo, array);
+ const qaSourceObjectRD = createSuccessfulRemoteDataObject(qualityAssuranceSourceObjectMorePid);
+ const paginatedListRD = createSuccessfulRemoteDataObject(paginatedList);
+
+ beforeEach(() => {
+ scheduler = getTestScheduler();
+
+ responseCacheEntry = new RequestEntry();
+ responseCacheEntry.response = new RestResponse(true, 200, 'Success');
+ requestService = jasmine.createSpyObj('requestService', {
+ generateRequestId: requestUUID,
+ send: true,
+ removeByHrefSubstring: {},
+ getByHref: observableOf(responseCacheEntry),
+ getByUUID: observableOf(responseCacheEntry),
+ });
+
+ rdbService = jasmine.createSpyObj('rdbService', {
+ buildSingle: cold('(a)', {
+ a: qaSourceObjectRD
+ }),
+ buildList: cold('(a)', {
+ a: paginatedListRD
+ }),
+ });
+
+ objectCache = new ObjectCacheServiceStub();
+ halService = jasmine.createSpyObj('halService', {
+ getEndpoint: cold('a|', { a: endpointURL })
+ });
+
+ notificationsService = {} as NotificationsService;
+ http = {} as HttpClient;
+ comparator = {} as any;
+
+ service = new QualityAssuranceSourceDataService(
+ requestService,
+ rdbService,
+ objectCache as ObjectCacheService,
+ halService,
+ notificationsService
+ );
+
+ spyOn((service as any).findAllData, 'findAll').and.callThrough();
+ spyOn((service as any), 'findById').and.callThrough();
+ });
+
+ describe('getSources', () => {
+ it('should call findAll', (done) => {
+ service.getSources().subscribe(
+ (res) => {
+ expect((service as any).findAllData.findAll).toHaveBeenCalledWith({}, true, true);
+ }
+ );
+ done();
+ });
+
+ it('should return a RemoteData> for the object with the given URL', () => {
+ const result = service.getSources();
+ const expected = cold('(a)', {
+ a: paginatedListRD
+ });
+ expect(result).toBeObservable(expected);
+ });
+ });
+
+ describe('getSource', () => {
+ it('should call findById', (done) => {
+ service.getSource(qualityAssuranceSourceObjectMorePid.id).subscribe(
+ (res) => {
+ expect((service as any).findById).toHaveBeenCalledWith(qualityAssuranceSourceObjectMorePid.id, true, true);
+ }
+ );
+ done();
+ });
+
+ it('should return a RemoteData for the object with the given URL', () => {
+ const result = service.getSource(qualityAssuranceSourceObjectMorePid.id);
+ const expected = cold('(a)', {
+ a: qaSourceObjectRD
+ });
+ expect(result).toBeObservable(expected);
+ });
+ });
+
+});
diff --git a/src/app/core/notifications/qa/source/quality-assurance-source-data.service.ts b/src/app/core/notifications/qa/source/quality-assurance-source-data.service.ts
new file mode 100644
index 00000000000..f6a58fdd45f
--- /dev/null
+++ b/src/app/core/notifications/qa/source/quality-assurance-source-data.service.ts
@@ -0,0 +1,104 @@
+import { Injectable } from '@angular/core';
+
+import { Observable } from 'rxjs';
+
+import { HALEndpointService } from '../../../shared/hal-endpoint.service';
+import { NotificationsService } from '../../../../shared/notifications/notifications.service';
+import { RemoteDataBuildService } from '../../../cache/builders/remote-data-build.service';
+import { ObjectCacheService } from '../../../cache/object-cache.service';
+import { dataService } from '../../../data/base/data-service.decorator';
+import { RequestService } from '../../../data/request.service';
+import { RemoteData } from '../../../data/remote-data';
+import { QualityAssuranceSourceObject } from '../models/quality-assurance-source.model';
+import { QUALITY_ASSURANCE_SOURCE_OBJECT } from '../models/quality-assurance-source-object.resource-type';
+import { FollowLinkConfig } from '../../../../shared/utils/follow-link-config.model';
+import { PaginatedList } from '../../../data/paginated-list.model';
+import { FindListOptions } from '../../../data/find-list-options.model';
+import { IdentifiableDataService } from '../../../data/base/identifiable-data.service';
+import { FindAllData, FindAllDataImpl } from '../../../data/base/find-all-data';
+import { SearchData, SearchDataImpl } from '../../../data/base/search-data';
+
+/**
+ * The service handling all Quality Assurance source REST requests.
+ */
+@Injectable()
+@dataService(QUALITY_ASSURANCE_SOURCE_OBJECT)
+export class QualityAssuranceSourceDataService extends IdentifiableDataService {
+
+ private findAllData: FindAllData;
+ private searchAllData: SearchData;
+
+ private searchByTargetMethod = 'byTarget';
+
+ /**
+ * Initialize service variables
+ * @param {RequestService} requestService
+ * @param {RemoteDataBuildService} rdbService
+ * @param {ObjectCacheService} objectCache
+ * @param {HALEndpointService} halService
+ * @param {NotificationsService} notificationsService
+ */
+ constructor(
+ protected requestService: RequestService,
+ protected rdbService: RemoteDataBuildService,
+ protected objectCache: ObjectCacheService,
+ protected halService: HALEndpointService,
+ protected notificationsService: NotificationsService
+ ) {
+ super('qualityassurancesources', requestService, rdbService, objectCache, halService);
+ this.findAllData = new FindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
+ this.searchAllData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
+ }
+
+ /**
+ * Return the list of Quality Assurance source.
+ *
+ * @param options Find list options object.
+ * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
+ * no valid cached version. Defaults to true
+ * @param reRequestOnStale Whether or not the request should automatically be re-
+ * requested after the response becomes stale
+ * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved.
+ *
+ * @return Observable>>
+ * The list of Quality Assurance source.
+ */
+ public getSources(options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> {
+ return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
+ }
+
+ /**
+ * Clear FindAll source requests from cache
+ */
+ public clearFindAllSourceRequests() {
+ this.requestService.setStaleByHrefSubstring('qualityassurancesources');
+ }
+
+ /**
+ * Return a single Quality Assurance source.
+ *
+ * @param id The Quality Assurance source id
+ * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
+ * no valid cached version. Defaults to true
+ * @param reRequestOnStale Whether or not the request should automatically be re-
+ * requested after the response becomes stale
+ * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved.
+ *
+ * @return Observable> The Quality Assurance source.
+ */
+ public getSource(id: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> {
+ return this.findById(id, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
+ }
+
+ /**
+ * Retrieves a paginated list of QualityAssuranceSourceObject objects that are associated with a given target object.
+ * @param options The options for the search query.
+ * @param useCachedVersionIfAvailable Whether to use a cached version of the data if available.
+ * @param reRequestOnStale Whether to re-request the data if the cached version is stale.
+ * @param linksToFollow The links to follow to retrieve the data.
+ * @returns An observable that emits a RemoteData object containing the paginated list of QualityAssuranceSourceObject objects.
+ */
+ public getSourcesByTarget(options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> {
+ return this.searchAllData.searchBy(this.searchByTargetMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
+ }
+}
diff --git a/src/app/core/notifications/qa/topics/quality-assurance-topic-data.service.spec.ts b/src/app/core/notifications/qa/topics/quality-assurance-topic-data.service.spec.ts
new file mode 100644
index 00000000000..bade6cace57
--- /dev/null
+++ b/src/app/core/notifications/qa/topics/quality-assurance-topic-data.service.spec.ts
@@ -0,0 +1,133 @@
+import { HttpClient } from '@angular/common/http';
+
+import { TestScheduler } from 'rxjs/testing';
+import { of as observableOf } from 'rxjs';
+import { cold, getTestScheduler } from 'jasmine-marbles';
+
+import { RequestService } from '../../../data/request.service';
+import { buildPaginatedList } from '../../../data/paginated-list.model';
+import { RemoteDataBuildService } from '../../../cache/builders/remote-data-build.service';
+import { ObjectCacheService } from '../../../cache/object-cache.service';
+import { RestResponse } from '../../../cache/response.models';
+import { PageInfo } from '../../../shared/page-info.model';
+import { HALEndpointService } from '../../../shared/hal-endpoint.service';
+import { NotificationsService } from '../../../../shared/notifications/notifications.service';
+import { createSuccessfulRemoteDataObject } from '../../../../shared/remote-data.utils';
+import { QualityAssuranceTopicDataService } from './quality-assurance-topic-data.service';
+import {
+ qualityAssuranceTopicObjectMoreAbstract,
+ qualityAssuranceTopicObjectMorePid
+} from '../../../../shared/mocks/notifications.mock';
+import { RequestEntry } from '../../../data/request-entry.model';
+import { ObjectCacheServiceStub } from '../../../../shared/testing/object-cache-service.stub';
+
+describe('QualityAssuranceTopicDataService', () => {
+ let scheduler: TestScheduler;
+ let service: QualityAssuranceTopicDataService;
+ let responseCacheEntry: RequestEntry;
+ let requestService: RequestService;
+ let rdbService: RemoteDataBuildService;
+ let objectCache: ObjectCacheServiceStub;
+ let halService: HALEndpointService;
+ let notificationsService: NotificationsService;
+ let http: HttpClient;
+ let comparator: any;
+
+ const endpointURL = 'https://rest.api/rest/api/integration/qualityassurancetopics';
+ const requestUUID = '8b3c913a-5a4b-438b-9181-be1a5b4a1c8a';
+
+ const pageInfo = new PageInfo();
+ const array = [qualityAssuranceTopicObjectMorePid, qualityAssuranceTopicObjectMoreAbstract];
+ const paginatedList = buildPaginatedList(pageInfo, array);
+ const qaTopicObjectRD = createSuccessfulRemoteDataObject(qualityAssuranceTopicObjectMorePid);
+ const paginatedListRD = createSuccessfulRemoteDataObject(paginatedList);
+
+ beforeEach(() => {
+ scheduler = getTestScheduler();
+
+ responseCacheEntry = new RequestEntry();
+ responseCacheEntry.response = new RestResponse(true, 200, 'Success');
+ requestService = jasmine.createSpyObj('requestService', {
+ generateRequestId: requestUUID,
+ send: true,
+ removeByHrefSubstring: {},
+ getByHref: observableOf(responseCacheEntry),
+ getByUUID: observableOf(responseCacheEntry),
+ });
+
+ rdbService = jasmine.createSpyObj('rdbService', {
+ buildSingle: cold('(a)', {
+ a: qaTopicObjectRD
+ }),
+ buildList: cold('(a)', {
+ a: paginatedListRD
+ }),
+ });
+
+ objectCache = new ObjectCacheServiceStub();
+ halService = jasmine.createSpyObj('halService', {
+ getEndpoint: cold('a|', { a: endpointURL })
+ });
+
+ notificationsService = {} as NotificationsService;
+ http = {} as HttpClient;
+ comparator = {} as any;
+
+ service = new QualityAssuranceTopicDataService(
+ requestService,
+ rdbService,
+ objectCache as ObjectCacheService,
+ halService,
+ notificationsService
+ );
+
+ spyOn((service as any).findAllData, 'findAll').and.callThrough();
+ spyOn((service as any), 'findById').and.callThrough();
+ spyOn((service as any).searchData, 'searchBy').and.callThrough();
+ });
+
+ describe('searchTopicsByTarget', () => {
+ it('should call searchData.searchBy with the correct parameters', () => {
+ const options = { elementsPerPage: 10 };
+ const useCachedVersionIfAvailable = true;
+ const reRequestOnStale = true;
+
+ service.searchTopicsByTarget(options, useCachedVersionIfAvailable, reRequestOnStale);
+
+ expect((service as any).searchData.searchBy).toHaveBeenCalledWith(
+ 'byTarget',
+ options,
+ useCachedVersionIfAvailable,
+ reRequestOnStale
+ );
+ });
+
+ it('should return a RemoteData> for the object with the given URL', () => {
+ const result = service.searchTopicsByTarget();
+ const expected = cold('(a)', {
+ a: paginatedListRD
+ });
+ expect(result).toBeObservable(expected);
+ });
+ });
+
+ describe('getTopic', () => {
+ it('should call findByHref', (done) => {
+ service.getTopic(qualityAssuranceTopicObjectMorePid.id).subscribe(
+ (res) => {
+ expect((service as any).findById).toHaveBeenCalledWith(qualityAssuranceTopicObjectMorePid.id, true, true);
+ }
+ );
+ done();
+ });
+
+ it('should return a RemoteData for the object with the given URL', () => {
+ const result = service.getTopic(qualityAssuranceTopicObjectMorePid.id);
+ const expected = cold('(a)', {
+ a: qaTopicObjectRD
+ });
+ expect(result).toBeObservable(expected);
+ });
+ });
+
+});
diff --git a/src/app/core/notifications/qa/topics/quality-assurance-topic-data.service.ts b/src/app/core/notifications/qa/topics/quality-assurance-topic-data.service.ts
new file mode 100644
index 00000000000..919aaac71a9
--- /dev/null
+++ b/src/app/core/notifications/qa/topics/quality-assurance-topic-data.service.ts
@@ -0,0 +1,101 @@
+import { Injectable } from '@angular/core';
+
+import { Observable } from 'rxjs';
+
+import { HALEndpointService } from '../../../shared/hal-endpoint.service';
+import { NotificationsService } from '../../../../shared/notifications/notifications.service';
+import { RemoteDataBuildService } from '../../../cache/builders/remote-data-build.service';
+import { ObjectCacheService } from '../../../cache/object-cache.service';
+import { RequestService } from '../../../data/request.service';
+import { RemoteData } from '../../../data/remote-data';
+import { QualityAssuranceTopicObject } from '../models/quality-assurance-topic.model';
+import { FollowLinkConfig } from '../../../../shared/utils/follow-link-config.model';
+import { PaginatedList } from '../../../data/paginated-list.model';
+import { FindListOptions } from '../../../data/find-list-options.model';
+import { IdentifiableDataService } from '../../../data/base/identifiable-data.service';
+import { dataService } from '../../../data/base/data-service.decorator';
+import { QUALITY_ASSURANCE_TOPIC_OBJECT } from '../models/quality-assurance-topic-object.resource-type';
+import { SearchData, SearchDataImpl } from '../../../data/base/search-data';
+import { FindAllData, FindAllDataImpl } from '../../../data/base/find-all-data';
+
+/**
+ * The service handling all Quality Assurance topic REST requests.
+ */
+@Injectable()
+@dataService(QUALITY_ASSURANCE_TOPIC_OBJECT)
+export class QualityAssuranceTopicDataService extends IdentifiableDataService {
+
+ private findAllData: FindAllData;
+ private searchData: SearchData;
+
+ private searchByTargetMethod = 'byTarget';
+ private searchBySourceMethod = 'bySource';
+
+ /**
+ * Initialize service variables
+ * @param {RequestService} requestService
+ * @param {RemoteDataBuildService} rdbService
+ * @param {ObjectCacheService} objectCache
+ * @param {HALEndpointService} halService
+ * @param {NotificationsService} notificationsService
+ */
+ constructor(
+ protected requestService: RequestService,
+ protected rdbService: RemoteDataBuildService,
+ protected objectCache: ObjectCacheService,
+ protected halService: HALEndpointService,
+ protected notificationsService: NotificationsService
+ ) {
+ super('qualityassurancetopics', requestService, rdbService, objectCache, halService);
+ this.findAllData = new FindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
+ this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
+ }
+
+ /**
+ * Search for Quality Assurance topics.
+ * @param options The search options.
+ * @param useCachedVersionIfAvailable Whether to use cached version if available.
+ * @param reRequestOnStale Whether to re-request on stale.
+ * @param linksToFollow The links to follow.
+ * @returns An observable of remote data containing a paginated list of Quality Assurance topics.
+ */
+ public searchTopicsByTarget(options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable