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