Merged in CST-10642-notify-logs (pull request #1183)

CST-10642 notify logs

Approved-by: Stefano Maffei
This commit is contained in:
Francesco Molinaro
2024-01-11 15:54:02 +00:00
committed by Stefano Maffei
58 changed files with 2566 additions and 22 deletions

View File

@@ -0,0 +1,71 @@
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { I18nBreadcrumbResolver } from '../../core/breadcrumbs/i18n-breadcrumb.resolver';
import { I18nBreadcrumbsService } from '../../core/breadcrumbs/i18n-breadcrumbs.service';
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';
@NgModule({
imports: [
RouterModule.forChild([
{
canActivate: [SiteAdministratorGuard],
path: '',
component: AdminNotifyDashboardComponent,
pathMatch: 'full',
resolve: {
breadcrumb: I18nBreadcrumbResolver,
},
data: {
title: 'admin.notify.dashboard.page.title',
breadcrumbKey: 'admin.notify.dashboard',
showBreadcrumbsFluid: false
},
},
{
path: 'inbound',
component: AdminNotifyIncomingComponent,
canActivate: [SiteAdministratorGuard],
resolve: {
breadcrumb: I18nBreadcrumbResolver,
},
data: {
title: 'admin.notify.dashboard.page.title',
breadcrumbKey: 'admin.notify.dashboard',
showBreadcrumbsFluid: false
},
},
{
path: 'outbound',
component: AdminNotifyOutgoingComponent,
canActivate: [SiteAdministratorGuard],
resolve: {
breadcrumb: I18nBreadcrumbResolver,
},
data: {
title: 'admin.notify.dashboard.page.title',
breadcrumbKey: 'admin.notify.dashboard',
showBreadcrumbsFluid: false
},
}
])
],
providers: [
I18nBreadcrumbResolver,
I18nBreadcrumbsService,
]
})
/**
* Routing module for the Notifications section of the admin sidebar
*/
export class AdminNotifyDashboardRoutingModule {
}

View File

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

View File

@@ -0,0 +1,57 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AdminNotifyDashboardComponent } from './admin-notify-dashboard.component';
import { TranslateModule } from '@ngx-translate/core';
import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
import { SearchService } from '../../core/shared/search/search.service';
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { buildPaginatedList } from '../../core/data/paginated-list.model';
import { AdminNotifySearchResult } from './models/admin-notify-message-search-result.model';
import { AdminNotifyMessage } from './models/admin-notify-message.model';
describe('AdminNotifyDashboardComponent', () => {
let component: AdminNotifyDashboardComponent;
let fixture: ComponentFixture<AdminNotifyDashboardComponent>;
let item1;
let item2;
let item3;
let searchResult1;
let searchResult2;
let searchResult3;
let results;
const mockBoxes = [
{ title: 'admin-notify-dashboard.received-ldn', boxes: [ undefined, undefined, undefined, undefined, undefined ] },
{ title: 'admin-notify-dashboard.generated-ldn', boxes: [ undefined, undefined, undefined, undefined, undefined ] }
];
beforeEach(async () => {
item1 = Object.assign(new AdminNotifyMessage(), { uuid: 'e1c51c69-896d-42dc-8221-1d5f2ad5516e' });
item2 = Object.assign(new AdminNotifyMessage(), { uuid: 'c8279647-1acc-41ae-b036-951d5f65649b' });
item3 = Object.assign(new AdminNotifyMessage(), { uuid: 'c3bcbff5-ec0c-4831-8e4c-94b9c933ccac' });
searchResult1 = Object.assign(new AdminNotifySearchResult(), { indexableObject: item1 });
searchResult2 = Object.assign(new AdminNotifySearchResult(), { indexableObject: item2 });
searchResult3 = Object.assign(new AdminNotifySearchResult(), { indexableObject: item3 });
results = buildPaginatedList(undefined, [searchResult1, searchResult2, searchResult3]);
await TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), NgbNavModule],
declarations: [ AdminNotifyDashboardComponent ],
providers: [{ provide: SearchService, useValue: { search: () => createSuccessfulRemoteDataObject$(results)}}]
})
.compileComponents();
fixture = TestBed.createComponent(AdminNotifyDashboardComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', (done) => {
component.notifyMetricsRows$.subscribe(boxes => {
expect(boxes).toEqual(mockBoxes);
done();
});
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,91 @@
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
}
]
})
export class AdminNotifyDashboardComponent implements OnInit{
public notifyMetricsRows$: Observable<AdminNotifyMetricsRow[]>;
private metricsConfig = environment.notifyMetrics;
private singleResultOptions = Object.assign(new PaginationComponentOptions(), {
id: 'single-result-options',
pageSize: 1
});
constructor(private searchService: SearchService) {
}
ngOnInit() {
const mertricsRowsConfigurations = this.metricsConfig
.map(row => row.boxes)
.map(boxes => boxes.map(box => box.config).filter(config => !!config));
const flatConfigurations = [].concat(...mertricsRowsConfigurations.map((config) => config));
const searchConfigurations = flatConfigurations
.map(config => Object.assign(new PaginatedSearchOptions({}),
{ configuration: config, pagination: this.singleResultOptions }
));
this.notifyMetricsRows$ = forkJoin(searchConfigurations.map(config => this.searchService.search(config)
.pipe(
getFirstCompletedRemoteData(),
map(response => this.mapSearchObjectsToMetricsBox(response.payload)),
)
)
).pipe(
map(metricBoxes => this.mapUpdatedBoxesToMetricsRows(metricBoxes))
);
}
/**
* Function to map received SearchObjects to notify boxes config
*
* @param searchObject The object to map
* @private
*/
private mapSearchObjectsToMetricsBox(searchObject: SearchObjects<DSpaceObject>): AdminNotifyMetricsBox {
const count = searchObject.pageInfo.totalElements;
const objectConfig = searchObject.configuration;
const metricsBoxes = [].concat(...this.metricsConfig.map((config) => config.boxes));
return {
...metricsBoxes.find(box => box.config === objectConfig),
count
};
}
/**
* Function to map updated boxes with count to each row of the configuration
*
* @param boxesWithCount The object to map
* @private
*/
private mapUpdatedBoxesToMetricsRows(boxesWithCount: AdminNotifyMetricsBox[]): AdminNotifyMetricsRow[] {
return this.metricsConfig.map(row => {
return {
...row,
boxes: row.boxes.map(rowBox => boxesWithCount.find(boxWithCount => boxWithCount.config === rowBox.config))
};
});
}
}

View File

@@ -0,0 +1,45 @@
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';
@NgModule({
imports: [
CommonModule,
RouterModule,
SharedModule,
AdminNotifyDashboardRoutingModule,
SearchModule,
SearchPageModule,
],
providers: [
AdminNotifyMessagesService,
DatePipe
],
declarations: [
AdminNotifyDashboardComponent,
AdminNotifyMetricsComponent,
AdminNotifyIncomingComponent,
AdminNotifyOutgoingComponent,
AdminNotifySearchResultComponent,
AdminNotifyDetailModalComponent
]
})
export class AdminNotifyDashboardModule {
}

View File

@@ -0,0 +1,14 @@
<div class="modal-header">
<h4 class="modal-title">{{'notify-message-modal.title' | translate}}</h4>
<button type="button" class="close" aria-label="Close" (click)="activeModal.dismiss('Cross click')">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body p-4">
<div *ngFor="let key of notifyMessageKeys">
<div class="row mb-4">
<div class="font-weight-bold col">{{ key + '.notify-detail-modal' | translate}}</div>
<div class="col text-right">{{'notify-detail-modal.' + notifyMessage[key] | translate: {default: notifyMessage[key]} }}</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,36 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AdminNotifyDetailModalComponent } from './admin-notify-detail-modal.component';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { TranslateModule } from '@ngx-translate/core';
describe('AdminNotifyDetailModalComponent', () => {
let component: AdminNotifyDetailModalComponent;
let fixture: ComponentFixture<AdminNotifyDetailModalComponent>;
const modalStub = jasmine.createSpyObj('modalStub', ['close']);
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
declarations: [ AdminNotifyDetailModalComponent ],
providers: [{ provide: NgbActiveModal, useValue: modalStub }]
})
.compileComponents();
fixture = TestBed.createComponent(AdminNotifyDetailModalComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should close', () => {
spyOn(component.response, 'emit');
component.closeModal();
expect(modalStub.close).toHaveBeenCalled();
expect(component.response.emit).toHaveBeenCalledWith(true);
});
});

View File

@@ -0,0 +1,35 @@
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";
@Component({
selector: 'ds-admin-notify-detail-modal',
templateUrl: './admin-notify-detail-modal.component.html',
})
export class AdminNotifyDetailModalComponent {
@Input() notifyMessage: AdminNotifyMessage;
@Input() notifyMessageKeys: string[];
/**
* An event fired when the modal is closed
*/
@Output()
response = new EventEmitter<boolean>();
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);
}
}

View File

