diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-dashboard-routing.module.ts b/src/app/admin/admin-notify-dashboard/admin-notify-dashboard-routing.module.ts new file mode 100644 index 0000000000..94f0347f49 --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-dashboard-routing.module.ts @@ -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 { + +} diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-dashboard.component.html b/src/app/admin/admin-notify-dashboard/admin-notify-dashboard.component.html new file mode 100644 index 0000000000..45872ae9c1 --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-dashboard.component.html @@ -0,0 +1,22 @@ +
+
+ +
+
diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-dashboard.component.spec.ts b/src/app/admin/admin-notify-dashboard/admin-notify-dashboard.component.spec.ts new file mode 100644 index 0000000000..74007055b4 --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-dashboard.component.spec.ts @@ -0,0 +1,57 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AdminNotifyDashboardComponent } from './admin-notify-dashboard.component'; +import { TranslateModule } from '@ngx-translate/core'; +import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap'; +import { SearchService } from '../../core/shared/search/search.service'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { buildPaginatedList } from '../../core/data/paginated-list.model'; +import { AdminNotifySearchResult } from './models/admin-notify-message-search-result.model'; +import { AdminNotifyMessage } from './models/admin-notify-message.model'; + +describe('AdminNotifyDashboardComponent', () => { + let component: AdminNotifyDashboardComponent; + let fixture: ComponentFixture; + + let item1; + let item2; + let item3; + let searchResult1; + let searchResult2; + let searchResult3; + let results; + + const mockBoxes = [ + { title: 'admin-notify-dashboard.received-ldn', boxes: [ undefined, undefined, undefined, undefined, undefined ] }, + { title: 'admin-notify-dashboard.generated-ldn', boxes: [ undefined, undefined, undefined, undefined, undefined ] } + ]; + + beforeEach(async () => { + item1 = Object.assign(new AdminNotifyMessage(), { uuid: 'e1c51c69-896d-42dc-8221-1d5f2ad5516e' }); + item2 = Object.assign(new AdminNotifyMessage(), { uuid: 'c8279647-1acc-41ae-b036-951d5f65649b' }); + item3 = Object.assign(new AdminNotifyMessage(), { uuid: 'c3bcbff5-ec0c-4831-8e4c-94b9c933ccac' }); + searchResult1 = Object.assign(new AdminNotifySearchResult(), { indexableObject: item1 }); + searchResult2 = Object.assign(new AdminNotifySearchResult(), { indexableObject: item2 }); + searchResult3 = Object.assign(new AdminNotifySearchResult(), { indexableObject: item3 }); + results = buildPaginatedList(undefined, [searchResult1, searchResult2, searchResult3]); + + await TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), NgbNavModule], + declarations: [ AdminNotifyDashboardComponent ], + providers: [{ provide: SearchService, useValue: { search: () => createSuccessfulRemoteDataObject$(results)}}] + }) + .compileComponents(); + + fixture = TestBed.createComponent(AdminNotifyDashboardComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', (done) => { + component.notifyMetricsRows$.subscribe(boxes => { + expect(boxes).toEqual(mockBoxes); + done(); + }); + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-dashboard.component.ts b/src/app/admin/admin-notify-dashboard/admin-notify-dashboard.component.ts new file mode 100644 index 0000000000..d2a20334c7 --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-dashboard.component.ts @@ -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; + + private metricsConfig = environment.notifyMetrics; + private singleResultOptions = Object.assign(new PaginationComponentOptions(), { + id: 'single-result-options', + pageSize: 1 + }); + + constructor(private searchService: SearchService) { + } + + ngOnInit() { + const mertricsRowsConfigurations = this.metricsConfig + .map(row => row.boxes) + .map(boxes => boxes.map(box => box.config).filter(config => !!config)); + const flatConfigurations = [].concat(...mertricsRowsConfigurations.map((config) => config)); + const searchConfigurations = flatConfigurations + .map(config => Object.assign(new PaginatedSearchOptions({}), + { configuration: config, pagination: this.singleResultOptions } + )); + + this.notifyMetricsRows$ = forkJoin(searchConfigurations.map(config => this.searchService.search(config) + .pipe( + getFirstCompletedRemoteData(), + map(response => this.mapSearchObjectsToMetricsBox(response.payload)), + ) + ) + ).pipe( + map(metricBoxes => this.mapUpdatedBoxesToMetricsRows(metricBoxes)) + ); + } + + /** + * Function to map received SearchObjects to notify boxes config + * + * @param searchObject The object to map + * @private + */ + private mapSearchObjectsToMetricsBox(searchObject: SearchObjects): AdminNotifyMetricsBox { + const count = searchObject.pageInfo.totalElements; + const objectConfig = searchObject.configuration; + const metricsBoxes = [].concat(...this.metricsConfig.map((config) => config.boxes)); + + return { + ...metricsBoxes.find(box => box.config === objectConfig), + count + }; + } + + /** + * Function to map updated boxes with count to each row of the configuration + * + * @param boxesWithCount The object to map + * @private + */ + private mapUpdatedBoxesToMetricsRows(boxesWithCount: AdminNotifyMetricsBox[]): AdminNotifyMetricsRow[] { + return this.metricsConfig.map(row => { + return { + ...row, + boxes: row.boxes.map(rowBox => boxesWithCount.find(boxWithCount => boxWithCount.config === rowBox.config)) + }; + }); + } +} diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-dashboard.module.ts b/src/app/admin/admin-notify-dashboard/admin-notify-dashboard.module.ts new file mode 100644 index 0000000000..9c4daf981b --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-dashboard.module.ts @@ -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 { + +} diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-detail-modal/admin-notify-detail-modal.component.html b/src/app/admin/admin-notify-dashboard/admin-notify-detail-modal/admin-notify-detail-modal.component.html new file mode 100644 index 0000000000..a43ac1526c --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-detail-modal/admin-notify-detail-modal.component.html @@ -0,0 +1,14 @@ + + diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-detail-modal/admin-notify-detail-modal.component.spec.ts b/src/app/admin/admin-notify-dashboard/admin-notify-detail-modal/admin-notify-detail-modal.component.spec.ts new file mode 100644 index 0000000000..0ddf449e5c --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-detail-modal/admin-notify-detail-modal.component.spec.ts @@ -0,0 +1,36 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AdminNotifyDetailModalComponent } from './admin-notify-detail-modal.component'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; + +describe('AdminNotifyDetailModalComponent', () => { + let component: AdminNotifyDetailModalComponent; + let fixture: ComponentFixture; + const modalStub = jasmine.createSpyObj('modalStub', ['close']); + + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [ AdminNotifyDetailModalComponent ], + providers: [{ provide: NgbActiveModal, useValue: modalStub }] + }) + .compileComponents(); + + fixture = TestBed.createComponent(AdminNotifyDetailModalComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should close', () => { + spyOn(component.response, 'emit'); + component.closeModal(); + expect(modalStub.close).toHaveBeenCalled(); + expect(component.response.emit).toHaveBeenCalledWith(true); + }); +}); diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-detail-modal/admin-notify-detail-modal.component.ts b/src/app/admin/admin-notify-dashboard/admin-notify-detail-modal/admin-notify-detail-modal.component.ts new file mode 100644 index 0000000000..36f984b01b --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-detail-modal/admin-notify-detail-modal.component.ts @@ -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(); + + + 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); + } +} diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-incoming/admin-notify-incoming.component.html b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-incoming/admin-notify-incoming.component.html new file mode 100644 index 0000000000..cec35283ed --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-incoming/admin-notify-incoming.component.html @@ -0,0 +1,29 @@ +
+
+
+

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

+ +
+
+
diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-incoming/admin-notify-incoming.component.spec.ts b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-incoming/admin-notify-incoming.component.spec.ts new file mode 100644 index 0000000000..f7388c5210 --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-incoming/admin-notify-incoming.component.spec.ts @@ -0,0 +1,58 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AdminNotifyIncomingComponent } from './admin-notify-incoming.component'; +import { TranslateModule } from '@ngx-translate/core'; +import { ActivatedRoute } from '@angular/router'; +import { MockActivatedRoute } from '../../../../shared/mocks/active-router.mock'; +import { provideMockStore } from '@ngrx/store/testing'; +import { HALEndpointService } from '../../../../core/shared/hal-endpoint.service'; +import { SEARCH_CONFIG_SERVICE } from '../../../../my-dspace-page/my-dspace-page.component'; +import { RouteService } from '../../../../core/services/route.service'; +import { routeServiceStub } from '../../../../shared/testing/route-service.stub'; +import { RequestService } from '../../../../core/data/request.service'; +import { RemoteDataBuildService } from '../../../../core/cache/builders/remote-data-build.service'; +import { getMockRemoteDataBuildService } from '../../../../shared/mocks/remote-data-build.service.mock'; +import { SearchConfigurationService } from '../../../../core/shared/search/search-configuration.service'; + +describe('AdminNotifyIncomingComponent', () => { + let component: AdminNotifyIncomingComponent; + let fixture: ComponentFixture; + let halService: HALEndpointService; + let requestService: RequestService; + let rdbService: RemoteDataBuildService; + + + + beforeEach(async () => { + rdbService = getMockRemoteDataBuildService(); + halService = jasmine.createSpyObj('halService', { + 'getRootHref': '/api' + }); + requestService = jasmine.createSpyObj('requestService', { + 'generateRequestId': 'client/1234', + 'send': '', + }); + await TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [ AdminNotifyIncomingComponent ], + providers: [ + { provide: SEARCH_CONFIG_SERVICE, useValue: SearchConfigurationService }, + { provide: RouteService, useValue: routeServiceStub }, + { provide: ActivatedRoute, useValue: new MockActivatedRoute() }, + { provide: HALEndpointService, useValue: halService }, + { provide: RequestService, useValue: requestService }, + { provide: RemoteDataBuildService, useValue: rdbService }, + provideMockStore({}), + ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(AdminNotifyIncomingComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-incoming/admin-notify-incoming.component.ts b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-incoming/admin-notify-incoming.component.ts new file mode 100644 index 0000000000..7375a98abe --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-incoming/admin-notify-incoming.component.ts @@ -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) { + } +} diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-outgoing/admin-notify-outgoing.component.html b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-outgoing/admin-notify-outgoing.component.html new file mode 100644 index 0000000000..9aae2db294 --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-outgoing/admin-notify-outgoing.component.html @@ -0,0 +1,29 @@ +
+
+
+

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

+ +
+
+
diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-outgoing/admin-notify-outgoing.component.spec.ts b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-outgoing/admin-notify-outgoing.component.spec.ts new file mode 100644 index 0000000000..a8af9a7fd6 --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-outgoing/admin-notify-outgoing.component.spec.ts @@ -0,0 +1,57 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AdminNotifyOutgoingComponent } from './admin-notify-outgoing.component'; +import { TranslateModule } from '@ngx-translate/core'; +import { ActivatedRoute } from '@angular/router'; +import { MockActivatedRoute } from '../../../../shared/mocks/active-router.mock'; +import { provideMockStore } from '@ngrx/store/testing'; +import { HALEndpointService } from '../../../../core/shared/hal-endpoint.service'; +import { SEARCH_CONFIG_SERVICE } from '../../../../my-dspace-page/my-dspace-page.component'; +import { RouteService } from '../../../../core/services/route.service'; +import { routeServiceStub } from '../../../../shared/testing/route-service.stub'; +import { RequestService } from '../../../../core/data/request.service'; +import { getMockRemoteDataBuildService } from '../../../../shared/mocks/remote-data-build.service.mock'; +import { RemoteDataBuildService } from '../../../../core/cache/builders/remote-data-build.service'; +import { SearchConfigurationService } from '../../../../core/shared/search/search-configuration.service'; + +describe('AdminNotifyOutgoingComponent', () => { + let component: AdminNotifyOutgoingComponent; + let fixture: ComponentFixture; + let halService: HALEndpointService; + let requestService: RequestService; + let rdbService: RemoteDataBuildService; + + + beforeEach(async () => { + rdbService = getMockRemoteDataBuildService(); + requestService = jasmine.createSpyObj('requestService', { + 'generateRequestId': 'client/1234', + 'send': '', + }); + halService = jasmine.createSpyObj('halService', { + 'getRootHref': '/api' + }); + await TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [ AdminNotifyOutgoingComponent ], + providers: [ + { provide: SEARCH_CONFIG_SERVICE, useValue: SearchConfigurationService }, + { provide: RouteService, useValue: routeServiceStub }, + { provide: ActivatedRoute, useValue: new MockActivatedRoute() }, + { provide: HALEndpointService, useValue: halService }, + { provide: RequestService, useValue: requestService }, + { provide: RemoteDataBuildService, useValue: rdbService }, + provideMockStore({}), + ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(AdminNotifyOutgoingComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-outgoing/admin-notify-outgoing.component.ts b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-outgoing/admin-notify-outgoing.component.ts new file mode 100644 index 0000000000..290c8ace76 --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-logs/admin-notify-outgoing/admin-notify-outgoing.component.ts @@ -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) { + } +} diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.component.html b/src/app/admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.component.html new file mode 100644 index 0000000000..c3ba6364dc --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.component.html @@ -0,0 +1,9 @@ +
+
{{ row.title | translate }}
+
+
+ +
+
+
+ diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.component.spec.ts b/src/app/admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.component.spec.ts new file mode 100644 index 0000000000..bf3a86ec2e --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.component.spec.ts @@ -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; + + 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(); + }); +}); diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.component.ts b/src/app/admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.component.ts new file mode 100644 index 0000000000..ca5dd0115a --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.component.ts @@ -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[]; +} diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.model.ts b/src/app/admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.model.ts new file mode 100644 index 0000000000..bbceb10c20 --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-metrics/admin-notify-metrics.model.ts @@ -0,0 +1,12 @@ +export interface AdminNotifyMetricsBox { + color: string; + textColor?: string; + title: string; + config: string; + count?: number; +} + +export interface AdminNotifyMetricsRow { + title: string; + boxes: AdminNotifyMetricsBox[] +} diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-search-result/admin-notify-search-result.component.html b/src/app/admin/admin-notify-dashboard/admin-notify-search-result/admin-notify-search-result.component.html new file mode 100644 index 0000000000..3f1fb24298 --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-search-result/admin-notify-search-result.component.html @@ -0,0 +1,41 @@ +
+ + + + + + + + + + + + + + + + + + + + + +
{{ 'notify-message-result.timestamp' | translate}}{{'notify-message-result.repositoryItem' | translate}}{{ 'notify-message-result.ldnService' | translate}}{{ 'notify-message-result.type' | translate }}{{ 'notify-message-result.status' | translate }}{{ 'notify-message-result.action' | translate }}
+
{{ message.queueLastStartTime | date:"YYYY/MM/d hh:mm:ss" }}
+
+ {{ message.relatedItem }} + +
{{ message.ldnService }}
+
+
{{ message.activityStreamType }}
+
+
{{ 'notify-detail-modal.' + message.queueStatusLabel | translate }}
+
+
+ + +
+
+
diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-search-result/admin-notify-search-result.component.scss b/src/app/admin/admin-notify-dashboard/admin-notify-search-result/admin-notify-search-result.component.scss new file mode 100644 index 0000000000..290f66fa6f --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-search-result/admin-notify-search-result.component.scss @@ -0,0 +1,5 @@ +.table-responsive { + th, td { + padding: 0.5rem !important; + } +} diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-search-result/admin-notify-search-result.component.spec.ts b/src/app/admin/admin-notify-dashboard/admin-notify-search-result/admin-notify-search-result.component.spec.ts new file mode 100644 index 0000000000..9279fd2423 --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-search-result/admin-notify-search-result.component.spec.ts @@ -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; + 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(); + }); +}); diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-search-result/admin-notify-search-result.component.ts b/src/app/admin/admin-notify-dashboard/admin-notify-search-result/admin-notify-search-result.component.ts new file mode 100644 index 0000000000..ece1419603 --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/admin-notify-search-result/admin-notify-search-result.component.ts @@ -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, 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, AdminNotifySearchResult> implements OnInit, OnDestroy{ + public messagesSubject$: BehaviorSubject = new BehaviorSubject([]); + public reprocessStatus = 'QUEUE_STATUS_QUEUED_FOR_RETRY'; + //we check on one type of config to render specific table headers + public isInbound: boolean; + + + /** + * 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); + })); + } +} diff --git a/src/app/admin/admin-notify-dashboard/models/admin-notify-message-search-result.model.ts b/src/app/admin/admin-notify-dashboard/models/admin-notify-message-search-result.model.ts new file mode 100644 index 0000000000..236a564f20 --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/models/admin-notify-message-search-result.model.ts @@ -0,0 +1,8 @@ +import { AdminNotifyMessage } from './admin-notify-message.model'; +import { searchResultFor } from '../../../shared/search/search-result-element-decorator'; +import { SearchResult } from '../../../shared/search/models/search-result.model'; + + +@searchResultFor(AdminNotifyMessage) +export class AdminNotifySearchResult extends SearchResult { +} diff --git a/src/app/admin/admin-notify-dashboard/models/admin-notify-message.model.ts b/src/app/admin/admin-notify-dashboard/models/admin-notify-message.model.ts new file mode 100644 index 0000000000..72fe58eacb --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/models/admin-notify-message.model.ts @@ -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; + + /** + * The observable pointing to the access status of the item + */ + @autoserialize + accessStatus: Observable; + + + + @deserialize + _links: { + self: { + href: string; + }; + }; + + get self(): string { + return this._links.self.href; + } + + getRenderTypes(): (string | GenericConstructor)[] { + return [this.constructor as GenericConstructor]; + } +} diff --git a/src/app/admin/admin-notify-dashboard/models/admin-notify-message.resource-type.ts b/src/app/admin/admin-notify-dashboard/models/admin-notify-message.resource-type.ts new file mode 100644 index 0000000000..994146adb3 --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/models/admin-notify-message.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from '../../../core/shared/resource-type'; + +/** + * The resource type for AdminNotifyMessage + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const ADMIN_NOTIFY_MESSAGE = new ResourceType('message'); diff --git a/src/app/admin/admin-notify-dashboard/services/admin-notify-messages.service.spec.ts b/src/app/admin/admin-notify-dashboard/services/admin-notify-messages.service.spec.ts new file mode 100644 index 0000000000..975950a33d --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/services/admin-notify-messages.service.spec.ts @@ -0,0 +1,114 @@ +import { cold } from 'jasmine-marbles'; +import { AdminNotifyMessagesService } from './admin-notify-messages.service'; +import { RequestService } from '../../../core/data/request.service'; +import { RemoteDataBuildService } from '../../../core/cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../../../core/cache/object-cache.service'; +import { HALEndpointService } from '../../../core/shared/hal-endpoint.service'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { LdnServicesService } from '../../admin-ldn-services/ldn-services-data/ldn-services-data.service'; +import { ItemDataService } from '../../../core/data/item-data.service'; +import { RequestEntry } from '../../../core/data/request-entry.model'; +import { RestResponse } from '../../../core/cache/response.models'; +import { BehaviorSubject, of } from 'rxjs'; +import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { RemoteData } from '../../../core/data/remote-data'; +import { RequestEntryState } from '../../../core/data/request-entry-state.model'; +import { + mockAdminNotifyMessages} from '../admin-notify-search-result/admin-notify-search-result.component.spec'; +import { take } from 'rxjs/operators'; +import { deepClone } from 'fast-json-patch'; +import { AdminNotifyMessage } from '../models/admin-notify-message.model'; + +describe('AdminNotifyMessagesService test', () => { + let service: AdminNotifyMessagesService; + let requestService: RequestService; + let rdbService: RemoteDataBuildService; + let objectCache: ObjectCacheService; + let halService: HALEndpointService; + let notificationsService: NotificationsService; + let ldnServicesService: LdnServicesService; + let itemDataService: ItemDataService; + let responseCacheEntry: RequestEntry; + let mockMessages: AdminNotifyMessage[]; + + const endpointURL = `https://rest.api/rest/api/ldn/messages`; + const requestUUID = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a'; + const remoteDataMocks = { + Success: new RemoteData(null, null, null, RequestEntryState.Success, null, null, 200), + }; + const testLdnServiceName = 'testLdnService'; + const testRelatedItemName = 'testRelatedItem'; + + function initTestService() { + return new AdminNotifyMessagesService( + requestService, + rdbService, + objectCache, + halService, + notificationsService, + ldnServicesService, + itemDataService + ); + } + + beforeEach(() => { + mockMessages = deepClone(mockAdminNotifyMessages); + objectCache = {} as ObjectCacheService; + notificationsService = {} as NotificationsService; + responseCacheEntry = new RequestEntry(); + responseCacheEntry.request = { href: endpointURL } as any; + responseCacheEntry.response = new RestResponse(true, 200, 'Success'); + + + requestService = jasmine.createSpyObj('requestService', { + generateRequestId: requestUUID, + send: true, + removeByHrefSubstring: {}, + getByHref: of(responseCacheEntry), + getByUUID: of(responseCacheEntry), + }); + + halService = jasmine.createSpyObj('halService', { + getEndpoint: of(endpointURL) + }); + + rdbService = jasmine.createSpyObj('rdbService', { + buildSingle: createSuccessfulRemoteDataObject$({}, 500), + buildList: cold('a', { a: remoteDataMocks.Success }), + buildFromRequestUUID: createSuccessfulRemoteDataObject$(mockMessages) + }); + + ldnServicesService = jasmine.createSpyObj('ldnServicesService', { + findById: createSuccessfulRemoteDataObject$({name: testLdnServiceName}), + }); + + itemDataService = jasmine.createSpyObj('itemDataService', { + findById: createSuccessfulRemoteDataObject$({name: testRelatedItemName}), + }); + + service = initTestService(); + }); + + describe('Admin Notify service', () => { + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('should get details for messages', (done) => { + service.getDetailedMessages(mockMessages).pipe(take(1)).subscribe((detailedMessages) => { + expect(detailedMessages[0].ldnService).toEqual(testLdnServiceName); + expect(detailedMessages[0].relatedItem).toEqual(testRelatedItemName); + done(); + }); + }); + + it('should reprocess message', (done) => { + const behaviorSubject = new BehaviorSubject(mockMessages); + service.reprocessMessage(mockMessages[0], behaviorSubject).pipe(take(1)).subscribe((reprocessedMessages) => { + expect(reprocessedMessages.length).toEqual(2); + expect(reprocessedMessages).toEqual(mockMessages); + done(); + }); + }); + }); +}); diff --git a/src/app/admin/admin-notify-dashboard/services/admin-notify-messages.service.ts b/src/app/admin/admin-notify-dashboard/services/admin-notify-messages.service.ts new file mode 100644 index 0000000000..ef3f33a97d --- /dev/null +++ b/src/app/admin/admin-notify-dashboard/services/admin-notify-messages.service.ts @@ -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} + */ +@Injectable() +@dataService(ADMIN_NOTIFY_MESSAGE) +export class AdminNotifyMessagesService extends IdentifiableDataService { + + protected reprocessEndpoint = 'enqueueretry'; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + private ldnServicesService: LdnServicesService, + private itemDataService: ItemDataService, + ) { + super('messages', requestService, rdbService, objectCache, halService); + } + + + /** + * Add detailed information to each message + * @param messages the messages to which add detailded info + */ + public getDetailedMessages(messages: AdminNotifyMessage[]): Observable { + return from(messages).pipe( + mergeMap(message => + message.target || message.origin ? this.ldnServicesService.findById((message.target || message.origin).toString()).pipe( + getAllSucceededRemoteDataPayload(), + map(detail => ({...message, ldnService: detail.name})) + ) : of(message), + ), + mergeMap(message => + message.object || message.context ? this.itemDataService.findById(message.object || message.context).pipe( + getAllSucceededRemoteDataPayload(), + map(detail => ({...message, relatedItem: detail.name})) + ) : of(message), + ), + scan((acc: any, value: any) => [...acc, value], []), + ); + } + + /** + * Reprocess message in status QUEUE_STATUS_QUEUED_FOR_RETRY and update results + * @param message the message to reprocess + * @param messageSubject the current visualised messages source + */ + public reprocessMessage(message: AdminNotifyMessage, messageSubject: BehaviorSubject): Observable { + const requestId = this.requestService.generateRequestId(); + + return this.halService.getEndpoint(this.reprocessEndpoint).pipe( + map(endpoint => endpoint.replace('{id}', message.id)), + map((endpointURL: string) => new GetRequest(requestId, endpointURL)), + tap(request => this.requestService.send(request)), + switchMap((request: RestRequest) => this.rdbService.buildFromRequestUUID(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; + }) + )), + ); + } +} diff --git a/src/app/admin/admin-routing-paths.ts b/src/app/admin/admin-routing-paths.ts index 511680bfd8..df8a3eb855 100644 --- a/src/app/admin/admin-routing-paths.ts +++ b/src/app/admin/admin-routing-paths.ts @@ -4,6 +4,8 @@ import { getAdminModuleRoute } from '../app-routing-paths'; export const REGISTRIES_MODULE_PATH = 'registries'; export const NOTIFICATIONS_MODULE_PATH = 'notifications'; +export const NOTIFY_DASHBOARD_MODULE_PATH = 'notify-dashboard'; + export const LDN_PATH = 'ldn'; export function getRegistriesModuleRoute() { diff --git a/src/app/admin/admin-routing.module.ts b/src/app/admin/admin-routing.module.ts index 3acc219bce..801b698d56 100644 --- a/src/app/admin/admin-routing.module.ts +++ b/src/app/admin/admin-routing.module.ts @@ -6,7 +6,12 @@ import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.reso import { AdminWorkflowPageComponent } from './admin-workflow-page/admin-workflow-page.component'; import { I18nBreadcrumbsService } from '../core/breadcrumbs/i18n-breadcrumbs.service'; import { AdminCurationTasksComponent } from './admin-curation-tasks/admin-curation-tasks.component'; -import { 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'; @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), 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: [ diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index eef4f1f68e..26e32d34e7 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -197,6 +197,7 @@ import { 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 { 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, SidebarService, SearchFilterService, - SearchFilterService, SearchConfigurationService, SelectableListService, RelationshipTypeDataService, @@ -410,6 +410,7 @@ export const models = Itemfilter, SubmissionCoarNotifyConfig, NotifyRequestsStatus, + AdminNotifyMessage ]; @NgModule({ diff --git a/src/app/core/shared/context.model.ts b/src/app/core/shared/context.model.ts index b4c02bee63..dcebf5794c 100644 --- a/src/app/core/shared/context.model.ts +++ b/src/app/core/shared/context.model.ts @@ -39,4 +39,6 @@ export enum Context { MyDSpaceValidation = 'mydspaceValidation', Bitstream = 'bitstream', + + CoarNotify = 'coarNotify', } diff --git a/src/app/core/shared/search/search-configuration.service.ts b/src/app/core/shared/search/search-configuration.service.ts index eed93ae201..9d93eced8e 100644 --- a/src/app/core/shared/search/search-configuration.service.ts +++ b/src/app/core/shared/search/search-configuration.service.ts @@ -105,7 +105,7 @@ export class SearchConfigurationService implements OnDestroy { protected linkService: LinkService, protected halService: HALEndpointService, protected requestService: RequestService, - protected rdb: RemoteDataBuildService,) { + protected rdb: RemoteDataBuildService) { this.initDefaults(); } diff --git a/src/app/core/shared/view-mode.model.ts b/src/app/core/shared/view-mode.model.ts index a27cb6954b..9c2f499b3d 100644 --- a/src/app/core/shared/view-mode.model.ts +++ b/src/app/core/shared/view-mode.model.ts @@ -7,4 +7,5 @@ export enum ViewMode { GridElement = 'grid', DetailedListElement = 'detailed', StandalonePage = 'standalone', + Table = 'table', } diff --git a/src/app/menu.resolver.ts b/src/app/menu.resolver.ts index 23ba31b103..15848963c9 100644 --- a/src/app/menu.resolver.ts +++ b/src/app/menu.resolver.ts @@ -362,19 +362,6 @@ export class MenuResolver implements Resolve { icon: 'terminal', 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', active: false, @@ -679,6 +666,41 @@ export class MenuResolver implements Resolve { icon: 'exclamation-circle', 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, { diff --git a/src/app/shared/notification-box/notification-box.component.html b/src/app/shared/notification-box/notification-box.component.html new file mode 100644 index 0000000000..924db37d9d --- /dev/null +++ b/src/app/shared/notification-box/notification-box.component.html @@ -0,0 +1,6 @@ +
+
+
{{ boxConfig.count ?? 0 }}
+
{{ boxConfig.title | translate }}
+
+
diff --git a/src/app/shared/notification-box/notification-box.component.scss b/src/app/shared/notification-box/notification-box.component.scss new file mode 100644 index 0000000000..9813f4aa7a --- /dev/null +++ b/src/app/shared/notification-box/notification-box.component.scss @@ -0,0 +1,7 @@ +.box-container { + min-width: max-content; +} +.box-counter { + font-size: calc(var(--bs-font-size-lg) * 1.5); +} + diff --git a/src/app/shared/notification-box/notification-box.component.spec.ts b/src/app/shared/notification-box/notification-box.component.spec.ts new file mode 100644 index 0000000000..a4118f81e3 --- /dev/null +++ b/src/app/shared/notification-box/notification-box.component.spec.ts @@ -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; + 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(); + }); +}); diff --git a/src/app/shared/notification-box/notification-box.component.ts b/src/app/shared/notification-box/notification-box.component.ts new file mode 100644 index 0000000000..00082e938f --- /dev/null +++ b/src/app/shared/notification-box/notification-box.component.ts @@ -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; +} diff --git a/src/app/shared/object-collection/object-collection.component.html b/src/app/shared/object-collection/object-collection.component.html index 3b0dca80ac..179a70bfd7 100644 --- a/src/app/shared/object-collection/object-collection.component.html +++ b/src/app/shared/object-collection/object-collection.component.html @@ -58,3 +58,19 @@ + + diff --git a/src/app/shared/object-collection/shared/listable-object/listable-object-component-loader.component.spec.ts b/src/app/shared/object-collection/shared/listable-object/listable-object-component-loader.component.spec.ts index e893fe807b..40422d3fce 100644 --- a/src/app/shared/object-collection/shared/listable-object/listable-object-component-loader.component.spec.ts +++ b/src/app/shared/object-collection/shared/listable-object/listable-object-component-loader.component.spec.ts @@ -18,7 +18,7 @@ const testType = 'TestType'; const testContext = Context.Search; const testViewMode = ViewMode.StandalonePage; -class TestType extends ListableObject { +export class TestType extends ListableObject { getRenderTypes(): (string | GenericConstructor)[] { return [testType]; } diff --git a/src/app/shared/object-collection/shared/listable-object/listable-object.decorator.ts b/src/app/shared/object-collection/shared/listable-object/listable-object.decorator.ts index 470bcfcdaf..4f1a04a985 100644 --- a/src/app/shared/object-collection/shared/listable-object/listable-object.decorator.ts +++ b/src/app/shared/object-collection/shared/listable-object/listable-object.decorator.ts @@ -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 more relevant than null */ -class MatchRelevancy { +export class MatchRelevancy { constructor(public match: any, public level: number, public relevancy: number) { @@ -133,7 +133,7 @@ export function getListableObjectComponent(types: (string | GenericConstructor, keys: any[], defaults: any[]): MatchRelevancy { +export function getMatch(typeMap: Map, keys: any[], defaults: any[]): MatchRelevancy { let currentMap = typeMap; let level = -1; let relevancy = 0; diff --git a/src/app/shared/object-collection/shared/objects-collection-tabulatable/objects-collection-tabulatable.component.ts b/src/app/shared/object-collection/shared/objects-collection-tabulatable/objects-collection-tabulatable.component.ts new file mode 100644 index 0000000000..9cc49ad134 --- /dev/null +++ b/src/app/shared/object-collection/shared/objects-collection-tabulatable/objects-collection-tabulatable.component.ts @@ -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> { + + /** + * 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>>(); + + /** + * The available link types + */ + linkTypes = CollectionElementLinkType; + + /** + * The available view modes + */ + viewModes = ViewMode; + + /** + * The available contexts + */ + contexts = Context; + + +} + diff --git a/src/app/shared/object-collection/shared/tabulatable-objects/tabulatable-objects-loader.component.html b/src/app/shared/object-collection/shared/tabulatable-objects/tabulatable-objects-loader.component.html new file mode 100644 index 0000000000..ff51746cb9 --- /dev/null +++ b/src/app/shared/object-collection/shared/tabulatable-objects/tabulatable-objects-loader.component.html @@ -0,0 +1 @@ + diff --git a/src/app/shared/object-collection/shared/tabulatable-objects/tabulatable-objects-loader.component.spec.ts b/src/app/shared/object-collection/shared/tabulatable-objects/tabulatable-objects-loader.component.spec.ts new file mode 100644 index 0000000000..a887482fa7 --- /dev/null +++ b/src/app/shared/object-collection/shared/tabulatable-objects/tabulatable-objects-loader.component.spec.ts @@ -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 { + page: TestType[] = [new TestType()]; +} + + +describe('TabulatableObjectsLoaderComponent', () => { + let component: TabulatableObjectsLoaderComponent; + let fixture: ComponentFixture; + + 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(); + }); +}); diff --git a/src/app/shared/object-collection/shared/tabulatable-objects/tabulatable-objects-loader.component.ts b/src/app/shared/object-collection/shared/tabulatable-objects/tabulatable-objects-loader.component.ts new file mode 100644 index 0000000000..fa57c1200d --- /dev/null +++ b/src/app/shared/object-collection/shared/tabulatable-objects/tabulatable-objects-loader.component.ts @@ -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; + + + /** + * 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>(); + + /** + * Array to track all subscriptions and unsubscribe them onDestroy + * @type {Array} + */ + protected subs: Subscription[] = []; + + /** + * The reference to the dynamic component + */ + protected compRef: ComponentRef; + + /** + * 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, 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>, + ]).subscribe(([simpleChanges, reloadedObjects]: [SimpleChanges, PaginatedList]) => { + 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} + */ + getComponent(renderTypes: (string | GenericConstructor)[], + viewMode: ViewMode, + context: Context): GenericConstructor { + 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]; + }); + } + } + +} diff --git a/src/app/shared/object-collection/shared/tabulatable-objects/tabulatable-objects.decorator.ts b/src/app/shared/object-collection/shared/tabulatable-objects/tabulatable-objects.decorator.ts new file mode 100644 index 0000000000..ae7e71f2fc --- /dev/null +++ b/src/app/shared/object-collection/shared/tabulatable-objects/tabulatable-objects.decorator.ts @@ -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>, 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)[], viewMode: ViewMode, context: Context = DEFAULT_CONTEXT, theme: string = DEFAULT_THEME) { + let currentBestMatch: MatchRelevancy = null; + for (const type of types) { + const typeMap = map.get(PaginatedList); + 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; +} diff --git a/src/app/shared/object-collection/shared/tabulatable-objects/tabulatable-objects.directive.ts b/src/app/shared/object-collection/shared/tabulatable-objects/tabulatable-objects.directive.ts new file mode 100644 index 0000000000..88c12dfe76 --- /dev/null +++ b/src/app/shared/object-collection/shared/tabulatable-objects/tabulatable-objects.directive.ts @@ -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) { } +} diff --git a/src/app/shared/object-list/search-result-list-element/tabulatable-search-result/tabulatable-result-list-elements.component.ts b/src/app/shared/object-list/search-result-list-element/tabulatable-search-result/tabulatable-result-list-elements.component.ts new file mode 100644 index 0000000000..94d132f822 --- /dev/null +++ b/src/app/shared/object-list/search-result-list-element/tabulatable-search-result/tabulatable-result-list-elements.component.ts @@ -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, K extends SearchResult> extends AbstractTabulatableElementComponent {} diff --git a/src/app/shared/object-table/object-table.component.html b/src/app/shared/object-table/object-table.component.html new file mode 100644 index 0000000000..39743a5922 --- /dev/null +++ b/src/app/shared/object-table/object-table.component.html @@ -0,0 +1,33 @@ + +
+
+ + +
+
+ + +
+ + diff --git a/src/app/shared/object-table/object-table.component.scss b/src/app/shared/object-table/object-table.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/shared/object-table/object-table.component.spec.ts b/src/app/shared/object-table/object-table.component.spec.ts new file mode 100644 index 0000000000..b20bed8d89 --- /dev/null +++ b/src/app/shared/object-table/object-table.component.spec.ts @@ -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; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ ObjectTableComponent ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(ObjectTableComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/object-table/object-table.component.ts b/src/app/shared/object-table/object-table.component.ts new file mode 100644 index 0000000000..8d930a27c2 --- /dev/null +++ b/src/app/shared/object-table/object-table.component.ts @@ -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>>; + + /** + * Setter to make sure the observable is turned into an observable + * @param objects The new objects to output + */ + @Input() set objects(objects: RemoteData>) { + 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 = new EventEmitter(); + + /** + * An event fired when the page wsize is changed. + * Event's payload equals to the newly selected page size. + */ + @Output() pageSizeChange: EventEmitter = new EventEmitter(); + + /** + * An event fired when the sort direction is changed. + * Event's payload equals to the newly selected sort direction. + */ + @Output() sortDirectionChange: EventEmitter = new EventEmitter(); + + /** + * An event fired when on of the pagination parameters changes + */ + @Output() paginationChange: EventEmitter = new EventEmitter(); + + /** + * An event fired when the sort field is changed. + * Event's payload equals to the newly selected sort field. + */ + @Output() sortFieldChange: EventEmitter = new EventEmitter(); + + /** + * If showPaginator is set to true, emit when the previous button is clicked + */ + @Output() prev = new EventEmitter(); + + /** + * If showPaginator is set to true, emit when the next button is clicked + */ + @Output() next = new EventEmitter(); + + 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); + } +} diff --git a/src/app/shared/search/search.component.html b/src/app/shared/search/search.component.html index d43f506866..b9525f1318 100644 --- a/src/app/shared/search/search.component.html +++ b/src/app/shared/search/search.component.html @@ -22,7 +22,7 @@
- +