@@ -0,0 +1,29 @@
<div class="container">
<div class="row">
<div class="col-12">
<h2 class="border-bottom pb-2">{{'admin-notify-dashboard.title'| translate}}</h2>
<div>
<div>
<ul class="nav nav-tabs">
<li class="nav-item">
<a class="nav-link" [routerLink]="'../'">{{'admin-notify-dashboard.metrics' | translate}}</a>
</li>
<li class="nav-item">
<a class="nav-link active">{{'admin.notify.dashboard.inbound-logs' | translate}}</a>
</li>
<li class="nav-item">
<a class="nav-link" [routerLink]="'../outbound'" [queryParams]="{view: 'table'}">{{'admin.notify.dashboard.outbound-logs' | translate}}</a>
</ul>
<div class="col-12 text-left h4 my-4">{{'admin.notify.dashboard.inbound' | translate}}</div>
<ds-themed-search
[configuration]="'NOTIFY.incoming'"
[showViewModes]="false"
[searchEnabled]="false"
[context]="context"
></ds-themed-search>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,58 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AdminNotifyIncomingComponent } from './admin-notify-incoming.component';
import { TranslateModule } from '@ngx-translate/core';
import { ActivatedRoute } from '@angular/router';
import { MockActivatedRoute } from '../../../../shared/mocks/active-router.mock';
import { provideMockStore } from '@ngrx/store/testing';
import { HALEndpointService } from '../../../../core/shared/hal-endpoint.service';
import { SEARCH_CONFIG_SERVICE } from '../../../../my-dspace-page/my-dspace-page.component';
import { RouteService } from '../../../../core/services/route.service';
import { routeServiceStub } from '../../../../shared/testing/route-service.stub';
import { RequestService } from '../../../../core/data/request.service';
import { RemoteDataBuildService } from '../../../../core/cache/builders/remote-data-build.service';
import { getMockRemoteDataBuildService } from '../../../../shared/mocks/remote-data-build.service.mock';
import { SearchConfigurationService } from '../../../../core/shared/search/search-configuration.service';
describe('AdminNotifyIncomingComponent', () => {
let component: AdminNotifyIncomingComponent;
let fixture: ComponentFixture<AdminNotifyIncomingComponent>;
let halService: HALEndpointService;
let requestService: RequestService;
let rdbService: RemoteDataBuildService;
beforeEach(async () => {
rdbService = getMockRemoteDataBuildService();
halService = jasmine.createSpyObj('halService', {
'getRootHref': '/api'
});
requestService = jasmine.createSpyObj('requestService', {
'generateRequestId': 'client/1234',
'send': '',
});
await TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
declarations: [ AdminNotifyIncomingComponent ],
providers: [
{ provide: SEARCH_CONFIG_SERVICE, useValue: SearchConfigurationService },
{ provide: RouteService, useValue: routeServiceStub },
{ provide: ActivatedRoute, useValue: new MockActivatedRoute() },
{ provide: HALEndpointService, useValue: halService },
{ provide: RequestService, useValue: requestService },
{ provide: RemoteDataBuildService, useValue: rdbService },
provideMockStore({}),
]
})
.compileComponents();
fixture = TestBed.createComponent(AdminNotifyIncomingComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,21 @@
import { Component, Inject } 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';
@Component({
selector: 'ds-admin-notify-incoming',
templateUrl: './admin-notify-incoming.component.html',
providers: [
{
provide: SEARCH_CONFIG_SERVICE,
useClass: SearchConfigurationService
}
]
})
export class AdminNotifyIncomingComponent {
protected readonly context = Context.CoarNotify;
constructor(@Inject(SEARCH_CONFIG_SERVICE) public searchConfigService: SearchConfigurationService) {
}
}

View File

@@ -0,0 +1,29 @@
<div class="container">
<div class="row">
<div class="col-12">
<h2 class="border-bottom pb-2">{{'admin-notify-dashboard.title'| translate}}</h2>
<div>
<div>
<ul class="nav nav-tabs">
<li class="nav-item">
<a class="nav-link" [routerLink]="'../'">{{'admin-notify-dashboard.metrics' | translate}}</a>
</li>
<li class="nav-item">
<a class="nav-link" [routerLink]="'../inbound'" [queryParams]="{view: 'table'}">{{'admin.notify.dashboard.inbound-logs' | translate}}</a>
</li>
<li class="nav-item">
<a class="nav-link active">{{'admin.notify.dashboard.outbound-logs' | translate}}</a>
</ul>
<div class="col-12 text-left h4 my-4">{{'admin.notify.dashboard.outbound' | translate}}</div>
<ds-themed-search
[configuration]="'NOTIFY.outgoing'"
[showViewModes]="false"
[searchEnabled]="false"
[context]="context"
></ds-themed-search>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,57 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AdminNotifyOutgoingComponent } from './admin-notify-outgoing.component';
import { TranslateModule } from '@ngx-translate/core';
import { ActivatedRoute } from '@angular/router';
import { MockActivatedRoute } from '../../../../shared/mocks/active-router.mock';
import { provideMockStore } from '@ngrx/store/testing';
import { HALEndpointService } from '../../../../core/shared/hal-endpoint.service';
import { SEARCH_CONFIG_SERVICE } from '../../../../my-dspace-page/my-dspace-page.component';
import { RouteService } from '../../../../core/services/route.service';
import { routeServiceStub } from '../../../../shared/testing/route-service.stub';
import { RequestService } from '../../../../core/data/request.service';
import { getMockRemoteDataBuildService } from '../../../../shared/mocks/remote-data-build.service.mock';
import { RemoteDataBuildService } from '../../../../core/cache/builders/remote-data-build.service';
import { SearchConfigurationService } from '../../../../core/shared/search/search-configuration.service';
describe('AdminNotifyOutgoingComponent', () => {
let component: AdminNotifyOutgoingComponent;
let fixture: ComponentFixture<AdminNotifyOutgoingComponent>;
let halService: HALEndpointService;
let requestService: RequestService;
let rdbService: RemoteDataBuildService;
beforeEach(async () => {
rdbService = getMockRemoteDataBuildService();
requestService = jasmine.createSpyObj('requestService', {
'generateRequestId': 'client/1234',
'send': '',
});
halService = jasmine.createSpyObj('halService', {
'getRootHref': '/api'
});
await TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
declarations: [ AdminNotifyOutgoingComponent ],
providers: [
{ provide: SEARCH_CONFIG_SERVICE, useValue: SearchConfigurationService },
{ provide: RouteService, useValue: routeServiceStub },
{ provide: ActivatedRoute, useValue: new MockActivatedRoute() },
{ provide: HALEndpointService, useValue: halService },
{ provide: RequestService, useValue: requestService },
{ provide: RemoteDataBuildService, useValue: rdbService },
provideMockStore({}),
]
})
.compileComponents();
fixture = TestBed.createComponent(AdminNotifyOutgoingComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,22 @@
import { Component, Inject } 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';
@Component({
selector: 'ds-admin-notify-outgoing',
templateUrl: './admin-notify-outgoing.component.html',
providers: [
{
provide: SEARCH_CONFIG_SERVICE,
useClass: SearchConfigurationService
}
]
})
export class AdminNotifyOutgoingComponent {
protected readonly context = Context.CoarNotify;
constructor(@Inject(SEARCH_CONFIG_SERVICE) public searchConfigService: SearchConfigurationService) {
}
}

View File

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

View File

@@ -0,0 +1,25 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AdminNotifyMetricsComponent } from './admin-notify-metrics.component';
import { TranslateModule } from '@ngx-translate/core';
describe('AdminNotifyMetricsComponent', () => {
let component: AdminNotifyMetricsComponent;
let fixture: ComponentFixture<AdminNotifyMetricsComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
declarations: [ AdminNotifyMetricsComponent ]
})
.compileComponents();
fixture = TestBed.createComponent(AdminNotifyMetricsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,12 @@
import { Component, Input } from '@angular/core';
import { AdminNotifyMetricsRow } from './admin-notify-metrics.model';
@Component({
selector: 'ds-admin-notify-metrics',
templateUrl: './admin-notify-metrics.component.html',
})
export class AdminNotifyMetricsComponent {
@Input()
boxesConfig: AdminNotifyMetricsRow[];
}

View File

@@ -0,0 +1,12 @@
export interface AdminNotifyMetricsBox {
color: string;
textColor?: string;
title: string;
config: string;
count?: number;
}
export interface AdminNotifyMetricsRow {
title: string;
boxes: AdminNotifyMetricsBox[]
}

View File

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

View File

@@ -0,0 +1,5 @@
.table-responsive {
th, td {
padding: 0.5rem !important;
}
}

View File

@@ -0,0 +1,177 @@
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';
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'
},
{
'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'
}
] as unknown as AdminNotifyMessage[];
describe('AdminNotifySearchResultComponent', () => {
let component: AdminNotifySearchResultComponent;
let fixture: ComponentFixture<AdminNotifySearchResultComponent>;
let objectCache: ObjectCacheService;
let requestService: RequestService;
let halService: HALEndpointService;
let rdbService: RemoteDataBuildService;
let adminNotifyMessageService: AdminNotifyMessagesService;
let searchConfigService: SearchConfigurationService;
let modalService: NgbModal;
const requestUUID = '34cfed7c-f597-49ef-9cbe-ea351f0023c2';
const testObject = {
uuid: 'test-property',
name: 'test-property',
values: ['value-1', 'value-2']
} as ConfigurationProperty;
beforeEach(async () => {
halService = jasmine.createSpyObj('halService', {
getEndpoint: cold('a', { a: '' })
});
adminNotifyMessageService = jasmine.createSpyObj('adminNotifyMessageService', {
getDetailedMessages: of(mockAdminNotifyMessages),
reprocessMessage: of(mockAdminNotifyMessages),
});
requestService = jasmine.createSpyObj('requestService', {
generateRequestId: requestUUID,
send: true
});
rdbService = jasmine.createSpyObj('rdbService', {
buildSingle: cold('a', {
a: {
payload: testObject
}
})
});
searchConfigService = jasmine.createSpyObj('searchConfigService', {
getCurrentConfiguration: of('NOTIFY.outgoing')
});
objectCache = {} as ObjectCacheService;
await TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
declarations: [ AdminNotifySearchResultComponent, AdminNotifyDetailModalComponent ],
providers: [
{ provide: AdminNotifyMessagesService, useValue: adminNotifyMessageService },
{ provide: RouteService, useValue: routeServiceStub },
{ provide: ActivatedRoute, useValue: new RouterStub() },
{ provide: HALEndpointService, useValue: halService },
{ provide: ObjectCacheService, useValue: objectCache },
{ provide: RequestService, useValue: requestService },
{ provide: RemoteDataBuildService, useValue: rdbService },
{ provide: SEARCH_CONFIG_SERVICE, useValue: searchConfigService },
DatePipe
]
})
.compileComponents();
fixture = TestBed.createComponent(AdminNotifySearchResultComponent);
component = fixture.componentInstance;
modalService = (component as any).modalService;
spyOn(modalService, 'open').and.returnValue(Object.assign({ componentInstance: Object.assign({ response: observableOf(true) }) }));
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
expect(component.isInbound).toBeFalsy();
});
it('should open modal', () => {
component.openDetailModal(mockAdminNotifyMessages[0]);
expect(modalService.open).toHaveBeenCalledWith(AdminNotifyDetailModalComponent);
});
it('should map messages', (done) => {
component.messagesSubject$.subscribe((messages) => {
expect(messages).toEqual(mockAdminNotifyMessages);
done();
});
});
it('should reprocess message', (done) => {
component.reprocessMessage(mockAdminNotifyMessages[0]);
component.messagesSubject$.subscribe((messages) => {
expect(messages).toEqual(mockAdminNotifyMessages);
done();
});
});
it('should unsubscribe on destroy', () => {
(component as any).subs = [of(null).subscribe()];
spyOn((component as any).subs[0], 'unsubscribe');
component.ngOnDestroy();
expect((component as any).subs[0].unsubscribe).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,137 @@
import { Component, Inject, OnDestroy, OnInit } from '@angular/core';
import { AdminNotifySearchResult } from '../models/admin-notify-message-search-result.model';
import { ViewMode } from '../../../core/shared/view-mode.model';
import { Context } from '../../../core/shared/context.model';
import { AdminNotifyMessage } from '../models/admin-notify-message.model';
import {
tabulatableObjectsComponent
} from '../../../shared/object-collection/shared/tabulatable-objects/tabulatable-objects.decorator';
import {
TabulatableResultListElementsComponent
} from '../../../shared/object-list/search-result-list-element/tabulatable-search-result/tabulatable-result-list-elements.component';
import { PaginatedList } from '../../../core/data/paginated-list.model';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { AdminNotifyDetailModalComponent } from '../admin-notify-detail-modal/admin-notify-detail-modal.component';
import { BehaviorSubject, Subscription } from 'rxjs';
import { AdminNotifyMessagesService } from '../services/admin-notify-messages.service';
import { SearchConfigurationService } from '../../../core/shared/search/search-configuration.service';
import { SEARCH_CONFIG_SERVICE } from '../../../my-dspace-page/my-dspace-page.component';
import { DatePipe } from '@angular/common';
@tabulatableObjectsComponent(PaginatedList<AdminNotifySearchResult>, ViewMode.Table, Context.CoarNotify)
@Component({
selector: 'ds-admin-notify-search-result',
templateUrl: './admin-notify-search-result.component.html',
styleUrls: ['./admin-notify-search-result.component.scss'],
providers: [
{
provide: SEARCH_CONFIG_SERVICE,
useClass: SearchConfigurationService
}
]
})
export class AdminNotifySearchResultComponent extends TabulatableResultListElementsComponent<PaginatedList<AdminNotifySearchResult>, AdminNotifySearchResult> implements OnInit, OnDestroy{
public messagesSubject$: BehaviorSubject<AdminNotifyMessage[]> = new BehaviorSubject([]);
public reprocessStatus = 'QUEUE_STATUS_QUEUED_FOR_RETRY';
//we check on one type of config to render specific table headers
public isInbound: boolean;
/**
* 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'];
/**
* 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 === 'NOTIFY.incoming';
})
);
}
ngOnDestroy() {
this.subs.forEach(sub => sub.unsubscribe());
}
/**
* Open modal for details visualization
* @param message the message to be displayed
*/
openDetailModal(message: AdminNotifyMessage) {
const modalRef = this.modalService.open(AdminNotifyDetailModalComponent);
const messageToOpen = {...message};
// we delete not necessary or not readable keys
delete messageToOpen.target;
delete messageToOpen.object;
delete messageToOpen.context;
delete messageToOpen.origin;
delete messageToOpen._links;
delete messageToOpen.metadata;
delete messageToOpen.thumbnail;
delete messageToOpen.item;
delete messageToOpen.accessStatus;
delete messageToOpen.queueStatus;
const messageKeys = Object.keys(messageToOpen);
messageKeys.forEach(key => {
if (this.dateTypeKeys.includes(key)) {
messageToOpen[key] = this.datePipe.transform(messageToOpen[key], this.dateFormat);
}
});
modalRef.componentInstance.notifyMessage = messageToOpen;
modalRef.componentInstance.notifyMessageKeys = messageKeys;
}
/**
* Reprocess message in status QUEUE_STATUS_QUEUED_FOR_RETRY and update results
* @param message the message to be reprocessed
*/
reprocessMessage(message: AdminNotifyMessage) {
this.subs.push(
this.adminNotifyMessagesService.reprocessMessage(message, this.messagesSubject$)
.subscribe(response => {
this.messagesSubject$.next(response);
}
)
);
}
/**
* Map readable results to messages
* @private
*/
private mapDetailsToMessages() {
this.subs.push(this.adminNotifyMessagesService.getDetailedMessages(this.objects?.page.map(pageResult => pageResult.indexableObject))
.subscribe(response => {
this.messagesSubject$.next(response);
}));
}
}

View File

@@ -0,0 +1,8 @@
import { AdminNotifyMessage } from './admin-notify-message.model';
import { searchResultFor } from '../../../shared/search/search-result-element-decorator';
import { SearchResult } from '../../../shared/search/models/search-result.model';
@searchResultFor(AdminNotifyMessage)
export class AdminNotifySearchResult extends SearchResult<AdminNotifyMessage> {
}

View File

@@ -0,0 +1,147 @@
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 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 attempts of the queue
*/
@autoserialize
queueAttempts: number;
/**
* Timestamp of the last queue attempt
*/
@autoserialize
queueLastStartTime: string;
/**
* The type of the activity stream
*/
@autoserialize
origin: number | string;
/**
* The type of the activity stream
*/
@autoserialize
target: number | string;
/**
* The label for the status of the queue
*/
@autoserialize
queueStatusLabel: string;
/**
* The timeout of the queue
*/
@autoserialize
queueTimeout: string;
/**
* The status of the queue
*/
@autoserialize
queueStatus: number;
/**
* Thumbnail link used when browsing items with showThumbs config enabled.
*/
@autoserialize
thumbnail: string;
/**
* The observable pointing to the item itself
*/
@autoserialize
item: Observable<AdminNotifyMessage>;
/**
* The observable pointing to the access status of the item
*/
@autoserialize
accessStatus: Observable<AdminNotifyMessage>;
@deserialize
_links: {
self: {
href: string;
};
};
get self(): string {
return this._links.self.href;
}
getRenderTypes(): (string | GenericConstructor<ListableObject>)[] {
return [this.constructor as GenericConstructor<ListableObject>];
}
}

View File

@@ -0,0 +1,9 @@
import { ResourceType } from '../../../core/shared/resource-type';
/**
* The resource type for AdminNotifyMessage
*
* Needs to be in a separate file to prevent circular
* dependencies in webpack.
*/
export const ADMIN_NOTIFY_MESSAGE = new ResourceType('message');

View File

@@ -0,0 +1,114 @@
import { cold } from 'jasmine-marbles';
import { AdminNotifyMessagesService } from './admin-notify-messages.service';
import { RequestService } from '../../../core/data/request.service';
import { RemoteDataBuildService } from '../../../core/cache/builders/remote-data-build.service';
import { ObjectCacheService } from '../../../core/cache/object-cache.service';
import { HALEndpointService } from '../../../core/shared/hal-endpoint.service';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { LdnServicesService } from '../../admin-ldn-services/ldn-services-data/ldn-services-data.service';
import { ItemDataService } from '../../../core/data/item-data.service';
import { RequestEntry } from '../../../core/data/request-entry.model';
import { RestResponse } from '../../../core/cache/response.models';
import { BehaviorSubject, of } from 'rxjs';
import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
import { RemoteData } from '../../../core/data/remote-data';
import { RequestEntryState } from '../../../core/data/request-entry-state.model';
import {
mockAdminNotifyMessages} from '../admin-notify-search-result/admin-notify-search-result.component.spec';
import { take } from 'rxjs/operators';
import { deepClone } from 'fast-json-patch';
import { AdminNotifyMessage } from '../models/admin-notify-message.model';
describe('AdminNotifyMessagesService test', () => {
let service: AdminNotifyMessagesService;
let requestService: RequestService;
let rdbService: RemoteDataBuildService;
let objectCache: ObjectCacheService;
let halService: HALEndpointService;
let notificationsService: NotificationsService;
let ldnServicesService: LdnServicesService;
let itemDataService: ItemDataService;
let responseCacheEntry: RequestEntry;
let mockMessages: AdminNotifyMessage[];
const endpointURL = `https://rest.api/rest/api/ldn/messages`;
const requestUUID = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a';
const remoteDataMocks = {
Success: new RemoteData(null, null, null, RequestEntryState.Success, null, null, 200),
};
const testLdnServiceName = 'testLdnService';
const testRelatedItemName = 'testRelatedItem';
function initTestService() {
return new AdminNotifyMessagesService(
requestService,
rdbService,
objectCache,
halService,
notificationsService,
ldnServicesService,
itemDataService
);
}
beforeEach(() => {
mockMessages = deepClone(mockAdminNotifyMessages);
objectCache = {} as ObjectCacheService;
notificationsService = {} as NotificationsService;
responseCacheEntry = new RequestEntry();
responseCacheEntry.request = { href: endpointURL } as any;
responseCacheEntry.response = new RestResponse(true, 200, 'Success');
requestService = jasmine.createSpyObj('requestService', {
generateRequestId: requestUUID,
send: true,
removeByHrefSubstring: {},
getByHref: of(responseCacheEntry),
getByUUID: of(responseCacheEntry),
});
halService = jasmine.createSpyObj('halService', {
getEndpoint: of(endpointURL)
});
rdbService = jasmine.createSpyObj('rdbService', {
buildSingle: createSuccessfulRemoteDataObject$({}, 500),
buildList: cold('a', { a: remoteDataMocks.Success }),
buildFromRequestUUID: createSuccessfulRemoteDataObject$(mockMessages)
});
ldnServicesService = jasmine.createSpyObj('ldnServicesService', {
findById: createSuccessfulRemoteDataObject$({name: testLdnServiceName}),
});
itemDataService = jasmine.createSpyObj('itemDataService', {
findById: createSuccessfulRemoteDataObject$({name: testRelatedItemName}),
});
service = initTestService();
});
describe('Admin Notify service', () => {
it('should be defined', () => {
expect(service).toBeDefined();
});
it('should get details for messages', (done) => {
service.getDetailedMessages(mockMessages).pipe(take(1)).subscribe((detailedMessages) => {
expect(detailedMessages[0].ldnService).toEqual(testLdnServiceName);
expect(detailedMessages[0].relatedItem).toEqual(testRelatedItemName);
done();
});
});
it('should reprocess message', (done) => {
const behaviorSubject = new BehaviorSubject(mockMessages);
service.reprocessMessage(mockMessages[0], behaviorSubject).pipe(take(1)).subscribe((reprocessedMessages) => {
expect(reprocessedMessages.length).toEqual(2);
expect(reprocessedMessages).toEqual(mockMessages);
done();
});
});
});
});

View File

@@ -0,0 +1,97 @@
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 { GetRequest } from '../../../core/data/request.models';
import { RestRequest } from '../../../core/data/rest-request.model';
/**
* Injectable service responsible for fetching/sending data from/to the REST API on the messages endpoint.
*
* @export
* @class AdminNotifyMessagesService
* @extends {IdentifiableDataService<AdminNotifyMessage>}
*/
@Injectable()
@dataService(ADMIN_NOTIFY_MESSAGE)
export class AdminNotifyMessagesService extends IdentifiableDataService<AdminNotifyMessage> {
protected reprocessEndpoint = 'enqueueretry';
constructor(
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected objectCache: ObjectCacheService,
protected halService: HALEndpointService,
protected notificationsService: NotificationsService,
private ldnServicesService: LdnServicesService,
private itemDataService: ItemDataService,
) {
super('messages', requestService, rdbService, objectCache, halService);
}
/**
* Add detailed information to each message
* @param messages the messages to which add detailded info
*/
public getDetailedMessages(messages: AdminNotifyMessage[]): Observable<AdminNotifyMessage[]> {
return from(messages).pipe(
mergeMap(message =>
message.target || message.origin ? this.ldnServicesService.findById((message.target || message.origin).toString()).pipe(
getAllSucceededRemoteDataPayload(),
map(detail => ({...message, ldnService: detail.name}))
) : of(message),
),
mergeMap(message =>
message.object || message.context ? this.itemDataService.findById(message.object || message.context).pipe(
getAllSucceededRemoteDataPayload(),
map(detail => ({...message, relatedItem: detail.name}))
) : of(message),
),
scan((acc: any, value: any) => [...acc, value], []),
);
}
/**
* Reprocess message in status QUEUE_STATUS_QUEUED_FOR_RETRY and update results
* @param message the message to reprocess
* @param messageSubject the current visualised messages source
*/
public reprocessMessage(message: AdminNotifyMessage, messageSubject: BehaviorSubject<AdminNotifyMessage[]>): Observable<AdminNotifyMessage[]> {
const requestId = this.requestService.generateRequestId();
return this.halService.getEndpoint(this.reprocessEndpoint).pipe(
map(endpoint => endpoint.replace('{id}', message.id)),
map((endpointURL: string) => new GetRequest(requestId, endpointURL)),
tap(request => this.requestService.send(request)),
switchMap((request: RestRequest) => this.rdbService.buildFromRequestUUID<AdminNotifyMessage>(request.uuid)),
getFirstCompletedRemoteData(),
getAllSucceededRemoteDataPayload(),
).pipe(
mergeMap((newMessage) => messageSubject.pipe(
map(messages => {
const messageToUpdate = messages.find(currentMessage => currentMessage.id === message.id);
const indexOfMessageToUpdate = messages.indexOf(messageToUpdate);
newMessage.target = messageToUpdate.target;
newMessage.object = messageToUpdate.object;
newMessage.origin = messageToUpdate.origin;
newMessage.context = messageToUpdate.context;
messages[indexOfMessageToUpdate] = newMessage;
return messages;
})
)),
);
}
}

View File

@@ -4,6 +4,8 @@ import { getAdminModuleRoute } from '../app-routing-paths';
export const REGISTRIES_MODULE_PATH = 'registries'; export const REGISTRIES_MODULE_PATH = 'registries';
export const NOTIFICATIONS_MODULE_PATH = 'notifications'; export const NOTIFICATIONS_MODULE_PATH = 'notifications';
export const NOTIFY_DASHBOARD_MODULE_PATH = 'notify-dashboard';
export const LDN_PATH = 'ldn'; export const LDN_PATH = 'ldn';
export function getRegistriesModuleRoute() { export function getRegistriesModuleRoute() {

View File

@@ -6,7 +6,12 @@ import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.reso
import { AdminWorkflowPageComponent } from './admin-workflow-page/admin-workflow-page.component'; import { AdminWorkflowPageComponent } from './admin-workflow-page/admin-workflow-page.component';
import { I18nBreadcrumbsService } from '../core/breadcrumbs/i18n-breadcrumbs.service'; import { I18nBreadcrumbsService } from '../core/breadcrumbs/i18n-breadcrumbs.service';
import { AdminCurationTasksComponent } from './admin-curation-tasks/admin-curation-tasks.component'; import { AdminCurationTasksComponent } from './admin-curation-tasks/admin-curation-tasks.component';
import { LDN_PATH, REGISTRIES_MODULE_PATH, NOTIFICATIONS_MODULE_PATH } from './admin-routing-paths'; import {
LDN_PATH,
REGISTRIES_MODULE_PATH,
NOTIFICATIONS_MODULE_PATH,
NOTIFY_DASHBOARD_MODULE_PATH
} from './admin-routing-paths';
import { BatchImportPageComponent } from './admin-import-batch-page/batch-import-page.component'; import { BatchImportPageComponent } from './admin-import-batch-page/batch-import-page.component';
@NgModule({ @NgModule({
@@ -69,6 +74,11 @@ import { BatchImportPageComponent } from './admin-import-batch-page/batch-import
loadChildren: () => import('../system-wide-alert/system-wide-alert.module').then((m) => m.SystemWideAlertModule), 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: NOTIFY_DASHBOARD_MODULE_PATH,
loadChildren: () => import('./admin-notify-dashboard/admin-notify-dashboard.module')
.then((m) => m.AdminNotifyDashboardModule),
},
]), ]),
], ],
providers: [ providers: [

View File

@@ -197,6 +197,7 @@ import {
import { SubmissionCoarNotifyConfig } from '../submission/sections/section-coar-notify/submission-coar-notify.config'; import { SubmissionCoarNotifyConfig } from '../submission/sections/section-coar-notify/submission-coar-notify.config';
import { NotifyRequestsStatus } from '../item-page/simple/notify-requests-status/notify-requests-status.model'; import { NotifyRequestsStatus } from '../item-page/simple/notify-requests-status/notify-requests-status.model';
import { NotifyRequestsStatusDataService } from './data/notify-services-status-data.service'; import { NotifyRequestsStatusDataService } from './data/notify-services-status-data.service';
import { AdminNotifyMessage } from '../admin/admin-notify-dashboard/models/admin-notify-message.model';
/** /**
@@ -283,7 +284,6 @@ const PROVIDERS = [
SearchService, SearchService,
SidebarService, SidebarService,
SearchFilterService, SearchFilterService,
SearchFilterService,
SearchConfigurationService, SearchConfigurationService,
SelectableListService, SelectableListService,
RelationshipTypeDataService, RelationshipTypeDataService,
@@ -410,6 +410,7 @@ export const models =
Itemfilter, Itemfilter,
SubmissionCoarNotifyConfig, SubmissionCoarNotifyConfig,
NotifyRequestsStatus, NotifyRequestsStatus,
AdminNotifyMessage
]; ];
@NgModule({ @NgModule({

View File

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

View File

@@ -105,7 +105,7 @@ export class SearchConfigurationService implements OnDestroy {
protected linkService: LinkService, protected linkService: LinkService,
protected halService: HALEndpointService, protected halService: HALEndpointService,
protected requestService: RequestService, protected requestService: RequestService,
protected rdb: RemoteDataBuildService,) { protected rdb: RemoteDataBuildService) {
this.initDefaults(); this.initDefaults();
} }

View File

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

View File

@@ -362,19 +362,6 @@ export class MenuResolver implements Resolve<boolean> {
icon: 'terminal', icon: 'terminal',
index: 10 index: 10
}, },
/* LDN Services */
{
id: 'ldn_services',
active: false,
visible: isSiteAdmin,
model: {
type: MenuItemType.LINK,
text: 'menu.section.services',
link: '/admin/ldn/services'
} as LinkMenuItemModel,
icon: 'inbox',
index: 14
},
{ {
id: 'health', id: 'health',
active: false, active: false,
@@ -679,6 +666,41 @@ export class MenuResolver implements Resolve<boolean> {
icon: 'exclamation-circle', icon: 'exclamation-circle',
index: 12 index: 12
}, },
/* COAR Notify section */
{
id: 'coar_notify',
active: false,
visible: authorized,
model: {
type: MenuItemType.TEXT,
text: 'menu.section.coar_notify'
} as TextMenuItemModel,
icon: 'inbox',
index: 13
},
{
id: 'notify_dashboard',
active: false,
parentID: 'coar_notify',
visible: authorized,
model: {
type: MenuItemType.LINK,
text: 'menu.section.notify_dashboard',
link: '/admin/notify-dashboard'
} as LinkMenuItemModel,
},
/* LDN Services */
{
id: 'ldn_services',
active: false,
parentID: 'coar_notify',
visible: authorized,
model: {
type: MenuItemType.LINK,
text: 'menu.section.services',
link: '/admin/ldn/services'
} as LinkMenuItemModel,
},
]; ];
menuList.forEach((menuSection) => this.menuService.addSection(MenuID.ADMIN, Object.assign(menuSection, { menuList.forEach((menuSection) => this.menuService.addSection(MenuID.ADMIN, Object.assign(menuSection, {

View File

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

View File

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

View File

@@ -0,0 +1,37 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NotificationBoxComponent } from './notification-box.component';
import { TranslateModule } from '@ngx-translate/core';
import {
AdminNotifyMetricsBox
} from '../../admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.model';
describe('NotificationBoxComponent', () => {
let component: NotificationBoxComponent;
let fixture: ComponentFixture<NotificationBoxComponent>;
let mockBoxConfig: AdminNotifyMetricsBox;
beforeEach(async () => {
mockBoxConfig = {
'color': '#D4EDDA',
'title': 'admin-notify-dashboard.delivered',
'config': 'NOTIFY.outgoing.delivered',
'count': 79
};
await TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
declarations: [ NotificationBoxComponent ]
})
.compileComponents();
fixture = TestBed.createComponent(NotificationBoxComponent);
component = fixture.componentInstance;
component.boxConfig = mockBoxConfig;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,19 @@
import { Component, Input } from '@angular/core';
import {
AdminNotifyMetricsBox
} from '../../admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.model';
import { listableObjectComponent } from '../object-collection/shared/listable-object/listable-object.decorator';
import {
AdminNotifySearchResult
} from '../../admin/admin-notify-dashboard/models/admin-notify-message-search-result.model';
import { ViewMode } from '../../core/shared/view-mode.model';
@listableObjectComponent(AdminNotifySearchResult, ViewMode.ListElement)
@Component({
selector: 'ds-notification-box',
templateUrl: './notification-box.component.html',
styleUrls: ['./notification-box.component.scss']
})
export class NotificationBoxComponent {
@Input() boxConfig: AdminNotifyMetricsBox;
}

View File

@@ -58,3 +58,19 @@
</ds-object-detail> </ds-object-detail>
<ds-object-table [config]="config"
[sortConfig]="sortConfig"
[objects]="objects"
[hideGear]="hideGear"
[linkType]="linkType"
[context]="context"
[hidePaginationDetail]="hidePaginationDetail"
[showPaginator]="showPaginator"
[showThumbnails]="showThumbnails"
(paginationChange)="onPaginationChange($event)"
(pageChange)="onPageChange($event)"
(pageSizeChange)="onPageSizeChange($event)"
(sortDirectionChange)="onSortDirectionChange($event)"
(sortFieldChange)="onSortFieldChange($event)"
*ngIf="(currentMode$ | async) === viewModeEnum.Table">
</ds-object-table>

View File

@@ -18,7 +18,7 @@ const testType = 'TestType';
const testContext = Context.Search; const testContext = Context.Search;
const testViewMode = ViewMode.StandalonePage; const testViewMode = ViewMode.StandalonePage;
class TestType extends ListableObject { export class TestType extends ListableObject {
getRenderTypes(): (string | GenericConstructor<ListableObject>)[] { getRenderTypes(): (string | GenericConstructor<ListableObject>)[] {
return [testType]; return [testType];
} }

View File

@@ -34,7 +34,7 @@ export const DEFAULT_THEME = '*';
* - { level: 1, relevancy: 1 } is less relevant than { level: 2, relevancy: 0 } * - { level: 1, relevancy: 1 } is less relevant than { level: 2, relevancy: 0 }
* - { level: 1, relevancy: 1 } is more relevant than null * - { level: 1, relevancy: 1 } is more relevant than null
*/ */
class MatchRelevancy { export class MatchRelevancy {
constructor(public match: any, constructor(public match: any,
public level: number, public level: number,
public relevancy: number) { public relevancy: number) {
@@ -133,7 +133,7 @@ export function getListableObjectComponent(types: (string | GenericConstructor<L
* @param defaults the default values to use for each level, in case no value is found for the key at that index * @param defaults the default values to use for each level, in case no value is found for the key at that index
* @returns matchAndLevel a {@link MatchRelevancy} object containing the match and its level of relevancy * @returns matchAndLevel a {@link MatchRelevancy} object containing the match and its level of relevancy
*/ */
function getMatch(typeMap: Map<any, any>, keys: any[], defaults: any[]): MatchRelevancy { export function getMatch(typeMap: Map<any, any>, keys: any[], defaults: any[]): MatchRelevancy {
let currentMap = typeMap; let currentMap = typeMap;
let level = -1; let level = -1;
let relevancy = 0; let relevancy = 0;

View File

@@ -0,0 +1,77 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { ListableObject } from '../listable-object.model';
import { CollectionElementLinkType } from '../../collection-element-link.type';
import { Context } from '../../../../core/shared/context.model';
import { ViewMode } from '../../../../core/shared/view-mode.model';
import { RemoteData } from '../../../../core/data/remote-data';
import { PaginatedList } from '../../../../core/data/paginated-list.model';
@Component({
selector: 'ds-objects-collection-tabulatable',
template: ``,
})
export class AbstractTabulatableElementComponent<T extends PaginatedList<ListableObject>> {
/**
* The object to render in this list element
*/
@Input() objects: T;
/**
* The link type to determine the type of link rendered in this element
*/
@Input() linkType: CollectionElementLinkType;
/**
* The identifier of the list this element resides in
*/
@Input() listID: string;
/**
* The value to display for this element
*/
@Input() value: string;
/**
* Whether to show the badge label or not
*/
@Input() showLabel = true;
/**
* Whether to show the thumbnail preview
*/
@Input() showThumbnails;
/**
* The context we matched on to get this component
*/
@Input() context: Context;
/**
* The viewmode we matched on to get this component
*/
@Input() viewMode: ViewMode;
/**
* Emit when the object has been reloaded.
*/
@Output() reloadedObject = new EventEmitter<RemoteData<PaginatedList<ListableObject>>>();
/**
* The available link types
*/
linkTypes = CollectionElementLinkType;
/**
* The available view modes
*/
viewModes = ViewMode;
/**
* The available contexts
*/
contexts = Context;
}

View File

@@ -0,0 +1 @@
<ng-template dsTabulatableObjects></ng-template>

View File

@@ -0,0 +1,59 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TabulatableObjectsLoaderComponent } from './tabulatable-objects-loader.component';
import { ThemeService } from '../../../theme-support/theme.service';
import { provideMockStore } from '@ngrx/store/testing';
import { ListableObject } from '../listable-object.model';
import { PaginatedList } from '../../../../core/data/paginated-list.model';
import { Context } from '../../../../core/shared/context.model';
import { TabulatableObjectsDirective } from './tabulatable-objects.directive';
import { ChangeDetectionStrategy } from '@angular/core';
import {
TabulatableResultListElementsComponent
} from '../../../object-list/search-result-list-element/tabulatable-search-result/tabulatable-result-list-elements.component';
import { TestType } from '../listable-object/listable-object-component-loader.component.spec';
const testType = 'TestType';
class TestTypes extends PaginatedList<ListableObject> {
page: TestType[] = [new TestType()];
}
describe('TabulatableObjectsLoaderComponent', () => {
let component: TabulatableObjectsLoaderComponent;
let fixture: ComponentFixture<TabulatableObjectsLoaderComponent>;
let themeService: ThemeService;
beforeEach(async () => {
themeService = jasmine.createSpyObj('themeService', {
getThemeName: 'dspace',
});
await TestBed.configureTestingModule({
declarations: [ TabulatableObjectsLoaderComponent, TabulatableObjectsDirective ],
providers: [
provideMockStore({}),
{ provide: ThemeService, useValue: themeService },
]
}).overrideComponent(TabulatableObjectsLoaderComponent, {
set: {
changeDetection: ChangeDetectionStrategy.Default,
entryComponents: [TabulatableResultListElementsComponent]
}
}).compileComponents();
fixture = TestBed.createComponent(TabulatableObjectsLoaderComponent);
component = fixture.componentInstance;
component.objects = new TestTypes();
component.context = Context.Search;
spyOn(component, 'getComponent').and.returnValue(TabulatableResultListElementsComponent as any);
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,206 @@
import {
ChangeDetectorRef,
Component,
ComponentRef,
EventEmitter,
Input,
OnChanges,
OnDestroy,
OnInit,
Output,
SimpleChanges,
ViewChild
} from '@angular/core';
import { ListableObject } from '../listable-object.model';
import { ViewMode } from '../../../../core/shared/view-mode.model';
import { Context } from '../../../../core/shared/context.model';
import { CollectionElementLinkType } from '../../collection-element-link.type';
import { combineLatest, Observable, of as observableOf, Subscription } from 'rxjs';
import { ThemeService } from '../../../theme-support/theme.service';
import { hasNoValue, hasValue, isNotEmpty } from '../../../empty.util';
import { take } from 'rxjs/operators';
import { GenericConstructor } from '../../../../core/shared/generic-constructor';
import { TabulatableObjectsDirective } from './tabulatable-objects.directive';
import { PaginatedList } from '../../../../core/data/paginated-list.model';
import { getTabulatableObjectsComponent } from './tabulatable-objects.decorator';
@Component({
selector: 'ds-tabulatable-objects-loader',
templateUrl: './tabulatable-objects-loader.component.html'
})
/**
* Component for determining what component to use depending on the item's entity type (dspace.entity.type)
*/
export class TabulatableObjectsLoaderComponent implements OnInit, OnChanges, OnDestroy {
/**
* The items to determine the component for
*/
@Input() objects: PaginatedList<ListableObject>;
/**
* The context of listable object
*/
@Input() context: Context;
/**
* The type of link used to render the links inside the listable object
*/
@Input() linkType: CollectionElementLinkType;
/**
* The identifier of the list this element resides in
*/
@Input() listID: string;
/**
* Whether to show the badge label or not
*/
@Input() showLabel = true;
/**
* Whether to show the thumbnail preview
*/
@Input() showThumbnails;
/**
* The value to display for this element
*/
@Input() value: string;
/**
* Directive hook used to place the dynamic child component
*/
@ViewChild(TabulatableObjectsDirective, { static: true }) tabulatableObjectsDirective: TabulatableObjectsDirective;
/**
* Emit when the listable object has been reloaded.
*/
@Output() contentChange = new EventEmitter<PaginatedList<ListableObject>>();
/**
* Array to track all subscriptions and unsubscribe them onDestroy
* @type {Array}
*/
protected subs: Subscription[] = [];
/**
* The reference to the dynamic component
*/
protected compRef: ComponentRef<Component>;
/**
* The view mode used to identify the components
*/
protected viewMode: ViewMode = ViewMode.Table;
/**
* The list of input and output names for the dynamic component
*/
protected inAndOutputNames: string[] = [
'objects',
'linkType',
'listID',
'showLabel',
'showThumbnails',
'context',
'viewMode',
'value',
'hideBadges',
'contentChange',
];
constructor(private cdr: ChangeDetectorRef, private themeService: ThemeService) {
}
/**
* Setup the dynamic child component
*/
ngOnInit(): void {
this.instantiateComponent(this.objects);
}
/**
* Whenever the inputs change, update the inputs of the dynamic component
*/
ngOnChanges(changes: SimpleChanges): void {
if (hasNoValue(this.compRef)) {
// sometimes the component has not been initialized yet, so it first needs to be initialized
// before being called again
this.instantiateComponent(this.objects, changes);
} else {
// if an input or output has changed
if (this.inAndOutputNames.some((name: any) => hasValue(changes[name]))) {
this.connectInputsAndOutputs();
if (this.compRef?.instance && 'ngOnChanges' in this.compRef.instance) {
(this.compRef.instance as any).ngOnChanges(changes);
}
}
}
}
ngOnDestroy() {
this.subs
.filter((subscription) => hasValue(subscription))
.forEach((subscription) => subscription.unsubscribe());
}
private instantiateComponent(objects: PaginatedList<ListableObject>, changes?: SimpleChanges): void {
// objects need to have same render type so we access just the first in the page
const component = this.getComponent(objects?.page[0]?.getRenderTypes(), this.viewMode, this.context);
const viewContainerRef = this.tabulatableObjectsDirective.viewContainerRef;
viewContainerRef.clear();
this.compRef = viewContainerRef.createComponent(
component, {
index: 0,
injector: undefined
}
);
if (hasValue(changes)) {
this.ngOnChanges(changes);
} else {
this.connectInputsAndOutputs();
}
if ((this.compRef.instance as any).reloadedObject) {
combineLatest([
observableOf(changes),
(this.compRef.instance as any).reloadedObject.pipe(take(1)) as Observable<PaginatedList<ListableObject>>,
]).subscribe(([simpleChanges, reloadedObjects]: [SimpleChanges, PaginatedList<ListableObject>]) => {
if (reloadedObjects) {
this.compRef.destroy();
this.objects = reloadedObjects;
this.instantiateComponent(reloadedObjects, simpleChanges);
this.cdr.detectChanges();
this.contentChange.emit(reloadedObjects);
}
});
}
}
/**
* Fetch the component depending on the item's entity type, view mode and context
* @returns {GenericConstructor<Component>}
*/
getComponent(renderTypes: (string | GenericConstructor<ListableObject>)[],
viewMode: ViewMode,
context: Context): GenericConstructor<Component> {
return getTabulatableObjectsComponent(renderTypes, viewMode, context, this.themeService.getThemeName());
}
/**
* Connect the in and outputs of this component to the dynamic component,
* to ensure they're in sync
*/
protected connectInputsAndOutputs(): void {
if (isNotEmpty(this.inAndOutputNames) && hasValue(this.compRef) && hasValue(this.compRef.instance)) {
this.inAndOutputNames.filter((name: any) => this[name] !== undefined).forEach((name: any) => {
this.compRef.instance[name] = this[name];
});
}
}
}

View File

@@ -0,0 +1,65 @@
import { ViewMode } from '../../../../core/shared/view-mode.model';
import { Context } from '../../../../core/shared/context.model';
import { hasNoValue, hasValue } from '../../../empty.util';
import { GenericConstructor } from '../../../../core/shared/generic-constructor';
import { ListableObject } from '../listable-object.model';
import {
DEFAULT_CONTEXT,
DEFAULT_THEME,
DEFAULT_VIEW_MODE, getMatch,
MatchRelevancy
} from '../listable-object/listable-object.decorator';
import { PaginatedList } from '../../../../core/data/paginated-list.model';
const map = new Map();
/**
* Decorator used for rendering tabulatable objects
* @param objectType The object type or entity type the component represents
* @param viewMode The view mode the component represents
* @param context The optional context the component represents
* @param theme The optional theme for the component
*/
export function tabulatableObjectsComponent(objectsType: string | GenericConstructor<PaginatedList<ListableObject>>, viewMode: ViewMode, context: Context = DEFAULT_CONTEXT, theme = DEFAULT_THEME) {
return function decorator(component: any) {
if (hasNoValue(objectsType)) {
return;
}
if (hasNoValue(map.get(objectsType))) {
map.set(objectsType, new Map());
}
if (hasNoValue(map.get(objectsType).get(viewMode))) {
map.get(objectsType).set(viewMode, new Map());
}
if (hasNoValue(map.get(objectsType).get(viewMode).get(context))) {
map.get(objectsType).get(viewMode).set(context, new Map());
}
map.get(objectsType).get(viewMode).get(context).set(theme, component);
};
}
/**
* Getter to retrieve the matching tabulatable objects component
*
* Looping over the provided types, it'll attempt to find the best match depending on the {@link MatchRelevancy} returned by getMatch()
* The most relevant match between types is kept and eventually returned
*
* @param types The types of which one should match the tabulatable component
* @param viewMode The view mode that should match the components
* @param context The context that should match the components
* @param theme The theme that should match the components
*/
export function getTabulatableObjectsComponent(types: (string | GenericConstructor<ListableObject>)[], viewMode: ViewMode, context: Context = DEFAULT_CONTEXT, theme: string = DEFAULT_THEME) {
let currentBestMatch: MatchRelevancy = null;
for (const type of types) {
const typeMap = map.get(PaginatedList<typeof type>);
if (hasValue(typeMap)) {
const match = getMatch(typeMap, [viewMode, context, theme], [DEFAULT_VIEW_MODE, DEFAULT_CONTEXT, DEFAULT_THEME]);
if (hasNoValue(currentBestMatch) || currentBestMatch.isLessRelevantThan(match)) {
currentBestMatch = match;
}
}
}
return hasValue(currentBestMatch) ? currentBestMatch.match : null;
}

View File

@@ -0,0 +1,11 @@
import { Directive, ViewContainerRef } from '@angular/core';
@Directive({
selector: '[dsTabulatableObjects]',
})
/**
* Directive used as a hook to know where to inject the dynamic listable object component
*/
export class TabulatableObjectsDirective {
constructor(public viewContainerRef: ViewContainerRef) { }
}

View File

@@ -0,0 +1,12 @@
import { Component } from '@angular/core';
import {
AbstractTabulatableElementComponent
} from '../../../object-collection/shared/objects-collection-tabulatable/objects-collection-tabulatable.component';
import { PaginatedList } from '../../../../core/data/paginated-list.model';
import { SearchResult } from '../../../search/models/search-result.model';
@Component({
selector: 'ds-search-result-list-element',
template: ``
})
export class TabulatableResultListElementsComponent<T extends PaginatedList<K>, K extends SearchResult<any>> extends AbstractTabulatableElementComponent<T> {}

View File

@@ -0,0 +1,33 @@
<ds-pagination
[paginationOptions]="config"
[pageInfoState]="objects?.payload"
[collectionSize]="objects?.payload?.totalElements"
[sortOptions]="sortConfig"
[hideGear]="hideGear"
[objects]="objects"
[hidePagerWhenSinglePage]="hidePagerWhenSinglePage"
[hidePaginationDetail]="hidePaginationDetail"
[showPaginator]="showPaginator"
(pageChange)="onPageChange($event)"
(pageSizeChange)="onPageSizeChange($event)"
(sortDirectionChange)="onSortDirectionChange($event)"
(sortFieldChange)="onSortFieldChange($event)"
(paginationChange)="onPaginationChange($event)"
(prev)="goPrev()"
(next)="goNext()"
[retainScrollPosition]="true"
>
<div *ngIf="objects?.hasSucceeded">
<div @fadeIn>
<ds-tabulatable-objects-loader [objects]="objects.payload"
[context]="context"
[showThumbnails]="showThumbnails"
[linkType]="linkType">
</ds-tabulatable-objects-loader>
</div>
</div>
<ds-error *ngIf="objects?.hasFailed" message="{{'error.objects' | translate}}"></ds-error>
<ds-themed-loading *ngIf="objects?.isLoading" message="{{'loading.objects' | translate}}"></ds-themed-loading>
</ds-pagination>

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ObjectTableComponent } from './object-table.component';
describe('ObjectTableComponent', () => {
let component: ObjectTableComponent;
let fixture: ComponentFixture<ObjectTableComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ ObjectTableComponent ]
})
.compileComponents();
fixture = TestBed.createComponent(ObjectTableComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,201 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, ViewEncapsulation } from '@angular/core';
import { ViewMode } from '../../core/shared/view-mode.model';
import { PaginationComponentOptions } from '../pagination/pagination-component-options.model';
import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model';
import { CollectionElementLinkType } from '../object-collection/collection-element-link.type';
import { Context } from '../../core/shared/context.model';
import { BehaviorSubject} from 'rxjs';
import { RemoteData } from '../../core/data/remote-data';
import { PaginatedList } from '../../core/data/paginated-list.model';
import { ListableObject } from '../object-collection/shared/listable-object.model';
import { fadeIn } from '../animations/fade';
@Component({
changeDetection: ChangeDetectionStrategy.Default,
encapsulation: ViewEncapsulation.Emulated,
selector: 'ds-object-table',
templateUrl: './object-table.component.html',
styleUrls: ['./object-table.component.scss'],
animations: [fadeIn]
})
export class ObjectTableComponent {
/**
* The view mode of this component
*/
viewMode = ViewMode.Table;
/**
* The current pagination configuration
*/
@Input() config: PaginationComponentOptions;
/**
* The current sort configuration
*/
@Input() sortConfig: SortOptions;
/**
* Whether or not the pagination should be rendered as simple previous and next buttons instead of the normal pagination
*/
@Input() showPaginator = true;
/**
* Whether to show the thumbnail preview
*/
@Input() showThumbnails;
/**
* The whether or not the gear is hidden
*/
@Input() hideGear = false;
/**
* Whether or not the pager is visible when there is only a single page of results
*/
@Input() hidePagerWhenSinglePage = true;
/**
* The link type of the listable elements
*/
@Input() linkType: CollectionElementLinkType;
/**
* The context of the listable elements
*/
@Input() context: Context;
/**
* Option for hiding the pagination detail
*/
@Input() hidePaginationDetail = false;
/**
* Behavior subject to output the current listable objects
*/
private _objects$: BehaviorSubject<RemoteData<PaginatedList<ListableObject>>>;
/**
* Setter to make sure the observable is turned into an observable
* @param objects The new objects to output
*/
@Input() set objects(objects: RemoteData<PaginatedList<ListableObject>>) {
this._objects$.next(objects);
}
/**
* Getter to return the current objects
*/
get objects() {
return this._objects$.getValue();
}
/**
* An event fired when the page is changed.
* Event's payload equals to the newly selected page.
*/
@Output() change: EventEmitter<{
pagination: PaginationComponentOptions,
sort: SortOptions
}> = new EventEmitter<{
pagination: PaginationComponentOptions,
sort: SortOptions
}>();
/**
* An event fired when the page is changed.
* Event's payload equals to the newly selected page.
*/
@Output() pageChange: EventEmitter<number> = new EventEmitter<number>();
/**
* An event fired when the page wsize is changed.
* Event's payload equals to the newly selected page size.
*/
@Output() pageSizeChange: EventEmitter<number> = new EventEmitter<number>();
/**
* An event fired when the sort direction is changed.
* Event's payload equals to the newly selected sort direction.
*/
@Output() sortDirectionChange: EventEmitter<SortDirection> = new EventEmitter<SortDirection>();
/**
* An event fired when on of the pagination parameters changes
*/
@Output() paginationChange: EventEmitter<any> = new EventEmitter<any>();
/**
* An event fired when the sort field is changed.
* Event's payload equals to the newly selected sort field.
*/
@Output() sortFieldChange: EventEmitter<string> = new EventEmitter<string>();
/**
* If showPaginator is set to true, emit when the previous button is clicked
*/
@Output() prev = new EventEmitter<boolean>();
/**
* If showPaginator is set to true, emit when the next button is clicked
*/
@Output() next = new EventEmitter<boolean>();
data: any = {};
constructor() {
this._objects$ = new BehaviorSubject(undefined);
}
/**
* Emits the current page when it changes
* @param event The new page
*/
onPageChange(event) {
this.pageChange.emit(event);
}
/**
* Emits the current page size when it changes
* @param event The new page size
*/
onPageSizeChange(event) {
this.pageSizeChange.emit(event);
}
/**
* Emits the current sort direction when it changes
* @param event The new sort direction
*/
onSortDirectionChange(event) {
this.sortDirectionChange.emit(event);
}
/**
* Emits the current sort field when it changes
* @param event The new sort field
*/
onSortFieldChange(event) {
this.sortFieldChange.emit(event);
}
/**
* Emits the current pagination when it changes
* @param event The new pagination
*/
onPaginationChange(event) {
this.paginationChange.emit(event);
}
/**
* Go to the previous page
*/
goPrev() {
this.prev.emit(true);
}
/**
* Go to the next page
*/
goNext() {
this.next.emit(true);
}
}

View File

@@ -22,7 +22,7 @@
</div> </div>
<div id="search-content" class="col-12"> <div id="search-content" class="col-12">
<div class="d-block d-md-none search-controls clearfix"> <div class="d-block d-md-none search-controls clearfix">
<ds-view-mode-switch [viewModeList]="viewModeList" [inPlaceSearch]="inPlaceSearch"></ds-view-mode-switch> <ds-view-mode-switch *ngIf="showViewModes" [viewModeList]="viewModeList" [inPlaceSearch]="inPlaceSearch"></ds-view-mode-switch>
<button (click)="openSidebar()" aria-controls="#search-body" <button (click)="openSidebar()" aria-controls="#search-body"
class="btn btn-outline-primary float-right open-sidebar"><i class="btn btn-outline-primary float-right open-sidebar"><i
class="fas fa-sliders"></i> {{"search.sidebar.open" class="fas fa-sliders"></i> {{"search.sidebar.open"

View File

@@ -285,6 +285,18 @@ import { NgxPaginationModule } from 'ngx-pagination';
import { SplitPipe } from './utils/split.pipe'; import { SplitPipe } from './utils/split.pipe';
import { ThemedUserMenuComponent } from './auth-nav-menu/user-menu/themed-user-menu.component'; import { ThemedUserMenuComponent } from './auth-nav-menu/user-menu/themed-user-menu.component';
import { ThemedLangSwitchComponent } from './lang-switch/themed-lang-switch.component'; import { ThemedLangSwitchComponent } from './lang-switch/themed-lang-switch.component';
import { NotificationBoxComponent } from './notification-box/notification-box.component';
import { ObjectTableComponent } from './object-table/object-table.component';
import { TabulatableObjectsLoaderComponent } from './object-collection/shared/tabulatable-objects/tabulatable-objects-loader.component';
import {
TabulatableObjectsDirective
} from './object-collection/shared/tabulatable-objects/tabulatable-objects.directive';
import {
AbstractTabulatableElementComponent
} from './object-collection/shared/objects-collection-tabulatable/objects-collection-tabulatable.component';
import {
TabulatableResultListElementsComponent
} from './object-list/search-result-list-element/tabulatable-search-result/tabulatable-result-list-elements.component';
const MODULES = [ const MODULES = [
CommonModule, CommonModule,
@@ -348,7 +360,9 @@ const COMPONENTS = [
ThemedObjectListComponent, ThemedObjectListComponent,
ObjectDetailComponent, ObjectDetailComponent,
ObjectGridComponent, ObjectGridComponent,
ObjectTableComponent,
AbstractListableElementComponent, AbstractListableElementComponent,
AbstractTabulatableElementComponent,
ObjectCollectionComponent, ObjectCollectionComponent,
PaginationComponent, PaginationComponent,
RSSComponent, RSSComponent,
@@ -414,6 +428,7 @@ const ENTRY_COMPONENTS = [
CollectionListElementComponent, CollectionListElementComponent,
CommunityListElementComponent, CommunityListElementComponent,
SearchResultListElementComponent, SearchResultListElementComponent,
TabulatableResultListElementsComponent,
CommunitySearchResultListElementComponent, CommunitySearchResultListElementComponent,
CollectionSearchResultListElementComponent, CollectionSearchResultListElementComponent,
CollectionGridElementComponent, CollectionGridElementComponent,
@@ -469,7 +484,9 @@ const ENTRY_COMPONENTS = [
AdvancedClaimedTaskActionRatingComponent, AdvancedClaimedTaskActionRatingComponent,
EpersonGroupListComponent, EpersonGroupListComponent,
EpersonSearchBoxComponent, EpersonSearchBoxComponent,
GroupSearchBoxComponent GroupSearchBoxComponent,
NotificationBoxComponent,
TabulatableObjectsLoaderComponent,
]; ];
const PROVIDERS = [ const PROVIDERS = [
@@ -488,6 +505,7 @@ const DIRECTIVES = [
RoleDirective, RoleDirective,
MetadataRepresentationDirective, MetadataRepresentationDirective,
ListableObjectDirective, ListableObjectDirective,
TabulatableObjectsDirective,
ClaimedTaskActionsDirective, ClaimedTaskActionsDirective,
FileValueAccessorDirective, FileValueAccessorDirective,
FileValidator, FileValidator,

View File

@@ -28,6 +28,8 @@
"admin.notifications.recitersuggestion.page.title": "Suggestions", "admin.notifications.recitersuggestion.page.title": "Suggestions",
"admin.notify.dashboard": "Dashboard",
"error-page.description.401": "unauthorized", "error-page.description.401": "unauthorized",
"error-page.description.403": "forbidden", "error-page.description.403": "forbidden",
@@ -3075,6 +3077,10 @@
"menu.section.icon.export": "Export menu section", "menu.section.icon.export": "Export menu section",
"menu.section.notify_dashboard": "Dashboard",
"menu.section.coar_notify": "COAR Notify",
"menu.section.icon.find": "Find menu section", "menu.section.icon.find": "Find menu section",
"menu.section.icon.health": "Health check menu section", "menu.section.icon.health": "Health check menu section",
@@ -3437,7 +3443,199 @@
"quality-assurance.event.reason": "Reason", "quality-assurance.event.reason": "Reason",
"orgunit.listelement.badge": "Organizational Unit", "admin-notify-dashboard.title": "Notify Dashboard",
"admin-notify-dashboard.metrics": "Metrics",
"admin-notify-dashboard.received-ldn": "Number of received LDN",
"admin-notify-dashboard.generated-ldn": "Number of generated LDN",
"admin-notify-dashboard.accepted": "Accepted",
"admin-notify-dashboard.processed": "Processed LDN",
"admin-notify-dashboard.failure": "Failure",
"admin-notify-dashboard.untrusted": "Untrusted",
"admin-notify-dashboard.delivered": "Delivered",
"admin-notify-dashboard.queued": "Queued",
"admin-notify-dashboard.queued-for-retry": "Queued for retry",
"admin-notify-dashboard.involved-items": "Involved items",
"admin.notify.dashboard.breadcrumbs": "Dashboard",
"admin.notify.dashboard.inbound": "Inbound messages",
"admin.notify.dashboard.inbound-logs": "Logs/Inbound",
"admin.notify.dashboard.outbound": "Outbound messages",
"admin.notify.dashboard.outbound-logs": "Logs/Outbound",
"NOTIFY.incoming.search.results.head": "Incoming",
"search.filters.filter.relateditem.head": "Related item",
"search.filters.filter.origin.head": "Origin",
"search.filters.filter.target.head": "Target",
"search.filters.filter.queue_status.head": "Queue status",
"search.filters.filter.activity_stream_type.head": "Activity stream type",
"search.filters.filter.coar_notify_type.head": "COAR Notify type",
"search.filters.filter.notification_type.head": "Notification type",
"search.filters.filter.relateditem.label": "Search related items",
"search.filters.filter.queue_status.label": "Search queue status",
"search.filters.filter.target.label": "Search target",
"search.filters.filter.activity_stream_type.label": "Search activity stream type",
"search.filters.filter.coar_notify_type.label": "Search COAR Notify type",
"search.filters.filter.notification_type.label": "Search notification type",
"search.filters.filter.relateditem.placeholder": "Related items",
"search.filters.filter.target.placeholder": "Target",
"search.filters.filter.origin.label": "Search source",
"search.filters.filter.origin.placeholder": "Source",
"search.filters.filter.queue_status.placeholder": "Queue status",
"search.filters.filter.activity_stream_type.placeholder": "Activity stream type",
"search.filters.filter.coar_notify_type.placeholder": "COAR Notify type",
"search.filters.filter.notification_type.placeholder": "Notification",
"search.filters.coar_notify_type.coar-notify:ReviewAction": "Review action",
"notify-detail-modal.coar-notify:ReviewAction": "Review action",
"search.filters.coar_notify_type.coar-notify:EndorsementAction": "Endorsement action",
"notify-detail-modal.coar-notify:EndorsementAction": "Endorsement action",
"search.filters.coar_notify_type.coar-notify:IngestAction": "Ingest action",
"notify-detail-modal.coar-notify:IngestAction": "Ingest action",
"search.filters.coar_notify_type.coar-notify:RelationshipAction": "Relationship action",
"notify-detail-modal.coar-notify:RelationshipAction": "Relationship action",
"search.filters.queue_status.QUEUE_STATUS_QUEUED": "Queued",
"notify-detail-modal.QUEUE_STATUS_QUEUED": "Queued",
"search.filters.queue_status.QUEUE_STATUS_QUEUED_FOR_RETRY": "Queued for retry",
"notify-detail-modal.QUEUE_STATUS_QUEUED_FOR_RETRY": "Queued for retry",
"search.filters.queue_status.QUEUE_STATUS_PROCESSING": "Processing",
"notify-detail-modal.QUEUE_STATUS_PROCESSING": "Processing",
"search.filters.queue_status.QUEUE_STATUS_PROCESSED": "Processed",
"notify-detail-modal.QUEUE_STATUS_PROCESSED": "Processed",
"search.filters.queue_status.QUEUE_STATUS_FAILED": "Failed",
"notify-detail-modal.QUEUE_STATUS_FAILED": "Failed",
"search.filters.queue_status.QUEUE_STATUS_UNTRUSTED": "Untrusted",
"notify-detail-modal.QUEUE_STATUS_UNTRUSTED": "Untrusted",
"search.filters.queue_status.QUEUE_STATUS_UNMAPPED_ACTION": "Unmapped Action",
"notify-detail-modal.QUEUE_STATUS_UNMAPPED_ACTION": "Unmapped Action",
"sorting.queue_last_start_time.DESC": "Last started queue Descending",
"sorting.queue_last_start_time.ASC": "Last started queue Ascending",
"sorting.queue_attempts.DESC": "Queue attempted Descending",
"sorting.queue_attempts.ASC": "Queue attempted Ascending",
"type.notify-detail-modal": "Type",
"id.notify-detail-modal": "Id",
"coarNotifyType.notify-detail-modal": "COAR Notify type",
"activityStreamType.notify-detail-modal": "Activity stream type",
"inReplyTo.notify-detail-modal": "In reply to",
"object.notify-detail-modal": "Repository Item",
"context.notify-detail-modal": "Repository Item",
"queueAttempts.notify-detail-modal": "Queue attempts",
"queueLastStartTime.notify-detail-modal": "Queue last started",
"origin.notify-detail-modal": "LDN Service",
"target.notify-detail-modal": "LDN Service",
"queueStatusLabel.notify-detail-modal": "Queue status",
"queueTimeout.notify-detail-modal": "Queue timeout",
"notify-message-modal.title": "Message Detail",
"notify-message-result.timestamp": "Timestamp",
"notify-message-result.repositoryItem": "Repository Item",
"notify-message-result.ldnService": "LDN Service",
"notify-message-result.type": "Type",
"notify-message-result.status": "Status",
"notify-message-result.action": "Action",
"notify-message-result.detail": "Detail",
"notify-message-result.reprocess": "Reprocess",
"notify-queue-status.processed": "Processed",
"notify-queue-status.failed": "Failed",
"notify-queue-status.queue_retry": "Queued for retry",
"notify-queue-status.unmapped_action": "Unmapped action",
"notify-queue-status.processing": "Processing",
"notify-queue-status.queued": "Queued",
"notify-queue-status.untrusted": "Untrusted",
"ldnService.notify-detail-modal": "LDN Service",
"relatedItem.notify-detail-modal": "Related Item",
"orgunit.listelement.badge": "Repository Item",
"orgunit.listelement.no-title": "Untitled", "orgunit.listelement.no-title": "Untitled",

View File

@@ -23,6 +23,9 @@ import { HomeConfig } from './homepage-config.interface';
import { MarkdownConfig } from './markdown-config.interface'; import { MarkdownConfig } from './markdown-config.interface';
import { FilterVocabularyConfig } from './filter-vocabulary-config'; import { FilterVocabularyConfig } from './filter-vocabulary-config';
import { DiscoverySortConfig } from './discovery-sort.config'; import { DiscoverySortConfig } from './discovery-sort.config';
import {
AdminNotifyMetricsRow
} from '../app/admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.model';
interface AppConfig extends Config { interface AppConfig extends Config {
ui: UIServerConfig; ui: UIServerConfig;
@@ -50,6 +53,7 @@ interface AppConfig extends Config {
markdown: MarkdownConfig; markdown: MarkdownConfig;
vocabularies: FilterVocabularyConfig[]; vocabularies: FilterVocabularyConfig[];
comcolSelectionSort: DiscoverySortConfig; comcolSelectionSort: DiscoverySortConfig;
notifyMetrics: AdminNotifyMetricsRow[];
} }
/** /**

View File

@@ -23,6 +23,9 @@ import { HomeConfig } from './homepage-config.interface';
import { MarkdownConfig } from './markdown-config.interface'; import { MarkdownConfig } from './markdown-config.interface';
import { FilterVocabularyConfig } from './filter-vocabulary-config'; import { FilterVocabularyConfig } from './filter-vocabulary-config';
import { DiscoverySortConfig } from './discovery-sort.config'; import { DiscoverySortConfig } from './discovery-sort.config';
import {
AdminNotifyMetricsRow
} from '../app/admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.model';
export class DefaultAppConfig implements AppConfig { export class DefaultAppConfig implements AppConfig {
production = false; production = false;
@@ -443,4 +446,69 @@ export class DefaultAppConfig implements AppConfig {
sortField:'dc.title', sortField:'dc.title',
sortDirection:'ASC', sortDirection:'ASC',
}; };
notifyMetrics: AdminNotifyMetricsRow[] = [
{
title: 'admin-notify-dashboard.received-ldn',
boxes: [
{
color: '#B8DAFF',
title: 'admin-notify-dashboard.accepted',
config: 'NOTIFY.incoming.accepted'
},
{
color: '#D4EDDA',
title: 'admin-notify-dashboard.processed',
config: 'NOTIFY.incoming.processed'
},
{
color: '#FDBBC7',
title: 'admin-notify-dashboard.failure',
config: 'NOTIFY.incoming.failure'
},
{
color: '#FDBBC7',
title: 'admin-notify-dashboard.untrusted',
config: 'NOTIFY.incoming.untrusted'
},
{
color: '#43515F',
title: 'admin-notify-dashboard.involved-items',
textColor: '#fff',
config: 'NOTIFY.incoming.involvedItems',
},
]
},
{
title: 'admin-notify-dashboard.generated-ldn',
boxes: [
{
color: '#D4EDDA',
title: 'admin-notify-dashboard.delivered',
config: 'NOTIFY.outgoing.delivered'
},
{
color: '#B8DAFF',
title: 'admin-notify-dashboard.queued',
config: 'NOTIFY.outgoing.queued'
},
{
color: '#FDEEBB',
title: 'admin-notify-dashboard.queued-for-retry',
config: 'NOTIFY.outgoing.queued_for_retry'
},
{
color: '#FDBBC7',
title: 'admin-notify-dashboard.failure',
config: 'NOTIFY.outgoing.failure'
},
{
color: '#43515F',
title: 'admin-notify-dashboard.involved-items',
textColor: '#fff',
config: 'NOTIFY.outgoing.involvedItems',
},
]
}
];
} }

View File

@@ -317,5 +317,70 @@ export const environment: BuildConfig = {
vocabulary: 'srsc', vocabulary: 'srsc',
enabled: true enabled: true
} }
],
notifyMetrics: [
{
title: 'admin-notify-dashboard.received-ldn',
boxes: [
{
color: '#B8DAFF',
title: 'admin-notify-dashboard.accepted',
config: 'NOTIFY.incoming.accepted'
},
{
color: '#D4EDDA',
title: 'admin-notify-dashboard.processed',
config: 'NOTIFY.incoming.processed'
},
{
color: '#FDBBC7',
title: 'admin-notify-dashboard.failure',
config: 'NOTIFY.incoming.failure'
},
{
color: '#FDBBC7',
title: 'admin-notify-dashboard.untrusted',
config: 'NOTIFY.incoming.untrusted'
},
{
color: '#43515F',
title: 'admin-notify-dashboard.involved-items',
textColor: '#fff',
config: 'NOTIFY.incoming.involvedItems',
},
]
},
{
title: 'admin-notify-dashboard.generated-ldn',
boxes: [
{
color: '#D4EDDA',
title: 'admin-notify-dashboard.delivered',
config: 'NOTIFY.outgoing.delivered'
},
{
color: '#B8DAFF',
title: 'admin-notify-dashboard.queued',
config: 'NOTIFY.outgoing.queued'
},
{
color: '#FDEEBB',
title: 'admin-notify-dashboard.queued-for-retry',
config: 'NOTIFY.outgoing.queued_for_retry'
},
{
color: '#FDBBC7',
title: 'admin-notify-dashboard.failure',
config: 'NOTIFY.outgoing.failure'
},
{
color: '#43515F',
title: 'admin-notify-dashboard.involved-items',
textColor: '#fff',
config: 'NOTIFY.outgoing.involvedItems',
},
]
}
] ]
}; };