diff --git a/config/config.example.yml b/config/config.example.yml index 69a9ffd320f..4b9c1a27ace 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -75,7 +75,7 @@ cache: anonymousCache: # Maximum number of pages to cache. Default is zero (0) which means anonymous user cache is disabled. # As all pages are cached in server memory, increasing this value will increase memory needs. - # Individual cached pages are usually small (<100KB), so a value of max=1000 would only require ~100MB of memory. + # Individual cached pages are usually small (<100KB), so a value of max=1000 would only require ~100MB of memory. max: 0 # Amount of time after which cached pages are considered stale (in ms). After becoming stale, the cached # copy is automatically refreshed on the next request. @@ -382,7 +382,13 @@ vocabularies: vocabulary: 'srsc' enabled: true -# Default collection/community sorting order at Advanced search, Create/update community and collection when there are not a query. +# Default collection/community sorting order at Advanced search, Create/update community and collection when there are not a query. comcolSelectionSort: sortField: 'dc.title' sortDirection: 'ASC' + +# Example of fallback collection for suggestions import +# suggestion: + # - collectionId: 8f7df5ca-f9c2-47a4-81ec-8a6393d6e5af + # source: "openaire" + diff --git a/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page-resolver.service.ts b/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page-resolver.service.ts new file mode 100644 index 00000000000..f524cd56c20 --- /dev/null +++ b/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page-resolver.service.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; + +/** + * Interface for the route parameters. + */ +export interface NotificationsSuggestionTargetsPageParams { + pageId?: string; + pageSize?: number; + currentPage?: number; +} + +/** + * This class represents a resolver that retrieve the route data before the route is activated. + */ +@Injectable() +export class NotificationsSuggestionTargetsPageResolver implements Resolve { + + /** + * Method for resolving the parameters in the current route. + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + * @param {RouterStateSnapshot} state The current RouterStateSnapshot + * @returns AdminNotificationsSuggestionTargetsPageParams Emits the route parameters + */ + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): NotificationsSuggestionTargetsPageParams { + return { + pageId: route.queryParams.pageId, + pageSize: parseInt(route.queryParams.pageSize, 10), + currentPage: parseInt(route.queryParams.page, 10) + }; + } +} diff --git a/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.html b/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.html new file mode 100644 index 00000000000..b04e7132f17 --- /dev/null +++ b/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.html @@ -0,0 +1 @@ + diff --git a/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.scss b/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.spec.ts b/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.spec.ts new file mode 100644 index 00000000000..cec8ddc00b8 --- /dev/null +++ b/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.spec.ts @@ -0,0 +1,40 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { TranslateModule } from '@ngx-translate/core'; +import { + NotificationsSuggestionTargetsPageComponent +} from '../../../quality-assurance-notifications-pages/notifications-suggestion-targets-page/notifications-suggestion-targets-page.component'; + +describe('NotificationsSuggestionTargetsPageComponent', () => { + let component: NotificationsSuggestionTargetsPageComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + CommonModule, + TranslateModule.forRoot() + ], + declarations: [ + NotificationsSuggestionTargetsPageComponent + ], + providers: [ + NotificationsSuggestionTargetsPageComponent + ], + schemas: [NO_ERRORS_SCHEMA] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(NotificationsSuggestionTargetsPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.ts b/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.ts new file mode 100644 index 00000000000..2256a1bc36a --- /dev/null +++ b/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.ts @@ -0,0 +1,10 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'ds-admin-notifications-publication-claim-page', + templateUrl: './admin-notifications-publication-claim-page.component.html', + styleUrls: ['./admin-notifications-publication-claim-page.component.scss'] +}) +export class AdminNotificationsPublicationClaimPageComponent { + +} diff --git a/src/app/admin/admin-notifications/admin-notifications-routing-paths.ts b/src/app/admin/admin-notifications/admin-notifications-routing-paths.ts new file mode 100644 index 00000000000..f92a96d2422 --- /dev/null +++ b/src/app/admin/admin-notifications/admin-notifications-routing-paths.ts @@ -0,0 +1,9 @@ +import { URLCombiner } from '../../core/url-combiner/url-combiner'; +import { getNotificationsModuleRoute } from '../admin-routing-paths'; + +export const QUALITY_ASSURANCE_EDIT_PATH = 'quality-assurance'; +export const PUBLICATION_CLAIMS_PATH = 'publication-claim'; + +export function getQualityAssuranceRoute(id: string) { + return new URLCombiner(getNotificationsModuleRoute(), QUALITY_ASSURANCE_EDIT_PATH, id).toString(); +} diff --git a/src/app/admin/admin-notifications/admin-notifications-routing.module.ts b/src/app/admin/admin-notifications/admin-notifications-routing.module.ts new file mode 100644 index 00000000000..755f2e784ba --- /dev/null +++ b/src/app/admin/admin-notifications/admin-notifications-routing.module.ts @@ -0,0 +1,122 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; + +import { AuthenticatedGuard } from '../../core/auth/authenticated.guard'; +import { I18nBreadcrumbResolver } from '../../core/breadcrumbs/i18n-breadcrumb.resolver'; +import { I18nBreadcrumbsService } from '../../core/breadcrumbs/i18n-breadcrumbs.service'; +import { PUBLICATION_CLAIMS_PATH } from './admin-notifications-routing-paths'; +import { AdminNotificationsPublicationClaimPageComponent } from './admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component'; +import { QUALITY_ASSURANCE_EDIT_PATH } from './admin-notifications-routing-paths'; +import { QualityAssuranceBreadcrumbResolver } from '../../core/breadcrumbs/quality-assurance-breadcrumb.resolver'; +import { QualityAssuranceBreadcrumbService } from '../../core/breadcrumbs/quality-assurance-breadcrumb.service'; +import { + AdminNotificationsPublicationClaimPageResolver +} from '../../quality-assurance-notifications-pages/notifications-suggestion-targets-page/notifications-suggestion-targets-page-resolver.service'; +import { + QualityAssuranceTopicsPageComponent +} from '../../quality-assurance-notifications-pages/quality-assurance-topics-page/quality-assurance-topics-page.component'; +import { + QualityAssuranceTopicsPageResolver +} from '../../quality-assurance-notifications-pages/quality-assurance-topics-page/quality-assurance-topics-page-resolver.service'; +import { + SourceDataResolver +} from '../../quality-assurance-notifications-pages/quality-assurance-source-page-component/quality-assurance-source-data.resolver'; +import { + QualityAssuranceEventsPageResolver +} from '../../quality-assurance-notifications-pages/quality-assurance-events-page/quality-assurance-events-page.resolver'; +import { + QualityAssuranceEventsPageComponent +} from '../../quality-assurance-notifications-pages/quality-assurance-events-page/quality-assurance-events-page.component'; +import { + QualityAssuranceSourcePageResolver +} from '../../quality-assurance-notifications-pages/quality-assurance-source-page-component/quality-assurance-source-page-resolver.service'; +import { + QualityAssuranceSourcePageComponent +} from '../../quality-assurance-notifications-pages/quality-assurance-source-page-component/quality-assurance-source-page.component'; + + +@NgModule({ + imports: [ + RouterModule.forChild([ + { + canActivate: [ AuthenticatedGuard ], + path: `${PUBLICATION_CLAIMS_PATH}`, + component: AdminNotificationsPublicationClaimPageComponent, + pathMatch: 'full', + resolve: { + breadcrumb: I18nBreadcrumbResolver, + suggestionTargetParams: AdminNotificationsPublicationClaimPageResolver + }, + data: { + title: 'admin.notifications.publicationclaim.page.title', + breadcrumbKey: 'admin.notifications.publicationclaim', + showBreadcrumbsFluid: false + } + }, + { + canActivate: [ AuthenticatedGuard ], + path: `${QUALITY_ASSURANCE_EDIT_PATH}/:sourceId`, + component: QualityAssuranceTopicsPageComponent, + pathMatch: 'full', + resolve: { + breadcrumb: QualityAssuranceBreadcrumbResolver, + openaireQualityAssuranceTopicsParams: QualityAssuranceTopicsPageResolver + }, + data: { + title: 'admin.quality-assurance.page.title', + breadcrumbKey: 'admin.quality-assurance', + showBreadcrumbsFluid: false + } + }, + { + canActivate: [ AuthenticatedGuard ], + path: `${QUALITY_ASSURANCE_EDIT_PATH}`, + component: QualityAssuranceSourcePageComponent, + pathMatch: 'full', + resolve: { + breadcrumb: I18nBreadcrumbResolver, + openaireQualityAssuranceSourceParams: QualityAssuranceSourcePageResolver, + sourceData: SourceDataResolver + }, + data: { + title: 'admin.notifications.source.breadcrumbs', + breadcrumbKey: 'admin.notifications.source', + showBreadcrumbsFluid: false + } + }, + { + canActivate: [ AuthenticatedGuard ], + path: `${QUALITY_ASSURANCE_EDIT_PATH}/:sourceId/:topicId`, + component: QualityAssuranceEventsPageComponent, + pathMatch: 'full', + resolve: { + breadcrumb: QualityAssuranceBreadcrumbResolver, + openaireQualityAssuranceEventsParams: QualityAssuranceEventsPageResolver + }, + data: { + title: 'admin.notifications.event.page.title', + breadcrumbKey: 'admin.notifications.event', + showBreadcrumbsFluid: false + } + } + ]) + ], + providers: [ + I18nBreadcrumbResolver, + I18nBreadcrumbsService, + AdminNotificationsPublicationClaimPageResolver, + SourceDataResolver, + QualityAssuranceSourcePageResolver, + QualityAssuranceTopicsPageResolver, + QualityAssuranceEventsPageResolver, + QualityAssuranceSourcePageResolver, + QualityAssuranceBreadcrumbResolver, + QualityAssuranceBreadcrumbService + ] +}) +/** + * Routing module for the Notifications section of the admin sidebar + */ +export class AdminNotificationsRoutingModule { + +} diff --git a/src/app/admin/admin-notifications/admin-notifications.module.ts b/src/app/admin/admin-notifications/admin-notifications.module.ts new file mode 100644 index 00000000000..ea670d222c0 --- /dev/null +++ b/src/app/admin/admin-notifications/admin-notifications.module.ts @@ -0,0 +1,33 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { CoreModule } from '../../core/core.module'; +import { SharedModule } from '../../shared/shared.module'; +import { AdminNotificationsRoutingModule } from './admin-notifications-routing.module'; +import { AdminNotificationsPublicationClaimPageComponent } from './admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component'; +import { NotificationsModule } from '../../notifications/notifications.module'; + + + + + + + +@NgModule({ + imports: [ + CommonModule, + SharedModule, + CoreModule.forRoot(), + AdminNotificationsRoutingModule, + NotificationsModule + ], + declarations: [ + AdminNotificationsPublicationClaimPageComponent, + ], + entryComponents: [] +}) +/** + * This module handles all components related to the notifications pages + */ +export class AdminNotificationsModule { + +} diff --git a/src/app/admin/admin-routing-paths.ts b/src/app/admin/admin-routing-paths.ts index 064e28f594a..508d352c50d 100644 --- a/src/app/admin/admin-routing-paths.ts +++ b/src/app/admin/admin-routing-paths.ts @@ -18,3 +18,8 @@ export function getLdnServicesModuleRoute() { export function getNotificatioQualityAssuranceRoute() { return new URLCombiner(`/${NOTIFICATIONS_MODULE_PATH}`, getQualityAssuranceEditRoute()).toString(); } + + +export function getNotificationsModuleRoute() { + return new URLCombiner(getAdminModuleRoute(), NOTIFICATIONS_MODULE_PATH).toString(); +} diff --git a/src/app/admin/admin-routing.module.ts b/src/app/admin/admin-routing.module.ts index b590df69ca2..23350e40430 100644 --- a/src/app/admin/admin-routing.module.ts +++ b/src/app/admin/admin-routing.module.ts @@ -6,12 +6,17 @@ 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 } from './admin-routing-paths'; +import { LDN_PATH, NOTIFICATIONS_MODULE_PATH, REGISTRIES_MODULE_PATH } from './admin-routing-paths'; import { BatchImportPageComponent } from './admin-import-batch-page/batch-import-page.component'; @NgModule({ imports: [ RouterModule.forChild([ + { + path: NOTIFICATIONS_MODULE_PATH, + loadChildren: () => import('./admin-notifications/admin-notifications.module') + .then((m) => m.AdminNotificationsModule), + }, { path: REGISTRIES_MODULE_PATH, loadChildren: () => import('./admin-registries/admin-registries.module') diff --git a/src/app/core/breadcrumbs/publication-claim-breadcrumb.resolver.spec.ts b/src/app/core/breadcrumbs/publication-claim-breadcrumb.resolver.spec.ts new file mode 100644 index 00000000000..b6f41424693 --- /dev/null +++ b/src/app/core/breadcrumbs/publication-claim-breadcrumb.resolver.spec.ts @@ -0,0 +1,31 @@ +import { PublicationClaimBreadcrumbResolver } from './publication-claim-breadcrumb.resolver'; + +describe('PublicationClaimBreadcrumbResolver', () => { + describe('resolve', () => { + let resolver: PublicationClaimBreadcrumbResolver; + let publicationClaimBreadcrumbService: any; + const fullPath = '/test/publication-claim/openaire:6bee076d-4f2a-4555-a475-04a267769b2a'; + const expectedKey = '6bee076d-4f2a-4555-a475-04a267769b2a'; + const expectedId = 'openaire:6bee076d-4f2a-4555-a475-04a267769b2a'; + let route; + + beforeEach(() => { + route = { + paramMap: { + get: function (param) { + return this[param]; + }, + targetId: expectedId, + } + }; + publicationClaimBreadcrumbService = {}; + resolver = new PublicationClaimBreadcrumbResolver(publicationClaimBreadcrumbService); + }); + + it('should resolve the breadcrumb config', () => { + const resolvedConfig = resolver.resolve(route as any, {url: fullPath } as any); + const expectedConfig = { provider: publicationClaimBreadcrumbService, key: expectedKey }; + expect(resolvedConfig).toEqual(expectedConfig); + }); + }); +}); diff --git a/src/app/core/breadcrumbs/publication-claim-breadcrumb.resolver.ts b/src/app/core/breadcrumbs/publication-claim-breadcrumb.resolver.ts new file mode 100644 index 00000000000..713500d6a73 --- /dev/null +++ b/src/app/core/breadcrumbs/publication-claim-breadcrumb.resolver.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; +import {BreadcrumbConfig} from '../../breadcrumbs/breadcrumb/breadcrumb-config.model'; +import { PublicationClaimBreadcrumbService } from './publication-claim-breadcrumb.service'; + +@Injectable({ + providedIn: 'root' +}) +export class PublicationClaimBreadcrumbResolver implements Resolve> { + constructor(protected breadcrumbService: PublicationClaimBreadcrumbService) { + } + + /** + * Method that resolve Publication Claim item into a breadcrumb + * The parameter are retrieved by the url since part of the Publication Claim route config + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + * @param {RouterStateSnapshot} state The current RouterStateSnapshot + * @returns BreadcrumbConfig object + */ + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): BreadcrumbConfig { + const targetId = route.paramMap.get('targetId').split(':')[1]; + return { provider: this.breadcrumbService, key: targetId }; + } +} diff --git a/src/app/core/breadcrumbs/publication-claim-breadcrumb.service.spec.ts b/src/app/core/breadcrumbs/publication-claim-breadcrumb.service.spec.ts new file mode 100644 index 00000000000..11062210bb3 --- /dev/null +++ b/src/app/core/breadcrumbs/publication-claim-breadcrumb.service.spec.ts @@ -0,0 +1,51 @@ +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; +import { getTestScheduler } from 'jasmine-marbles'; +import { PublicationClaimBreadcrumbService } from './publication-claim-breadcrumb.service'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { of } from 'rxjs'; + +describe('PublicationClaimBreadcrumbService', () => { + let service: PublicationClaimBreadcrumbService; + let dsoNameService: any = { + getName: (str) => str + }; + let translateService: any = { + instant: (str) => str, + }; + + let dataService: any = { + findById: (str) => createSuccessfulRemoteDataObject$(str), + }; + + let authorizationService: any = { + isAuthorized: (str) => of(true), + }; + + let exampleKey; + + const ADMIN_PUBLICATION_CLAIMS_PATH = 'admin/notifications/publication-claim'; + const ADMIN_PUBLICATION_CLAIMS_BREADCRUMB_KEY = 'admin.notifications.publicationclaim.page.title'; + + function init() { + exampleKey = 'suggestion.suggestionFor.breadcrumb'; + } + + beforeEach(waitForAsync(() => { + init(); + TestBed.configureTestingModule({}).compileComponents(); + })); + + beforeEach(() => { + service = new PublicationClaimBreadcrumbService(dataService,dsoNameService,translateService, authorizationService); + }); + + describe('getBreadcrumbs', () => { + it('should return a breadcrumb based on a string', () => { + const breadcrumbs = service.getBreadcrumbs(exampleKey); + getTestScheduler().expectObservable(breadcrumbs).toBe('(a|)', { a: [new Breadcrumb(ADMIN_PUBLICATION_CLAIMS_BREADCRUMB_KEY, ADMIN_PUBLICATION_CLAIMS_PATH), + new Breadcrumb(exampleKey, undefined)] + }); + }); + }); +}); diff --git a/src/app/core/breadcrumbs/publication-claim-breadcrumb.service.ts b/src/app/core/breadcrumbs/publication-claim-breadcrumb.service.ts new file mode 100644 index 00000000000..1a87fd7de60 --- /dev/null +++ b/src/app/core/breadcrumbs/publication-claim-breadcrumb.service.ts @@ -0,0 +1,46 @@ +import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; +import { BreadcrumbsProviderService } from './breadcrumbsProviderService'; +import { combineLatest, Observable } from 'rxjs'; +import { Injectable } from '@angular/core'; +import { ItemDataService } from '../data/item-data.service'; +import { getFirstCompletedRemoteData } from '../shared/operators'; +import { map } from 'rxjs/operators'; +import { DSONameService } from './dso-name.service'; +import { TranslateService } from '@ngx-translate/core'; +import { AuthorizationDataService } from '../data/feature-authorization/authorization-data.service'; +import { FeatureID } from '../data/feature-authorization/feature-id'; + + + +/** + * Service to calculate Publication claims breadcrumbs + */ +@Injectable({ + providedIn: 'root' +}) +export class PublicationClaimBreadcrumbService implements BreadcrumbsProviderService { + private ADMIN_PUBLICATION_CLAIMS_PATH = 'admin/notifications/publication-claim'; + private ADMIN_PUBLICATION_CLAIMS_BREADCRUMB_KEY = 'admin.notifications.publicationclaim.page.title'; + + constructor(private dataService: ItemDataService, + private dsoNameService: DSONameService, + private tranlsateService: TranslateService, + protected authorizationService: AuthorizationDataService) { + } + + + /** + * Method to calculate the breadcrumbs + * @param key The key used to resolve the breadcrumb + */ + getBreadcrumbs(key: string): Observable { + return combineLatest([this.dataService.findById(key).pipe(getFirstCompletedRemoteData()),this.authorizationService.isAuthorized(FeatureID.AdministratorOf)]).pipe( + map(([item, isAdmin]) => { + const itemName = this.dsoNameService.getName(item.payload); + return isAdmin ? [new Breadcrumb(this.tranlsateService.instant(this.ADMIN_PUBLICATION_CLAIMS_BREADCRUMB_KEY), this.ADMIN_PUBLICATION_CLAIMS_PATH), + new Breadcrumb(this.tranlsateService.instant('suggestion.suggestionFor.breadcrumb', {name: itemName}), undefined)] : + [new Breadcrumb(this.tranlsateService.instant('suggestion.suggestionFor.breadcrumb', {name: itemName}), undefined)]; + }) + ); + } +} diff --git a/src/app/core/cache/builders/build-decorators.spec.ts b/src/app/core/cache/builders/build-decorators.spec.ts index 150a07f0066..e4baaa4a5a8 100644 --- a/src/app/core/cache/builders/build-decorators.spec.ts +++ b/src/app/core/cache/builders/build-decorators.spec.ts @@ -1,7 +1,7 @@ import { HALLink } from '../../shared/hal-link.model'; import { HALResource } from '../../shared/hal-resource.model'; import { ResourceType } from '../../shared/resource-type'; -import { getLinkDefinition, link } from './build-decorators'; +import { dataService, getDataServiceFor, getLinkDefinition, link } from './build-decorators'; class TestHALResource implements HALResource { _links: { @@ -46,5 +46,17 @@ describe('build decorators', () => { expect(result).toBeUndefined(); }); }); + + describe(`set data service`, () => { + it(`should throw error`, () => { + expect(dataService(null)).toThrow(); + }); + + it(`should set properly data service for type`, () => { + const target = new TestHALResource(); + dataService(testType)(target); + expect(getDataServiceFor(testType)).toEqual(target); + }); + }); }); }); diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 5df1908642a..dd8419634f7 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -185,8 +185,6 @@ import { FlatBrowseDefinition } from './shared/flat-browse-definition.model'; import { ValueListBrowseDefinition } from './shared/value-list-browse-definition.model'; import { NonHierarchicalBrowseDefinition } from './shared/non-hierarchical-browse-definition'; import { BulkAccessConditionOptions } from './config/models/bulk-access-condition-options.model'; -import { SuggestionTarget } from './notifications/reciter-suggestions/models/suggestion-target.model'; -import { SuggestionSource } from './notifications/reciter-suggestions/models/suggestion-source.model'; import { LdnServicesService } from '../admin/admin-ldn-services/ldn-services-data/ldn-services-data.service'; import { LdnService } from '../admin/admin-ldn-services/ldn-services-model/ldn-services.model'; import { LdnItemfiltersService } from '../admin/admin-ldn-services/ldn-services-data/ldn-itemfilters-data.service'; @@ -197,6 +195,8 @@ 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 { SuggestionTarget } from './notifications/models/suggestion-target.model'; +import { SuggestionSource } from './notifications/models/suggestion-source.model'; /** diff --git a/src/app/core/data/base/base-data.service.ts b/src/app/core/data/base/base-data.service.ts index c7cd5b0a705..84b1686024b 100644 --- a/src/app/core/data/base/base-data.service.ts +++ b/src/app/core/data/base/base-data.service.ts @@ -29,11 +29,11 @@ export const EMBED_SEPARATOR = '%2F'; /** * Common functionality for data services. * Specific functionality that not all services would need - * is implemented in "DataService feature" classes (e.g. {@link CreateData} + * is implemented in "UpdateDataServiceImpl feature" classes (e.g. {@link CreateData} * - * All DataService (or DataService feature) classes must + * All UpdateDataServiceImpl (or UpdateDataServiceImpl feature) classes must * - extend this class (or {@link IdentifiableDataService}) - * - implement any DataService features it requires in order to forward calls to it + * - implement any UpdateDataServiceImpl features it requires in order to forward calls to it * * ``` * export class SomeDataService extends BaseDataService implements CreateData, SearchData { @@ -385,7 +385,7 @@ export class BaseDataService implements HALDataServic /** * Return the links to traverse from the root of the api to the - * endpoint this DataService represents + * endpoint this UpdateDataServiceImpl represents * * e.g. if the api root links to 'foo', and the endpoint at 'foo' * links to 'bar' the linkPath for the BarDataService would be diff --git a/src/app/core/data/base/create-data.ts b/src/app/core/data/base/create-data.ts index 3ffcd9adf20..d2e009f6698 100644 --- a/src/app/core/data/base/create-data.ts +++ b/src/app/core/data/base/create-data.ts @@ -37,7 +37,7 @@ export interface CreateData { } /** - * A DataService feature to create objects. + * A UpdateDataServiceImpl feature to create objects. * * Concrete data services can use this feature by implementing {@link CreateData} * and delegating its method to an inner instance of this class. diff --git a/src/app/core/data/base/find-all-data.ts b/src/app/core/data/base/find-all-data.ts index 57884e537e5..bc0c1fb613e 100644 --- a/src/app/core/data/base/find-all-data.ts +++ b/src/app/core/data/base/find-all-data.ts @@ -42,7 +42,7 @@ export interface FindAllData { } /** - * A DataService feature to list all objects. + * A UpdateDataServiceImpl feature to list all objects. * * Concrete data services can use this feature by implementing {@link FindAllData} * and delegating its method to an inner instance of this class. diff --git a/src/app/core/data/base/patch-data.ts b/src/app/core/data/base/patch-data.ts index e30c394a347..1f93671458f 100644 --- a/src/app/core/data/base/patch-data.ts +++ b/src/app/core/data/base/patch-data.ts @@ -54,7 +54,7 @@ export interface PatchData { } /** - * A DataService feature to patch and update objects. + * A UpdateDataServiceImpl feature to patch and update objects. * * Concrete data services can use this feature by implementing {@link PatchData} * and delegating its method to an inner instance of this class. diff --git a/src/app/core/data/base/put-data.ts b/src/app/core/data/base/put-data.ts index bd2a8d29299..66ae73405eb 100644 --- a/src/app/core/data/base/put-data.ts +++ b/src/app/core/data/base/put-data.ts @@ -31,7 +31,7 @@ export interface PutData { } /** - * A DataService feature to send PUT requests. + * A UpdateDataServiceImpl feature to send PUT requests. * * Concrete data services can use this feature by implementing {@link PutData} * and delegating its method to an inner instance of this class. diff --git a/src/app/core/data/base/search-data.ts b/src/app/core/data/base/search-data.ts index 536d6d6e254..ff0b4929458 100644 --- a/src/app/core/data/base/search-data.ts +++ b/src/app/core/data/base/search-data.ts @@ -51,7 +51,7 @@ export interface SearchData { } /** - * A DataService feature to search for objects. + * A UpdateDataServiceImpl feature to search for objects. * * Concrete data services can use this feature by implementing {@link SearchData} * and delegating its method to an inner instance of this class. diff --git a/src/app/core/data/href-only-data.service.ts b/src/app/core/data/href-only-data.service.ts index 0a765de101b..8393d714605 100644 --- a/src/app/core/data/href-only-data.service.ts +++ b/src/app/core/data/href-only-data.service.ts @@ -17,8 +17,8 @@ import { HALDataService } from './base/hal-data-service.interface'; import { dataService } from './base/data-service.decorator'; /** - * A DataService with only findByHref methods. Its purpose is to be used for resources that don't - * need to be retrieved by ID, or have any way to update them, but require a DataService in order + * A UpdateDataServiceImpl with only findByHref methods. Its purpose is to be used for resources that don't + * need to be retrieved by ID, or have any way to update them, but require a UpdateDataServiceImpl in order * for their links to be resolved by the LinkService. * * an @dataService annotation can be added for any number of these resource types diff --git a/src/app/core/data/update-data.service.spec.ts b/src/app/core/data/update-data.service.spec.ts new file mode 100644 index 00000000000..426fa87eb6d --- /dev/null +++ b/src/app/core/data/update-data.service.spec.ts @@ -0,0 +1,144 @@ +import { of as observableOf } from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { RequestService } from './request.service'; +import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { HrefOnlyDataService } from './href-only-data.service'; +import { getMockHrefOnlyDataService } from '../../shared/mocks/href-only-data.service.mock'; +import { RestResponse } from '../cache/response.models'; +import { cold, getTestScheduler, hot } from 'jasmine-marbles'; +import { Item } from '../shared/item.model'; +import { Version } from '../shared/version.model'; +import { VersionHistory } from '../shared/version-history.model'; +import { RequestEntry } from './request-entry.model'; +import { testPatchDataImplementation } from './base/patch-data.spec'; +import { UpdateDataServiceImpl } from './update-data.service'; +import { testSearchDataImplementation } from './base/search-data.spec'; +import { testDeleteDataImplementation } from './base/delete-data.spec'; +import { testCreateDataImplementation } from './base/create-data.spec'; +import { testFindAllDataImplementation } from './base/find-all-data.spec'; +import { testPutDataImplementation } from './base/put-data.spec'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; + +describe('VersionDataService test', () => { + let scheduler: TestScheduler; + let service: UpdateDataServiceImpl; + let requestService: RequestService; + let rdbService: RemoteDataBuildService; + let objectCache: ObjectCacheService; + let halService: HALEndpointService; + let hrefOnlyDataService: HrefOnlyDataService; + let responseCacheEntry: RequestEntry; + + const notificationsService = {} as NotificationsService; + + const item = Object.assign(new Item(), { + id: '1234-1234', + uuid: '1234-1234', + bundles: observableOf({}), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'dc.type': [ + { + language: null, + value: 'Article' + } + ], + 'dc.contributor.author': [ + { + language: 'en_US', + value: 'Smith, Donald' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '2015-06-26' + } + ] + } + }); + + const versionHistory = Object.assign(new VersionHistory(), { + id: '1', + draftVersion: true, + }); + + const mockVersion: Version = Object.assign(new Version(), { + item: createSuccessfulRemoteDataObject$(item), + versionhistory: createSuccessfulRemoteDataObject$(versionHistory), + version: 1, + }); + const mockVersionRD = createSuccessfulRemoteDataObject(mockVersion); + + const endpointURL = `https://rest.api/rest/api/versioning/versions`; + const requestUUID = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a'; + + objectCache = {} as ObjectCacheService; + const comparatorEntry = {} as any; + function initTestService() { + hrefOnlyDataService = getMockHrefOnlyDataService(); + return new UpdateDataServiceImpl( + 'testLinkPath', + requestService, + rdbService, + objectCache, + halService, + notificationsService, + comparatorEntry, + 10 * 1000 + ); + } + + beforeEach(() => { + + scheduler = getTestScheduler(); + + halService = jasmine.createSpyObj('halService', { + getEndpoint: cold('a', { a: endpointURL }) + }); + responseCacheEntry = new RequestEntry(); + responseCacheEntry.request = { href: 'https://rest.api/' } as any; + responseCacheEntry.response = new RestResponse(true, 200, 'Success'); + + requestService = jasmine.createSpyObj('requestService', { + generateRequestId: requestUUID, + send: true, + removeByHrefSubstring: {}, + getByHref: observableOf(responseCacheEntry), + getByUUID: observableOf(responseCacheEntry), + }); + rdbService = jasmine.createSpyObj('rdbService', { + buildSingle: hot('(a|)', { + a: mockVersionRD + }) + }); + + service = initTestService(); + + spyOn((service as any), 'findById').and.callThrough(); + }); + + afterEach(() => { + service = null; + }); + + describe('composition', () => { + const initService = () => new UpdateDataServiceImpl(null, null, null, null, null, null, null, null); + + testPatchDataImplementation(initService); + testSearchDataImplementation(initService); + testDeleteDataImplementation(initService); + testCreateDataImplementation(initService); + testFindAllDataImplementation(initService); + testPutDataImplementation(initService); + }); + +}); diff --git a/src/app/core/data/update-data.service.ts b/src/app/core/data/update-data.service.ts index 9f707a82da9..715b2ee413c 100644 --- a/src/app/core/data/update-data.service.ts +++ b/src/app/core/data/update-data.service.ts @@ -1,13 +1,317 @@ -import { Observable } from 'rxjs'; +import { Operation } from 'fast-json-patch'; +import { AsyncSubject, from as observableFrom, Observable } from 'rxjs'; +import { + find, + map, + mergeMap, + switchMap, + take, + toArray +} from 'rxjs/operators'; +import { hasValue } from '../../shared/empty.util'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { RequestParam } from '../cache/models/request-param.model'; +import { ObjectCacheEntry } from '../cache/object-cache.reducer'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { ChangeAnalyzer } from './change-analyzer'; +import { PaginatedList } from './paginated-list.model'; import { RemoteData } from './remote-data'; +import { + DeleteByIDRequest, + PostRequest +} from './request.models'; +import { RequestService } from './request.service'; import { RestRequestMethod } from './rest-request-method'; -import { Operation } from 'fast-json-patch'; +import { NoContent } from '../shared/NoContent.model'; +import { CacheableObject } from '../cache/cacheable-object.model'; +import { FindListOptions } from './find-list-options.model'; +import { FindAllData, FindAllDataImpl } from './base/find-all-data'; +import { SearchData, SearchDataImpl } from './base/search-data'; +import { CreateData, CreateDataImpl } from './base/create-data'; +import { PatchData, PatchDataImpl } from './base/patch-data'; +import { IdentifiableDataService } from './base/identifiable-data.service'; +import { PutData, PutDataImpl } from './base/put-data'; +import { DeleteData, DeleteDataImpl } from './base/delete-data'; + /** - * Represents a data service to update a given object + * Interface to list the methods used by the injected service in components */ export interface UpdateDataService { patch(dso: T, operations: Operation[]): Observable>; update(object: T): Observable>; - commitUpdates(method?: RestRequestMethod); + commitUpdates(method?: RestRequestMethod): void; +} + + +/** + * Specific functionalities that not all services would need. + * Goal of the class is to update remote objects, handling custom methods that don't belong to BaseDataService + * The class implements also the following common interfaces + * + * findAllData: FindAllData; + * searchData: SearchData; + * createData: CreateData; + * patchData: PatchData; + * putData: PutData; + * deleteData: DeleteData; + * + * Custom methods are: + * + * deleteOnRelated - delete all related objects to the given one + * postOnRelated - post all the related objects to the given one + * invalidate - invalidate the DSpaceObject making all requests as stale + * invalidateByHref - invalidate the href making all requests as stale + */ + +export class UpdateDataServiceImpl extends IdentifiableDataService implements FindAllData, SearchData, CreateData, PatchData, PutData, DeleteData { + private findAllData: FindAllDataImpl; + private searchData: SearchDataImpl; + private createData: CreateDataImpl; + private patchData: PatchDataImpl; + private putData: PutDataImpl; + private deleteData: DeleteDataImpl; + + + constructor( + protected linkPath: string, + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected comparator: ChangeAnalyzer, + protected responseMsToLive: number, + ) { + super(linkPath, requestService, rdbService, objectCache, halService, responseMsToLive); + this.findAllData = new FindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + this.createData = new CreateDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService ,this.responseMsToLive); + this.patchData = new PatchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, comparator ,this.responseMsToLive, this.constructIdEndpoint); + this.putData = new PutDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + this.deleteData = new DeleteDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService ,this.responseMsToLive, this.constructIdEndpoint); + } + + + /** + * Create the HREF with given options object + * + * @param options The [[FindListOptions]] object + * @param linkPath The link path for the object + * @return {Observable} + * Return an observable that emits created HREF + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + public getFindAllHref(options: FindListOptions = {}, linkPath?: string, ...linksToFollow: FollowLinkConfig[]): Observable { + return this.findAllData.getFindAllHref(options, linkPath, ...linksToFollow); + } + + /** + * Create the HREF for a specific object's search method with given options object + * + * @param searchMethod The search method for the object + * @param options The [[FindListOptions]] object + * @return {Observable} + * Return an observable that emits created HREF + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + public getSearchByHref(searchMethod: string, options: FindListOptions = {}, ...linksToFollow: FollowLinkConfig[]): Observable { + return this.searchData.getSearchByHref(searchMethod, options, ...linksToFollow); + } + + /** + * Returns {@link RemoteData} of all object with a list of {@link FollowLinkConfig}, to indicate which embedded + * info should be added to the objects + * + * @param options Find list options object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + * @return {Observable>>} + * Return an observable that emits object list + */ + findAll(options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * Make a new FindListRequest with given search method + * + * @param searchMethod The search method for the object + * @param options The [[FindListOptions]] object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + * @return {Observable>} + * Return an observable that emits response from the server + */ + searchBy(searchMethod: string, options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * Send a patch request for a specified object + * @param {T} object The object to send a patch request for + * @param {Operation[]} operations The patch operations to be performed + */ + patch(object: T, operations: Operation[]): Observable> { + return this.patchData.patch(object, operations); + } + + createPatchFromCache(object: T): Observable { + return this.patchData.createPatchFromCache(object); + } + + /** + * Send a PUT request for the specified object + * + * @param object The object to send a put request for. + */ + put(object: T): Observable> { + return this.putData.put(object); + } + + /** + * Add a new patch to the object cache + * The patch is derived from the differences between the given object and its version in the object cache + * @param {DSpaceObject} object The given object + */ + update(object: T): Observable> { + return this.patchData.update(object); + } + + /** + * Create a new DSpaceObject on the server, and store the response + * in the object cache + * + * @param {CacheableObject} object + * The object to create + * @param {RequestParam[]} params + * Array with additional params to combine with query string + */ + create(object: T, ...params: RequestParam[]): Observable> { + return this.createData.create(object, ...params); + } + + /** + <<<<<<< HEAD + * Perform a post on an endpoint related item with ID. Ex.: endpoint//related?item= + * @param itemId The item id + * @param relatedItemId The related item Id + * @param body The optional POST body + * @return the RestResponse as an Observable + */ + public postOnRelated(itemId: string, relatedItemId: string, body?: any) { + const requestId = this.requestService.generateRequestId(); + const hrefObs = this.getIDHrefObs(itemId); + + hrefObs.pipe( + take(1) + ).subscribe((href: string) => { + const request = new PostRequest(requestId, href + '/related?item=' + relatedItemId, body); + if (hasValue(this.responseMsToLive)) { + request.responseMsToLive = this.responseMsToLive; + } + this.requestService.send(request); + }); + + return this.rdbService.buildFromRequestUUID(requestId); + } + + /** + * Perform a delete on an endpoint related item. Ex.: endpoint//related + * @param itemId The item id + * @return the RestResponse as an Observable + */ + public deleteOnRelated(itemId: string): Observable> { + const requestId = this.requestService.generateRequestId(); + const hrefObs = this.getIDHrefObs(itemId); + + hrefObs.pipe( + find((href: string) => hasValue(href)), + map((href: string) => { + const request = new DeleteByIDRequest(requestId, href + '/related', itemId); + if (hasValue(this.responseMsToLive)) { + request.responseMsToLive = this.responseMsToLive; + } + this.requestService.send(request); + }) + ).subscribe(); + + return this.rdbService.buildFromRequestUUID(requestId); + } + + /* + * Invalidate an existing DSpaceObject by marking all requests it is included in as stale + * @param objectId The id of the object to be invalidated + * @return An Observable that will emit `true` once all requests are stale + */ + invalidate(objectId: string): Observable { + return this.getIDHrefObs(objectId).pipe( + switchMap((href: string) => this.invalidateByHref(href)) + ); + } + + /** + * Invalidate an existing DSpaceObject by marking all requests it is included in as stale + * @param href The self link of the object to be invalidated + * @return An Observable that will emit `true` once all requests are stale + */ + invalidateByHref(href: string): Observable { + const done$ = new AsyncSubject(); + + this.objectCache.getByHref(href).pipe( + switchMap((oce: ObjectCacheEntry) => observableFrom(oce.requestUUIDs).pipe( + mergeMap((requestUUID: string) => this.requestService.setStaleByUUID(requestUUID)), + toArray(), + )), + ).subscribe(() => { + done$.next(true); + done$.complete(); + }); + + return done$; + } + + /** + * Delete an existing DSpace Object on the server + * @param objectId The id of the object to be removed + * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual + * metadata should be saved as real metadata + * @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode, + * errorMessage, timeCompleted, etc + */ + delete(objectId: string, copyVirtualMetadata?: string[]): Observable> { + return this.deleteData.delete(objectId, copyVirtualMetadata); + } + + /** + * Delete an existing DSpace Object on the server + * @param href The self link of the object to be removed + * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual + * metadata should be saved as real metadata + * @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode, + * errorMessage, timeCompleted, etc + * Only emits once all request related to the DSO has been invalidated. + */ + deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable> { + return this.deleteData.deleteByHref(href, copyVirtualMetadata); + } + + /** + * Commit current object changes to the server + * @param method The RestRequestMethod for which de server sync buffer should be committed + */ + commitUpdates(method?: RestRequestMethod) { + this.patchData.commitUpdates(method); + } } diff --git a/src/app/core/data/version-data.service.spec.ts b/src/app/core/data/version-data.service.spec.ts index dd3f9eec945..0579b998b87 100644 --- a/src/app/core/data/version-data.service.spec.ts +++ b/src/app/core/data/version-data.service.spec.ts @@ -128,7 +128,7 @@ describe('VersionDataService test', () => { }); describe('getHistoryFromVersion', () => { - it('should proxy the call to DataService.findByHref', () => { + it('should proxy the call to UpdateDataServiceImpl.findByHref', () => { scheduler.schedule(() => service.getHistoryFromVersion(mockVersion, true, true)); scheduler.flush(); diff --git a/src/app/core/eperson/eperson-data.service.spec.ts b/src/app/core/eperson/eperson-data.service.spec.ts index c1bc3563a32..cbddf1e6c3e 100644 --- a/src/app/core/eperson/eperson-data.service.spec.ts +++ b/src/app/core/eperson/eperson-data.service.spec.ts @@ -315,7 +315,7 @@ describe('EPersonDataService', () => { service.deleteEPerson(EPersonMock).subscribe(); }); - it('should call DataService.delete with the EPerson\'s UUID', () => { + it('should call UpdateDataServiceImpl.delete with the EPerson\'s UUID', () => { expect(service.delete).toHaveBeenCalledWith(EPersonMock.id); }); }); diff --git a/src/app/core/notifications/reciter-suggestions/models/suggestion-objects.resource-type.ts b/src/app/core/notifications/models/suggestion-objects.resource-type.ts similarity index 76% rename from src/app/core/notifications/reciter-suggestions/models/suggestion-objects.resource-type.ts rename to src/app/core/notifications/models/suggestion-objects.resource-type.ts index 8f87027a8c5..8f83d86376b 100644 --- a/src/app/core/notifications/reciter-suggestions/models/suggestion-objects.resource-type.ts +++ b/src/app/core/notifications/models/suggestion-objects.resource-type.ts @@ -1,4 +1,4 @@ -import { ResourceType } from '../../../shared/resource-type'; +import { ResourceType } from '../../shared/resource-type'; /** * The resource type for the Suggestion object diff --git a/src/app/core/notifications/reciter-suggestions/models/suggestion-source-object.resource-type.ts b/src/app/core/notifications/models/suggestion-source-object.resource-type.ts similarity index 77% rename from src/app/core/notifications/reciter-suggestions/models/suggestion-source-object.resource-type.ts rename to src/app/core/notifications/models/suggestion-source-object.resource-type.ts index 2e26fe4301c..e319ed5109b 100644 --- a/src/app/core/notifications/reciter-suggestions/models/suggestion-source-object.resource-type.ts +++ b/src/app/core/notifications/models/suggestion-source-object.resource-type.ts @@ -1,4 +1,4 @@ -import { ResourceType } from '../../../shared/resource-type'; +import { ResourceType } from '../../shared/resource-type'; /** * The resource type for the Suggestion Source object diff --git a/src/app/core/notifications/reciter-suggestions/models/suggestion-source.model.ts b/src/app/core/notifications/models/suggestion-source.model.ts similarity index 71% rename from src/app/core/notifications/reciter-suggestions/models/suggestion-source.model.ts rename to src/app/core/notifications/models/suggestion-source.model.ts index 007520800d3..12d9d7e9d84 100644 --- a/src/app/core/notifications/reciter-suggestions/models/suggestion-source.model.ts +++ b/src/app/core/notifications/models/suggestion-source.model.ts @@ -1,11 +1,11 @@ import { autoserialize, deserialize } from 'cerialize'; import { SUGGESTION_SOURCE } from './suggestion-source-object.resource-type'; -import { excludeFromEquals } from '../../../utilities/equals.decorators'; -import { ResourceType } from '../../../shared/resource-type'; -import { HALLink } from '../../../shared/hal-link.model'; -import { typedObject } from '../../../cache/builders/build-decorators'; -import {CacheableObject} from '../../../cache/cacheable-object.model'; +import { excludeFromEquals } from '../../utilities/equals.decorators'; +import { ResourceType } from '../../shared/resource-type'; +import { HALLink } from '../../shared/hal-link.model'; +import { typedObject } from '../../cache/builders/build-decorators'; +import {CacheableObject} from '../../cache/cacheable-object.model'; /** * The interface representing the Suggestion Source model diff --git a/src/app/core/notifications/reciter-suggestions/models/suggestion-target-object.resource-type.ts b/src/app/core/notifications/models/suggestion-target-object.resource-type.ts similarity index 77% rename from src/app/core/notifications/reciter-suggestions/models/suggestion-target-object.resource-type.ts rename to src/app/core/notifications/models/suggestion-target-object.resource-type.ts index 71dd41912ac..81b1b5c2619 100644 --- a/src/app/core/notifications/reciter-suggestions/models/suggestion-target-object.resource-type.ts +++ b/src/app/core/notifications/models/suggestion-target-object.resource-type.ts @@ -1,4 +1,4 @@ -import { ResourceType } from '../../../shared/resource-type'; +import { ResourceType } from '../../shared/resource-type'; /** * The resource type for the Suggestion Target object diff --git a/src/app/core/notifications/reciter-suggestions/models/suggestion-target.model.ts b/src/app/core/notifications/models/suggestion-target.model.ts similarity index 75% rename from src/app/core/notifications/reciter-suggestions/models/suggestion-target.model.ts rename to src/app/core/notifications/models/suggestion-target.model.ts index 2afe170e777..99d9a8628ad 100644 --- a/src/app/core/notifications/reciter-suggestions/models/suggestion-target.model.ts +++ b/src/app/core/notifications/models/suggestion-target.model.ts @@ -1,12 +1,12 @@ import { autoserialize, deserialize } from 'cerialize'; -import { CacheableObject } from '../../../cache/cacheable-object.model'; +import { CacheableObject } from '../../cache/cacheable-object.model'; import { SUGGESTION_TARGET } from './suggestion-target-object.resource-type'; -import { excludeFromEquals } from '../../../utilities/equals.decorators'; -import { ResourceType } from '../../../shared/resource-type'; -import { HALLink } from '../../../shared/hal-link.model'; -import { typedObject } from '../../../cache/builders/build-decorators'; +import { excludeFromEquals } from '../../utilities/equals.decorators'; +import { ResourceType } from '../../shared/resource-type'; +import { HALLink } from '../../shared/hal-link.model'; +import { typedObject } from '../../cache/builders/build-decorators'; /** * The interface representing the Suggestion Target model diff --git a/src/app/core/notifications/reciter-suggestions/models/suggestion.model.ts b/src/app/core/notifications/models/suggestion.model.ts similarity index 74% rename from src/app/core/notifications/reciter-suggestions/models/suggestion.model.ts rename to src/app/core/notifications/models/suggestion.model.ts index c36d36794b5..ad58b1cfe55 100644 --- a/src/app/core/notifications/reciter-suggestions/models/suggestion.model.ts +++ b/src/app/core/notifications/models/suggestion.model.ts @@ -1,13 +1,16 @@ import { autoserialize, autoserializeAs, deserialize } from 'cerialize'; import { SUGGESTION } from './suggestion-objects.resource-type'; -import { excludeFromEquals } from '../../../utilities/equals.decorators'; -import { ResourceType } from '../../../shared/resource-type'; -import { HALLink } from '../../../shared/hal-link.model'; -import { typedObject } from '../../../cache/builders/build-decorators'; -import { MetadataMap, MetadataMapSerializer } from '../../../shared/metadata.models'; -import {CacheableObject} from '../../../cache/cacheable-object.model'; +import { excludeFromEquals } from '../../utilities/equals.decorators'; +import { ResourceType } from '../../shared/resource-type'; +import { HALLink } from '../../shared/hal-link.model'; +import { typedObject } from '../../cache/builders/build-decorators'; +import { MetadataMap, MetadataMapSerializer } from '../../shared/metadata.models'; +import {CacheableObject} from '../../cache/cacheable-object.model'; +/** + * The interface representing Suggestion Evidences such as scores (authorScore, datescore) + */ export interface SuggestionEvidences { [sectionId: string]: { score: string; diff --git a/src/app/core/notifications/reciter-suggestions/target/suggestion-target-data.service.ts b/src/app/core/notifications/reciter-suggestions/target/suggestion-target-data.service.ts deleted file mode 100644 index ce5f131c1d5..00000000000 --- a/src/app/core/notifications/reciter-suggestions/target/suggestion-target-data.service.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { Injectable } from '@angular/core'; -import { dataService } from '../../../data/base/data-service.decorator'; - -import { IdentifiableDataService } from '../../../data/base/identifiable-data.service'; -import { SuggestionTarget } from '../models/suggestion-target.model'; -import { FindAllData, FindAllDataImpl } from '../../../data/base/find-all-data'; -import { Store } from '@ngrx/store'; -import { RequestService } from '../../../data/request.service'; -import { RemoteDataBuildService } from '../../../cache/builders/remote-data-build.service'; -import { CoreState } from '../../../core-state.model'; -import { ObjectCacheService } from '../../../cache/object-cache.service'; -import { HALEndpointService } from '../../../shared/hal-endpoint.service'; -import { NotificationsService } from '../../../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; -import { FindListOptions } from '../../../data/find-list-options.model'; -import { FollowLinkConfig } from '../../../../shared/utils/follow-link-config.model'; -import { PaginatedList } from '../../../data/paginated-list.model'; -import { RemoteData } from '../../../data/remote-data'; -import { Observable } from 'rxjs/internal/Observable'; -import { RequestParam } from '../../../cache/models/request-param.model'; -import { SearchData, SearchDataImpl } from '../../../data/base/search-data'; -import { DefaultChangeAnalyzer } from '../../../data/default-change-analyzer.service'; -import { SUGGESTION_TARGET } from '../models/suggestion-target-object.resource-type'; - -@Injectable() -@dataService(SUGGESTION_TARGET) -export class SuggestionTargetDataService extends IdentifiableDataService { - - protected linkPath = 'suggestiontargets'; - private findAllData: FindAllData; - private searchBy: SearchData; - protected searchFindBySourceMethod = 'findBySource'; - protected searchFindByTargetMethod = 'findByTarget'; - protected searchFindByTargetAndSourceMethod = 'findByTargetAndSource'; - - constructor( - protected requestService: RequestService, - protected rdbService: RemoteDataBuildService, - protected store: Store, - protected objectCache: ObjectCacheService, - protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DefaultChangeAnalyzer) { - super('suggestiontargets', requestService, rdbService, objectCache, halService); - this.findAllData = new FindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); - this.searchBy = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); - } - /** - * Return the list of Suggestion Target for a given source - * - * @param source - * The source for which to find targets. - * @param options - * Find list options object. - * @param linksToFollow - * List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved. - * @return Observable>> - * The list of Suggestion Target. - */ - public getTargets( - source: string, - options: FindListOptions = {}, - ...linksToFollow: FollowLinkConfig[] - ): Observable>> { - options.searchParams = [new RequestParam('source', source)]; - - return this.searchBy.searchBy(this.searchFindBySourceMethod, options, true, true, ...linksToFollow); - } - /** - * Return a single Suggestion target. - * - * @param id The Suggestion Target id - * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's - * no valid cached version. Defaults to true - * @param reRequestOnStale Whether or not the request should automatically be re- - * requested after the response becomes stale - * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved. - * - * @return Observable> The Quality Assurance source. - */ - public getTarget(id: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { - return this.findById(id, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); - } - - /** - * Return the list of Suggestion Target for a given user - * - * @param userId - * The user Id for which to find targets. - * @param options - * Find list options object. - * @param linksToFollow - * List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved. - * @return Observable>> - * The list of Suggestion Target. - */ - public getTargetsByUser( - userId: string, - options: FindListOptions = {}, - ...linksToFollow: FollowLinkConfig[] - ): Observable>> { - options.searchParams = [new RequestParam('target', userId)]; - - return this.searchBy.searchBy(this.searchFindByTargetMethod, options, true, true, ...linksToFollow); - } - /** - * Return a Suggestion Target for a given id - * - * @param targetId - * The target id to retrieve. - * - * @return Observable> - * The list of Suggestion Target. - */ - public getTargetById(targetId: string): Observable> { - return this.findById(targetId); - } - -} diff --git a/src/app/core/notifications/reciter-suggestions/source/suggestion-source-data.service.ts b/src/app/core/notifications/source/suggestion-source-data.service.ts similarity index 59% rename from src/app/core/notifications/reciter-suggestions/source/suggestion-source-data.service.ts rename to src/app/core/notifications/source/suggestion-source-data.service.ts index c3e142044e1..f00a84c95b5 100644 --- a/src/app/core/notifications/reciter-suggestions/source/suggestion-source-data.service.ts +++ b/src/app/core/notifications/source/suggestion-source-data.service.ts @@ -1,24 +1,27 @@ import { Injectable } from '@angular/core'; -import { dataService } from '../../../data/base/data-service.decorator'; +import { dataService } from '../../data/base/data-service.decorator'; import { SUGGESTION_SOURCE } from '../models/suggestion-source-object.resource-type'; -import { IdentifiableDataService } from '../../../data/base/identifiable-data.service'; +import { IdentifiableDataService } from '../../data/base/identifiable-data.service'; import { SuggestionSource } from '../models/suggestion-source.model'; -import { FindAllData, FindAllDataImpl } from '../../../data/base/find-all-data'; +import { FindAllData, FindAllDataImpl } from '../../data/base/find-all-data'; import { Store } from '@ngrx/store'; -import { RequestService } from '../../../data/request.service'; -import { RemoteDataBuildService } from '../../../cache/builders/remote-data-build.service'; -import { CoreState } from '../../../core-state.model'; -import { ObjectCacheService } from '../../../cache/object-cache.service'; -import { HALEndpointService } from '../../../shared/hal-endpoint.service'; -import { NotificationsService } from '../../../../shared/notifications/notifications.service'; +import { RequestService } from '../../data/request.service'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { CoreState } from '../../core-state.model'; +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { HttpClient } from '@angular/common/http'; -import { FindListOptions } from '../../../data/find-list-options.model'; -import { FollowLinkConfig } from '../../../../shared/utils/follow-link-config.model'; -import { PaginatedList } from '../../../data/paginated-list.model'; -import { RemoteData } from '../../../data/remote-data'; +import { FindListOptions } from '../../data/find-list-options.model'; +import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; +import { PaginatedList } from '../../data/paginated-list.model'; +import { RemoteData } from '../../data/remote-data'; import { Observable } from 'rxjs/internal/Observable'; -import { DefaultChangeAnalyzer } from '../../../data/default-change-analyzer.service'; +import { DefaultChangeAnalyzer } from '../../data/default-change-analyzer.service'; +/** + * Service that retrieves Suggestion Source data + */ @Injectable() @dataService(SUGGESTION_SOURCE) export class SuggestionSourceDataService extends IdentifiableDataService { @@ -53,7 +56,7 @@ export class SuggestionSourceDataService extends IdentifiableDataService[]): Observable>> { - return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + return this.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); } /** @@ -71,4 +74,23 @@ export class SuggestionSourceDataService extends IdentifiableDataService[]): Observable> { return this.findById(id, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); } + + + /** + * Returns {@link RemoteData} of all object with a list of {@link FollowLinkConfig}, to indicate which embedded + * info should be added to the objects + * + * @param options Find list options object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + * @return {Observable>>} + * Return an observable that emits object list + */ + findAll(options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } } diff --git a/src/app/core/notifications/source/suggestions-source-data.service.spec.ts b/src/app/core/notifications/source/suggestions-source-data.service.spec.ts new file mode 100644 index 00000000000..28f34b863d8 --- /dev/null +++ b/src/app/core/notifications/source/suggestions-source-data.service.spec.ts @@ -0,0 +1,115 @@ +import { TestScheduler } from 'rxjs/testing'; +import { RequestService } from '../../data/request.service'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { RequestEntry } from '../../data/request-entry.model'; +import { cold, getTestScheduler } from 'jasmine-marbles'; +import { RestResponse } from '../../cache/response.models'; +import { of as observableOf } from 'rxjs'; +import { Store } from '@ngrx/store'; +import { CoreState } from '../../core-state.model'; +import { HttpClient } from '@angular/common/http'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { DefaultChangeAnalyzer } from '../../data/default-change-analyzer.service'; +import { testFindAllDataImplementation } from '../../data/base/find-all-data.spec'; +import { FindAllData } from '../../data/base/find-all-data'; +import { GetRequest } from '../../data/request.models'; +import { + createSuccessfulRemoteDataObject$ +} from '../../../shared/remote-data.utils'; +import { RemoteData } from '../../data/remote-data'; +import { RequestEntryState } from '../../data/request-entry-state.model'; +import { SuggestionSourceDataService } from './suggestion-source-data.service'; +import { SuggestionSource } from '../models/suggestion-source.model'; + +describe('SuggestionSourceDataService test', () => { + let scheduler: TestScheduler; + let service: SuggestionSourceDataService; + let requestService: RequestService; + let rdbService: RemoteDataBuildService; + let objectCache: ObjectCacheService; + let halService: HALEndpointService; + let notificationsService: NotificationsService; + let http: HttpClient; + let comparator: DefaultChangeAnalyzer; + let responseCacheEntry: RequestEntry; + + const store = {} as Store; + const endpointURL = `https://rest.api/rest/api/suggestionsources`; + const requestUUID = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a'; + + const remoteDataMocks = { + Success: new RemoteData(null, null, null, RequestEntryState.Success, null, null, 200), + }; + + function initTestService() { + return new SuggestionSourceDataService( + requestService, + rdbService, + store, + objectCache, + halService, + notificationsService, + http, + comparator + ); + } + + beforeEach(() => { + scheduler = getTestScheduler(); + + objectCache = {} as ObjectCacheService; + http = {} as HttpClient; + notificationsService = {} as NotificationsService; + comparator = {} as DefaultChangeAnalyzer; + responseCacheEntry = new RequestEntry(); + responseCacheEntry.request = { href: 'https://rest.api/' } as any; + responseCacheEntry.response = new RestResponse(true, 200, 'Success'); + + requestService = jasmine.createSpyObj('requestService', { + generateRequestId: requestUUID, + send: true, + removeByHrefSubstring: {}, + getByHref: observableOf(responseCacheEntry), + getByUUID: observableOf(responseCacheEntry), + }); + + halService = jasmine.createSpyObj('halService', { + getEndpoint: observableOf(endpointURL) + }); + + rdbService = jasmine.createSpyObj('rdbService', { + buildSingle: createSuccessfulRemoteDataObject$({}, 500), + buildList: cold('a', { a: remoteDataMocks.Success }) + }); + + + service = initTestService(); + }); + + describe('composition', () => { + const initFindAllService = () => new SuggestionSourceDataService(null, null, null, null, null, null, null, null) as unknown as FindAllData; + testFindAllDataImplementation(initFindAllService); + }); + + describe('getSources', () => { + it('should send a new GetRequest', () => { + const expected = new GetRequest(requestService.generateRequestId(), `${endpointURL}`); + scheduler.schedule(() => service.getSources().subscribe()); + scheduler.flush(); + + expect(requestService.send).toHaveBeenCalledWith(expected, true); + }); + }); + + describe('getSource', () => { + it('should send a new GetRequest', () => { + const expected = new GetRequest(requestService.generateRequestId(), `${endpointURL}/testId`); + scheduler.schedule(() => service.getSource('testId').subscribe()); + scheduler.flush(); + + expect(requestService.send).toHaveBeenCalledWith(expected, true); + }); + }); +}); diff --git a/src/app/core/notifications/suggestion-data.service.spec.ts b/src/app/core/notifications/suggestion-data.service.spec.ts new file mode 100644 index 00000000000..c0bc97ea129 --- /dev/null +++ b/src/app/core/notifications/suggestion-data.service.spec.ts @@ -0,0 +1,173 @@ +import { TestScheduler } from 'rxjs/testing'; +import { SuggestionDataServiceImpl, SuggestionsDataService } from './suggestions-data.service'; +import { RequestService } from '../data/request.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { HttpClient } from '@angular/common/http'; +import { DefaultChangeAnalyzer } from '../data/default-change-analyzer.service'; +import { Suggestion } from './models/suggestion.model'; +import { cold, getTestScheduler } from 'jasmine-marbles'; +import { RequestEntry } from '../data/request-entry.model'; +import { RestResponse } from '../cache/response.models'; +import { of as observableOf } from 'rxjs'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { RemoteData } from '../data/remote-data'; +import { RequestEntryState } from '../data/request-entry-state.model'; +import { SuggestionSource } from './models/suggestion-source.model'; +import { SuggestionTarget } from './models/suggestion-target.model'; +import { SuggestionSourceDataService } from './source/suggestion-source-data.service'; +import { SuggestionTargetDataService } from './target/suggestion-target-data.service'; +import { RequestParam } from '../cache/models/request-param.model'; + +describe('SuggestionDataService test', () => { + let scheduler: TestScheduler; + let service: SuggestionsDataService; + let requestService: RequestService; + let rdbService: RemoteDataBuildService; + let objectCache: ObjectCacheService; + let halService: HALEndpointService; + let notificationsService: NotificationsService; + let http: HttpClient; + let comparatorSuggestion: DefaultChangeAnalyzer; + let comparatorSuggestionSource: DefaultChangeAnalyzer; + let comparatorSuggestionTarget: DefaultChangeAnalyzer; + let suggestionSourcesDataService: SuggestionSourceDataService; + let suggestionTargetsDataService: SuggestionTargetDataService; + let suggestionsDataService: SuggestionDataServiceImpl; + let responseCacheEntry: RequestEntry; + + + const testSource = 'test-source'; + const testUserId = '1234-4321'; + const endpointURL = `https://rest.api/rest/api/`; + const requestUUID = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a'; + const remoteDataMocks = { + Success: new RemoteData(null, null, null, RequestEntryState.Success, null, null, 200), + }; + + function initTestService() { + return new SuggestionsDataService( + requestService, + rdbService, + objectCache, + halService, + notificationsService, + http, + comparatorSuggestion, + comparatorSuggestionSource, + comparatorSuggestionTarget + ); + } + + beforeEach(() => { + scheduler = getTestScheduler(); + + objectCache = {} as ObjectCacheService; + http = {} as HttpClient; + notificationsService = {} as NotificationsService; + comparatorSuggestion = {} as DefaultChangeAnalyzer; + comparatorSuggestionTarget = {} as DefaultChangeAnalyzer; + comparatorSuggestionSource = {} as DefaultChangeAnalyzer; + responseCacheEntry = new RequestEntry(); + responseCacheEntry.request = { href: 'https://rest.api/' } as any; + responseCacheEntry.response = new RestResponse(true, 200, 'Success'); + + requestService = jasmine.createSpyObj('requestService', { + generateRequestId: requestUUID, + send: true, + removeByHrefSubstring: {}, + getByHref: observableOf(responseCacheEntry), + getByUUID: observableOf(responseCacheEntry), + setStaleByHrefSubstring: observableOf(true) + }); + + halService = jasmine.createSpyObj('halService', { + getEndpoint: observableOf(endpointURL) + }); + + rdbService = jasmine.createSpyObj('rdbService', { + buildSingle: createSuccessfulRemoteDataObject$({}, 500), + buildList: cold('a', { a: remoteDataMocks.Success }) + }); + + + suggestionSourcesDataService = jasmine.createSpyObj('suggestionSourcesDataService', { + getSources: observableOf(null), + }); + + suggestionTargetsDataService = jasmine.createSpyObj('suggestionTargetsDataService', { + getTargets: observableOf(null), + getTargetsByUser: observableOf(null), + findById: observableOf(null), + }); + + suggestionsDataService = jasmine.createSpyObj('suggestionsDataService', { + searchBy: observableOf(null), + delete: observableOf(null), + }); + + + service = initTestService(); + /* eslint-disable-next-line @typescript-eslint/dot-notation */ + service['suggestionSourcesDataService'] = suggestionSourcesDataService; + /* eslint-disable-next-line @typescript-eslint/dot-notation */ + service['suggestionTargetsDataService'] = suggestionTargetsDataService; + /* eslint-disable-next-line @typescript-eslint/dot-notation */ + service['suggestionsDataService'] = suggestionsDataService; + }); + + describe('Suggestion targets service', () => { + it('should call suggestionSourcesDataService.getTargets', () => { + const options = { + searchParams: [new RequestParam('source', testSource)] + }; + service.getTargets(testSource); + expect(suggestionTargetsDataService.getTargets).toHaveBeenCalledWith('findBySource', options); + }); + + it('should call suggestionSourcesDataService.getTargetsByUser', () => { + const options = { + searchParams: [new RequestParam('target', testUserId)] + }; + service.getTargetsByUser(testUserId); + expect(suggestionTargetsDataService.getTargetsByUser).toHaveBeenCalledWith(testUserId, options); + }); + + it('should call suggestionSourcesDataService.getTargetById', () => { + service.getTargetById('1'); + expect(suggestionTargetsDataService.findById).toHaveBeenCalledWith('1'); + }); + }); + + + describe('Suggestion sources service', () => { + it('should call suggestionSourcesDataService.getSources', () => { + service.getSources(); + expect(suggestionSourcesDataService.getSources).toHaveBeenCalled(); + }); + }); + + describe('Suggestion service', () => { + it('should call suggestionsDataService.searchBy', () => { + const options = { + searchParams: [new RequestParam('target', testUserId), new RequestParam('source', testSource)] + }; + service.getSuggestionsByTargetAndSource(testUserId, testSource); + expect(suggestionsDataService.searchBy).toHaveBeenCalledWith('findByTargetAndSource', options, false, true); + }); + + it('should call suggestionsDataService.delete', () => { + service.deleteSuggestion('1'); + expect(suggestionsDataService.delete).toHaveBeenCalledWith('1'); + }); + }); + + describe('Request service', () => { + it('should call requestService.setStaleByHrefSubstring', () => { + service.clearSuggestionRequests(); + expect(requestService.setStaleByHrefSubstring).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/app/core/notifications/reciter-suggestions/suggestions-data.service.ts b/src/app/core/notifications/suggestions-data.service.ts similarity index 76% rename from src/app/core/notifications/reciter-suggestions/suggestions-data.service.ts rename to src/app/core/notifications/suggestions-data.service.ts index 944a13e3a3c..17b14825782 100644 --- a/src/app/core/notifications/reciter-suggestions/suggestions-data.service.ts +++ b/src/app/core/notifications/suggestions-data.service.ts @@ -5,39 +5,35 @@ import { Store } from '@ngrx/store'; import { Observable } from 'rxjs'; -import { HALEndpointService } from '../../shared/hal-endpoint.service'; -import { NotificationsService } from '../../../shared/notifications/notifications.service'; -import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; -import { ObjectCacheService } from '../../cache/object-cache.service'; -import { dataService } from '../../cache/builders/build-decorators'; -import { RequestService } from '../../data/request.service'; -import { DataService } from '../../data/data.service'; -import { ChangeAnalyzer } from '../../data/change-analyzer'; -import { DefaultChangeAnalyzer } from '../../data/default-change-analyzer.service'; -import { RemoteData } from '../../data/remote-data'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { dataService } from '../cache/builders/build-decorators'; +import { RequestService } from '../data/request.service'; +import { UpdateDataServiceImpl } from '../data/update-data.service'; +import { ChangeAnalyzer } from '../data/change-analyzer'; +import { DefaultChangeAnalyzer } from '../data/default-change-analyzer.service'; +import { RemoteData } from '../data/remote-data'; import { SUGGESTION } from './models/suggestion-objects.resource-type'; -import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; -import { PaginatedList } from '../../data/paginated-list.model'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { PaginatedList } from '../data/paginated-list.model'; import { SuggestionSource } from './models/suggestion-source.model'; import { SuggestionTarget } from './models/suggestion-target.model'; import { Suggestion } from './models/suggestion.model'; -import { RequestParam } from '../../cache/models/request-param.model'; -import { NoContent } from '../../shared/NoContent.model'; -import {CoreState} from '../../core-state.model'; -import {FindListOptions} from '../../data/find-list-options.model'; +import { RequestParam } from '../cache/models/request-param.model'; +import { NoContent } from '../shared/NoContent.model'; +import {CoreState} from '../core-state.model'; +import {FindListOptions} from '../data/find-list-options.model'; import { SuggestionSourceDataService } from './source/suggestion-source-data.service'; import { SuggestionTargetDataService } from './target/suggestion-target-data.service'; /* tslint:disable:max-classes-per-file */ /** - * A private DataService implementation to delegate specific methods to. + * A private UpdateDataServiceImpl implementation to delegate specific methods to. */ -class SuggestionDataServiceImpl extends DataService { - /** - * The REST endpoint. - */ - protected linkPath = 'suggestions'; +export class SuggestionDataServiceImpl extends UpdateDataServiceImpl { /** * Initialize service variables @@ -49,6 +45,7 @@ class SuggestionDataServiceImpl extends DataService { * @param {NotificationsService} notificationsService * @param {HttpClient} http * @param {ChangeAnalyzer} comparator + * @param responseMsToLive */ constructor( protected requestService: RequestService, @@ -58,8 +55,10 @@ class SuggestionDataServiceImpl extends DataService { protected halService: HALEndpointService, protected notificationsService: NotificationsService, protected http: HttpClient, - protected comparator: ChangeAnalyzer) { - super(); + protected comparator: ChangeAnalyzer, + protected responseMsToLive: number, + ) { + super('suggestions', requestService, rdbService, objectCache, halService, notificationsService, comparator ,responseMsToLive); } } @@ -70,24 +69,25 @@ class SuggestionDataServiceImpl extends DataService { @dataService(SUGGESTION) export class SuggestionsDataService { protected searchFindBySourceMethod = 'findBySource'; - protected searchFindByTargetMethod = 'findByTarget'; protected searchFindByTargetAndSourceMethod = 'findByTargetAndSource'; /** - * A private DataService implementation to delegate specific methods to. + * A private UpdateDataServiceImpl implementation to delegate specific methods to. */ private suggestionsDataService: SuggestionDataServiceImpl; /** - * A private DataService implementation to delegate specific methods to. + * A private UpdateDataServiceImpl implementation to delegate specific methods to. */ private suggestionSourcesDataService: SuggestionSourceDataService; /** - * A private DataService implementation to delegate specific methods to. + * A private UpdateDataServiceImpl implementation to delegate specific methods to. */ private suggestionTargetsDataService: SuggestionTargetDataService; + private responseMsToLive = 10 * 1000; + /** * Initialize service variables * @param {RequestService} requestService @@ -111,13 +111,13 @@ export class SuggestionsDataService { protected comparatorSources: DefaultChangeAnalyzer, protected comparatorTargets: DefaultChangeAnalyzer, ) { - this.suggestionsDataService = new SuggestionDataServiceImpl(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparatorSuggestions); + this.suggestionsDataService = new SuggestionDataServiceImpl(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparatorSuggestions, this.responseMsToLive); this.suggestionSourcesDataService = new SuggestionSourceDataService(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparatorSources); this.suggestionTargetsDataService = new SuggestionTargetDataService(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparatorTargets); } /** - * Return the list of Suggestion Target + * Return the list of Suggestion Sources * * @param options * Find list options object. @@ -168,7 +168,6 @@ export class SuggestionsDataService { ...linksToFollow: FollowLinkConfig[] ): Observable>> { options.searchParams = [new RequestParam('target', userId)]; - //return this.suggestionTargetsDataService.getTargetsByUser(this.searchFindByTargetMethod, options, ...linksToFollow); return this.suggestionTargetsDataService.getTargetsByUser(userId, options, ...linksToFollow); } @@ -193,14 +192,6 @@ export class SuggestionsDataService { return this.suggestionsDataService.delete(suggestionId); } - /** - * Used to fetch Suggestion notification for user - * @suggestionId - */ - public getSuggestion(suggestionId: string, ...linksToFollow: FollowLinkConfig[]): Observable> { - return this.suggestionsDataService.findById(suggestionId, true, true, ...linksToFollow); - } - /** * Return the list of Suggestion for a given target and source * @@ -226,7 +217,7 @@ export class SuggestionsDataService { new RequestParam('source', source) ]; - return this.suggestionsDataService.searchBy(this.searchFindByTargetAndSourceMethod, options, true, true, ...linksToFollow); + return this.suggestionsDataService.searchBy(this.searchFindByTargetAndSourceMethod, options, false, true, ...linksToFollow); } /** diff --git a/src/app/core/notifications/target/suggestion-target-data.service.ts b/src/app/core/notifications/target/suggestion-target-data.service.ts new file mode 100644 index 00000000000..a2f1507b100 --- /dev/null +++ b/src/app/core/notifications/target/suggestion-target-data.service.ts @@ -0,0 +1,141 @@ +import { Injectable } from '@angular/core'; +import { dataService } from '../../data/base/data-service.decorator'; + +import { IdentifiableDataService } from '../../data/base/identifiable-data.service'; +import { SuggestionTarget } from '../models/suggestion-target.model'; +import { FindAllData, FindAllDataImpl } from '../../data/base/find-all-data'; +import { Store } from '@ngrx/store'; +import { RequestService } from '../../data/request.service'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { CoreState } from '../../core-state.model'; +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { HttpClient } from '@angular/common/http'; +import { FindListOptions } from '../../data/find-list-options.model'; +import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; +import { PaginatedList } from '../../data/paginated-list.model'; +import { RemoteData } from '../../data/remote-data'; +import { Observable } from 'rxjs/internal/Observable'; +import { RequestParam } from '../../cache/models/request-param.model'; +import { SearchData, SearchDataImpl } from '../../data/base/search-data'; +import { DefaultChangeAnalyzer } from '../../data/default-change-analyzer.service'; +import { SUGGESTION_TARGET } from '../models/suggestion-target-object.resource-type'; + +@Injectable() +@dataService(SUGGESTION_TARGET) +export class SuggestionTargetDataService extends IdentifiableDataService { + + protected linkPath = 'suggestiontargets'; + private findAllData: FindAllData; + private searchData: SearchData; + protected searchFindBySourceMethod = 'findBySource'; + protected searchFindByTargetMethod = 'findByTarget'; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DefaultChangeAnalyzer) { + super('suggestiontargets', requestService, rdbService, objectCache, halService); + this.findAllData = new FindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + } + /** + * Return the list of Suggestion Target for a given source + * + * @param source + * The source for which to find targets. + * @param options + * Find list options object. + * @param linksToFollow + * List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved. + * @return Observable>> + * The list of Suggestion Target. + */ + public getTargets( + source: string, + options: FindListOptions = {}, + ...linksToFollow: FollowLinkConfig[] + ): Observable>> { + options.searchParams = [new RequestParam('source', source)]; + + return this.searchBy(this.searchFindBySourceMethod, options, true, true, ...linksToFollow); + } + + /** + * Return the list of Suggestion Target for a given user + * + * @param userId + * The user Id for which to find targets. + * @param options + * Find list options object. + * @param linksToFollow + * List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved. + * @return Observable>> + * The list of Suggestion Target. + */ + public getTargetsByUser( + userId: string, + options: FindListOptions = {}, + ...linksToFollow: FollowLinkConfig[] + ): Observable>> { + options.searchParams = [new RequestParam('target', userId)]; + + return this.searchBy(this.searchFindByTargetMethod, options, true, true, ...linksToFollow); + } + /** + * Return a Suggestion Target for a given id + * + * @param targetId + * The target id to retrieve. + * + * @return Observable> + * The list of Suggestion Target. + */ + public getTargetById(targetId: string): Observable> { + return this.findById(targetId); + } + + /** + * Make a new FindListRequest with given search method + * + * @param searchMethod The search method for the object + * @param options The [[FindListOptions]] object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + * @return {Observable>} + * Return an observable that emits response from the server + */ + public searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + + /** + * Returns {@link RemoteData} of all object with a list of {@link FollowLinkConfig}, to indicate which embedded + * info should be added to the objects + * + * @param options Find list options object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + * @return {Observable>>} + * Return an observable that emits object list + */ + findAll(options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + +} diff --git a/src/app/core/notifications/target/suggestions-target-data.service.spec.ts b/src/app/core/notifications/target/suggestions-target-data.service.spec.ts new file mode 100644 index 00000000000..9207603a5ad --- /dev/null +++ b/src/app/core/notifications/target/suggestions-target-data.service.spec.ts @@ -0,0 +1,138 @@ +import { TestScheduler } from 'rxjs/testing'; +import { RequestService } from '../../data/request.service'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { RequestEntry } from '../../data/request-entry.model'; +import { cold, getTestScheduler } from 'jasmine-marbles'; +import { RestResponse } from '../../cache/response.models'; +import { of as observableOf } from 'rxjs'; +import { Store } from '@ngrx/store'; +import { CoreState } from '../../core-state.model'; +import { HttpClient } from '@angular/common/http'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { SearchData } from '../../data/base/search-data'; +import { testSearchDataImplementation } from '../../data/base/search-data.spec'; +import { SuggestionTargetDataService } from './suggestion-target-data.service'; +import { DefaultChangeAnalyzer } from '../../data/default-change-analyzer.service'; +import { SuggestionTarget } from '../models/suggestion-target.model'; +import { testFindAllDataImplementation } from '../../data/base/find-all-data.spec'; +import { FindAllData } from '../../data/base/find-all-data'; +import { GetRequest } from '../../data/request.models'; +import { + createSuccessfulRemoteDataObject$ +} from '../../../shared/remote-data.utils'; +import { RequestParam } from '../../cache/models/request-param.model'; +import { RemoteData } from '../../data/remote-data'; +import { RequestEntryState } from '../../data/request-entry-state.model'; + +describe('SuggestionTargetDataService test', () => { + let scheduler: TestScheduler; + let service: SuggestionTargetDataService; + let requestService: RequestService; + let rdbService: RemoteDataBuildService; + let objectCache: ObjectCacheService; + let halService: HALEndpointService; + let notificationsService: NotificationsService; + let http: HttpClient; + let comparator: DefaultChangeAnalyzer; + let responseCacheEntry: RequestEntry; + + const store = {} as Store; + const endpointURL = `https://rest.api/rest/api/suggestiontargets`; + const requestUUID = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a'; + + const remoteDataMocks = { + Success: new RemoteData(null, null, null, RequestEntryState.Success, null, null, 200), + }; + + function initTestService() { + return new SuggestionTargetDataService( + requestService, + rdbService, + store, + objectCache, + halService, + notificationsService, + http, + comparator + ); + } + + beforeEach(() => { + scheduler = getTestScheduler(); + + objectCache = {} as ObjectCacheService; + http = {} as HttpClient; + notificationsService = {} as NotificationsService; + comparator = {} as DefaultChangeAnalyzer; + responseCacheEntry = new RequestEntry(); + responseCacheEntry.request = { href: 'https://rest.api/' } as any; + responseCacheEntry.response = new RestResponse(true, 200, 'Success'); + + requestService = jasmine.createSpyObj('requestService', { + generateRequestId: requestUUID, + send: true, + removeByHrefSubstring: {}, + getByHref: observableOf(responseCacheEntry), + getByUUID: observableOf(responseCacheEntry), + }); + + halService = jasmine.createSpyObj('halService', { + getEndpoint: observableOf(endpointURL) + }); + + rdbService = jasmine.createSpyObj('rdbService', { + buildSingle: createSuccessfulRemoteDataObject$({}, 500), + buildList: cold('a', { a: remoteDataMocks.Success }) + }); + + + service = initTestService(); + }); + + describe('composition', () => { + const initSearchService = () => new SuggestionTargetDataService(null, null, null, null, null, null, null, null) as unknown as SearchData; + const initFindAllService = () => new SuggestionTargetDataService(null, null, null, null, null, null, null, null) as unknown as FindAllData; + testSearchDataImplementation(initSearchService); + testFindAllDataImplementation(initFindAllService); + }); + + describe('getTargetById', () => { + it('should send a new GetRequest', () => { + const expected = new GetRequest(requestService.generateRequestId(), endpointURL + '/testId'); + scheduler.schedule(() => service.getTargetById('testId').subscribe()); + scheduler.flush(); + + expect(requestService.send).toHaveBeenCalledWith(expected, true); + }); + }); + + describe('getTargetsByUser', () => { + it('should send a new GetRequest', () => { + const options = { + searchParams: [new RequestParam('target', 'testId')] + }; + const searchFindByTargetMethod = 'findByTarget'; + const expected = new GetRequest(requestService.generateRequestId(), `${endpointURL}/search/${searchFindByTargetMethod}?target=testId`); + scheduler.schedule(() => service.getTargetsByUser('testId', options).subscribe()); + scheduler.flush(); + + expect(requestService.send).toHaveBeenCalledWith(expected, true); + }); + }); + + describe('getTargets', () => { + it('should send a new GetRequest', () => { + const options = { + searchParams: [new RequestParam('source', 'testId')] + }; + const searchFindBySourceMethod = 'findBySource'; + const expected = new GetRequest(requestService.generateRequestId(), `${endpointURL}/search/${searchFindBySourceMethod}?source=testId`); + scheduler.schedule(() => service.getTargets('testId', options).subscribe()); + scheduler.flush(); + + expect(requestService.send).toHaveBeenCalledWith(expected, true); + }); + }); +}); diff --git a/src/app/core/shared/search/search-filter.service.ts b/src/app/core/shared/search/search-filter.service.ts index 80ba200d383..bf232cb141b 100644 --- a/src/app/core/shared/search/search-filter.service.ts +++ b/src/app/core/shared/search/search-filter.service.ts @@ -27,6 +27,7 @@ const filterStateSelector = (state: SearchFiltersState) => state.searchFilter; export const FILTER_CONFIG: InjectionToken = new InjectionToken('filterConfig'); export const IN_PLACE_SEARCH: InjectionToken = new InjectionToken('inPlaceSearch'); export const REFRESH_FILTER: InjectionToken> = new InjectionToken('refreshFilters'); +export const SCOPE: InjectionToken = new InjectionToken('scope'); /** * Service that performs all actions that have to do with search filters and facets diff --git a/src/app/core/submission/workflowitem-data.service.spec.ts b/src/app/core/submission/workflowitem-data.service.spec.ts index 3f6ec54fdad..64ffbe57180 100644 --- a/src/app/core/submission/workflowitem-data.service.spec.ts +++ b/src/app/core/submission/workflowitem-data.service.spec.ts @@ -126,7 +126,7 @@ describe('WorkflowItemDataService test', () => { }); describe('findByItem', () => { - it('should proxy the call to DataService.findByHref', () => { + it('should proxy the call to UpdateDataServiceImpl.findByHref', () => { scheduler.schedule(() => service.findByItem('1234-1234', true, true, pageInfo)); scheduler.flush(); diff --git a/src/app/core/submission/workspaceitem-data.service.spec.ts b/src/app/core/submission/workspaceitem-data.service.spec.ts index 2a3a3d343d1..25a849baa28 100644 --- a/src/app/core/submission/workspaceitem-data.service.spec.ts +++ b/src/app/core/submission/workspaceitem-data.service.spec.ts @@ -1,4 +1,4 @@ -import { HttpClient } from '@angular/common/http'; +import { HttpClient, HttpHeaders } from '@angular/common/http'; import { of as observableOf } from 'rxjs'; import { TestScheduler } from 'rxjs/testing'; @@ -8,7 +8,7 @@ import { ObjectCacheService } from '../cache/object-cache.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { RequestService } from '../data/request.service'; import { PageInfo } from '../shared/page-info.model'; -import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; +import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { HrefOnlyDataService } from '../data/href-only-data.service'; import { getMockHrefOnlyDataService } from '../../shared/mocks/href-only-data.service.mock'; import { WorkspaceitemDataService } from './workspaceitem-data.service'; @@ -19,7 +19,13 @@ import { Item } from '../shared/item.model'; import { WorkspaceItem } from './models/workspaceitem.model'; import { RequestEntry } from '../data/request-entry.model'; import { CoreState } from '../core-state.model'; +import { testSearchDataImplementation } from '../data/base/search-data.spec'; import { testDeleteDataImplementation } from '../data/base/delete-data.spec'; +import { SearchData } from '../data/base/search-data'; +import { DeleteData } from '../data/base/delete-data'; +import { RequestParam } from '../cache/models/request-param.model'; +import { PostRequest } from '../data/request.models'; +import { HttpOptions } from '../dspace-rest/dspace-rest.service'; describe('WorkspaceitemDataService test', () => { let scheduler: TestScheduler; @@ -67,15 +73,12 @@ describe('WorkspaceitemDataService test', () => { const wsiRD = createSuccessfulRemoteDataObject(wsi); const endpointURL = `https://rest.api/rest/api/submission/workspaceitems`; - const searchRequestURL = `https://rest.api/rest/api/submission/workspaceitems/search/item?uuid=1234-1234`; - const searchRequestURL$ = observableOf(searchRequestURL); const requestUUID = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a'; objectCache = {} as ObjectCacheService; const notificationsService = {} as NotificationsService; const http = {} as HttpClient; - const comparator = {} as any; const comparatorEntry = {} as any; const store = {} as Store; const pageInfo = new PageInfo(); @@ -83,20 +86,23 @@ describe('WorkspaceitemDataService test', () => { function initTestService() { hrefOnlyDataService = getMockHrefOnlyDataService(); return new WorkspaceitemDataService( - comparator, + comparatorEntry, halService, http, notificationsService, requestService, rdbService, objectCache, - store, + store ); } describe('composition', () => { - const initService = () => new WorkspaceitemDataService(null, null, null, null, null, null, null, null); - testDeleteDataImplementation(initService); + const initSearchService = () => new WorkspaceitemDataService(null, null, null, null, null, null, null, null) as unknown as SearchData; + const initDeleteService = () => new WorkspaceitemDataService(null, null, null, null, null, null, null, null) as unknown as DeleteData; + + testSearchDataImplementation(initSearchService); + testDeleteDataImplementation(initDeleteService); }); describe('', () => { @@ -105,7 +111,7 @@ describe('WorkspaceitemDataService test', () => { scheduler = getTestScheduler(); halService = jasmine.createSpyObj('halService', { - getEndpoint: cold('a', { a: endpointURL }) + getEndpoint: observableOf(endpointURL) }); responseCacheEntry = new RequestEntry(); responseCacheEntry.request = { href: 'https://rest.api/' } as any; @@ -121,13 +127,13 @@ describe('WorkspaceitemDataService test', () => { rdbService = jasmine.createSpyObj('rdbService', { buildSingle: hot('a|', { a: wsiRD - }) + }), + buildFromRequestUUID: createSuccessfulRemoteDataObject$({}) }); service = initTestService(); spyOn((service as any), 'findByHref').and.callThrough(); - spyOn((service as any), 'getIDHref').and.callThrough(); }); afterEach(() => { @@ -135,11 +141,11 @@ describe('WorkspaceitemDataService test', () => { }); describe('findByItem', () => { - it('should proxy the call to DataService.findByHref', () => { + it('should proxy the call to UpdateDataServiceImpl.findByHref', () => { scheduler.schedule(() => service.findByItem('1234-1234', true, true, pageInfo)); scheduler.flush(); - - expect((service as any).findByHref).toHaveBeenCalled(); + const searchUrl = service.getIDHref('item', [new RequestParam('uuid', encodeURIComponent('1234-1234'))]); + expect((service as any).findByHref).toHaveBeenCalledWith(searchUrl, true, true); }); it('should return a RemoteData for the search', () => { @@ -151,6 +157,19 @@ describe('WorkspaceitemDataService test', () => { }); }); - }); + describe('importExternalSourceEntry', () => { + it('should send a POST request containing the provided item request', (done) => { + const options: HttpOptions = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Content-Type', 'text/uri-list'); + options.headers = headers; + + service.importExternalSourceEntry('externalHref', 'testId').subscribe(() => { + expect(requestService.send).toHaveBeenCalledWith(new PostRequest(requestUUID, `${endpointURL}?owningCollection=testId`, 'externalHref', options)); + done(); + }); + }); + }); + }); }); diff --git a/src/app/core/submission/workspaceitem-data.service.ts b/src/app/core/submission/workspaceitem-data.service.ts index f430dd43ea6..38810d8fd6f 100644 --- a/src/app/core/submission/workspaceitem-data.service.ts +++ b/src/app/core/submission/workspaceitem-data.service.ts @@ -23,16 +23,19 @@ import {hasValue} from '../../shared/empty.util'; import { IdentifiableDataService } from '../data/base/identifiable-data.service'; import { NoContent } from '../shared/NoContent.model'; import { DeleteData, DeleteDataImpl } from '../data/base/delete-data'; +import { SearchData, SearchDataImpl } from '../data/base/search-data'; +import { PaginatedList } from '../data/paginated-list.model'; /** * A service that provides methods to make REST requests with workspaceitems endpoint. */ @Injectable() @dataService(WorkspaceItem.type) -export class WorkspaceitemDataService extends IdentifiableDataService { +export class WorkspaceitemDataService extends IdentifiableDataService implements DeleteData, SearchData{ protected linkPath = 'workspaceitems'; protected searchByItemLinkPath = 'item'; private deleteData: DeleteData; + private searchData: SearchData; constructor( protected comparator: DSOChangeAnalyzer, @@ -45,7 +48,7 @@ export class WorkspaceitemDataService extends IdentifiableDataService) { super('workspaceitems', requestService, rdbService, objectCache, halService); this.deleteData = new DeleteDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive, this.constructIdEndpoint); - + this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); } public delete(objectId: string, copyVirtualMetadata?: string[]): Observable> { return this.deleteData.delete(objectId, copyVirtualMetadata); @@ -107,4 +110,21 @@ export class WorkspaceitemDataService extends IdentifiableDataService>} + * Return an observable that emits response from the server + */ + searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } } diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.ts index d44817be842..60b1dffa450 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.ts +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.ts @@ -12,7 +12,6 @@ import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject'; import { getFirstCompletedRemoteData, } from '../../core/shared/operators'; -import { UpdateDataService } from '../../core/data/update-data.service'; import { ResourceType } from '../../core/shared/resource-type'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { TranslateService } from '@ngx-translate/core'; @@ -22,6 +21,7 @@ import { ArrayMoveChangeAnalyzer } from '../../core/data/array-move-change-analy import { DATA_SERVICE_FACTORY } from '../../core/data/base/data-service.decorator'; import { GenericConstructor } from '../../core/shared/generic-constructor'; import { HALDataService } from '../../core/data/base/hal-data-service.interface'; +import { UpdateDataService } from '../../core/data/update-data.service'; @Component({ selector: 'ds-dso-edit-metadata', diff --git a/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.html b/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.html index c79d19e267c..52df841d3b6 100644 --- a/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.html +++ b/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.html @@ -32,6 +32,18 @@
+ + - + @@ -23,6 +23,17 @@ + + + + + {{value}} diff --git a/src/app/item-page/field-components/metadata-values/metadata-values.component.ts b/src/app/item-page/field-components/metadata-values/metadata-values.component.ts index fcf98b094df..34dd8cf351a 100644 --- a/src/app/item-page/field-components/metadata-values/metadata-values.component.ts +++ b/src/app/item-page/field-components/metadata-values/metadata-values.component.ts @@ -4,7 +4,8 @@ import { APP_CONFIG, AppConfig } from '../../../../config/app-config.interface'; import { BrowseDefinition } from '../../../core/shared/browse-definition.model'; import { hasValue } from '../../../shared/empty.util'; import { VALUE_LIST_BROWSE_DEFINITION } from '../../../core/shared/value-list-browse-definition.resource-type'; -import { environment } from './../../../../environments/environment'; +import { ImageField } from '../../simple/field-components/specific-field/item-page-field.component'; +import { environment } from '../../../../environments/environment'; /** * This component renders the configured 'values' into the ds-metadata-field-wrapper component. @@ -56,6 +57,11 @@ export class MetadataValuesComponent implements OnChanges { @Input() browseDefinition?: BrowseDefinition; + /** + * Optional {@code ImageField} reference that represents an image to be displayed inline. + */ + @Input() img?: ImageField; + ngOnChanges(changes: SimpleChanges): void { this.renderMarkdown = !!this.appConfig.markdown.enabled && this.enableMarkdown; } @@ -92,6 +98,7 @@ export class MetadataValuesComponent implements OnChanges { return queryParams; } + /** * Checks if the given link value is an internal link. * @param linkValue - The link value to check. diff --git a/src/app/item-page/item-shared.module.ts b/src/app/item-page/item-shared.module.ts index 9c2bbba6194..8b7243acde0 100644 --- a/src/app/item-page/item-shared.module.ts +++ b/src/app/item-page/item-shared.module.ts @@ -1,21 +1,36 @@ -import { RelatedEntitiesSearchComponent } from './simple/related-entities/related-entities-search/related-entities-search.component'; +import { + RelatedEntitiesSearchComponent +} from './simple/related-entities/related-entities-search/related-entities-search.component'; import { NgModule } from '@angular/core'; -import { CommonModule } from '@angular/common'; +import { CommonModule, NgOptimizedImage } from '@angular/common'; import { SearchModule } from '../shared/search/search.module'; import { SharedModule } from '../shared/shared.module'; import { TranslateModule } from '@ngx-translate/core'; import { DYNAMIC_FORM_CONTROL_MAP_FN } from '@ng-dynamic-forms/core'; -import { dsDynamicFormControlMapFn } from '../shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component'; -import { TabbedRelatedEntitiesSearchComponent } from './simple/related-entities/tabbed-related-entities-search/tabbed-related-entities-search.component'; -import { ItemVersionsDeleteModalComponent } from './versions/item-versions-delete-modal/item-versions-delete-modal.component'; -import { ItemVersionsSummaryModalComponent } from './versions/item-versions-summary-modal/item-versions-summary-modal.component'; +import { + dsDynamicFormControlMapFn +} from '../shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component'; +import { + TabbedRelatedEntitiesSearchComponent +} from './simple/related-entities/tabbed-related-entities-search/tabbed-related-entities-search.component'; +import { + ItemVersionsDeleteModalComponent +} from './versions/item-versions-delete-modal/item-versions-delete-modal.component'; +import { + ItemVersionsSummaryModalComponent +} from './versions/item-versions-summary-modal/item-versions-summary-modal.component'; import { MetadataValuesComponent } from './field-components/metadata-values/metadata-values.component'; -import { GenericItemPageFieldComponent } from './simple/field-components/specific-field/generic/generic-item-page-field.component'; -import { MetadataRepresentationListComponent } from './simple/metadata-representation-list/metadata-representation-list.component'; +import { + GenericItemPageFieldComponent +} from './simple/field-components/specific-field/generic/generic-item-page-field.component'; +import { + MetadataRepresentationListComponent +} from './simple/metadata-representation-list/metadata-representation-list.component'; import { RelatedItemsComponent } from './simple/related-items/related-items-component'; import { ThemedMetadataRepresentationListComponent } from './simple/metadata-representation-list/themed-metadata-representation-list.component'; +import { ItemPageImgFieldComponent } from './simple/field-components/specific-field/img/item-page-img-field.component'; const ENTRY_COMPONENTS = [ ItemVersionsDeleteModalComponent, @@ -32,6 +47,7 @@ const COMPONENTS = [ MetadataRepresentationListComponent, ThemedMetadataRepresentationListComponent, RelatedItemsComponent, + ItemPageImgFieldComponent, ]; @NgModule({ @@ -42,7 +58,8 @@ const COMPONENTS = [ CommonModule, SearchModule, SharedModule, - TranslateModule + TranslateModule, + NgOptimizedImage ], exports: [ ...COMPONENTS diff --git a/src/app/item-page/simple/field-components/specific-field/img/item-page-img-field.component.spec.ts b/src/app/item-page/simple/field-components/specific-field/img/item-page-img-field.component.spec.ts new file mode 100644 index 00000000000..b96daa47ad6 --- /dev/null +++ b/src/app/item-page/simple/field-components/specific-field/img/item-page-img-field.component.spec.ts @@ -0,0 +1,85 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ItemPageImgFieldComponent } from './item-page-img-field.component'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { TranslateLoaderMock } from '../../../../../shared/testing/translate-loader.mock'; +import { APP_CONFIG } from '../../../../../../config/app-config.interface'; +import { environment } from '../../../../../../environments/environment'; +import { BrowseDefinitionDataService } from '../../../../../core/browse/browse-definition-data.service'; +import { BrowseDefinitionDataServiceStub } from '../../../../../shared/testing/browse-definition-data-service.stub'; +import { GenericItemPageFieldComponent } from '../generic/generic-item-page-field.component'; +import { MetadataValuesComponent } from '../../../../field-components/metadata-values/metadata-values.component'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { mockItemWithMetadataFieldsAndValue } from '../item-page-field.component.spec'; +import { By } from '@angular/platform-browser'; +import { ImageField } from '../item-page-field.component'; + +let component: ItemPageImgFieldComponent; +let fixture: ComponentFixture; + +const mockField = 'organization.identifier.ror'; +const mockValue = 'http://ror.org/awesome-identifier'; +const mockLabel = 'ROR label'; +const mockUrlRegex = '(.*)ror.org'; +const mockImg = { + URI: './assets/images/ror-icon.svg', + alt: 'item.page.image.alt.ROR', + heightVar: '--ds-item-page-img-field-ror-inline-height' +} as ImageField; + +describe('ItemPageImgFieldComponent', () => { + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + })], + providers: [ + { provide: APP_CONFIG, useValue: environment }, + { provide: BrowseDefinitionDataService, useValue: BrowseDefinitionDataServiceStub } + ], + declarations: [ItemPageImgFieldComponent, GenericItemPageFieldComponent, MetadataValuesComponent], + schemas: [NO_ERRORS_SCHEMA] + }) + .overrideComponent(GenericItemPageFieldComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }) + .compileComponents(); + + fixture = TestBed.createComponent(ItemPageImgFieldComponent); + component = fixture.componentInstance; + component.item = mockItemWithMetadataFieldsAndValue([mockField], mockValue); + component.fields = [mockField]; + component.label = mockLabel; + component.urlRegex = mockUrlRegex; + component.img = mockImg; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should display display img tag', () => { + const image = fixture.debugElement.query(By.css('img.link-logo')); + expect(image).not.toBeNull(); + }); + + it('should have right attributes', () => { + const image = fixture.debugElement.query(By.css('img.link-logo')); + expect(image.attributes.src).toEqual(mockImg.URI); + expect(image.attributes.alt).toEqual(mockImg.alt); + + const imageEl = image.nativeElement; + expect(imageEl.style.height).toContain(mockImg.heightVar); + }); + + it('should have the right value', () => { + const imageAnchor = fixture.debugElement.query(By.css('a.link-anchor')); + const anchorEl = imageAnchor.nativeElement; + expect(anchorEl.innerHTML).toContain(mockValue); + }); +}); diff --git a/src/app/item-page/simple/field-components/specific-field/img/item-page-img-field.component.ts b/src/app/item-page/simple/field-components/specific-field/img/item-page-img-field.component.ts new file mode 100644 index 00000000000..d442323b53b --- /dev/null +++ b/src/app/item-page/simple/field-components/specific-field/img/item-page-img-field.component.ts @@ -0,0 +1,46 @@ +import { Component, Input } from '@angular/core'; +import { ImageField, ItemPageFieldComponent } from '../item-page-field.component'; +import { Item } from '../../../../../core/shared/item.model'; + +@Component({ + selector: 'ds-item-page-img-field', + templateUrl: '../item-page-field.component.html' +}) +/** + * Component that renders an inline image for a given field. + * This component uses a given {@code ImageField} configuration to correctly render the img. + */ +export class ItemPageImgFieldComponent extends ItemPageFieldComponent { + + /** + * The item to display metadata for + */ + @Input() item: Item; + + /** + * Separator string between multiple values of the metadata fields defined + * @type {string} + */ + @Input() separator: string; + + /** + * Fields (schema.element.qualifier) used to render their values. + */ + @Input() fields: string[]; + + /** + * Label i18n key for the rendered metadata + */ + @Input() label: string; + + /** + * Image Configuration + */ + @Input() img: ImageField; + + /** + * Whether any valid HTTP(S) URL should be rendered as a link + */ + @Input() urlRegex?: string; + +} diff --git a/src/app/item-page/simple/field-components/specific-field/item-page-field.component.html b/src/app/item-page/simple/field-components/specific-field/item-page-field.component.html index 91d40b0ad70..f45d4657a46 100644 --- a/src/app/item-page/simple/field-components/specific-field/item-page-field.component.html +++ b/src/app/item-page/simple/field-components/specific-field/item-page-field.component.html @@ -6,5 +6,6 @@ [enableMarkdown]="enableMarkdown" [urlRegex]="urlRegex" [browseDefinition]="browseDefinition|async" + [img]="img" >
diff --git a/src/app/item-page/simple/field-components/specific-field/item-page-field.component.ts b/src/app/item-page/simple/field-components/specific-field/item-page-field.component.ts index fc526dabcc5..57f49e36476 100644 --- a/src/app/item-page/simple/field-components/specific-field/item-page-field.component.ts +++ b/src/app/item-page/simple/field-components/specific-field/item-page-field.component.ts @@ -6,6 +6,25 @@ import { BrowseDefinition } from '../../../../core/shared/browse-definition.mode import { BrowseDefinitionDataService } from '../../../../core/browse/browse-definition-data.service'; import { getRemoteDataPayload } from '../../../../core/shared/operators'; +/** + * Interface that encapsulate Image configuration for this component. + */ +export interface ImageField { + /** + * URI that is used to retrieve the image. + */ + URI: string; + /** + * i18n Key that represents the alt text to display + */ + alt: string; + /** + * CSS variable that contains the height of the inline image. + */ + heightVar: string; +} + + /** * This component can be used to represent metadata on a simple item page. * It expects one input parameter of type Item to which the metadata belongs. @@ -51,6 +70,11 @@ export class ItemPageFieldComponent { */ urlRegex?: string; + /** + * Image Configuration + */ + img: ImageField; + /** * Return browse definition that matches any field used in this component if it is configured as a browse * link in dspace.cfg (webui.browse.link.) diff --git a/src/app/item-page/simple/notify-requests-status/notify-requests-status-component/notify-requests-status.component.ts b/src/app/item-page/simple/notify-requests-status/notify-requests-status-component/notify-requests-status.component.ts index a04ed96363e..18061ee292c 100644 --- a/src/app/item-page/simple/notify-requests-status/notify-requests-status-component/notify-requests-status.component.ts +++ b/src/app/item-page/simple/notify-requests-status/notify-requests-status-component/notify-requests-status.component.ts @@ -56,7 +56,7 @@ export class NotifyRequestsStatusComponent implements OnInit { */ private groupDataByStatus(notifyRequestsStatus: NotifyRequestsStatus) { const statusMap: Map = new Map(); - notifyRequestsStatus.notifyStatus.forEach( + notifyRequestsStatus.notifyStatus?.forEach( (notifyStatus: NotifyStatuses) => { const status = notifyStatus.status; diff --git a/src/app/menu.resolver.ts b/src/app/menu.resolver.ts index 75e72470c64..52a9617865e 100644 --- a/src/app/menu.resolver.ts +++ b/src/app/menu.resolver.ts @@ -47,7 +47,7 @@ import { import { ExportBatchSelectorComponent } from './shared/dso-selector/modal-wrappers/export-batch-selector/export-batch-selector.component'; -import { NOTIFICATIONS_RECITER_SUGGESTION_PATH } from './quality-assurance-notifications-pages/notifications-pages-routing-paths'; +import { PUBLICATION_CLAIMS_PATH } from './admin/admin-notifications/admin-notifications-routing-paths'; /** * Creates all of the app's menus @@ -171,7 +171,8 @@ export class MenuResolver implements Resolve { this.authorizationService.isAuthorized(FeatureID.AdministratorOf), this.authorizationService.isAuthorized(FeatureID.CanSubmit), this.authorizationService.isAuthorized(FeatureID.CanEditItem), - ]).subscribe(([isCollectionAdmin, isCommunityAdmin, isSiteAdmin, canSubmit, canEditItem]) => { + this.authorizationService.isAuthorized(FeatureID.CanSeeQA) + ]).subscribe(([isCollectionAdmin, isCommunityAdmin, isSiteAdmin, canSubmit, canEditItem, canSeeQa]) => { const newSubMenuList = [ { id: 'new_community', @@ -387,6 +388,41 @@ export class MenuResolver implements Resolve { icon: 'heartbeat', index: 11 }, + /* Notifications */ + { + id: 'notifications', + active: false, + visible: canSeeQa || isSiteAdmin, + model: { + type: MenuItemType.TEXT, + text: 'menu.section.notifications' + } as TextMenuItemModel, + icon: 'bell', + index: 4 + }, + { + id: 'notifications_quality-assurance', + parentID: 'notifications', + active: false, + visible: canSeeQa, + model: { + type: MenuItemType.LINK, + text: 'menu.section.quality-assurance', + link: '/notifications/quality-assurance' + } as LinkMenuItemModel, + }, + { + id: 'notifications_publication-claim', + parentID: 'notifications', + active: false, + visible: isSiteAdmin, + model: { + type: MenuItemType.LINK, + text: 'menu.section.notifications_publication-claim', + link: '/admin/notifications/' + PUBLICATION_CLAIMS_PATH + } as LinkMenuItemModel, + }, + /* Admin Search */ ]; menuList.forEach((menuSection) => this.menuService.addSection(MenuID.ADMIN, Object.assign(menuSection, { shouldPersistOnRouteChange: true @@ -556,47 +592,9 @@ export class MenuResolver implements Resolve { * Create menu sections dependent on whether or not the current user is a site administrator */ createSiteAdministratorMenuSections() { - combineLatest([ - this.authorizationService.isAuthorized(FeatureID.AdministratorOf), - this.authorizationService.isAuthorized(FeatureID.CanSeeQA) - ]) - .subscribe(([authorized, canSeeQA]) => { + this.authorizationService.isAuthorized(FeatureID.AdministratorOf) + .subscribe((authorized) => { const menuList = [ - /* Notifications */ - { - id: 'notifications', - active: false, - visible: authorized && canSeeQA, - model: { - type: MenuItemType.TEXT, - text: 'menu.section.notifications' - } as TextMenuItemModel, - icon: 'bell', - index: 4 - }, - { - id: 'notifications_quality-assurance', - parentID: 'notifications', - active: false, - visible: authorized, - model: { - type: MenuItemType.LINK, - text: 'menu.section.quality-assurance', - link: '/notifications/quality-assurance' - } as LinkMenuItemModel, - }, - { - id: 'notifications_reciter', - parentID: 'notifications', - active: false, - visible: authorized, - model: { - type: MenuItemType.LINK, - text: 'menu.section.notifications_reciter', - link: '/notifications/' + NOTIFICATIONS_RECITER_SUGGESTION_PATH - } as LinkMenuItemModel, - }, - /* Admin Search */ { id: 'admin_search', active: false, diff --git a/src/app/notifications/notifications-effects.ts b/src/app/notifications/notifications-effects.ts index c401cb5951a..9c446cb66ad 100644 --- a/src/app/notifications/notifications-effects.ts +++ b/src/app/notifications/notifications-effects.ts @@ -1,6 +1,6 @@ import { QualityAssuranceSourceEffects } from './qa/source/quality-assurance-source.effects'; import { QualityAssuranceTopicsEffects } from './qa/topics/quality-assurance-topics.effects'; -import {SuggestionTargetsEffects} from './reciter-suggestions/suggestion-targets/suggestion-targets.effects'; +import { SuggestionTargetsEffects } from './suggestion-targets/suggestion-targets.effects'; export const notificationsEffects = [ QualityAssuranceTopicsEffects, diff --git a/src/app/notifications/notifications.module.ts b/src/app/notifications/notifications.module.ts index 20100b70f6c..0ecd491a192 100644 --- a/src/app/notifications/notifications.module.ts +++ b/src/app/notifications/notifications.module.ts @@ -26,29 +26,24 @@ import { QualityAssuranceSourceService } from './qa/source/quality-assurance-sou import { QualityAssuranceSourceDataService } from '../core/notifications/qa/source/quality-assurance-source-data.service'; -import { SuggestionTargetsComponent } from './reciter-suggestions/suggestion-targets/suggestion-targets.component'; -import { SuggestionActionsComponent } from './reciter-suggestions/suggestion-actions/suggestion-actions.component'; -import { - SuggestionListElementComponent -} from './reciter-suggestions/suggestion-list-element/suggestion-list-element.component'; + +import { SuggestionsPopupComponent } from './suggestions-popup/suggestions-popup.component'; +import { SuggestionSourceDataService } from '../core/notifications/source/suggestion-source-data.service'; +import { SuggestionTargetDataService } from '../core/notifications/target/suggestion-target-data.service'; +import { SuggestionsDataService } from '../core/notifications/suggestions-data.service'; +import { PublicationClaimComponent } from '../notifications/suggestion-targets/publication-claim/publication-claim.component'; + +import { SuggestionsNotificationComponent } from './suggestions-notification/suggestions-notification.component'; +import { SuggestionsService } from './suggestions.service'; +import { SuggestionTargetsStateService } from './suggestion-targets/suggestion-targets.state.service'; +import { SuggestionActionsComponent } from './suggestion-actions/suggestion-actions.component'; +import { SuggestionListElementComponent } from './suggestion-list-element/suggestion-list-element.component'; import { SuggestionEvidencesComponent -} from './reciter-suggestions/suggestion-list-element/suggestion-evidences/suggestion-evidences.component'; -import { SuggestionsPopupComponent } from './reciter-suggestions/suggestions-popup/suggestions-popup.component'; -import { - SuggestionsNotificationComponent -} from './reciter-suggestions/suggestions-notification/suggestions-notification.component'; -import { SuggestionsService } from './reciter-suggestions/suggestions.service'; -import { SuggestionsDataService } from '../core/notifications/reciter-suggestions/suggestions-data.service'; -import { - SuggestionSourceDataService -} from '../core/notifications/reciter-suggestions/source/suggestion-source-data.service'; -import { - SuggestionTargetDataService -} from '../core/notifications/reciter-suggestions/target/suggestion-target-data.service'; -import { - SuggestionTargetsStateService -} from './reciter-suggestions/suggestion-targets/suggestion-targets.state.service'; +} from './suggestion-list-element/suggestion-evidences/suggestion-evidences.component'; + + + const MODULES = [ CommonModule, @@ -64,7 +59,7 @@ const COMPONENTS = [ QualityAssuranceTopicsComponent, QualityAssuranceEventsComponent, QualityAssuranceSourceComponent, - SuggestionTargetsComponent, + PublicationClaimComponent, SuggestionActionsComponent, SuggestionListElementComponent, SuggestionEvidencesComponent, diff --git a/src/app/notifications/notifications.reducer.ts b/src/app/notifications/notifications.reducer.ts index 5c42e412f5e..665c5d73929 100644 --- a/src/app/notifications/notifications.reducer.ts +++ b/src/app/notifications/notifications.reducer.ts @@ -1,7 +1,7 @@ import { ActionReducerMap, createFeatureSelector } from '@ngrx/store'; import { qualityAssuranceSourceReducer, QualityAssuranceSourceState } from './qa/source/quality-assurance-source.reducer'; import { qualityAssuranceTopicsReducer, QualityAssuranceTopicState, } from './qa/topics/quality-assurance-topics.reducer'; -import { SuggestionTargetsReducer, SuggestionTargetState } from './reciter-suggestions/suggestion-targets/suggestion-targets.reducer'; +import { SuggestionTargetsReducer, SuggestionTargetState } from './suggestion-targets/suggestion-targets.reducer'; /** * The OpenAIRE State diff --git a/src/app/notifications/reciter-suggestions/selectors.ts b/src/app/notifications/reciter-suggestions/selectors.ts deleted file mode 100644 index f08914cb839..00000000000 --- a/src/app/notifications/reciter-suggestions/selectors.ts +++ /dev/null @@ -1,97 +0,0 @@ -import {createFeatureSelector, createSelector, MemoizedSelector} from '@ngrx/store'; -import { suggestionNotificationsSelector, SuggestionNotificationsState } from '../notifications.reducer'; -import { SuggestionTarget } from '../../core/notifications/reciter-suggestions/models/suggestion-target.model'; -import { SuggestionTargetState } from './suggestion-targets/suggestion-targets.reducer'; -import {subStateSelector} from '../../submission/selectors'; - -/** - * Returns the Reciter Suggestion Target state. - * @function _getReciterSuggestionTargetState - * @param {AppState} state Top level state. - * @return {SuggestionNotificationsState} - */ -const _getReciterSuggestionTargetState = createFeatureSelector('suggestionNotifications'); - -// Reciter Suggestion Targets -// ---------------------------------------------------------------------------- - -/** - * Returns the Reciter Suggestion Targets State. - * @function reciterSuggestionTargetStateSelector - * @return {SuggestionNotificationsState} - */ -export function reciterSuggestionTargetStateSelector(): MemoizedSelector { - return subStateSelector(suggestionNotificationsSelector, 'suggestionTarget'); -} - -/** - * Returns the Reciter Suggestion Targets list. - * @function reciterSuggestionTargetObjectSelector - * @return {SuggestionTarget[]} - */ -export function reciterSuggestionTargetObjectSelector(): MemoizedSelector { - return subStateSelector(reciterSuggestionTargetStateSelector(), 'targets'); -} - -/** - * Returns true if the Reciter Suggestion Targets are loaded. - * @function isReciterSuggestionTargetLoadedSelector - * @return {boolean} - */ -export const isReciterSuggestionTargetLoadedSelector = createSelector(_getReciterSuggestionTargetState, - (state: SuggestionNotificationsState) => state.suggestionTarget.loaded -); - -/** - * Returns true if the deduplication sets are processing. - * @function isDeduplicationSetsProcessingSelector - * @return {boolean} - */ -export const isReciterSuggestionTargetProcessingSelector = createSelector(_getReciterSuggestionTargetState, - (state: SuggestionNotificationsState) => state.suggestionTarget.processing -); - -/** - * Returns the total available pages of Reciter Suggestion Targets. - * @function getreciterSuggestionTargetTotalPagesSelector - * @return {number} - */ -export const getReciterSuggestionTargetTotalPagesSelector = createSelector(_getReciterSuggestionTargetState, - (state: SuggestionNotificationsState) => state.suggestionTarget.totalPages -); - -/** - * Returns the current page of Reciter Suggestion Targets. - * @function getreciterSuggestionTargetCurrentPageSelector - * @return {number} - */ -export const getReciterSuggestionTargetCurrentPageSelector = createSelector(_getReciterSuggestionTargetState, - (state: SuggestionNotificationsState) => state.suggestionTarget.currentPage -); - -/** - * Returns the total number of Reciter Suggestion Targets. - * @function getreciterSuggestionTargetTotalsSelector - * @return {number} - */ -export const getReciterSuggestionTargetTotalsSelector = createSelector(_getReciterSuggestionTargetState, - (state: SuggestionNotificationsState) => state.suggestionTarget.totalElements -); - -/** - * Returns Suggestion Targets for the current user. - * @function getCurrentUserReciterSuggestionTargetSelector - * @return {SuggestionTarget[]} - */ -export const getCurrentUserSuggestionTargetsSelector = createSelector(_getReciterSuggestionTargetState, - (state: SuggestionNotificationsState) => state.suggestionTarget.currentUserTargets -); - -/** - * Returns whether or not the user has consulted their suggestions - * @function getCurrentUserReciterSuggestionTargetSelector - * @return {boolean} - */ -export const getCurrentUserSuggestionTargetsVisitedSelector = createSelector(_getReciterSuggestionTargetState, - (state: SuggestionNotificationsState) => state.suggestionTarget.currentUserTargetsVisited -); diff --git a/src/app/notifications/reciter-suggestions/suggestions-notification/suggestions-notification.component.html b/src/app/notifications/reciter-suggestions/suggestions-notification/suggestions-notification.component.html deleted file mode 100644 index 577aa496b38..00000000000 --- a/src/app/notifications/reciter-suggestions/suggestions-notification/suggestions-notification.component.html +++ /dev/null @@ -1,8 +0,0 @@ - - -
-
-
-
-
-
diff --git a/src/app/notifications/reciter-suggestions/suggestions-popup/suggestions-popup.component.html b/src/app/notifications/reciter-suggestions/suggestions-popup/suggestions-popup.component.html deleted file mode 100644 index 8b137891791..00000000000 --- a/src/app/notifications/reciter-suggestions/suggestions-popup/suggestions-popup.component.html +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/app/notifications/reciter-suggestions/suggestions-popup/suggestions-popup.component.ts b/src/app/notifications/reciter-suggestions/suggestions-popup/suggestions-popup.component.ts deleted file mode 100644 index 820df205b3d..00000000000 --- a/src/app/notifications/reciter-suggestions/suggestions-popup/suggestions-popup.component.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { Component, OnDestroy, OnInit } from '@angular/core'; -import { TranslateService } from '@ngx-translate/core'; -import { SuggestionTargetsStateService } from '../suggestion-targets/suggestion-targets.state.service'; -import { NotificationsService } from '../../../shared/notifications/notifications.service'; -import { SuggestionsService } from '../suggestions.service'; -import { takeUntil } from 'rxjs/operators'; -import { SuggestionTarget } from '../../../core/notifications/reciter-suggestions/models/suggestion-target.model'; -import { isNotEmpty } from '../../../shared/empty.util'; -import { combineLatest, Subject } from 'rxjs'; - -@Component({ - selector: 'ds-suggestions-popup', - templateUrl: './suggestions-popup.component.html', - styleUrls: ['./suggestions-popup.component.scss'] -}) -export class SuggestionsPopupComponent implements OnInit, OnDestroy { - - labelPrefix = 'mydspace.'; - - subscription; - - constructor( - private translateService: TranslateService, - private reciterSuggestionStateService: SuggestionTargetsStateService, - private notificationsService: NotificationsService, - private suggestionsService: SuggestionsService - ) { } - - ngOnInit() { - this.initializePopup(); - } - - public initializePopup() { - this.reciterSuggestionStateService.dispatchRefreshUserSuggestionsAction(); - const notifier = new Subject(); - this.subscription = combineLatest([ - this.reciterSuggestionStateService.getCurrentUserSuggestionTargets(), - this.reciterSuggestionStateService.hasUserVisitedSuggestions() - ]).pipe(takeUntil(notifier)).subscribe(([suggestions, visited]) => { - if (isNotEmpty(suggestions)) { - if (!visited) { - suggestions.forEach((suggestionTarget: SuggestionTarget) => this.showNotificationForNewSuggestions(suggestionTarget)); - this.reciterSuggestionStateService.dispatchMarkUserSuggestionsAsVisitedAction(); - notifier.next(null); - notifier.complete(); - } - } - }); - } - - /** - * Show a notification to user for a new suggestions detected - * @param suggestionTarget - * @private - */ - private showNotificationForNewSuggestions(suggestionTarget: SuggestionTarget): void { - const content = this.translateService.instant(this.labelPrefix + 'notification.suggestion', - this.suggestionsService.getNotificationSuggestionInterpolation(suggestionTarget)); - this.notificationsService.success('', content, {timeOut:0}, true); - } - - ngOnDestroy() { - if (this.subscription) { - this.subscription.unsubscribe(); - } - } - -} diff --git a/src/app/notifications/reciter-suggestions/suggestion-actions/suggestion-actions.component.html b/src/app/notifications/suggestion-actions/suggestion-actions.component.html similarity index 69% rename from src/app/notifications/reciter-suggestions/suggestion-actions/suggestion-actions.component.html rename to src/app/notifications/suggestion-actions/suggestion-actions.component.html index 7ec3e61395e..2a46191deea 100644 --- a/src/app/notifications/reciter-suggestions/suggestion-actions/suggestion-actions.component.html +++ b/src/app/notifications/suggestion-actions/suggestion-actions.component.html @@ -19,10 +19,11 @@ - - + diff --git a/src/app/notifications/reciter-suggestions/suggestion-actions/suggestion-actions.component.scss b/src/app/notifications/suggestion-actions/suggestion-actions.component.scss similarity index 100% rename from src/app/notifications/reciter-suggestions/suggestion-actions/suggestion-actions.component.scss rename to src/app/notifications/suggestion-actions/suggestion-actions.component.scss diff --git a/src/app/notifications/reciter-suggestions/suggestion-actions/suggestion-actions.component.ts b/src/app/notifications/suggestion-actions/suggestion-actions.component.ts similarity index 70% rename from src/app/notifications/reciter-suggestions/suggestion-actions/suggestion-actions.component.ts rename to src/app/notifications/suggestion-actions/suggestion-actions.component.ts index ea2e4cfe337..5f1443794ca 100644 --- a/src/app/notifications/reciter-suggestions/suggestion-actions/suggestion-actions.component.ts +++ b/src/app/notifications/suggestion-actions/suggestion-actions.component.ts @@ -1,12 +1,16 @@ import { Component, EventEmitter, Input, Output } from '@angular/core'; -import { ItemType } from '../../../core/shared/item-relationships/item-type.model'; +import { ItemType } from '../../core/shared/item-relationships/item-type.model'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; -import { Suggestion } from '../../../core/notifications/reciter-suggestions/models/suggestion.model'; -import { SuggestionApproveAndImport } from '../suggestion-list-element/suggestion-list-element.component'; -import { Collection } from '../../../core/shared/collection.model'; + +import { Collection } from '../../core/shared/collection.model'; import { take } from 'rxjs/operators'; -import { CreateItemParentSelectorComponent } from '../../../shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component'; +import { CreateItemParentSelectorComponent } from '../../shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component'; +import { Suggestion } from '../../core/notifications/models/suggestion.model'; +import { SuggestionApproveAndImport } from '../suggestion-list-element/suggestion-list-element.component'; +/** + * Show and trigger the actions to submit for a suggestion + */ @Component({ selector: 'ds-suggestion-actions', styleUrls: [ './suggestion-actions.component.scss' ], @@ -27,7 +31,7 @@ export class SuggestionActionsComponent { /** * The component is used to Delete suggestion */ - @Output() notMineClicked = new EventEmitter(); + @Output() ignoreSuggestionClicked = new EventEmitter(); /** * The component is used to approve & import @@ -71,8 +75,8 @@ export class SuggestionActionsComponent { /** * Delete the suggestion */ - notMine() { - this.notMineClicked.emit(this.isBulk ? undefined : this.object.id); + ignoreSuggestion() { + this.ignoreSuggestionClicked.emit(this.isBulk ? undefined : this.object.id); } /** @@ -82,11 +86,11 @@ export class SuggestionActionsComponent { this.seeEvidences.emit(!this.seeEvidence); } - notMineLabel(): string { - return this.isBulk ? 'reciter.suggestion.notMine.bulk' : 'reciter.suggestion.notMine' ; + ignoreSuggestionLabel(): string { + return this.isBulk ? 'suggestion.ignoreSuggestion.bulk' : 'suggestion.ignoreSuggestion' ; } approveAndImportLabel(): string { - return this.isBulk ? 'reciter.suggestion.approveAndImport.bulk' : 'reciter.suggestion.approveAndImport'; + return this.isBulk ? 'suggestion.approveAndImport.bulk' : 'suggestion.approveAndImport'; } } diff --git a/src/app/notifications/reciter-suggestions/suggestion-list-element/suggestion-evidences/suggestion-evidences.component.html b/src/app/notifications/suggestion-list-element/suggestion-evidences/suggestion-evidences.component.html similarity index 66% rename from src/app/notifications/reciter-suggestions/suggestion-list-element/suggestion-evidences/suggestion-evidences.component.html rename to src/app/notifications/suggestion-list-element/suggestion-evidences/suggestion-evidences.component.html index 5ad4f0a9783..e23c244eb49 100644 --- a/src/app/notifications/reciter-suggestions/suggestion-list-element/suggestion-evidences/suggestion-evidences.component.html +++ b/src/app/notifications/suggestion-list-element/suggestion-evidences/suggestion-evidences.component.html @@ -3,9 +3,9 @@ - - - + + + diff --git a/src/app/notifications/suggestion-list-element/suggestion-evidences/suggestion-evidences.component.scss b/src/app/notifications/suggestion-list-element/suggestion-evidences/suggestion-evidences.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/app/notifications/reciter-suggestions/suggestion-list-element/suggestion-evidences/suggestion-evidences.component.ts b/src/app/notifications/suggestion-list-element/suggestion-evidences/suggestion-evidences.component.ts similarity index 59% rename from src/app/notifications/reciter-suggestions/suggestion-list-element/suggestion-evidences/suggestion-evidences.component.ts rename to src/app/notifications/suggestion-list-element/suggestion-evidences/suggestion-evidences.component.ts index 3f1e3d19b83..d58f8d39c18 100644 --- a/src/app/notifications/reciter-suggestions/suggestion-list-element/suggestion-evidences/suggestion-evidences.component.ts +++ b/src/app/notifications/suggestion-list-element/suggestion-evidences/suggestion-evidences.component.ts @@ -1,7 +1,11 @@ import { Component, Input } from '@angular/core'; -import { fadeIn } from '../../../../shared/animations/fade'; -import { SuggestionEvidences } from '../../../../core/notifications/reciter-suggestions/models/suggestion.model'; +import { fadeIn } from '../../../shared/animations/fade'; +import { SuggestionEvidences } from '../../../core/notifications/models/suggestion.model'; + +/** + * Show suggestion evidences such as score (authorScore, dateScore) + */ @Component({ selector: 'ds-suggestion-evidences', styleUrls: [ './suggestion-evidences.component.scss' ], diff --git a/src/app/notifications/reciter-suggestions/suggestion-list-element/suggestion-list-element.component.html b/src/app/notifications/suggestion-list-element/suggestion-list-element.component.html similarity index 85% rename from src/app/notifications/reciter-suggestions/suggestion-list-element/suggestion-list-element.component.html rename to src/app/notifications/suggestion-list-element/suggestion-list-element.component.html index f37d595c45b..ef27876f2cb 100644 --- a/src/app/notifications/reciter-suggestions/suggestion-list-element/suggestion-list-element.component.html +++ b/src/app/notifications/suggestion-list-element/suggestion-list-element.component.html @@ -4,13 +4,14 @@
+ [checked]="isSelected" (change)="changeSelected($event)" + [attr.aria-label]="object.display"/>
-
{{'reciter.suggestion.totalScore' | translate}}
+
{{'suggestion.totalScore' | translate}}
{{ object.score }}
@@ -19,6 +20,7 @@ @@ -29,7 +31,7 @@ [isCollectionFixed]="isCollectionFixed" (approveAndImport)="onApproveAndImport($event)" (seeEvidences)="onSeeEvidences($event)" - (notMineClicked)="onNotMine($event)" + (ignoreSuggestionClicked)="onIgnoreSuggestion($event)" > diff --git a/src/app/notifications/reciter-suggestions/suggestion-list-element/suggestion-list-element.component.scss b/src/app/notifications/suggestion-list-element/suggestion-list-element.component.scss similarity index 100% rename from src/app/notifications/reciter-suggestions/suggestion-list-element/suggestion-list-element.component.scss rename to src/app/notifications/suggestion-list-element/suggestion-list-element.component.scss diff --git a/src/app/notifications/suggestion-list-element/suggestion-list-element.component.spec.ts b/src/app/notifications/suggestion-list-element/suggestion-list-element.component.spec.ts new file mode 100644 index 00000000000..61d1a19996c --- /dev/null +++ b/src/app/notifications/suggestion-list-element/suggestion-list-element.component.spec.ts @@ -0,0 +1,81 @@ +import { SuggestionListElementComponent } from './suggestion-list-element.component'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { TestScheduler } from 'rxjs/testing'; +import { getTestScheduler } from 'jasmine-marbles'; +import { mockSuggestionPublicationOne } from '../../shared/mocks/publication-claim.mock'; +import { Item } from '../../core/shared/item.model'; + + +describe('SuggestionListElementComponent', () => { + let component: SuggestionListElementComponent; + let fixture: ComponentFixture; + let scheduler: TestScheduler; + + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot() + ], + declarations: [SuggestionListElementComponent], + providers: [ + NgbModal + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents().then(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(SuggestionListElementComponent); + component = fixture.componentInstance; + scheduler = getTestScheduler(); + + component.object = mockSuggestionPublicationOne; + }); + + describe('SuggestionListElementComponent test', () => { + + it('should create', () => { + scheduler.schedule(() => fixture.detectChanges()); + scheduler.flush(); + const expectedIndexableObject = Object.assign(new Item(), { + id: mockSuggestionPublicationOne.id, + metadata: mockSuggestionPublicationOne.metadata + }); + expect(component).toBeTruthy(); + expect(component.listableObject.hitHighlights).toEqual({}); + expect(component.listableObject.indexableObject).toEqual(expectedIndexableObject); + }); + + it('should check if has evidence', () => { + expect(component.hasEvidences()).toBeTruthy(); + }); + + it('should set seeEvidences', () => { + component.onSeeEvidences(true); + expect(component.seeEvidence).toBeTruthy(); + }); + + it('should emit selection', () => { + spyOn(component.selected, 'next'); + component.changeSelected({target: { checked: true}}); + expect(component.selected.next).toHaveBeenCalledWith(true); + }); + + it('should emit for deletion', () => { + spyOn(component.ignoreSuggestionClicked, 'emit'); + component.onIgnoreSuggestion('1234'); + expect(component.ignoreSuggestionClicked.emit).toHaveBeenCalledWith('1234'); + }); + + it('should emit for approve and import', () => { + const event = {collectionId:'1234', suggestion: mockSuggestionPublicationOne}; + spyOn(component.approveAndImport, 'emit'); + component.onApproveAndImport(event); + expect(component.approveAndImport.emit).toHaveBeenCalledWith(event); + }); + }); +}); diff --git a/src/app/notifications/reciter-suggestions/suggestion-list-element/suggestion-list-element.component.ts b/src/app/notifications/suggestion-list-element/suggestion-list-element.component.ts similarity index 77% rename from src/app/notifications/reciter-suggestions/suggestion-list-element/suggestion-list-element.component.ts rename to src/app/notifications/suggestion-list-element/suggestion-list-element.component.ts index 08604d9daff..bc5db3d1bfb 100644 --- a/src/app/notifications/reciter-suggestions/suggestion-list-element/suggestion-list-element.component.ts +++ b/src/app/notifications/suggestion-list-element/suggestion-list-element.component.ts @@ -1,17 +1,24 @@ import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { Suggestion } from 'src/app/core/notifications/models/suggestion.model'; +import { fadeIn } from '../../shared/animations/fade'; +import { Item } from '../../core/shared/item.model'; +import { isNotEmpty } from '../../shared/empty.util'; -import { fadeIn } from '../../../shared/animations/fade'; -import { Suggestion } from '../../../core/notifications/reciter-suggestions/models/suggestion.model'; -import { Item } from '../../../core/shared/item.model'; -import { isNotEmpty } from '../../../shared/empty.util'; + +/** + * A simple interface to unite a specific suggestion and the id of the chosen collection + */ export interface SuggestionApproveAndImport { suggestion: Suggestion; collectionId: string; } +/** + * Show all the suggestions by researcher + */ @Component({ selector: 'ds-suggestion-list-item', styleUrls: ['./suggestion-list-element.component.scss'], @@ -33,7 +40,7 @@ export class SuggestionListElementComponent implements OnInit { /** * The component is used to Delete suggestion */ - @Output() notMineClicked = new EventEmitter(); + @Output() ignoreSuggestionClicked = new EventEmitter(); /** * The component is used to approve & import @@ -69,8 +76,8 @@ export class SuggestionListElementComponent implements OnInit { /** * Delete the suggestion */ - onNotMine(suggestionId: string) { - this.notMineClicked.emit(suggestionId); + onIgnoreSuggestion(suggestionId: string) { + this.ignoreSuggestionClicked.emit(suggestionId); } /** diff --git a/src/app/notifications/reciter-suggestions/suggestion-targets/suggestion-targets.component.html b/src/app/notifications/suggestion-targets/publication-claim/publication-claim.component.html similarity index 65% rename from src/app/notifications/reciter-suggestions/suggestion-targets/suggestion-targets.component.html rename to src/app/notifications/suggestion-targets/publication-claim/publication-claim.component.html index 280f574ec4c..f2ab1e5b652 100644 --- a/src/app/notifications/reciter-suggestions/suggestion-targets/suggestion-targets.component.html +++ b/src/app/notifications/suggestion-targets/publication-claim/publication-claim.component.html @@ -1,9 +1,9 @@
- +

{{'suggestion.title'| translate}}

- + {{'reciter.suggestion.title'| transla [retainScrollPosition]="false" (paginationChange)="getSuggestionTargets()"> - +
{{'reciter.suggestion.evidence.score' | translate}}{{'reciter.suggestion.evidence.type' | translate}}{{'reciter.suggestion.evidence.notes' | translate}}{{'suggestion.evidence.score' | translate}}{{'suggestion.evidence.type' | translate}}{{'suggestion.evidence.notes' | translate}}
- - + +
{{'reciter.suggestion.table.name' | translate}}{{'reciter.suggestion.table.actions' | translate}}{{'suggestion.table.name' | translate}}{{'suggestion.table.actions' | translate}}
- {{targetElement.display}} + {{targetElement.display}}
-
diff --git a/src/app/notifications/suggestion-targets/publication-claim/publication-claim.component.scss b/src/app/notifications/suggestion-targets/publication-claim/publication-claim.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/app/notifications/reciter-suggestions/suggestion-targets/suggestion-targets.component.ts b/src/app/notifications/suggestion-targets/publication-claim/publication-claim.component.ts similarity index 75% rename from src/app/notifications/reciter-suggestions/suggestion-targets/suggestion-targets.component.ts rename to src/app/notifications/suggestion-targets/publication-claim/publication-claim.component.ts index ad45d1d8fcb..1b6d70cba4e 100644 --- a/src/app/notifications/reciter-suggestions/suggestion-targets/suggestion-targets.component.ts +++ b/src/app/notifications/suggestion-targets/publication-claim/publication-claim.component.ts @@ -4,23 +4,24 @@ import { Router } from '@angular/router'; import { Observable, Subscription } from 'rxjs'; import { distinctUntilChanged, take } from 'rxjs/operators'; -import { SuggestionTarget } from '../../../core/notifications/reciter-suggestions/models/suggestion-target.model'; + import { hasValue } from '../../../shared/empty.util'; import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; -import { SuggestionTargetsStateService } from './suggestion-targets.state.service'; import { getSuggestionPageRoute } from '../../../suggestions-page/suggestions-page-routing-paths'; -import { SuggestionsService } from '../suggestions.service'; import { PaginationService } from '../../../core/pagination/pagination.service'; +import { SuggestionTarget } from '../../../core/notifications/models/suggestion-target.model'; +import { SuggestionTargetsStateService } from '../suggestion-targets.state.service'; +import { SuggestionsService } from '../../suggestions.service'; /** * Component to display the Suggestion Target list. */ @Component({ - selector: 'ds-suggestion-target', - templateUrl: './suggestion-targets.component.html', - styleUrls: ['./suggestion-targets.component.scss'], + selector: 'ds-publication-claim', + templateUrl: './publication-claim.component.html', + styleUrls: ['./publication-claim.component.scss'], }) -export class SuggestionTargetsComponent implements OnInit { +export class PublicationClaimComponent implements OnInit { /** * The source for which to list targets @@ -69,16 +70,11 @@ export class SuggestionTargetsComponent implements OnInit { * Component initialization. */ ngOnInit(): void { - this.targets$ = this.suggestionTargetsStateService.getReciterSuggestionTargets(); - this.totalElements$ = this.suggestionTargetsStateService.getReciterSuggestionTargetsTotals(); - } + this.targets$ = this.suggestionTargetsStateService.getSuggestionTargets(); + this.totalElements$ = this.suggestionTargetsStateService.getSuggestionTargetsTotals(); - /** - * First Suggestion Targets loading after view initialization. - */ - ngAfterViewInit(): void { this.subs.push( - this.suggestionTargetsStateService.isReciterSuggestionTargetsLoaded().pipe( + this.suggestionTargetsStateService.isSuggestionTargetsLoaded().pipe( take(1) ).subscribe(() => { this.getSuggestionTargets(); @@ -93,7 +89,7 @@ export class SuggestionTargetsComponent implements OnInit { * 'true' if the targets are loading, 'false' otherwise. */ public isTargetsLoading(): Observable { - return this.suggestionTargetsStateService.isReciterSuggestionTargetsLoading(); + return this.suggestionTargetsStateService.isSuggestionTargetsLoading(); } /** @@ -103,7 +99,7 @@ export class SuggestionTargetsComponent implements OnInit { * 'true' if there are operations running on the targets (ex.: a REST call), 'false' otherwise. */ public isTargetsProcessing(): Observable { - return this.suggestionTargetsStateService.isReciterSuggestionTargetsProcessing(); + return this.suggestionTargetsStateService.isSuggestionTargetsProcessing(); } /** @@ -111,10 +107,8 @@ export class SuggestionTargetsComponent implements OnInit { * * @param {string} id * the id of suggestion target - * @param {string} name - * the name of suggestion target */ - public redirectToSuggestions(id: string, name: string) { + public redirectToSuggestions(id: string) { this.router.navigate([getSuggestionPageRoute(id)]); } @@ -136,8 +130,7 @@ export class SuggestionTargetsComponent implements OnInit { distinctUntilChanged(), take(1) ).subscribe((options: PaginationComponentOptions) => { - console.log('HELLO suggestion called!', options); - this.suggestionTargetsStateService.dispatchRetrieveReciterSuggestionTargets( + this.suggestionTargetsStateService.dispatchRetrieveSuggestionTargets( this.source, options.pageSize, options.currentPage diff --git a/src/app/notifications/reciter-suggestions/suggestion-targets/suggestion-targets.actions.ts b/src/app/notifications/suggestion-targets/suggestion-targets.actions.ts similarity index 96% rename from src/app/notifications/reciter-suggestions/suggestion-targets/suggestion-targets.actions.ts rename to src/app/notifications/suggestion-targets/suggestion-targets.actions.ts index f65df984238..281e07d99e1 100644 --- a/src/app/notifications/reciter-suggestions/suggestion-targets/suggestion-targets.actions.ts +++ b/src/app/notifications/suggestion-targets/suggestion-targets.actions.ts @@ -1,7 +1,8 @@ /* eslint-disable max-classes-per-file */ import { Action } from '@ngrx/store'; -import { type } from '../../../shared/ngrx/type'; -import { SuggestionTarget } from '../../../core/notifications/reciter-suggestions/models/suggestion-target.model'; +import { type } from '../../shared/ngrx/type'; +import { SuggestionTarget } from '../../core/notifications/models/suggestion-target.model'; + /** * For each action type in an action group, make a simple diff --git a/src/app/notifications/reciter-suggestions/suggestion-targets/suggestion-targets.effects.ts b/src/app/notifications/suggestion-targets/suggestion-targets.effects.ts similarity index 86% rename from src/app/notifications/reciter-suggestions/suggestion-targets/suggestion-targets.effects.ts rename to src/app/notifications/suggestion-targets/suggestion-targets.effects.ts index 05793afd6bf..f8be925d8ba 100644 --- a/src/app/notifications/reciter-suggestions/suggestion-targets/suggestion-targets.effects.ts +++ b/src/app/notifications/suggestion-targets/suggestion-targets.effects.ts @@ -9,15 +9,15 @@ import { of } from 'rxjs'; import { AddTargetAction, AddUserSuggestionsAction, - RefreshUserSuggestionsAction, RetrieveAllTargetsErrorAction, RetrieveTargetsBySourceAction, SuggestionTargetActionTypes, } from './suggestion-targets.actions'; -import { PaginatedList } from '../../../core/data/paginated-list.model'; +import { PaginatedList } from '../../core/data/paginated-list.model'; import { SuggestionsService } from '../suggestions.service'; -import { NotificationsService } from '../../../shared/notifications/notifications.service'; -import { SuggestionTarget } from '../../../core/notifications/reciter-suggestions/models/suggestion-target.model'; +import { SuggestionTarget } from '../../core/notifications/models/suggestion-target.model'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; + /** * Provides effect methods for the Suggestion Targets actions. @@ -55,7 +55,7 @@ export class SuggestionTargetsEffects { retrieveAllTargetsErrorAction$ = createEffect(() => this.actions$.pipe( ofType(SuggestionTargetActionTypes.RETRIEVE_TARGETS_BY_SOURCE_ERROR), tap(() => { - this.notificationsService.error(null, this.translate.get('reciter.suggestion.target.error.service.retrieve')); + this.notificationsService.error(null, this.translate.get('suggestion.target.error.service.retrieve')); }) ), { dispatch: false }); @@ -64,7 +64,7 @@ export class SuggestionTargetsEffects { */ refreshUserSuggestionsAction$ = createEffect(() => this.actions$.pipe( ofType(SuggestionTargetActionTypes.REFRESH_USER_SUGGESTIONS), - switchMap((action: RefreshUserSuggestionsAction) => { + switchMap(() => { return this.store$.select((state: any) => state.core.auth.userId) .pipe( switchMap((userId: string) => { @@ -76,7 +76,8 @@ export class SuggestionTargetsEffects { }), catchError((errors) => of(errors)) ); - })), { dispatch: false }); + })) + ); /** * Initialize the effect class variables. diff --git a/src/app/notifications/reciter-suggestions/suggestion-targets/suggestion-targets.reducer.ts b/src/app/notifications/suggestion-targets/suggestion-targets.reducer.ts similarity index 95% rename from src/app/notifications/reciter-suggestions/suggestion-targets/suggestion-targets.reducer.ts rename to src/app/notifications/suggestion-targets/suggestion-targets.reducer.ts index 4cf91f8ea39..6ba29303b34 100644 --- a/src/app/notifications/reciter-suggestions/suggestion-targets/suggestion-targets.reducer.ts +++ b/src/app/notifications/suggestion-targets/suggestion-targets.reducer.ts @@ -1,5 +1,6 @@ +import { SuggestionTarget } from '../../core/notifications/models/suggestion-target.model'; import { SuggestionTargetActionTypes, SuggestionTargetsActions } from './suggestion-targets.actions'; -import { SuggestionTarget } from '../../../core/notifications/reciter-suggestions/models/suggestion-target.model'; + /** * The interface representing the OpenAIRE suggestion targets state. diff --git a/src/app/notifications/reciter-suggestions/suggestion-targets/suggestion-targets.state.service.ts b/src/app/notifications/suggestion-targets/suggestion-targets.state.service.ts similarity index 54% rename from src/app/notifications/reciter-suggestions/suggestion-targets/suggestion-targets.state.service.ts rename to src/app/notifications/suggestion-targets/suggestion-targets.state.service.ts index 745e541df68..1ca01b10f29 100644 --- a/src/app/notifications/reciter-suggestions/suggestion-targets/suggestion-targets.state.service.ts +++ b/src/app/notifications/suggestion-targets/suggestion-targets.state.service.ts @@ -4,23 +4,23 @@ import { select, Store } from '@ngrx/store'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; -import { - getCurrentUserSuggestionTargetsSelector, - getCurrentUserSuggestionTargetsVisitedSelector, - getReciterSuggestionTargetCurrentPageSelector, - getReciterSuggestionTargetTotalsSelector, - isReciterSuggestionTargetLoadedSelector, - isReciterSuggestionTargetProcessingSelector, - reciterSuggestionTargetObjectSelector -} from '../selectors'; -import { SuggestionTarget } from '../../../core/notifications/reciter-suggestions/models/suggestion-target.model'; + import { ClearSuggestionTargetsAction, MarkUserSuggestionsAsVisitedAction, RefreshUserSuggestionsAction, RetrieveTargetsBySourceAction } from './suggestion-targets.actions'; -import { SuggestionNotificationsState } from '../../notifications.reducer'; +import { SuggestionNotificationsState } from '../../notifications/notifications.reducer'; +import { SuggestionTarget } from '../../core/notifications/models/suggestion-target.model'; +import { + getCurrentUserSuggestionTargetsSelector, getCurrentUserSuggestionTargetsVisitedSelector, + getSuggestionTargetCurrentPageSelector, + getSuggestionTargetTotalsSelector, + isReciterSuggestionTargetProcessingSelector, + isSuggestionTargetLoadedSelector, + suggestionTargetObjectSelector +} from '../../suggestion-notifications/selectors'; /** * The service handling the Suggestion targets State. @@ -35,80 +35,80 @@ export class SuggestionTargetsStateService { constructor(private store: Store) { } /** - * Returns the list of Reciter Suggestion Targets from the state. + * Returns the list of Suggestion Targets from the state. * - * @return Observable - * The list of Reciter Suggestion Targets. + * @return Observable + * The list of Suggestion Targets. */ - public getReciterSuggestionTargets(): Observable { - return this.store.pipe(select(reciterSuggestionTargetObjectSelector())); + public getSuggestionTargets(): Observable { + return this.store.pipe(select(suggestionTargetObjectSelector())); } /** - * Returns the information about the loading status of the Reciter Suggestion Targets (if it's running or not). + * Returns the information about the loading status of the Suggestion Targets (if it's running or not). * * @return Observable * 'true' if the targets are loading, 'false' otherwise. */ - public isReciterSuggestionTargetsLoading(): Observable { + public isSuggestionTargetsLoading(): Observable { return this.store.pipe( - select(isReciterSuggestionTargetLoadedSelector), + select(isSuggestionTargetLoadedSelector), map((loaded: boolean) => !loaded) ); } /** - * Returns the information about the loading status of the Reciter Suggestion Targets (whether or not they were loaded). + * Returns the information about the loading status of the Suggestion Targets (whether or not they were loaded). * * @return Observable * 'true' if the targets are loaded, 'false' otherwise. */ - public isReciterSuggestionTargetsLoaded(): Observable { - return this.store.pipe(select(isReciterSuggestionTargetLoadedSelector)); + public isSuggestionTargetsLoaded(): Observable { + return this.store.pipe(select(isSuggestionTargetLoadedSelector)); } /** - * Returns the information about the processing status of the Reciter Suggestion Targets (if it's running or not). + * Returns the information about the processing status of the Suggestion Targets (if it's running or not). * * @return Observable * 'true' if there are operations running on the targets (ex.: a REST call), 'false' otherwise. */ - public isReciterSuggestionTargetsProcessing(): Observable { + public isSuggestionTargetsProcessing(): Observable { return this.store.pipe(select(isReciterSuggestionTargetProcessingSelector)); } /** - * Returns, from the state, the total available pages of the Reciter Suggestion Targets. + * Returns, from the state, the total available pages of the Suggestion Targets. * * @return Observable - * The number of the Reciter Suggestion Targets pages. + * The number of the Suggestion Targets pages. */ - public getReciterSuggestionTargetsTotalPages(): Observable { - return this.store.pipe(select(getReciterSuggestionTargetTotalsSelector)); + public getSuggestionTargetsTotalPages(): Observable { + return this.store.pipe(select(getSuggestionTargetTotalsSelector)); } /** - * Returns the current page of the Reciter Suggestion Targets, from the state. + * Returns the current page of the Suggestion Targets, from the state. * * @return Observable - * The number of the current Reciter Suggestion Targets page. + * The number of the current Suggestion Targets page. */ - public getReciterSuggestionTargetsCurrentPage(): Observable { - return this.store.pipe(select(getReciterSuggestionTargetCurrentPageSelector)); + public getSuggestionTargetsCurrentPage(): Observable { + return this.store.pipe(select(getSuggestionTargetCurrentPageSelector)); } /** - * Returns the total number of the Reciter Suggestion Targets. + * Returns the total number of the Suggestion Targets. * * @return Observable - * The number of the Reciter Suggestion Targets. + * The number of the Suggestion Targets. */ - public getReciterSuggestionTargetsTotals(): Observable { - return this.store.pipe(select(getReciterSuggestionTargetTotalsSelector)); + public getSuggestionTargetsTotals(): Observable { + return this.store.pipe(select(getSuggestionTargetTotalsSelector)); } /** - * Dispatch a request to change the Reciter Suggestion Targets state, retrieving the targets from the server. + * Dispatch a request to change the Suggestion Targets state, retrieving the targets from the server. * * @param source * the source for which to retrieve suggestion targets @@ -117,15 +117,15 @@ export class SuggestionTargetsStateService { * @param currentPage * The number of the current page. */ - public dispatchRetrieveReciterSuggestionTargets(source: string, elementsPerPage: number, currentPage: number): void { + public dispatchRetrieveSuggestionTargets(source: string, elementsPerPage: number, currentPage: number): void { this.store.dispatch(new RetrieveTargetsBySourceAction(source, elementsPerPage, currentPage)); } /** - * Returns, from the state, the reciter suggestion targets for the current user. + * Returns, from the state, the suggestion targets for the current user. * * @return Observable - * The Reciter Suggestion Targets object. + * The Suggestion Targets object. */ public getCurrentUserSuggestionTargets(): Observable { return this.store.pipe(select(getCurrentUserSuggestionTargetsSelector)); diff --git a/src/app/notifications/suggestion.service.spec.ts b/src/app/notifications/suggestion.service.spec.ts new file mode 100644 index 00000000000..082c7c3f04e --- /dev/null +++ b/src/app/notifications/suggestion.service.spec.ts @@ -0,0 +1,176 @@ +import { SuggestionsService } from './suggestions.service'; +import { ResearcherProfileDataService } from '../core/profile/researcher-profile-data.service'; + +import { TestScheduler } from 'rxjs/testing'; +import { getTestScheduler } from 'jasmine-marbles'; +import { of as observableOf } from 'rxjs'; +import { FindListOptions } from '../core/data/find-list-options.model'; +import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model'; +import { ResearcherProfile } from '../core/profile/model/researcher-profile.model'; +import { createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils'; +import { WorkspaceitemDataService } from '../core/submission/workspaceitem-data.service'; +import { mockSuggestionPublicationOne } from '../shared/mocks/publication-claim.mock'; +import { ResourceType } from '../core/shared/resource-type'; +import { SuggestionsDataService } from '../core/notifications/suggestions-data.service'; +import { SuggestionTargetDataService } from '../core/notifications/target/suggestion-target-data.service'; +import { SuggestionTarget } from '../core/notifications/models/suggestion-target.model'; + + + +describe('SuggestionsService test', () => { + let scheduler: TestScheduler; + let service: SuggestionsService; + let researcherProfileService: ResearcherProfileDataService; + let suggestionsDataService: SuggestionsDataService; + let suggestionTargetDataService: SuggestionTargetDataService; + let translateService: any = { + instant: (str) => str, + }; + const suggestionTarget = { + id: '1234:4321', + display: 'display', + source: 'source', + total: 8, + type: new ResourceType('suggestiontarget') + }; + + const mockResercherProfile = { + id: '1234', + uuid: '1234', + visible: true + }; + + function initTestService() { + return new SuggestionsService( + researcherProfileService, + suggestionsDataService, + suggestionTargetDataService, + translateService + ); + } + + beforeEach(() => { + scheduler = getTestScheduler(); + + researcherProfileService = jasmine.createSpyObj('researcherProfileService', { + findById: createSuccessfulRemoteDataObject$(mockResercherProfile as ResearcherProfile), + findRelatedItemId: observableOf('1234'), + }); + + suggestionTargetDataService = jasmine.createSpyObj('suggestionTargetsDataService', { + getTargets: observableOf(null), + findById: observableOf(null), + }); + + suggestionsDataService = jasmine.createSpyObj('suggestionsDataService', { + searchBy: observableOf(null), + delete: observableOf(null), + deleteSuggestion: createSuccessfulRemoteDataObject$({}), + getSuggestionsByTargetAndSource : observableOf(null), + clearSuggestionRequests : null, + getTargetsByUser: observableOf(null), + }); + + service = initTestService(); + + }); + + describe('Suggestion service', () => { + it('should create', () => { + expect(service).toBeDefined(); + }); + + it('should get targets', () => { + const sortOptions = new SortOptions('display', SortDirection.ASC); + const findListOptions: FindListOptions = { + elementsPerPage: 10, + currentPage: 1, + sort: sortOptions + }; + service.getTargets('source', 10, 1); + expect(suggestionTargetDataService.getTargets).toHaveBeenCalledWith('source', findListOptions); + }); + + it('should get suggestions', () => { + const sortOptions = new SortOptions('display', SortDirection.ASC); + const findListOptions: FindListOptions = { + elementsPerPage: 10, + currentPage: 1, + sort: sortOptions + }; + service.getSuggestions('source:target', 10, 1, sortOptions); + expect(suggestionsDataService.getSuggestionsByTargetAndSource).toHaveBeenCalledWith('target', 'source', findListOptions); + }); + + it('should clear suggestions', () => { + service.clearSuggestionRequests(); + expect(suggestionsDataService.clearSuggestionRequests).toHaveBeenCalled(); + }); + + it('should delete reviewed suggestion', () => { + service.deleteReviewedSuggestion('1234'); + expect(suggestionsDataService.deleteSuggestion).toHaveBeenCalledWith('1234'); + }); + + it('should retrieve current user suggestions', () => { + service.retrieveCurrentUserSuggestions('1234'); + expect(researcherProfileService.findById).toHaveBeenCalledWith('1234', true); + }); + + it('should approve and import suggestion', () => { + spyOn(service, 'resolveCollectionId'); + const workspaceitemService = {importExternalSourceEntry: (x,y) => observableOf(null)}; + service.approveAndImport(workspaceitemService as unknown as WorkspaceitemDataService, mockSuggestionPublicationOne, '1234'); + expect(service.resolveCollectionId).toHaveBeenCalled(); + }); + + it('should approve and import suggestions', () => { + spyOn(service, 'approveAndImport'); + const workspaceitemService = {importExternalSourceEntry: (x,y) => observableOf(null)}; + service.approveAndImportMultiple(workspaceitemService as unknown as WorkspaceitemDataService, [mockSuggestionPublicationOne], '1234'); + expect(service.approveAndImport).toHaveBeenCalledWith(workspaceitemService as unknown as WorkspaceitemDataService, mockSuggestionPublicationOne, '1234'); + }); + + it('should delete suggestion', () => { + spyOn(service, 'deleteReviewedSuggestion').and.returnValue(createSuccessfulRemoteDataObject$({})); + service.ignoreSuggestion('1234'); + expect(service.deleteReviewedSuggestion).toHaveBeenCalledWith('1234'); + }); + + it('should delete suggestions', () => { + spyOn(service, 'ignoreSuggestion'); + service.ignoreSuggestionMultiple([mockSuggestionPublicationOne]); + expect(service.ignoreSuggestion).toHaveBeenCalledWith(mockSuggestionPublicationOne.id); + }); + + it('should get target Uuid', () => { + expect(service.getTargetUuid(suggestionTarget as SuggestionTarget)).toBe('4321'); + expect(service.getTargetUuid({id: ''} as SuggestionTarget)).toBe(null); + }); + + it('should get suggestion interpolation', () => { + const result = service.getNotificationSuggestionInterpolation(suggestionTarget as SuggestionTarget); + expect(result.count).toEqual(suggestionTarget.total); + expect(result.source).toEqual('suggestion.source.' + suggestionTarget.source); + expect(result.type).toEqual('suggestion.type.' + suggestionTarget.source); + expect(result.suggestionId).toEqual(suggestionTarget.id); + expect(result.displayName).toEqual(suggestionTarget.display); + }); + + it('should translate suggestion type', () => { + expect(service.translateSuggestionType('source')).toEqual('suggestion.type.source'); + }); + + it('should translate suggestion source', () => { + expect(service.translateSuggestionSource('source')).toEqual('suggestion.source.source'); + }); + + it('should resolve collection id', () => { + expect(service.resolveCollectionId(mockSuggestionPublicationOne, '1234')).toEqual('1234'); + }); + + it('should check if collection is fixed', () => { + expect(service.isCollectionFixed([mockSuggestionPublicationOne])).toBeFalse(); + }); + }); +}); diff --git a/src/app/notifications/suggestions-notification/suggestions-notification.component.html b/src/app/notifications/suggestions-notification/suggestions-notification.component.html new file mode 100644 index 00000000000..838bdb95ad4 --- /dev/null +++ b/src/app/notifications/suggestions-notification/suggestions-notification.component.html @@ -0,0 +1,9 @@ + + +
+
+ {{ 'notification.suggestion.please' | translate }} + {{ 'notification.suggestion.review' | translate}} +
+
+
diff --git a/src/app/notifications/suggestions-notification/suggestions-notification.component.scss b/src/app/notifications/suggestions-notification/suggestions-notification.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/app/notifications/reciter-suggestions/suggestions-notification/suggestions-notification.component.ts b/src/app/notifications/suggestions-notification/suggestions-notification.component.ts similarity index 66% rename from src/app/notifications/reciter-suggestions/suggestions-notification/suggestions-notification.component.ts rename to src/app/notifications/suggestions-notification/suggestions-notification.component.ts index 74c62e4de35..107da67fce8 100644 --- a/src/app/notifications/reciter-suggestions/suggestions-notification/suggestions-notification.component.ts +++ b/src/app/notifications/suggestions-notification/suggestions-notification.component.ts @@ -1,11 +1,13 @@ import { Component, OnInit } from '@angular/core'; -import { SuggestionTarget } from '../../../core/notifications/reciter-suggestions/models/suggestion-target.model'; -import { TranslateService } from '@ngx-translate/core'; + import { SuggestionTargetsStateService } from '../suggestion-targets/suggestion-targets.state.service'; -import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { SuggestionsService } from '../suggestions.service'; import { Observable } from 'rxjs'; +import { SuggestionTarget } from '../../core/notifications/models/suggestion-target.model'; +/** + * Show suggestions notification, used on myDSpace and Profile pages + */ @Component({ selector: 'ds-suggestions-notification', templateUrl: './suggestions-notification.component.html', @@ -13,22 +15,19 @@ import { Observable } from 'rxjs'; }) export class SuggestionsNotificationComponent implements OnInit { - labelPrefix = 'mydspace.'; - /** * The user suggestion targets. */ suggestionsRD$: Observable; constructor( - private translateService: TranslateService, - private reciterSuggestionStateService: SuggestionTargetsStateService, - private notificationsService: NotificationsService, + private suggestionTargetsStateService: SuggestionTargetsStateService, private suggestionsService: SuggestionsService ) { } ngOnInit() { - this.suggestionsRD$ = this.reciterSuggestionStateService.getCurrentUserSuggestionTargets(); + this.suggestionTargetsStateService.dispatchRefreshUserSuggestionsAction(); + this.suggestionsRD$ = this.suggestionTargetsStateService.getCurrentUserSuggestionTargets(); } /** diff --git a/src/app/notifications/suggestions-popup/suggestions-popup.component.html b/src/app/notifications/suggestions-popup/suggestions-popup.component.html new file mode 100644 index 00000000000..474dfa329e7 --- /dev/null +++ b/src/app/notifications/suggestions-popup/suggestions-popup.component.html @@ -0,0 +1,27 @@ +
+ +
+ + diff --git a/src/app/notifications/suggestions-popup/suggestions-popup.component.scss b/src/app/notifications/suggestions-popup/suggestions-popup.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/app/notifications/reciter-suggestions/suggestions-popup/suggestions-popup.component.spec.ts b/src/app/notifications/suggestions-popup/suggestions-popup.component.spec.ts similarity index 87% rename from src/app/notifications/reciter-suggestions/suggestions-popup/suggestions-popup.component.spec.ts rename to src/app/notifications/suggestions-popup/suggestions-popup.component.spec.ts index 242698b2342..c8d59115d81 100644 --- a/src/app/notifications/reciter-suggestions/suggestions-popup/suggestions-popup.component.spec.ts +++ b/src/app/notifications/suggestions-popup/suggestions-popup.component.spec.ts @@ -3,11 +3,9 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { SuggestionsPopupComponent } from './suggestions-popup.component'; import { TranslateModule } from '@ngx-translate/core'; import { SuggestionTargetsStateService } from '../suggestion-targets/suggestion-targets.state.service'; -import { NotificationsService } from '../../../shared/notifications/notifications.service'; -import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub'; import { NO_ERRORS_SCHEMA } from '@angular/core'; import { of as observableOf } from 'rxjs'; -import { mockSuggestionTargetsObjectOne } from '../../../shared/mocks/reciter-suggestion-targets.mock'; +import { mockSuggestionTargetsObjectOne } from '../../shared/mocks/publication-claim-targets.mock'; import { SuggestionsService } from '../suggestions.service'; describe('SuggestionsPopupComponent', () => { @@ -34,7 +32,6 @@ describe('SuggestionsPopupComponent', () => { providers: [ { provide: SuggestionTargetsStateService, useValue: suggestionStateService }, { provide: SuggestionsService, useValue: suggestionService }, - { provide: NotificationsService, useValue: new NotificationsServiceStub() }, ], schemas: [NO_ERRORS_SCHEMA] @@ -61,10 +58,10 @@ describe('SuggestionsPopupComponent', () => { describe('when there are publication suggestions', () => { beforeEach(() => { - suggestionStateService.hasUserVisitedSuggestions.and.returnValue(observableOf(false)); suggestionStateService.getCurrentUserSuggestionTargets.and.returnValue(observableOf([mockSuggestionTargetsObjectOne])); suggestionStateService.dispatchMarkUserSuggestionsAsVisitedAction.and.returnValue(observableOf(null)); + suggestionStateService.dispatchRefreshUserSuggestionsAction.and.returnValue(observableOf(null)); fixture = TestBed.createComponent(SuggestionsPopupComponent); component = fixture.componentInstance; @@ -72,7 +69,7 @@ describe('SuggestionsPopupComponent', () => { }); it('should show a notification when new publication suggestions are available', () => { - expect((component as any).notificationsService.success).toHaveBeenCalled(); + expect(suggestionStateService.dispatchRefreshUserSuggestionsAction).toHaveBeenCalled(); expect(suggestionStateService.dispatchMarkUserSuggestionsAsVisitedAction).toHaveBeenCalled(); }); diff --git a/src/app/notifications/suggestions-popup/suggestions-popup.component.ts b/src/app/notifications/suggestions-popup/suggestions-popup.component.ts new file mode 100644 index 00000000000..1921c4fe93d --- /dev/null +++ b/src/app/notifications/suggestions-popup/suggestions-popup.component.ts @@ -0,0 +1,82 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { SuggestionTargetsStateService } from '../suggestion-targets/suggestion-targets.state.service'; +import { SuggestionsService } from '../suggestions.service'; +import { take, takeUntil } from 'rxjs/operators'; +import { isNotEmpty } from '../../shared/empty.util'; +import { combineLatest, Observable, of, Subject } from 'rxjs'; +import { trigger } from '@angular/animations'; + + +import { fromTopEnter } from '../../shared/animations/fromTop'; +import { SuggestionTarget } from '../../core/notifications/models/suggestion-target.model'; + +/** + * Show suggestions on a popover window, used on the homepage + */ +@Component({ + selector: 'ds-suggestions-popup', + templateUrl: './suggestions-popup.component.html', + styleUrls: ['./suggestions-popup.component.scss'], + animations: [ + trigger('enterLeave', [ + fromTopEnter + ]) + ], +}) +export class SuggestionsPopupComponent implements OnInit, OnDestroy { + + labelPrefix = 'notification.'; + + subscription; + + suggestionsRD$: Observable; + + + constructor( + private suggestionTargetsStateService: SuggestionTargetsStateService, + private suggestionsService: SuggestionsService + ) { } + + ngOnInit() { + this.initializePopup(); + } + + public initializePopup() { + const notifier = new Subject(); + this.subscription = combineLatest([ + this.suggestionTargetsStateService.getCurrentUserSuggestionTargets().pipe(take(2)), + this.suggestionTargetsStateService.hasUserVisitedSuggestions() + ]).pipe(takeUntil(notifier)).subscribe(([suggestions, visited]) => { + this.suggestionTargetsStateService.dispatchRefreshUserSuggestionsAction(); + if (isNotEmpty(suggestions)) { + if (!visited) { + this.suggestionsRD$ = of(suggestions); + this.suggestionTargetsStateService.dispatchMarkUserSuggestionsAsVisitedAction(); + notifier.next(null); + notifier.complete(); + } + } + }); + } + + ngOnDestroy() { + if (this.subscription) { + this.subscription.unsubscribe(); + } + } + + /** + * Interpolated params to build the notification suggestions notification. + * @param suggestionTarget + */ + public getNotificationSuggestionInterpolation(suggestionTarget: SuggestionTarget): any { + return this.suggestionsService.getNotificationSuggestionInterpolation(suggestionTarget); + } + + /** + * Hide popup from view + */ + public removePopup() { + this.suggestionsRD$ = null; + } +} diff --git a/src/app/notifications/reciter-suggestions/suggestions.service.ts b/src/app/notifications/suggestions.service.ts similarity index 77% rename from src/app/notifications/reciter-suggestions/suggestions.service.ts rename to src/app/notifications/suggestions.service.ts index 2b51b59b346..e2b32b38ec3 100644 --- a/src/app/notifications/reciter-suggestions/suggestions.service.ts +++ b/src/app/notifications/suggestions.service.ts @@ -1,40 +1,31 @@ import { Injectable } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; import { of, forkJoin, Observable } from 'rxjs'; import { catchError, map, mergeMap, take } from 'rxjs/operators'; +import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model'; +import { FindListOptions } from '../core/data/find-list-options.model'; +import { PaginatedList } from '../core/data/paginated-list.model'; +import { RemoteData } from '../core/data/remote-data'; +import { SuggestionTarget } from '../core/notifications/models/suggestion-target.model'; +import { SuggestionsDataService } from '../core/notifications/suggestions-data.service'; +import { SuggestionTargetDataService } from '../core/notifications/target/suggestion-target-data.service'; +import { ResearcherProfileDataService } from '../core/profile/researcher-profile-data.service'; +import { NoContent } from '../core/shared/NoContent.model'; +import { getFirstSucceededRemoteListPayload, getFirstSucceededRemoteDataPayload, getAllSucceededRemoteDataPayload, getFinishedRemoteData } from '../core/shared/operators'; +import { getSuggestionPageRoute } from '../suggestions-page/suggestions-page-routing-paths'; +import { environment } from '../../environments/environment'; +import { SuggestionConfig } from '../../config/suggestion-config.interfaces'; +import { Suggestion } from '../core/notifications/models/suggestion.model'; +import { ResearcherProfile } from '../core/profile/model/researcher-profile.model'; +import { hasValue, isNotEmpty } from '../shared/empty.util'; +import { WorkspaceitemDataService } from '../core/submission/workspaceitem-data.service'; +import { WorkspaceItem } from '../core/submission/models/workspaceitem.model'; -import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; -import { RemoteData } from '../../core/data/remote-data'; -import { PaginatedList } from '../../core/data/paginated-list.model'; -import { SuggestionTarget } from '../../core/notifications/reciter-suggestions/models/suggestion-target.model'; -import { AuthService } from '../../core/auth/auth.service'; -import { hasValue, isNotEmpty } from '../../shared/empty.util'; -import { ResearcherProfile } from '../../core/profile/model/researcher-profile.model'; -import { - getAllSucceededRemoteDataPayload, - getFinishedRemoteData, - getFirstSucceededRemoteDataPayload, - getFirstSucceededRemoteListPayload -} from '../../core/shared/operators'; -import { Suggestion } from '../../core/notifications/reciter-suggestions/models/suggestion.model'; -import { WorkspaceitemDataService } from '../../core/submission/workspaceitem-data.service'; -import { TranslateService } from '@ngx-translate/core'; -import { NoContent } from '../../core/shared/NoContent.model'; -import { environment } from '../../../environments/environment'; -import { WorkspaceItem } from '../../core/submission/models/workspaceitem.model'; -import {FindListOptions} from '../../core/data/find-list-options.model'; -import {SuggestionConfig} from '../../../config/layout-config.interfaces'; -import { ResearcherProfileDataService } from '../../core/profile/researcher-profile-data.service'; -import { - SuggestionSourceDataService -} from '../../core/notifications/reciter-suggestions/source/suggestion-source-data.service'; -import { - SuggestionTargetDataService -} from '../../core/notifications/reciter-suggestions/target/suggestion-target-data.service'; -import { - SuggestionsDataService -} from '../../core/notifications/reciter-suggestions/suggestions-data.service'; +/** + * useful for multiple approvals and ignores operation + * */ export interface SuggestionBulkResult { success: number; fails: number; @@ -48,17 +39,14 @@ export class SuggestionsService { /** * Initialize the service variables. - * @param {AuthService} authService * @param {ResearcherProfileDataService} researcherProfileService - * @param {SuggestionSourceDataService} suggestionSourceDataService * @param {SuggestionTargetDataService} suggestionTargetDataService * @param {SuggestionsDataService} suggestionsDataService + * @param translateService */ constructor( - private authService: AuthService, private researcherProfileService: ResearcherProfileDataService, private suggestionsDataService: SuggestionsDataService, - private suggestionSourceDataService: SuggestionSourceDataService, private suggestionTargetDataService: SuggestionTargetDataService, private translateService: TranslateService ) { @@ -157,7 +145,7 @@ export class SuggestionsService { * The EPerson id for which to retrieve suggestion targets */ public retrieveCurrentUserSuggestions(userUuid: string): Observable { - return this.researcherProfileService.findById(userUuid).pipe( + return this.researcherProfileService.findById(userUuid, true).pipe( getFirstSucceededRemoteDataPayload(), mergeMap((profile: ResearcherProfile) => { if (isNotEmpty(profile)) { @@ -191,7 +179,7 @@ export class SuggestionsService { return workspaceitemService.importExternalSourceEntry(suggestion.externalSourceUri, resolvedCollectionId) .pipe( getFirstSucceededRemoteDataPayload(), - catchError((error) => of(null)) + catchError(() => of(null)) ); } @@ -199,9 +187,9 @@ export class SuggestionsService { * Perform the delete operation over a single suggestion. * @param suggestionId */ - public notMine(suggestionId): Observable> { + public ignoreSuggestion(suggestionId): Observable> { return this.deleteReviewedSuggestion(suggestionId).pipe( - catchError((error) => of(null)) + catchError(() => of(null)) ); } @@ -226,11 +214,11 @@ export class SuggestionsService { } /** - * Perform a bulk notMine operation. + * Perform a bulk ignoreSuggestion operation. * @param suggestions the array containing the suggestions */ - public notMineMultiple(suggestions: Suggestion[]): Observable { - return forkJoin(suggestions.map((suggestion: Suggestion) => this.notMine(suggestion.id))) + public ignoreSuggestionMultiple(suggestions: Suggestion[]): Observable { + return forkJoin(suggestions.map((suggestion: Suggestion) => this.ignoreSuggestion(suggestion.id))) .pipe(map((results: RemoteData[]) => { return { success: results.filter((result) => result != null).length, @@ -260,16 +248,17 @@ export class SuggestionsService { source: this.translateService.instant(this.translateSuggestionSource(suggestionTarget.source)), type: this.translateService.instant(this.translateSuggestionType(suggestionTarget.source)), suggestionId: suggestionTarget.id, - displayName: suggestionTarget.display + displayName: suggestionTarget.display, + url: getSuggestionPageRoute(suggestionTarget.id) }; } public translateSuggestionType(source: string): string { - return 'reciter.suggestion.type.' + source; + return 'suggestion.type.' + source; } public translateSuggestionSource(source: string): string { - return 'reciter.suggestion.source.' + source; + return 'suggestion.source.' + source; } /** diff --git a/src/app/profile-page/profile-page.module.ts b/src/app/profile-page/profile-page.module.ts index 4d3f02fbebf..126cf7bd8a8 100644 --- a/src/app/profile-page/profile-page.module.ts +++ b/src/app/profile-page/profile-page.module.ts @@ -14,16 +14,15 @@ import { UiSwitchModule } from 'ngx-ui-switch'; import { ProfileClaimItemModalComponent } from './profile-claim-item-modal/profile-claim-item-modal.component'; import { NotificationsModule } from '../notifications/notifications.module'; - @NgModule({ - imports: [ - ProfilePageRoutingModule, - CommonModule, - SharedModule, - FormModule, - UiSwitchModule, - NotificationsModule - ], + imports: [ + ProfilePageRoutingModule, + CommonModule, + SharedModule, + FormModule, + UiSwitchModule, + NotificationsModule + ], exports: [ ProfilePageComponent, ThemedProfilePageComponent, diff --git a/src/app/quality-assurance-notifications-pages/notifications-pages-routing.module.ts b/src/app/quality-assurance-notifications-pages/notifications-pages-routing.module.ts index c64ae6381ac..c57b22a028b 100644 --- a/src/app/quality-assurance-notifications-pages/notifications-pages-routing.module.ts +++ b/src/app/quality-assurance-notifications-pages/notifications-pages-routing.module.ts @@ -6,7 +6,7 @@ import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.reso import { I18nBreadcrumbsService } from '../core/breadcrumbs/i18n-breadcrumbs.service'; import { NOTIFICATIONS_RECITER_SUGGESTION_PATH } from './notifications-pages-routing-paths'; import { NotificationsSuggestionTargetsPageComponent } from './notifications-suggestion-targets-page/notifications-suggestion-targets-page.component'; -import { NotificationsSuggestionTargetsPageResolver } from './notifications-suggestion-targets-page/notifications-suggestion-targets-page-resolver.service'; +import { AdminNotificationsPublicationClaimPageResolver } from './notifications-suggestion-targets-page/notifications-suggestion-targets-page-resolver.service'; import { QUALITY_ASSURANCE_EDIT_PATH } from './notifications-pages-routing-paths'; import { QualityAssuranceTopicsPageComponent } from './quality-assurance-topics-page/quality-assurance-topics-page.component'; import { QualityAssuranceEventsPageComponent } from './quality-assurance-events-page/quality-assurance-events-page.component'; @@ -30,7 +30,7 @@ import { pathMatch: 'full', resolve: { breadcrumb: I18nBreadcrumbResolver, - reciterSuggestionTargetParams: NotificationsSuggestionTargetsPageResolver + reciterSuggestionTargetParams: AdminNotificationsPublicationClaimPageResolver }, data: { title: 'admin.notifications.recitersuggestion.page.title', @@ -104,7 +104,7 @@ import { providers: [ I18nBreadcrumbResolver, I18nBreadcrumbsService, - NotificationsSuggestionTargetsPageResolver, + AdminNotificationsPublicationClaimPageResolver, SourceDataResolver, QualityAssuranceSourcePageResolver, QualityAssuranceTopicsPageResolver, diff --git a/src/app/quality-assurance-notifications-pages/notifications-suggestion-targets-page/notifications-suggestion-targets-page-resolver.service.ts b/src/app/quality-assurance-notifications-pages/notifications-suggestion-targets-page/notifications-suggestion-targets-page-resolver.service.ts index f524cd56c20..783387281e1 100644 --- a/src/app/quality-assurance-notifications-pages/notifications-suggestion-targets-page/notifications-suggestion-targets-page-resolver.service.ts +++ b/src/app/quality-assurance-notifications-pages/notifications-suggestion-targets-page/notifications-suggestion-targets-page-resolver.service.ts @@ -4,7 +4,8 @@ import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/r /** * Interface for the route parameters. */ -export interface NotificationsSuggestionTargetsPageParams { + +export interface AdminNotificationsPublicationClaimPageParams { pageId?: string; pageSize?: number; currentPage?: number; @@ -14,7 +15,8 @@ export interface NotificationsSuggestionTargetsPageParams { * This class represents a resolver that retrieve the route data before the route is activated. */ @Injectable() -export class NotificationsSuggestionTargetsPageResolver implements Resolve { + +export class AdminNotificationsPublicationClaimPageResolver implements Resolve { /** * Method for resolving the parameters in the current route. @@ -22,7 +24,8 @@ export class NotificationsSuggestionTargetsPageResolver implements Resolve + diff --git a/src/app/quality-assurance-notifications-pages/notifications-suggestion-targets-page/notifications-suggestion-targets-page.component.spec.ts b/src/app/quality-assurance-notifications-pages/notifications-suggestion-targets-page/notifications-suggestion-targets-page.component.spec.ts index f1b01c747d0..bbca7f8d393 100644 --- a/src/app/quality-assurance-notifications-pages/notifications-suggestion-targets-page/notifications-suggestion-targets-page.component.spec.ts +++ b/src/app/quality-assurance-notifications-pages/notifications-suggestion-targets-page/notifications-suggestion-targets-page.component.spec.ts @@ -4,6 +4,9 @@ import { NotificationsSuggestionTargetsPageComponent } from './notifications-sug import { NO_ERRORS_SCHEMA } from '@angular/core'; import { CommonModule } from '@angular/common'; import { TranslateModule } from '@ngx-translate/core'; +import { + AdminNotificationsPublicationClaimPageComponent +} from '../../admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component'; describe('NotificationsSuggestionTargetsPageComponent', () => { let component: NotificationsSuggestionTargetsPageComponent; @@ -19,7 +22,7 @@ describe('NotificationsSuggestionTargetsPageComponent', () => { NotificationsSuggestionTargetsPageComponent ], providers: [ - NotificationsSuggestionTargetsPageComponent + AdminNotificationsPublicationClaimPageComponent ], schemas: [NO_ERRORS_SCHEMA] }) diff --git a/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.spec.ts b/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.spec.ts index e2dbaaa0fff..edefff85922 100644 --- a/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.spec.ts +++ b/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.spec.ts @@ -5,13 +5,14 @@ import { DSpaceObjectType } from '../../../core/shared/dspace-object-type.model' import { Item } from '../../../core/shared/item.model'; import { DSOSelectorModalWrapperComponent, SelectorActionType } from './dso-selector-modal-wrapper.component'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, ActivatedRouteSnapshot } from '@angular/router'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; import { By } from '@angular/platform-browser'; import { DSOSelectorComponent } from '../dso-selector/dso-selector.component'; import { MockComponent } from 'ng-mocks'; import { MetadataValue } from '../../../core/shared/metadata.models'; import { createSuccessfulRemoteDataObject } from '../../remote-data.utils'; +import { hasValue } from '../../empty.util'; describe('DSOSelectorModalWrapperComponent', () => { let component: DSOSelectorModalWrapperComponent; @@ -83,6 +84,20 @@ describe('DSOSelectorModalWrapperComponent', () => { }); }); + describe('selectObject with emit only', () => { + beforeEach(() => { + spyOn(component, 'navigate'); + spyOn(component, 'close'); + spyOn(component.select, 'emit'); + component.emitOnly = true; + component.selectObject(item); + }); + it('should call the close and navigate method on the component with the given DSO', () => { + expect(component.close).toHaveBeenCalled(); + expect(component.select.emit).toHaveBeenCalledWith(item); + }); + }); + describe('close', () => { beforeEach(() => { component.close(); @@ -113,6 +128,19 @@ describe('DSOSelectorModalWrapperComponent', () => { expect(component.close).toHaveBeenCalled(); }); }); + + describe('should find route data', () => { + beforeEach(() => { + spyOn(component, 'findRouteData'); + component.ngOnInit(); + }); + it('should call the findRouteData method on the component', () => { + expect(component.findRouteData).toHaveBeenCalled(); + }); + it('should return undefined', () => { + expect(component.findRouteData((route) => hasValue(route.data), {} as unknown as ActivatedRouteSnapshot)).toEqual(undefined); + }); + }); }); @Component({ diff --git a/src/app/shared/eperson-group-list/eperson-group-list.component.ts b/src/app/shared/eperson-group-list/eperson-group-list.component.ts index 7cad7a97830..1a08740a946 100644 --- a/src/app/shared/eperson-group-list/eperson-group-list.component.ts +++ b/src/app/shared/eperson-group-list/eperson-group-list.component.ts @@ -96,7 +96,7 @@ export class EpersonGroupListComponent implements OnInit, OnDestroy { private pageConfigSub: Subscription; /** - * Initialize instance variables and inject the properly DataService + * Initialize instance variables and inject the properly UpdateDataServiceImpl * * @param {DSONameService} dsoNameService * @param {Injector} parentInjector diff --git a/src/app/shared/input-suggestions/filter-suggestions/filter-input-suggestions.component.spec.ts b/src/app/shared/input-suggestions/filter-suggestions/filter-input-suggestions.component.spec.ts index 6a0324d2ac4..6a67d84c288 100644 --- a/src/app/shared/input-suggestions/filter-suggestions/filter-input-suggestions.component.spec.ts +++ b/src/app/shared/input-suggestions/filter-suggestions/filter-input-suggestions.component.spec.ts @@ -54,4 +54,31 @@ describe('FilterInputSuggestionsComponent', () => { expect(comp.onClickSuggestion).toHaveBeenCalledWith(suggestions[clickedIndex]); }); }); + + describe('component methods', () => { + const testData = { + value: 'test-field' + } as unknown as any; + + beforeEach(() => { + spyOn(comp.submitSuggestion, 'emit'); + spyOn(comp.clickSuggestion, 'emit'); + spyOn(comp, 'close'); + }); + + it('should properly submit', () => { + comp.onSubmit(testData); + expect(comp.submitSuggestion.emit).toHaveBeenCalledWith(testData); + expect(comp.value).toBe(testData); + }); + + it('should update value on suggestion clicked', () => { + comp.onClickSuggestion(testData); + expect(comp.clickSuggestion.emit).toHaveBeenCalledWith(testData); + expect(comp.value).toBe(testData.value); + expect(comp.blockReopen).toBeTruthy(); + expect(comp.close).toHaveBeenCalled(); + }); + }); + }); diff --git a/src/app/shared/mocks/reciter-suggestion-targets.mock.ts b/src/app/shared/mocks/publication-claim-targets.mock.ts similarity index 93% rename from src/app/shared/mocks/reciter-suggestion-targets.mock.ts rename to src/app/shared/mocks/publication-claim-targets.mock.ts index f6810f99129..932a98bee55 100644 --- a/src/app/shared/mocks/reciter-suggestion-targets.mock.ts +++ b/src/app/shared/mocks/publication-claim-targets.mock.ts @@ -1,5 +1,5 @@ import { ResourceType } from '../../core/shared/resource-type'; -import { SuggestionTarget } from '../../core/notifications/reciter-suggestions/models/suggestion-target.model'; +import { SuggestionTarget } from '../../core/notifications/models/suggestion-target.model'; // REST Mock --------------------------------------------------------------------- // ------------------------------------------------------------------------------- diff --git a/src/app/shared/mocks/reciter-suggestion.mock.ts b/src/app/shared/mocks/publication-claim.mock.ts similarity index 96% rename from src/app/shared/mocks/reciter-suggestion.mock.ts rename to src/app/shared/mocks/publication-claim.mock.ts index 652871e6432..02364347f4c 100644 --- a/src/app/shared/mocks/reciter-suggestion.mock.ts +++ b/src/app/shared/mocks/publication-claim.mock.ts @@ -2,11 +2,12 @@ // REST Mock --------------------------------------------------------------------- // ------------------------------------------------------------------------------- -import { Suggestion } from '../../core/notifications/reciter-suggestions/models/suggestion.model'; -import { SUGGESTION } from '../../core/notifications/reciter-suggestions/models/suggestion-objects.resource-type'; + +import { Suggestion } from '../../core/notifications/models/suggestion.model'; +import { SUGGESTION } from '../../core/notifications/models/suggestion-objects.resource-type'; export const mockSuggestionPublicationOne: Suggestion = { - id: '24694772', + id: '24694773', display: 'publication one', source: 'reciter', externalSourceUri: 'https://dspace7.4science.cloud/server/api/integration/reciterSourcesEntry/pubmed/entryValues/24694772', diff --git a/src/app/shared/mocks/suggestion.mock.ts b/src/app/shared/mocks/suggestion.mock.ts index 8b1ab7acd55..ed7f9045d56 100644 --- a/src/app/shared/mocks/suggestion.mock.ts +++ b/src/app/shared/mocks/suggestion.mock.ts @@ -1,6 +1,7 @@ import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { Item } from '../../core/shared/item.model'; import { SearchResult } from '../search/models/search-result.model'; +import { of as observableOf } from 'rxjs'; // REST Mock --------------------------------------------------------------------- // ------------------------------------------------------------------------------- @@ -1333,7 +1334,8 @@ export function getMockSuggestionNotificationsStateService(): any { getOpenaireBrokerTopicsCurrentPage: jasmine.createSpy('getOpenaireBrokerTopicsCurrentPage'), getOpenaireBrokerTopicsTotals: jasmine.createSpy('getOpenaireBrokerTopicsTotals'), dispatchRetrieveOpenaireBrokerTopics: jasmine.createSpy('dispatchRetrieveOpenaireBrokerTopics'), - dispatchMarkUserSuggestionsAsVisitedAction: jasmine.createSpy('dispatchMarkUserSuggestionsAsVisitedAction') + dispatchMarkUserSuggestionsAsVisitedAction: jasmine.createSpy('dispatchMarkUserSuggestionsAsVisitedAction'), + dispatchRefreshUserSuggestionsAction: undefined }); } /** @@ -1342,10 +1344,17 @@ export function getMockSuggestionNotificationsStateService(): any { export function getMockSuggestionsService(): any { return jasmine.createSpyObj('SuggestionsService', { getTargets: jasmine.createSpy('getTargets'), - getSuggestions: jasmine.createSpy('getSuggestions'), + getSuggestions: observableOf([]), clearSuggestionRequests: jasmine.createSpy('clearSuggestionRequests'), deleteReviewedSuggestion: jasmine.createSpy('deleteReviewedSuggestion'), retrieveCurrentUserSuggestions: jasmine.createSpy('retrieveCurrentUserSuggestions'), getTargetUuid: jasmine.createSpy('getTargetUuid'), + ignoreSuggestion: observableOf(null), + ignoreSuggestionMultiple: observableOf({success: 1, fails: 0}), + approveAndImportMultiple: observableOf({success: 1, fails: 0}), + approveAndImport: observableOf({id: '1234'}), + isCollectionFixed: false, + translateSuggestionSource: 'testSource', + translateSuggestionType: 'testType', }); } diff --git a/src/app/shared/mydspace-actions/mydspace-actions-service.factory.ts b/src/app/shared/mydspace-actions/mydspace-actions-service.factory.ts index 6408dddac6e..987ca1411e1 100644 --- a/src/app/shared/mydspace-actions/mydspace-actions-service.factory.ts +++ b/src/app/shared/mydspace-actions/mydspace-actions-service.factory.ts @@ -13,7 +13,7 @@ import { CacheableObject } from '../../core/cache/cacheable-object.model'; import { IdentifiableDataService } from '../../core/data/base/identifiable-data.service'; /** - * Class to return DataService for given ResourceType + * Class to return UpdateDataServiceImpl for given ResourceType */ export class MyDSpaceActionsServiceFactory> { public getConstructor(type: ResourceType): TService { diff --git a/src/app/shared/mydspace-actions/mydspace-actions.ts b/src/app/shared/mydspace-actions/mydspace-actions.ts index 931929ccce5..184f059bd80 100644 --- a/src/app/shared/mydspace-actions/mydspace-actions.ts +++ b/src/app/shared/mydspace-actions/mydspace-actions.ts @@ -47,7 +47,7 @@ export abstract class MyDSpaceActionsComponent(false); /** - * Instance of DataService related to mydspace object + * Instance of UpdateDataServiceImpl related to mydspace object */ protected objectDataService: TService; diff --git a/src/app/shared/search/search-filters/search-filter/search-facet-filter-wrapper/search-facet-filter-wrapper.component.ts b/src/app/shared/search/search-filters/search-filter/search-facet-filter-wrapper/search-facet-filter-wrapper.component.ts index 0aa131d4282..f635c027b5c 100644 --- a/src/app/shared/search/search-filters/search-filter/search-facet-filter-wrapper/search-facet-filter-wrapper.component.ts +++ b/src/app/shared/search/search-filters/search-filter/search-facet-filter-wrapper/search-facet-filter-wrapper.component.ts @@ -4,6 +4,7 @@ import { FilterType } from '../../../models/filter-type.model'; import { SearchFilterConfig } from '../../../models/search-filter-config.model'; import { FILTER_CONFIG, + SCOPE, IN_PLACE_SEARCH, REFRESH_FILTER } from '../../../../../core/shared/search/search-filter.service'; @@ -35,6 +36,11 @@ export class SearchFacetFilterWrapperComponent implements OnInit { */ @Input() refreshFilters: BehaviorSubject; + /** + * The current scope + */ + @Input() scope: string; + /** * The constructor of the search facet filter that should be rendered, based on the filter config's type */ @@ -56,7 +62,8 @@ export class SearchFacetFilterWrapperComponent implements OnInit { providers: [ { provide: FILTER_CONFIG, useFactory: () => (this.filterConfig), deps: [] }, { provide: IN_PLACE_SEARCH, useFactory: () => (this.inPlaceSearch), deps: [] }, - { provide: REFRESH_FILTER, useFactory: () => (this.refreshFilters), deps: [] } + { provide: REFRESH_FILTER, useFactory: () => (this.refreshFilters), deps: [] }, + { provide: SCOPE, useFactory: () => (this.scope), deps: [] }, ], parent: this.injector }); diff --git a/src/app/shared/search/search-filters/search-filter/search-facet-filter/search-facet-filter.component.spec.ts b/src/app/shared/search/search-filters/search-filter/search-facet-filter/search-facet-filter.component.spec.ts index 92d2e5265ba..fc348722e55 100644 --- a/src/app/shared/search/search-filters/search-filter/search-facet-filter/search-facet-filter.component.spec.ts +++ b/src/app/shared/search/search-filters/search-filter/search-facet-filter/search-facet-filter.component.spec.ts @@ -4,6 +4,7 @@ import { TranslateModule } from '@ngx-translate/core'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { FILTER_CONFIG, + SCOPE, IN_PLACE_SEARCH, REFRESH_FILTER, SearchFilterService @@ -99,6 +100,7 @@ describe('SearchFacetFilterComponent', () => { { provide: SEARCH_CONFIG_SERVICE, useValue: new SearchConfigurationServiceStub() }, { provide: IN_PLACE_SEARCH, useValue: false }, { provide: REFRESH_FILTER, useValue: new BehaviorSubject(false) }, + { provide: SCOPE, useValue: undefined }, { provide: SearchFilterService, useValue: { getSelectedValuesForFilter: () => observableOf(selectedValues), diff --git a/src/app/shared/search/search-filters/search-filter/search-facet-filter/search-facet-filter.component.ts b/src/app/shared/search/search-filters/search-filter/search-facet-filter/search-facet-filter.component.ts index 2b2eb9b11a7..994b488d9c7 100644 --- a/src/app/shared/search/search-filters/search-filter/search-facet-filter/search-facet-filter.component.ts +++ b/src/app/shared/search/search-filters/search-filter/search-facet-filter/search-facet-filter.component.ts @@ -22,6 +22,7 @@ import { SearchFilterConfig } from '../../../models/search-filter-config.model'; import { SearchService } from '../../../../../core/shared/search/search.service'; import { FILTER_CONFIG, + SCOPE, IN_PLACE_SEARCH, REFRESH_FILTER, SearchFilterService @@ -104,7 +105,9 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { @Inject(SEARCH_CONFIG_SERVICE) public searchConfigService: SearchConfigurationService, @Inject(IN_PLACE_SEARCH) public inPlaceSearch: boolean, @Inject(FILTER_CONFIG) public filterConfig: SearchFilterConfig, - @Inject(REFRESH_FILTER) public refreshFilters: BehaviorSubject) { + @Inject(REFRESH_FILTER) public refreshFilters: BehaviorSubject, + @Inject(SCOPE) public scope: string, + ) { } /** @@ -114,8 +117,11 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { this.currentUrl = this.router.url; this.filterValues$ = new BehaviorSubject(createPendingRemoteDataObject()); this.currentPage = this.getCurrentPage().pipe(distinctUntilChanged()); - - this.searchOptions$ = this.searchConfigService.searchOptions; + this.searchOptions$ = this.searchConfigService.searchOptions.pipe( + map((options: SearchOptions) => hasNoValue(this.scope) ? options : Object.assign({}, options, { + scope: this.scope, + })), + ); this.subs.push( this.searchOptions$.subscribe(() => this.updateFilterValueList()), this.refreshFilters.asObservable().pipe( diff --git a/src/app/shared/search/search-filters/search-filter/search-filter.component.html b/src/app/shared/search/search-filters/search-filter/search-filter.component.html index a352c5587b3..97809ef8547 100644 --- a/src/app/shared/search/search-filters/search-filter/search-filter.component.html +++ b/src/app/shared/search/search-filters/search-filter/search-filter.component.html @@ -19,6 +19,7 @@ (@slide.start)="startSlide($event)" (@slide.done)="finishSlide($event)" class="search-filter-wrapper" [ngClass]="{ 'closed' : closed, 'notab': notab }"> diff --git a/src/app/shared/search/search-filters/search-filter/search-filter.component.ts b/src/app/shared/search/search-filters/search-filter/search-filter.component.ts index d1d3bd729d4..67e8906bb5d 100644 --- a/src/app/shared/search/search-filters/search-filter/search-filter.component.ts +++ b/src/app/shared/search/search-filters/search-filter/search-filter.component.ts @@ -6,7 +6,7 @@ import { filter, map, startWith, switchMap, take } from 'rxjs/operators'; import { SearchFilterConfig } from '../../models/search-filter-config.model'; import { SearchFilterService } from '../../../../core/shared/search/search-filter.service'; import { slide } from '../../../animations/slide'; -import { isNotEmpty } from '../../../empty.util'; +import { isNotEmpty, hasValue } from '../../../empty.util'; import { SearchService } from '../../../../core/shared/search/search.service'; import { SearchConfigurationService } from '../../../../core/shared/search/search-configuration.service'; import { SEARCH_CONFIG_SERVICE } from '../../../../my-dspace-page/my-dspace-page.component'; @@ -38,6 +38,11 @@ export class SearchFilterComponent implements OnInit { */ @Input() refreshFilters: BehaviorSubject; + /** + * The current scope + */ + @Input() scope: string; + /** * True when the filter is 100% collapsed in the UI */ @@ -171,6 +176,9 @@ export class SearchFilterComponent implements OnInit { } else { return this.searchConfigService.searchOptions.pipe( switchMap((options) => { + if (hasValue(this.scope)) { + options.scope = this.scope; + } return this.searchService.getFacetValuesFor(this.filter, 1, options).pipe( filter((RD) => !RD.isLoading), map((valuesRD) => { diff --git a/src/app/shared/search/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.spec.ts b/src/app/shared/search/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.spec.ts index e6c74d80478..4469a124ce6 100644 --- a/src/app/shared/search/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.spec.ts +++ b/src/app/shared/search/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.spec.ts @@ -14,6 +14,7 @@ import { CommonModule } from '@angular/common'; import { SearchService } from '../../../../../core/shared/search/search.service'; import { FILTER_CONFIG, + SCOPE, IN_PLACE_SEARCH, SearchFilterService, REFRESH_FILTER @@ -75,7 +76,8 @@ describe('SearchHierarchyFilterComponent', () => { { provide: SEARCH_CONFIG_SERVICE, useValue: new SearchConfigurationServiceStub() }, { provide: IN_PLACE_SEARCH, useValue: false }, { provide: FILTER_CONFIG, useValue: Object.assign(new SearchFilterConfig(), { name: testSearchFilter }) }, - { provide: REFRESH_FILTER, useValue: new BehaviorSubject(false)} + { provide: REFRESH_FILTER, useValue: new BehaviorSubject(false)}, + { provide: SCOPE, useValue: undefined }, ], schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); diff --git a/src/app/shared/search/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.ts b/src/app/shared/search/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.ts index f9b3f2bff94..d53fa37cf4b 100644 --- a/src/app/shared/search/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.ts +++ b/src/app/shared/search/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.ts @@ -9,6 +9,7 @@ import { import { SearchService } from '../../../../../core/shared/search/search.service'; import { FILTER_CONFIG, + SCOPE, IN_PLACE_SEARCH, SearchFilterService, REFRESH_FILTER } from '../../../../../core/shared/search/search-filter.service'; @@ -49,9 +50,10 @@ export class SearchHierarchyFilterComponent extends SearchFacetFilterComponent i @Inject(SEARCH_CONFIG_SERVICE) public searchConfigService: SearchConfigurationService, @Inject(IN_PLACE_SEARCH) public inPlaceSearch: boolean, @Inject(FILTER_CONFIG) public filterConfig: SearchFilterConfig, - @Inject(REFRESH_FILTER) public refreshFilters: BehaviorSubject + @Inject(REFRESH_FILTER) public refreshFilters: BehaviorSubject, + @Inject(SCOPE) public scope: string, ) { - super(searchService, filterService, rdbs, router, searchConfigService, inPlaceSearch, filterConfig, refreshFilters); + super(searchService, filterService, rdbs, router, searchConfigService, inPlaceSearch, filterConfig, refreshFilters, scope); } vocabularyExists$: Observable; diff --git a/src/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.spec.ts b/src/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.spec.ts index 3a146f50590..03f47a75698 100644 --- a/src/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.spec.ts +++ b/src/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.spec.ts @@ -4,6 +4,7 @@ import { TranslateModule } from '@ngx-translate/core'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { FILTER_CONFIG, + SCOPE, IN_PLACE_SEARCH, REFRESH_FILTER, SearchFilterService @@ -105,6 +106,7 @@ describe('SearchRangeFilterComponent', () => { { provide: SEARCH_CONFIG_SERVICE, useValue: new SearchConfigurationServiceStub() }, { provide: IN_PLACE_SEARCH, useValue: false }, { provide: REFRESH_FILTER, useValue: new BehaviorSubject(false) }, + { provide: SCOPE, useValue: undefined }, { provide: SearchFilterService, useValue: { getSelectedValuesForFilter: () => selectedValues, diff --git a/src/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.ts b/src/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.ts index ed20e63c52f..a6eaf436c97 100644 --- a/src/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.ts +++ b/src/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.ts @@ -10,6 +10,7 @@ import { facetLoad, SearchFacetFilterComponent } from '../search-facet-filter/se import { SearchFilterConfig } from '../../../models/search-filter-config.model'; import { FILTER_CONFIG, + SCOPE, IN_PLACE_SEARCH, REFRESH_FILTER, SearchFilterService @@ -101,8 +102,9 @@ export class SearchRangeFilterComponent extends SearchFacetFilterComponent imple @Inject(FILTER_CONFIG) public filterConfig: SearchFilterConfig, @Inject(PLATFORM_ID) private platformId: any, @Inject(REFRESH_FILTER) public refreshFilters: BehaviorSubject, + @Inject(SCOPE) public scope: string, private route: RouteService) { - super(searchService, filterService, rdbs, router, searchConfigService, inPlaceSearch, filterConfig, refreshFilters); + super(searchService, filterService, rdbs, router, searchConfigService, inPlaceSearch, filterConfig, refreshFilters, scope); } diff --git a/src/app/shared/search/search-filters/search-filters.component.html b/src/app/shared/search/search-filters/search-filters.component.html index e392cd2663e..c006d80c44f 100644 --- a/src/app/shared/search/search-filters/search-filters.component.html +++ b/src/app/shared/search/search-filters/search-filters.component.html @@ -1,7 +1,7 @@

{{"search.filters.head" | translate}}

- +
{{"search.filters.reset" | translate}} diff --git a/src/app/shared/search/search-settings/search-settings.component.spec.ts b/src/app/shared/search/search-settings/search-settings.component.spec.ts index d0b51f04b1f..bdcdb6f171f 100644 --- a/src/app/shared/search/search-settings/search-settings.component.spec.ts +++ b/src/app/shared/search/search-settings/search-settings.component.spec.ts @@ -1,4 +1,3 @@ -import { SearchService } from '../../../core/shared/search/search.service'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { SearchSettingsComponent } from './search-settings.component'; import { of as observableOf } from 'rxjs'; @@ -6,15 +5,11 @@ import { PaginationComponentOptions } from '../../pagination/pagination-componen import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model'; import { TranslateModule } from '@ngx-translate/core'; import { RouterTestingModule } from '@angular/router/testing'; -import { ActivatedRoute } from '@angular/router'; import { NO_ERRORS_SCHEMA } from '@angular/core'; import { EnumKeysPipe } from '../../utils/enum-keys-pipe'; import { By } from '@angular/platform-browser'; -import { SearchFilterService } from '../../../core/shared/search/search-filter.service'; import { VarDirective } from '../../utils/var.directive'; import { SEARCH_CONFIG_SERVICE } from '../../../my-dspace-page/my-dspace-page.component'; -import { SidebarService } from '../../sidebar/sidebar.service'; -import { SidebarServiceStub } from '../../testing/sidebar-service.stub'; import { PaginationService } from '../../../core/pagination/pagination.service'; import { PaginationServiceStub } from '../../testing/pagination-service.stub'; @@ -22,32 +17,23 @@ describe('SearchSettingsComponent', () => { let comp: SearchSettingsComponent; let fixture: ComponentFixture; - let searchServiceObject: SearchService; let pagination: PaginationComponentOptions; let sort: SortOptions; - let mockResults; - let searchServiceStub; let queryParam; let scopeParam; let paginatedSearchOptions; - let paginationService; + let paginationService: PaginationServiceStub; - let activatedRouteStub; - beforeEach(waitForAsync(() => { + beforeEach(waitForAsync(async () => { pagination = new PaginationComponentOptions(); pagination.id = 'search-results-pagination'; pagination.currentPage = 1; pagination.pageSize = 10; sort = new SortOptions('score', SortDirection.DESC); - mockResults = ['test', 'data']; - searchServiceStub = { - searchOptions: { pagination: pagination, sort: sort }, - search: () => mockResults, - }; queryParam = 'test query'; scopeParam = '7669c72a-3f2a-451f-a3b9-9210e7a4c02f'; @@ -58,30 +44,12 @@ describe('SearchSettingsComponent', () => { sort, }; - activatedRouteStub = { - queryParams: observableOf({ - query: queryParam, - scope: scopeParam, - }), - }; - paginationService = new PaginationServiceStub(pagination, sort); - TestBed.configureTestingModule({ + await TestBed.configureTestingModule({ imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], declarations: [SearchSettingsComponent, EnumKeysPipe, VarDirective], providers: [ - { provide: SearchService, useValue: searchServiceStub }, - - { provide: ActivatedRoute, useValue: activatedRouteStub }, - { - provide: SidebarService, - useValue: SidebarServiceStub, - }, - { - provide: SearchFilterService, - useValue: {}, - }, { provide: PaginationService, useValue: paginationService, @@ -111,10 +79,7 @@ describe('SearchSettingsComponent', () => { // SearchPageComponent test instance fixture.detectChanges(); - searchServiceObject = (comp as any).service; spyOn(comp, 'reloadOrder'); - spyOn(searchServiceObject, 'search').and.callThrough(); - }); it('it should show the order settings with the respective selectable options', () => { diff --git a/src/app/shared/search/search-settings/search-settings.component.ts b/src/app/shared/search/search-settings/search-settings.component.ts index 0efd38b5b28..23c9e78c3a8 100644 --- a/src/app/shared/search/search-settings/search-settings.component.ts +++ b/src/app/shared/search/search-settings/search-settings.component.ts @@ -1,7 +1,5 @@ import { Component, Inject, Input } from '@angular/core'; -import { SearchService } from '../../../core/shared/search/search.service'; import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model'; -import { ActivatedRoute, Router } from '@angular/router'; import { SearchConfigurationService } from '../../../core/shared/search/search-configuration.service'; import { SEARCH_CONFIG_SERVICE } from '../../../my-dspace-page/my-dspace-page.component'; import { PaginationService } from '../../../core/pagination/pagination.service'; @@ -26,11 +24,10 @@ export class SearchSettingsComponent { */ @Input() sortOptionsList: SortOptions[]; - constructor(private service: SearchService, - private route: ActivatedRoute, - private router: Router, - private paginationService: PaginationService, - @Inject(SEARCH_CONFIG_SERVICE) public searchConfigurationService: SearchConfigurationService) { + constructor( + protected paginationService: PaginationService, + @Inject(SEARCH_CONFIG_SERVICE) public searchConfigurationService: SearchConfigurationService, + ) { } /** diff --git a/src/app/shared/search/search.component.spec.ts b/src/app/shared/search/search.component.spec.ts index d0d9bdda86a..9d069636a94 100644 --- a/src/app/shared/search/search.component.spec.ts +++ b/src/app/shared/search/search.component.spec.ts @@ -36,8 +36,6 @@ import { getCollectionPageRoute } from '../../collection-page/collection-page-ro let comp: SearchComponent; let fixture: ComponentFixture; -let searchServiceObject: SearchService; -let searchConfigurationServiceObject: SearchConfigurationService; const store: Store = jasmine.createSpyObj('store', { /* eslint-disable no-empty,@typescript-eslint/no-empty-function */ dispatch: {}, @@ -93,7 +91,6 @@ const mockDso2 = Object.assign(new Item(), { } } }); -const sort: SortOptions = new SortOptions('score', SortDirection.DESC); const mockSearchResults: SearchObjects = Object.assign(new SearchObjects(), { page: [mockDso, mockDso2] }); @@ -106,23 +103,13 @@ const searchServiceStub = jasmine.createSpyObj('SearchService', { getSearchConfigurationFor: createSuccessfulRemoteDataObject$(searchConfig), trackSearch: {}, }) as SearchService; -const configurationParam = 'default'; const queryParam = 'test query'; const scopeParam = '7669c72a-3f2a-451f-a3b9-9210e7a4c02f'; -const fixedFilter = 'fixed filter'; const defaultSearchOptions = new PaginatedSearchOptions({ pagination }); const paginatedSearchOptions$ = new BehaviorSubject(defaultSearchOptions); -const paginatedSearchOptions = new PaginatedSearchOptions({ - configuration: configurationParam, - query: queryParam, - scope: scopeParam, - fixedFilter: fixedFilter, - pagination, - sort -}); const activatedRouteStub = { snapshot: { queryParamMap: new Map([ @@ -155,14 +142,11 @@ const filtersConfigRD = createSuccessfulRemoteDataObject([mockFilterConfig, mock const filtersConfigRD$ = observableOf(filtersConfigRD); const routeServiceStub = { - getRouteParameterValue: () => { - return observableOf(''); - }, getQueryParameterValue: () => { - return observableOf(''); + return observableOf(null); }, getQueryParamsWithPrefix: () => { - return observableOf(''); + return observableOf(null); }, setParameter: () => { return; @@ -252,16 +236,10 @@ describe('SearchComponent', () => { comp.paginationId = paginationId; spyOn((comp as any), 'getSearchOptions').and.returnValue(paginatedSearchOptions$.asObservable()); - - searchServiceObject = TestBed.inject(SearchService); - searchConfigurationServiceObject = TestBed.inject(SEARCH_CONFIG_SERVICE); - }); afterEach(() => { comp = null; - searchServiceObject = null; - searchConfigurationServiceObject = null; }); it('should init search parameters properly and call retrieveSearchResults', fakeAsync(() => { diff --git a/src/app/shared/search/search.component.ts b/src/app/shared/search/search.component.ts index 61f3a119c87..fc07893d721 100644 --- a/src/app/shared/search/search.component.ts +++ b/src/app/shared/search/search.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, Component, EventEmitter, Inject, Input, OnInit, Output } from '@angular/core'; +import { ChangeDetectionStrategy, Component, EventEmitter, Inject, Input, OnInit, Output, OnDestroy } from '@angular/core'; import { NavigationStart, Router } from '@angular/router'; import { BehaviorSubject, combineLatest, Observable, Subscription } from 'rxjs'; @@ -11,7 +11,7 @@ import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { pushInOut } from '../animations/push'; import { HostWindowService } from '../host-window.service'; import { SidebarService } from '../sidebar/sidebar.service'; -import { hasValue, hasValueOperator, isNotEmpty } from '../empty.util'; +import { hasValue, hasValueOperator, isEmpty, isNotEmpty } from '../empty.util'; import { RouteService } from '../../core/services/route.service'; import { SEARCH_CONFIG_SERVICE } from '../../my-dspace-page/my-dspace-page.component'; import { PaginatedSearchOptions } from './models/paginated-search-options.model'; @@ -34,7 +34,7 @@ import { CollectionElementLinkType } from '../object-collection/collection-eleme import { environment } from 'src/environments/environment'; import { SubmissionObject } from '../../core/submission/models/submission-object.model'; import { SearchFilterConfig } from './models/search-filter-config.model'; -import { WorkspaceItem } from '../..//core/submission/models/workspaceitem.model'; +import { WorkspaceItem } from '../../core/submission/models/workspaceitem.model'; import { ITEM_MODULE_PATH } from '../../item-page/item-page-routing-paths'; import { COLLECTION_MODULE_PATH } from '../../collection-page/collection-page-routing-paths'; import { COMMUNITY_MODULE_PATH } from '../../community-page/community-page-routing-paths'; @@ -50,7 +50,7 @@ import { COMMUNITY_MODULE_PATH } from '../../community-page/community-page-routi /** * This component renders a sidebar, a search input bar and the search results. */ -export class SearchComponent implements OnInit { +export class SearchComponent implements OnDestroy, OnInit { /** * The list of available configuration options @@ -166,6 +166,11 @@ export class SearchComponent implements OnInit { */ @Input() query: string; + /** + * The fallback scope when no scope is defined in the url, if this is also undefined no scope will be set + */ + @Input() scope: string; + /** * The current configuration used during the search */ @@ -179,7 +184,7 @@ export class SearchComponent implements OnInit { /** * The current sort options used */ - currentScope$: BehaviorSubject = new BehaviorSubject(''); + currentScope$: Observable; /** * The current sort options used @@ -299,6 +304,10 @@ export class SearchComponent implements OnInit { this.routeService.setParameter('fixedFilterQuery', this.fixedFilterQuery); } + this.currentScope$ = this.routeService.getQueryParameterValue('scope').pipe( + map((routeValue: string) => hasValue(routeValue) ? routeValue : this.scope), + ); + this.isSidebarCollapsed$ = this.isSidebarCollapsed(); this.searchLink = this.getSearchLink(); this.currentContext$.next(this.context); @@ -306,9 +315,8 @@ export class SearchComponent implements OnInit { // Determinate PaginatedSearchOptions and listen to any update on it const configuration$: Observable = this.searchConfigService .getCurrentConfiguration(this.configuration).pipe(distinctUntilChanged()); - const searchSortOptions$: Observable = configuration$.pipe( - switchMap((configuration: string) => this.searchConfigService - .getConfigurationSearchConfig(configuration)), + const searchSortOptions$: Observable = combineLatest([configuration$, this.currentScope$]).pipe( + switchMap(([configuration, scope]: [string, string]) => this.searchConfigService.getConfigurationSearchConfig(configuration, scope)), map((searchConfig: SearchConfig) => this.searchConfigService.getConfigurationSortOptions(searchConfig)), distinctUntilChanged() ); @@ -321,13 +329,13 @@ export class SearchComponent implements OnInit { ); const searchOptions$: Observable = this.getSearchOptions().pipe(distinctUntilChanged()); - this.subs.push(combineLatest([configuration$, searchSortOptions$, searchOptions$, sortOption$]).pipe( - filter(([configuration, searchSortOptions, searchOptions, sortOption]: [string, SortOptions[], PaginatedSearchOptions, SortOptions]) => { + this.subs.push(combineLatest([configuration$, searchSortOptions$, searchOptions$, sortOption$, this.currentScope$]).pipe( + filter(([configuration, searchSortOptions, searchOptions, sortOption, scope]: [string, SortOptions[], PaginatedSearchOptions, SortOptions, string]) => { // filter for search options related to instanced paginated id return searchOptions.pagination.id === this.paginationId; }), debounceTime(100) - ).subscribe(([configuration, searchSortOptions, searchOptions, sortOption]: [string, SortOptions[], PaginatedSearchOptions, SortOptions]) => { + ).subscribe(([configuration, searchSortOptions, searchOptions, sortOption, scope]: [string, SortOptions[], PaginatedSearchOptions, SortOptions, string]) => { // Build the PaginatedSearchOptions object const combinedOptions = Object.assign({}, searchOptions, { @@ -337,6 +345,9 @@ export class SearchComponent implements OnInit { if (combinedOptions.query === '') { combinedOptions.query = this.query; } + if (isEmpty(combinedOptions.scope)) { + combinedOptions.scope = scope; + } const newSearchOptions = new PaginatedSearchOptions(combinedOptions); // check if search options are changed // if so retrieve new related results otherwise skip it @@ -344,13 +355,12 @@ export class SearchComponent implements OnInit { // Initialize variables this.currentConfiguration$.next(configuration); this.currentSortOptions$.next(newSearchOptions.sort); - this.currentScope$.next(newSearchOptions.scope); this.sortOptionsList$.next(searchSortOptions); this.searchOptions$.next(newSearchOptions); this.initialized$.next(true); // retrieve results this.retrieveSearchResults(newSearchOptions); - this.retrieveFilters(searchOptions); + this.retrieveFilters(newSearchOptions); } })); diff --git a/src/app/shared/search/themed-search.component.ts b/src/app/shared/search/themed-search.component.ts index 03f6f37e251..ef0afad7b3c 100644 --- a/src/app/shared/search/themed-search.component.ts +++ b/src/app/shared/search/themed-search.component.ts @@ -15,12 +15,38 @@ import { ListableObject } from '../object-collection/shared/listable-object.mode */ @Component({ selector: 'ds-themed-search', - styleUrls: [], templateUrl: '../theme-support/themed.component.html', }) export class ThemedSearchComponent extends ThemedComponent { - protected inAndOutputNames: (keyof SearchComponent & keyof this)[] = ['configurationList', 'context', 'configuration', 'fixedFilterQuery', 'useCachedVersionIfAvailable', 'inPlaceSearch', 'linkType', 'paginationId', 'searchEnabled', 'sideBarWidth', 'searchFormPlaceholder', 'selectable', 'selectionConfig', 'showCsvExport', 'showSidebar', 'showThumbnails', 'showViewModes', 'useUniquePageId', 'viewModeList', 'showScopeSelector', 'resultFound', 'deselectObject', 'selectObject', 'trackStatistics', 'query']; + protected inAndOutputNames: (keyof SearchComponent & keyof this)[] = [ + 'configurationList', + 'context', + 'configuration', + 'fixedFilterQuery', + 'useCachedVersionIfAvailable', + 'inPlaceSearch', + 'linkType', + 'paginationId', + 'searchEnabled', + 'sideBarWidth', + 'searchFormPlaceholder', + 'selectable', + 'selectionConfig', + 'showCsvExport', + 'showSidebar', + 'showThumbnails', + 'showViewModes', + 'useUniquePageId', + 'viewModeList', + 'showScopeSelector', + 'trackStatistics', + 'query', + 'scope', + 'resultFound', + 'deselectObject', + 'selectObject', + ]; @Input() configurationList: SearchConfigurationOption[]; @@ -52,7 +78,7 @@ export class ThemedSearchComponent extends ThemedComponent { @Input() showSidebar: boolean; - @Input() showThumbnails; + @Input() showThumbnails: boolean; @Input() showViewModes: boolean; @@ -66,6 +92,8 @@ export class ThemedSearchComponent extends ThemedComponent { @Input() query: string; + @Input() scope: string; + @Output() resultFound: EventEmitter> = new EventEmitter(); @Output() deselectObject: EventEmitter = new EventEmitter(); diff --git a/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.spec.ts b/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.spec.ts index d008bf61f1c..72c555f39ca 100644 --- a/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.spec.ts +++ b/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.spec.ts @@ -80,6 +80,9 @@ describe('SubmissionSectionUploadFileEditComponent test suite', () => { const fileData: any = mockUploadFiles[0]; const pathCombiner = new JsonPatchOperationPathCombiner('sections', sectionId, 'files', fileIndex); + let noAccessConditionsMock = Object.assign({}, mockFileFormData); + delete noAccessConditionsMock.accessConditions; + beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ imports: [ @@ -299,6 +302,28 @@ describe('SubmissionSectionUploadFileEditComponent test suite', () => { })); + it('should update Bitstream data properly when access options are omitted', fakeAsync(() => { + compAsAny.formRef = {formGroup: null}; + compAsAny.fileData = fileData; + compAsAny.pathCombiner = pathCombiner; + formService.validateAllFormFields.and.callFake(() => null); + formService.isValid.and.returnValue(of(true)); + formService.getFormData.and.returnValue(of(noAccessConditionsMock)); + const response = [ + Object.assign(mockSubmissionObject, { + sections: { + upload: { + files: mockUploadFiles + } + } + }) + ]; + operationsService.jsonPatchByResourceID.and.returnValue(of(response)); + comp.saveBitstreamData(); + tick(); + expect(uploadService.updateFileData).toHaveBeenCalled(); + })); + it('should not save Bitstream File data properly when form is not valid', fakeAsync(() => { compAsAny.formRef = {formGroup: null}; compAsAny.pathCombiner = pathCombiner; diff --git a/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.ts b/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.ts index 9ee4a7dda5e..eb4dc5aaf86 100644 --- a/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.ts +++ b/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.ts @@ -416,56 +416,58 @@ export class SubmissionSectionUploadFileEditComponent this.operationsBuilder.remove(this.pathCombiner.getPath(path)); }); const accessConditionsToSave = []; - formData.accessConditions - .map((accessConditions) => accessConditions.accessConditionGroup) - .filter((accessCondition) => isNotEmpty(accessCondition)) - .forEach((accessCondition) => { - let accessConditionOpt; - - this.availableAccessConditionOptions - .filter((element) => isNotNull(accessCondition.name) && element.name === accessCondition.name[0].value) - .forEach((element) => accessConditionOpt = element); - - if (accessConditionOpt) { - const currentAccessCondition = Object.assign({}, accessCondition); - currentAccessCondition.name = this.retrieveValueFromField(accessCondition.name); - - /* When start and end date fields are deactivated, their values may be still present in formData, - therefore it is necessary to delete them if they're not allowed by the current access condition option. */ - if (!accessConditionOpt.hasStartDate) { - delete currentAccessCondition.startDate; - } else if (accessCondition.startDate) { - const startDate = this.retrieveValueFromField(accessCondition.startDate); - // Clamp the start date to the maximum, if any, since the - // datepicker sometimes exceeds it. - let startDateDate = new Date(startDate); - if (accessConditionOpt.maxStartDate) { + if (formData.hasOwnProperty('accessConditions')) { + formData.accessConditions + .filter((accessConditions) => isNotNull(accessConditions)) + .map((accessConditions) => accessConditions.accessConditionGroup) + .filter((accessCondition) => isNotEmpty(accessCondition)) + .forEach((accessCondition) => { + let accessConditionOpt; + + this.availableAccessConditionOptions + .filter((element) => isNotNull(accessCondition.name) && element.name === accessCondition.name[0].value) + .forEach((element) => accessConditionOpt = element); + + if (accessConditionOpt) { + const currentAccessCondition = Object.assign({}, accessCondition); + currentAccessCondition.name = this.retrieveValueFromField(accessCondition.name); + + /* When start and end date fields are deactivated, their values may be still present in formData, + therefore it is necessary to delete them if they're not allowed by the current access condition option. */ + if (!accessConditionOpt.hasStartDate) { + delete currentAccessCondition.startDate; + } else if (accessCondition.startDate) { + const startDate = this.retrieveValueFromField(accessCondition.startDate); + // Clamp the start date to the maximum, if any, since the + // datepicker sometimes exceeds it. + let startDateDate = new Date(startDate); + if (accessConditionOpt.maxStartDate) { const maxStartDateDate = new Date(accessConditionOpt.maxStartDate); if (startDateDate > maxStartDateDate) { - startDateDate = maxStartDateDate; + startDateDate = maxStartDateDate; } + } + currentAccessCondition.startDate = dateToISOFormat(startDateDate); } - currentAccessCondition.startDate = dateToISOFormat(startDateDate); - } - if (!accessConditionOpt.hasEndDate) { - delete currentAccessCondition.endDate; - } else if (accessCondition.endDate) { - const endDate = this.retrieveValueFromField(accessCondition.endDate); - // Clamp the end date to the maximum, if any, since the - // datepicker sometimes exceeds it. - let endDateDate = new Date(endDate); - if (accessConditionOpt.maxEndDate) { + if (!accessConditionOpt.hasEndDate) { + delete currentAccessCondition.endDate; + } else if (accessCondition.endDate) { + const endDate = this.retrieveValueFromField(accessCondition.endDate); + // Clamp the end date to the maximum, if any, since the + // datepicker sometimes exceeds it. + let endDateDate = new Date(endDate); + if (accessConditionOpt.maxEndDate) { const maxEndDateDate = new Date(accessConditionOpt.maxEndDate); if (endDateDate > maxEndDateDate) { - endDateDate = maxEndDateDate; + endDateDate = maxEndDateDate; } + } + currentAccessCondition.endDate = dateToISOFormat(endDateDate); } - currentAccessCondition.endDate = dateToISOFormat(endDateDate); + accessConditionsToSave.push(currentAccessCondition); } - accessConditionsToSave.push(currentAccessCondition); - } - }); - + }); + } if (isNotEmpty(accessConditionsToSave)) { this.operationsBuilder.add(this.pathCombiner.getPath('accessConditions'), accessConditionsToSave, true); } diff --git a/src/app/suggestion-notifications/selectors.ts b/src/app/suggestion-notifications/selectors.ts new file mode 100644 index 00000000000..1dd0df11a74 --- /dev/null +++ b/src/app/suggestion-notifications/selectors.ts @@ -0,0 +1,98 @@ +import {createFeatureSelector, createSelector, MemoizedSelector} from '@ngrx/store'; +import { suggestionNotificationsSelector, SuggestionNotificationsState } from '../notifications/notifications.reducer'; + +import {subStateSelector} from '../submission/selectors'; +import { SuggestionTargetState } from '../notifications/suggestion-targets/suggestion-targets.reducer'; +import { SuggestionTarget } from '../core/notifications/models/suggestion-target.model'; + +/** + * Returns the Reciter Suggestion Target state. + * @function _getSuggestionTargetState + * @param {AppState} state Top level state. + * @return {SuggestionNotificationsState} + */ +const _getSuggestionTargetState = createFeatureSelector('suggestionNotifications'); + +// Reciter Suggestion Targets +// ---------------------------------------------------------------------------- + +/** + * Returns the Suggestion Targets State. + * @function suggestionTargetStateSelector + * @return {SuggestionNotificationsState} + */ +export function suggestionTargetStateSelector(): MemoizedSelector { + return subStateSelector(suggestionNotificationsSelector, 'suggestionTarget'); +} + +/** + * Returns the Suggestion Targets list. + * @function suggestionTargetObjectSelector + * @return {SuggestionTarget[]} + */ +export function suggestionTargetObjectSelector(): MemoizedSelector { + return subStateSelector(suggestionTargetStateSelector(), 'targets'); +} + +/** + * Returns true if the Suggestion Targets are loaded. + * @function isSuggestionTargetLoadedSelector + * @return {boolean} + */ +export const isSuggestionTargetLoadedSelector = createSelector(_getSuggestionTargetState, + (state: SuggestionNotificationsState) => state.suggestionTarget.loaded +); + +/** + * Returns true if the deduplication sets are processing. + * @function isDeduplicationSetsProcessingSelector + * @return {boolean} + */ +export const isReciterSuggestionTargetProcessingSelector = createSelector(_getSuggestionTargetState, + (state: SuggestionNotificationsState) => state.suggestionTarget.processing +); + +/** + * Returns the total available pages of Reciter Suggestion Targets. + * @function getSuggestionTargetTotalPagesSelector + * @return {number} + */ +export const getSuggestionTargetTotalPagesSelector = createSelector(_getSuggestionTargetState, + (state: SuggestionNotificationsState) => state.suggestionTarget.totalPages +); + +/** + * Returns the current page of Suggestion Targets. + * @function getSuggestionTargetCurrentPageSelector + * @return {number} + */ +export const getSuggestionTargetCurrentPageSelector = createSelector(_getSuggestionTargetState, + (state: SuggestionNotificationsState) => state.suggestionTarget.currentPage +); + +/** + * Returns the total number of Suggestion Targets. + * @function getSuggestionTargetTotalsSelector + * @return {number} + */ +export const getSuggestionTargetTotalsSelector = createSelector(_getSuggestionTargetState, + (state: SuggestionNotificationsState) => state.suggestionTarget.totalElements +); + +/** + * Returns Suggestion Targets for the current user. + * @function getCurrentUserSuggestionTargetSelector + * @return {SuggestionTarget[]} + */ +export const getCurrentUserSuggestionTargetsSelector = createSelector(_getSuggestionTargetState, + (state: SuggestionNotificationsState) => state.suggestionTarget.currentUserTargets +); + +/** + * Returns whether or not the user has consulted their suggestions + * @function getCurrentUserSuggestionTargetSelector + * @return {boolean} + */ +export const getCurrentUserSuggestionTargetsVisitedSelector = createSelector(_getSuggestionTargetState, + (state: SuggestionNotificationsState) => state.suggestionTarget.currentUserTargetsVisited +); diff --git a/src/app/suggestions-page/suggestions-page-routing.module.ts b/src/app/suggestions-page/suggestions-page-routing.module.ts index 05dc6321b70..f7d4ecf9556 100644 --- a/src/app/suggestions-page/suggestions-page-routing.module.ts +++ b/src/app/suggestions-page/suggestions-page-routing.module.ts @@ -4,7 +4,7 @@ import { RouterModule } from '@angular/router'; import { SuggestionsPageResolver } from './suggestions-page.resolver'; import { SuggestionsPageComponent } from './suggestions-page.component'; import { AuthenticatedGuard } from '../core/auth/authenticated.guard'; -import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver'; +import { PublicationClaimBreadcrumbResolver } from '../core/breadcrumbs/publication-claim-breadcrumb.resolver'; @NgModule({ imports: [ @@ -13,11 +13,11 @@ import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.reso path: ':targetId', resolve: { suggestionTargets: SuggestionsPageResolver, - breadcrumb: I18nBreadcrumbResolver + breadcrumb: PublicationClaimBreadcrumbResolver//I18nBreadcrumbResolver }, data: { - title: 'admin.notifications.recitersuggestion.page.title', - breadcrumbKey: 'admin.notifications.recitersuggestion', + title: 'admin.notifications.publicationclaim.page.title', + breadcrumbKey: 'admin.notifications.publicationclaim', showBreadcrumbsFluid: false }, canActivate: [AuthenticatedGuard], @@ -27,7 +27,8 @@ import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.reso ]) ], providers: [ - SuggestionsPageResolver + SuggestionsPageResolver, + PublicationClaimBreadcrumbResolver ] }) export class SuggestionsPageRoutingModule { diff --git a/src/app/suggestions-page/suggestions-page.component.html b/src/app/suggestions-page/suggestions-page.component.html index fb5f08b6a4c..4e367b638e8 100644 --- a/src/app/suggestions-page/suggestions-page.component.html +++ b/src/app/suggestions-page/suggestions-page.component.html @@ -4,22 +4,21 @@
-

- {{ translateSuggestionType() | translate }} - {{'reciter.suggestion.suggestionFor' | translate}} - {{researcherName}} - {{'reciter.suggestion.from.source' | translate}} {{ translateSuggestionSource() | translate }} -

+

+ {{'suggestion.suggestionFor' | translate}} + {{researcherName}} + {{'suggestion.from.source' | translate}} {{ translateSuggestionSource() | translate }} +

- + ({{ getSelectedSuggestionsCount() }}) + (ignoreSuggestionClicked)="ignoreSuggestionAllSelected()">
@@ -33,15 +32,16 @@

  • +
    {{ 'suggestion.count.missing' | translate }}
    diff --git a/src/app/suggestions-page/suggestions-page.component.spec.ts b/src/app/suggestions-page/suggestions-page.component.spec.ts index 37d01a4adce..7cdeaddc788 100644 --- a/src/app/suggestions-page/suggestions-page.component.spec.ts +++ b/src/app/suggestions-page/suggestions-page.component.spec.ts @@ -1,36 +1,39 @@ import { CommonModule } from '@angular/common'; import { BrowserModule } from '@angular/platform-browser'; import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { async, ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { of as observableOf } from 'rxjs'; import { SuggestionsPageComponent } from './suggestions-page.component'; -import { SuggestionListElementComponent } from '../notifications/reciter-suggestions/suggestion-list-element/suggestion-list-element.component'; -import { SuggestionsService } from '../notifications/reciter-suggestions/suggestions.service'; + import { getMockSuggestionNotificationsStateService, getMockSuggestionsService } from '../shared/mocks/suggestion.mock'; -import { buildPaginatedList, PaginatedList } from '../core/data/paginated-list.model'; -import { Suggestion } from '../core/notifications/reciter-suggestions/models/suggestion.model'; -import { mockSuggestionPublicationOne, mockSuggestionPublicationTwo } from '../shared/mocks/reciter-suggestion.mock'; -import { SuggestionEvidencesComponent } from '../notifications/reciter-suggestions/suggestion-list-element/suggestion-evidences/suggestion-evidences.component'; +import { mockSuggestionPublicationOne, mockSuggestionPublicationTwo } from '../shared/mocks/publication-claim.mock'; import { ObjectKeysPipe } from '../shared/utils/object-keys-pipe'; import { VarDirective } from '../shared/utils/var.directive'; import { ActivatedRoute, Router } from '@angular/router'; import { RouterStub } from '../shared/testing/router.stub'; -import { mockSuggestionTargetsObjectOne } from '../shared/mocks/reciter-suggestion-targets.mock'; +import { mockSuggestionTargetsObjectOne } from '../shared/mocks/publication-claim-targets.mock'; import { AuthService } from '../core/auth/auth.service'; import { NotificationsService } from '../shared/notifications/notifications.service'; import { NotificationsServiceStub } from '../shared/testing/notifications-service.stub'; import { getMockTranslateService } from '../shared/mocks/translate.service.mock'; -import { SuggestionTargetsStateService } from '../notifications/reciter-suggestions/suggestion-targets/suggestion-targets.state.service'; import { WorkspaceitemDataService } from '../core/submission/workspaceitem-data.service'; import { createSuccessfulRemoteDataObject } from '../shared/remote-data.utils'; -import { PageInfo } from '../core/shared/page-info.model'; import { TestScheduler } from 'rxjs/testing'; import { getTestScheduler } from 'jasmine-marbles'; import { PaginationServiceStub } from '../shared/testing/pagination-service.stub'; import { PaginationService } from '../core/pagination/pagination.service'; +import { + SuggestionEvidencesComponent +} from '../notifications/suggestion-list-element/suggestion-evidences/suggestion-evidences.component'; +import { + SuggestionApproveAndImport, + SuggestionListElementComponent +} from '../notifications/suggestion-list-element/suggestion-list-element.component'; +import { SuggestionsService } from '../notifications/suggestions.service'; +import { SuggestionTargetsStateService } from '../notifications/suggestion-targets/suggestion-targets.state.service'; describe('SuggestionPageComponent', () => { let component: SuggestionsPageComponent; @@ -38,7 +41,6 @@ describe('SuggestionPageComponent', () => { let scheduler: TestScheduler; const mockSuggestionsService = getMockSuggestionsService(); const mockSuggestionsTargetStateService = getMockSuggestionNotificationsStateService(); - const suggestionTargetsList: PaginatedList = buildPaginatedList(new PageInfo(), [mockSuggestionPublicationOne, mockSuggestionPublicationTwo]); const router = new RouterStub(); const routeStub = { data: observableOf({ @@ -104,4 +106,112 @@ describe('SuggestionPageComponent', () => { expect(component.researcherName).toBe(mockSuggestionTargetsObjectOne.display); expect(component.updatePage).toHaveBeenCalled(); }); + + it('should update page on pagination change', () => { + spyOn(component, 'updatePage').and.stub(); + + scheduler.schedule(() => fixture.detectChanges()); + scheduler.flush(); + component.onPaginationChange(); + expect(component.updatePage).toHaveBeenCalled(); + }); + + it('should update suggestion on page update', (done) => { + spyOn(component.processing$, 'next'); + spyOn(component.suggestionsRD$, 'next'); + + scheduler.schedule(() => fixture.detectChanges()); + scheduler.flush(); + paginationService.getFindListOptions().subscribe(() => { + expect(component.processing$.next).toHaveBeenCalled(); + expect(mockSuggestionsService.getSuggestions).toHaveBeenCalled(); + expect(component.suggestionsRD$.next).toHaveBeenCalled(); + expect(mockSuggestionsService.clearSuggestionRequests).toHaveBeenCalled(); + done(); + }); + component.updatePage(); + }); + + it('should flag suggestion for deletion', fakeAsync(() => { + spyOn(component, 'updatePage').and.stub(); + + scheduler.schedule(() => fixture.detectChanges()); + scheduler.flush(); + component.ignoreSuggestion('1'); + expect(mockSuggestionsService.ignoreSuggestion).toHaveBeenCalledWith('1'); + expect(mockSuggestionsTargetStateService.dispatchRefreshUserSuggestionsAction).toHaveBeenCalled(); + tick(201); + expect(component.updatePage).toHaveBeenCalled(); + })); + + it('should flag all suggestion for deletion', () => { + spyOn(component, 'updatePage').and.stub(); + + scheduler.schedule(() => fixture.detectChanges()); + scheduler.flush(); + component.ignoreSuggestionAllSelected(); + expect(mockSuggestionsService.ignoreSuggestionMultiple).toHaveBeenCalled(); + expect(mockSuggestionsTargetStateService.dispatchRefreshUserSuggestionsAction).toHaveBeenCalled(); + expect(component.updatePage).toHaveBeenCalled(); + }); + + it('should approve and import', () => { + spyOn(component, 'updatePage').and.stub(); + + scheduler.schedule(() => fixture.detectChanges()); + scheduler.flush(); + component.approveAndImport({collectionId: '1234'} as unknown as SuggestionApproveAndImport); + expect(mockSuggestionsService.approveAndImport).toHaveBeenCalled(); + expect(mockSuggestionsTargetStateService.dispatchRefreshUserSuggestionsAction).toHaveBeenCalled(); + expect(component.updatePage).toHaveBeenCalled(); + }); + + it('should approve and import multiple suggestions', () => { + spyOn(component, 'updatePage').and.stub(); + + scheduler.schedule(() => fixture.detectChanges()); + scheduler.flush(); + component.approveAndImportAllSelected({collectionId: '1234'} as unknown as SuggestionApproveAndImport); + expect(mockSuggestionsService.approveAndImportMultiple).toHaveBeenCalled(); + expect(mockSuggestionsTargetStateService.dispatchRefreshUserSuggestionsAction).toHaveBeenCalled(); + expect(component.updatePage).toHaveBeenCalled(); + }); + + it('should select and deselect suggestion', () => { + component.selectedSuggestions = {}; + component.onSelected(mockSuggestionPublicationOne, true); + expect(component.selectedSuggestions[mockSuggestionPublicationOne.id]).toBe(mockSuggestionPublicationOne); + component.onSelected(mockSuggestionPublicationOne, false); + expect(component.selectedSuggestions[mockSuggestionPublicationOne.id]).toBeUndefined(); + }); + + it('should toggle all suggestions', () => { + component.selectedSuggestions = {}; + component.onToggleSelectAll([mockSuggestionPublicationOne, mockSuggestionPublicationTwo]); + expect(component.selectedSuggestions[mockSuggestionPublicationOne.id]).toEqual(mockSuggestionPublicationOne); + expect(component.selectedSuggestions[mockSuggestionPublicationTwo.id]).toEqual(mockSuggestionPublicationTwo); + component.onToggleSelectAll([mockSuggestionPublicationOne, mockSuggestionPublicationTwo]); + expect(component.selectedSuggestions).toEqual({}); + }); + + it('should return all selected suggestions count', () => { + component.selectedSuggestions = {}; + component.onToggleSelectAll([mockSuggestionPublicationOne, mockSuggestionPublicationTwo]); + expect(component.getSelectedSuggestionsCount()).toEqual(2); + }); + + it('should check if all collection is fixed', () => { + component.isCollectionFixed([mockSuggestionPublicationOne, mockSuggestionPublicationTwo]); + expect(mockSuggestionsService.isCollectionFixed).toHaveBeenCalled(); + }); + + it('should translate suggestion source', () => { + component.translateSuggestionSource(); + expect(mockSuggestionsService.translateSuggestionSource).toHaveBeenCalled(); + }); + + it('should translate suggestion type', () => { + component.translateSuggestionType(); + expect(mockSuggestionsService.translateSuggestionType).toHaveBeenCalled(); + }); }); diff --git a/src/app/suggestions-page/suggestions-page.component.ts b/src/app/suggestions-page/suggestions-page.component.ts index 72ebc43bc2b..e4305cc2266 100644 --- a/src/app/suggestions-page/suggestions-page.component.ts +++ b/src/app/suggestions-page/suggestions-page.component.ts @@ -9,25 +9,33 @@ import { SortDirection, SortOptions, } from '../core/cache/models/sort-options.m import { PaginatedList } from '../core/data/paginated-list.model'; import { RemoteData } from '../core/data/remote-data'; import { getFirstSucceededRemoteDataPayload } from '../core/shared/operators'; -import { SuggestionBulkResult, SuggestionsService } from '../notifications/reciter-suggestions/suggestions.service'; import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model'; -import { Suggestion } from '../core/notifications/reciter-suggestions/models/suggestion.model'; -import { SuggestionTarget } from '../core/notifications/reciter-suggestions/models/suggestion-target.model'; import { AuthService } from '../core/auth/auth.service'; -import { SuggestionApproveAndImport } from '../notifications/reciter-suggestions/suggestion-list-element/suggestion-list-element.component'; import { NotificationsService } from '../shared/notifications/notifications.service'; -import { SuggestionTargetsStateService } from '../notifications/reciter-suggestions/suggestion-targets/suggestion-targets.state.service'; import { WorkspaceitemDataService } from '../core/submission/workspaceitem-data.service'; import { PaginationService } from '../core/pagination/pagination.service'; import { WorkspaceItem } from '../core/submission/models/workspaceitem.model'; import {FindListOptions} from '../core/data/find-list-options.model'; import {redirectOn4xx} from '../core/shared/authorized.operators'; +import { + getWorkspaceItemEditRoute +} from '../workflowitems-edit-page/workflowitems-edit-page-routing-paths'; +import { Suggestion } from '../core/notifications/models/suggestion.model'; +import { SuggestionTarget } from '../core/notifications/models/suggestion-target.model'; +import { SuggestionBulkResult, SuggestionsService } from '../notifications/suggestions.service'; +import { SuggestionTargetsStateService } from '../notifications/suggestion-targets/suggestion-targets.state.service'; +import { SuggestionApproveAndImport } from '../notifications/suggestion-list-element/suggestion-list-element.component'; @Component({ selector: 'ds-suggestion-page', templateUrl: './suggestions-page.component.html', styleUrls: ['./suggestions-page.component.scss'], }) + +/** + * Component used to visualize one of the suggestions from the publication claim page or from the notification pop up + */ + export class SuggestionsPageComponent implements OnInit { /** @@ -139,15 +147,6 @@ export class SuggestionsPageComponent implements OnInit { this.processing$.next(false); this.suggestionsRD$.next(results); this.suggestionService.clearSuggestionRequests(); - // navigate to the mydspace if no suggestions remains - - // if (results.totalElements === 0) { - // const content = this.translateService.instant('reciter.suggestion.empty', - // this.suggestionService.getNotificationSuggestionInterpolation(this.suggestionTarget)); - // this.notificationService.success('', content, {timeOut:0}, true); - // TODO if the target is not the current use route to the suggestion target page - // this.router.navigate(['/mydspace']); - // } }); } @@ -155,20 +154,21 @@ export class SuggestionsPageComponent implements OnInit { * Used to delete a suggestion. * @suggestionId */ - notMine(suggestionId) { - this.suggestionService.notMine(suggestionId).subscribe((res) => { + ignoreSuggestion(suggestionId) { + this.suggestionService.ignoreSuggestion(suggestionId).subscribe(() => { this.suggestionTargetsStateService.dispatchRefreshUserSuggestionsAction(); - this.updatePage(); + //We add a little delay in the page refresh so that we ensure the deletion has been propagated + setTimeout(() => this.updatePage(), 200); }); } /** * Used to delete all selected suggestions. */ - notMineAllSelected() { + ignoreSuggestionAllSelected() { this.isBulkOperationPending = true; this.suggestionService - .notMineMultiple(Object.values(this.selectedSuggestions)) + .ignoreSuggestionMultiple(Object.values(this.selectedSuggestions)) .subscribe((results: SuggestionBulkResult) => { this.suggestionTargetsStateService.dispatchRefreshUserSuggestionsAction(); this.updatePage(); @@ -176,12 +176,12 @@ export class SuggestionsPageComponent implements OnInit { this.selectedSuggestions = {}; if (results.success > 0) { this.notificationService.success( - this.translateService.get('reciter.suggestion.notMine.bulk.success', + this.translateService.get('suggestion.ignoreSuggestion.bulk.success', {count: results.success})); } if (results.fails > 0) { this.notificationService.error( - this.translateService.get('reciter.suggestion.notMine.bulk.error', + this.translateService.get('suggestion.ignoreSuggestion.bulk.error', {count: results.fails})); } }); @@ -194,7 +194,7 @@ export class SuggestionsPageComponent implements OnInit { approveAndImport(event: SuggestionApproveAndImport) { this.suggestionService.approveAndImport(this.workspaceItemService, event.suggestion, event.collectionId) .subscribe((workspaceitem: WorkspaceItem) => { - const content = this.translateService.instant('reciter.suggestion.approveAndImport.success', { workspaceItemId: workspaceitem.id }); + const content = this.translateService.instant('suggestion.approveAndImport.success', { url: getWorkspaceItemEditRoute(workspaceitem.id) }); this.notificationService.success('', content, {timeOut:0}, true); this.suggestionTargetsStateService.dispatchRefreshUserSuggestionsAction(); this.updatePage(); @@ -216,12 +216,12 @@ export class SuggestionsPageComponent implements OnInit { this.selectedSuggestions = {}; if (results.success > 0) { this.notificationService.success( - this.translateService.get('reciter.suggestion.approveAndImport.bulk.success', + this.translateService.get('suggestion.approveAndImport.bulk.success', {count: results.success})); } if (results.fails > 0) { this.notificationService.error( - this.translateService.get('reciter.suggestion.approveAndImport.bulk.error', + this.translateService.get('suggestion.approveAndImport.bulk.error', {count: results.fails})); } }); diff --git a/src/app/suggestions-page/suggestions-page.module.ts b/src/app/suggestions-page/suggestions-page.module.ts index f60bcfccaa3..90fbdbcf705 100644 --- a/src/app/suggestions-page/suggestions-page.module.ts +++ b/src/app/suggestions-page/suggestions-page.module.ts @@ -4,17 +4,17 @@ import { CommonModule } from '@angular/common'; import { SuggestionsPageComponent } from './suggestions-page.component'; import { SharedModule } from '../shared/shared.module'; import { SuggestionsPageRoutingModule } from './suggestions-page-routing.module'; -import { SuggestionsService } from '../notifications/reciter-suggestions/suggestions.service'; import { NotificationsModule } from '../notifications/notifications.module'; -import { SuggestionsDataService } from '../core/notifications/reciter-suggestions/suggestions-data.service'; +import { SuggestionsDataService } from '../core/notifications/suggestions-data.service'; +import { SuggestionsService } from '../notifications/suggestions.service'; @NgModule({ declarations: [SuggestionsPageComponent], imports: [ CommonModule, SharedModule, - NotificationsModule, - SuggestionsPageRoutingModule + SuggestionsPageRoutingModule, + NotificationsModule ], providers: [ SuggestionsDataService, diff --git a/src/app/suggestions-page/suggestions-page.resolver.ts b/src/app/suggestions-page/suggestions-page.resolver.ts index 87b7559b018..a2074d38bfb 100644 --- a/src/app/suggestions-page/suggestions-page.resolver.ts +++ b/src/app/suggestions-page/suggestions-page.resolver.ts @@ -6,10 +6,9 @@ import { find } from 'rxjs/operators'; import { RemoteData } from '../core/data/remote-data'; import { hasValue } from '../shared/empty.util'; -import { SuggestionTarget } from '../core/notifications/reciter-suggestions/models/suggestion-target.model'; -import { - SuggestionTargetDataService -} from '../core/notifications/reciter-suggestions/target/suggestion-target-data.service'; +import { SuggestionTarget } from '../core/notifications/models/suggestion-target.model'; +import { SuggestionTargetDataService } from '../core/notifications/target/suggestion-target-data.service'; + /** * This class represents a resolver that requests a specific collection before the route is activated diff --git a/src/app/workflowitems-edit-page/workflowitems-edit-page-routing-paths.ts b/src/app/workflowitems-edit-page/workflowitems-edit-page-routing-paths.ts index 326eebe4a79..a4d4a0f2c93 100644 --- a/src/app/workflowitems-edit-page/workflowitems-edit-page-routing-paths.ts +++ b/src/app/workflowitems-edit-page/workflowitems-edit-page-routing-paths.ts @@ -28,9 +28,14 @@ export function getWorkspaceItemDeleteRoute(wsiId: string) { return new URLCombiner(getWorkspaceItemModuleRoute(), wsiId, WORKSPACE_ITEM_DELETE_PATH).toString(); } +export function getWorkspaceItemEditRoute(wsiId: string) { + return new URLCombiner(getWorkspaceItemModuleRoute(), wsiId, WORKSPACE_ITEM_EDIT_PATH).toString(); +} + export const WORKFLOW_ITEM_EDIT_PATH = 'edit'; export const WORKFLOW_ITEM_DELETE_PATH = 'delete'; export const WORKFLOW_ITEM_VIEW_PATH = 'view'; export const WORKFLOW_ITEM_SEND_BACK_PATH = 'sendback'; export const ADVANCED_WORKFLOW_PATH = 'advanced'; export const WORKSPACE_ITEM_DELETE_PATH = 'delete'; +export const WORKSPACE_ITEM_EDIT_PATH = 'edit'; diff --git a/src/assets/i18n/de.json5 b/src/assets/i18n/de.json5 index cff2ac05702..4f9988acb68 100644 --- a/src/assets/i18n/de.json5 +++ b/src/assets/i18n/de.json5 @@ -2190,6 +2190,44 @@ // "info.privacy.title": "Privacy Statement", "info.privacy.title": "Datenschutzerklärung", + // "info.feedback.breadcrumbs": "Feedback", + "info.feedback.breadcrumbs": "Feedback", + + // "info.feedback.head": "Feedback", + "info.feedback.head": "Feedback", + + // "info.feedback.title": "Feedback", + "info.feedback.title": "Feedback", + + // "info.feedback.info": "Thanks for sharing your feedback about the DSpace system. Your comments are appreciated!", + "info.feedback.info": "Wir freuen uns auf Ihr Feedback. Sofern Sie eine Rückmeldung wünschen, werden wir uns in Kürze bei Ihnen melden.", + + // "info.feedback.email_help": "This address will be used to follow up on your feedback.", + "info.feedback.email_help": "Sollten wir Rückfragen zu Ihrem Feedback haben, dann kontaktieren wir Sie unter dieser E-Mailadresse.", + + // "info.feedback.send": "Send Feedback", + "info.feedback.send": "Feedback senden", + + // "info.feedback.comments": "Comments", + "info.feedback.comments": "Ihr Kommentar", + + // "info.feedback.email-label": "Your Email", + "info.feedback.email-label": "Ihre E-Mailadresse", + + // "info.feedback.create.success": "Feedback Sent Successfully!", + "info.feedback.create.success": "Vielen Dank für Ihr Feedback! Ihr Feedback wurde erfolgreich versendet.", + + // "info.feedback.error.email.required": "A valid email address is required", + "info.feedback.error.email.required": "Bitte geben Sie eine gültige E-Mailadresse ein!", + + // "info.feedback.error.message.required": "A comment is required", + "info.feedback.error.message.required": "Bitte geben Sie einen Kommentar ein!", + + // "info.feedback.page-label": "Page", + "info.feedback.page-label": "URL der Seite", + + // "info.feedback.page_help": "The page related to your feedback", + "info.feedback.page_help": "Dies ist die URL der Seite, auf die sich Ihr Feedback bezieht.", // "item.alerts.private": "This item is private", diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 4b8f831b8d1..89c53543ab5 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -1798,12 +1798,12 @@ "form.create": "Create", - "form.repeatable.sort.tip": "Drop the item in the new position", - "form.number-picker.decrement": "Decrement {{field}}", "form.number-picker.increment": "Increment {{field}}", + "form.repeatable.sort.tip": "Drop the item in the new position", + "grant-deny-request-copy.deny": "Don't send copy", "grant-deny-request-copy.email.back": "Back", @@ -2510,6 +2510,8 @@ "item.page.claim.tooltip": "Claim this item as profile", + "item.page.image.alt.ROR": "ROR logo", + "item.preview.dc.identifier.uri": "Identifier:", "item.preview.dc.contributor.author": "Authors:", @@ -2562,6 +2564,20 @@ "item.preview.oaire.fundingStream": "Funding Stream:", + "item.preview.oairecerif.identifier.url": "URL", + + "item.preview.organization.address.addressCountry": "Country", + + "item.preview.organization.foundingDate": "Founding Date", + + "item.preview.organization.identifier.crossrefid": "CrossRef ID", + + "item.preview.organization.identifier.isni": "ISNI", + + "item.preview.organization.identifier.ror": "ROR ID", + + "item.preview.organization.legalName": "Legal Name", + "item.select.confirm": "Confirm selected", "item.select.empty": "No items to show", @@ -2990,7 +3006,7 @@ "menu.section.quality-assurance": "Quality Assurance", - "menu.section.notifications_reciter": "Publication Claim", + "menu.section.notifications_publication-claim": "Publication Claim", "menu.section.pin": "Pin sidebar", @@ -3122,6 +3138,14 @@ "mydspace.view-btn": "View", + "mydspace.import": "Import", + + "notification.suggestion": "We found {{count}} publications in the {{source}} that seems to be related to your profile.
    ", + + "notification.suggestion.review": "review the suggestions", + + "notification.suggestion.please": "Please", + "nav.browse.header": "All of DSpace", "nav.community-browse.header": "By Community", @@ -3188,6 +3212,8 @@ "quality-assurance.source.error.service.retrieve": "An error occurred while loading the Quality Assurance source", + "quality-assurance.events.description": "Below the list of all the suggestions for the selected topic.", + "quality-assurance.loading": "Loading ...", "quality-assurance.events.topic": "Topic:", @@ -3306,6 +3332,8 @@ "orgunit.page.titleprefix": "Organizational Unit: ", + "orgunit.page.ror": "ROR Identifier", + "pagination.options.description": "Pagination options", "pagination.results-per-page": "Results Per Page", @@ -3592,6 +3620,72 @@ "media-viewer.playlist": "Playlist", + "suggestion.loading": "Loading ...", + + "suggestion.title": "Publication Claim", + + "suggestion.title.breadcrumbs": "Publication Claim", + + "suggestion.targets.description": "Below you can see all the suggestions ", + + "suggestion.targets": "Current Suggestions", + + "suggestion.table.name": "Researcher Name", + + "suggestion.table.actions": "Actions", + + "suggestion.button.review": "Review {{ total }} suggestion(s)", + + "suggestion.button.review.title": "Review {{ total }} suggestion(s) for ", + + "suggestion.noTargets": "No target found.", + + "suggestion.target.error.service.retrieve": "An error occurred while loading the Suggestion targets", + + "suggestion.evidence.type": "Type", + + "suggestion.evidence.score": "Score", + + "suggestion.evidence.notes": "Notes", + + "suggestion.approveAndImport": "Approve & import", + + "suggestion.approveAndImport.success": "The suggestion has been imported successfully. View.", + + "suggestion.approveAndImport.bulk": "Approve & import Selected", + + "suggestion.approveAndImport.bulk.success": "{{ count }} suggestions have been imported successfully ", + + "suggestion.approveAndImport.bulk.error": "{{ count }} suggestions haven't been imported due to unexpected server errors", + + "suggestion.ignoreSuggestion": "Ignore Suggestion", + + "suggestion.ignoreSuggestion.success": "The suggestion has been discarded", + + "suggestion.ignoreSuggestion.bulk": "Ignore Suggestion Selected", + + "suggestion.ignoreSuggestion.bulk.success": "{{ count }} suggestions have been discarded ", + + "suggestion.ignoreSuggestion.bulk.error": "{{ count }} suggestions haven't been discarded due to unexpected server errors", + + "suggestion.seeEvidence": "See evidence", + + "suggestion.hideEvidence": "Hide evidence", + + "suggestion.suggestionFor": "Suggestions for", + + "suggestion.suggestionFor.breadcrumb": "Suggestions for {{ name }}", + + "suggestion.source.openaire": "OpenAIRE Graph", + + "suggestion.from.source": "from the ", + + "suggestion.count.missing": "You have no publication claims left", + + "suggestion.totalScore": "Total Score", + + "suggestion.type.openaire": "OpenAIRE", + "register-email.title": "New user registration", "register-page.create-profile.header": "Create Profile", @@ -4150,6 +4244,18 @@ "sorting.lastModified.DESC": "Last modified Descending", + "sorting.person.familyName.ASC": "Surname Ascending", + + "sorting.person.familyName.DESC": "Surname Descending", + + "sorting.person.givenName.ASC": "Name Ascending", + + "sorting.person.givenName.DESC": "Name Descending", + + "sorting.person.birthDate.ASC": "Birth Date Ascending", + + "sorting.person.birthDate.DESC": "Birth Date Descending", + "statistics.title": "Statistics", "statistics.header": "Statistics for {{ scope }}", @@ -4274,6 +4380,8 @@ "submission.import-external.source.lcname": "Library of Congress Names", + "submission.import-external.source.ror": "Research Organization Registry (ROR)", + "submission.import-external.preview.title": "Item Preview", "submission.import-external.preview.title.Publication": "Publication Preview", @@ -4366,6 +4474,8 @@ "submission.sections.describe.relationship-lookup.external-source.import-modal.head.arxiv": "Importing from arXiv", + "submission.sections.describe.relationship-lookup.external-source.import-modal.head.ror": "Import from ROR", + "submission.sections.describe.relationship-lookup.external-source.import-modal.import": "Import", "submission.sections.describe.relationship-lookup.external-source.import-modal.Journal.title": "Import Remote Journal", @@ -4388,6 +4498,12 @@ "submission.sections.describe.relationship-lookup.external-source.import-modal.select": "Select a local match:", + "submission.sections.describe.relationship-lookup.external-source.import-modal.isOrgUnitOfProject.title": "Import Remote Organization", + + "submission.sections.describe.relationship-lookup.external-source.import-modal.isOrgUnitOfProject.added.local-entity": "Successfully added local organization to the selection", + + "submission.sections.describe.relationship-lookup.external-source.import-modal.isOrgUnitOfProject.added.new-entity": "Successfully imported and added external organization to the selection", + "submission.sections.describe.relationship-lookup.search-tab.deselect-all": "Deselect all", "submission.sections.describe.relationship-lookup.search-tab.deselect-page": "Deselect page", @@ -4444,6 +4560,8 @@ "submission.sections.describe.relationship-lookup.search-tab.tab-title.arxiv": "arXiv ({{ count }})", + "submission.sections.describe.relationship-lookup.search-tab.tab-title.ror": "ROR ({{ count }})", + "submission.sections.describe.relationship-lookup.search-tab.tab-title.orcidWorks": "ORCID ({{ count }})", "submission.sections.describe.relationship-lookup.search-tab.tab-title.crossref": "CrossRef ({{ count }})", @@ -4468,6 +4586,8 @@ "submission.sections.describe.relationship-lookup.search-tab.tab-title.isPublicationOfAuthor": "Publication of the Author", + "submission.sections.describe.relationship-lookup.search-tab.tab-title.isOrgUnitOfProject": "OrgUnit of the Project", + "submission.sections.describe.relationship-lookup.selection-tab.title.openAIREFunding": "Funding OpenAIRE API", "submission.sections.describe.relationship-lookup.selection-tab.title.isProjectOfPublication": "Project", @@ -4516,6 +4636,8 @@ "submission.sections.describe.relationship-lookup.title.isPublicationOfAuthor": "Publication", + "submission.sections.describe.relationship-lookup.title.isOrgUnitOfProject": "OrgUnit", + "submission.sections.describe.relationship-lookup.search-tab.toggle-dropdown": "Toggle dropdown", "submission.sections.describe.relationship-lookup.selection-tab.settings": "Settings", @@ -4578,6 +4700,8 @@ "submission.sections.describe.relationship-lookup.selection-tab.title.wos": "Search Results", + "submission.sections.describe.relationship-lookup.selection-tab.title.ror": "Search Results", + "submission.sections.describe.relationship-lookup.selection-tab.title": "Search Results", "submission.sections.describe.relationship-lookup.name-variant.notification.content": "Would you like to save \"{{ value }}\" as a name variant for this person so you and others can reuse it for future submissions? If you don't you can still use it for this submission.", @@ -5050,6 +5174,8 @@ "supervision.search.results.head": "Workflow and Workspace tasks", + "orgunit.search.results.head": "Organizational Unit Search Results", + "workflow-item.edit.breadcrumbs": "Edit workflowitem", "workflow-item.edit.title": "Edit workflowitem", @@ -5510,6 +5636,10 @@ "vocabulary-treeview.search.form.add": "Add", + "admin.notifications.publicationclaim.breadcrumbs": "Publication Claim", + + "admin.notifications.publicationclaim.page.title": "Publication Claim", + "coar-notify-support.title": "COAR Notify Protocol", "coar-notify-support-title.content": "Here, we fully support the COAR Notify protocol, which is designed to enhance the communication between repositories. To learn more about the COAR Notify protocol, you can visit their official website here.", @@ -5837,5 +5967,4 @@ "request-status-alert-box.rejected": "The requested {{ offerType }} for {{ serviceName }} has been rejected.", "request-status-alert-box.requested": "The requested {{ offerType }} for {{ serviceName }} is pending.", - } diff --git a/src/assets/images/ror-icon.svg b/src/assets/images/ror-icon.svg new file mode 100644 index 00000000000..24735df519b --- /dev/null +++ b/src/assets/images/ror-icon.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + diff --git a/src/config/app-config.interface.ts b/src/config/app-config.interface.ts index a0d91564150..6c4b99cb0f4 100644 --- a/src/config/app-config.interface.ts +++ b/src/config/app-config.interface.ts @@ -14,7 +14,7 @@ import { AuthConfig } from './auth-config.interfaces'; import { UIServerConfig } from './ui-server-config.interface'; import { MediaViewerConfig } from './media-viewer-config.interface'; import { BrowseByConfig } from './browse-by-config.interface'; -import {SuggestionConfig} from './layout-config.interfaces'; +import { SuggestionConfig } from './suggestion-config.interfaces'; import { BundleConfig } from './bundle-config.interface'; import { ActuatorsConfig } from './actuators.config'; import { InfoConfig } from './info-config.interface'; @@ -23,7 +23,7 @@ import { HomeConfig } from './homepage-config.interface'; import { MarkdownConfig } from './markdown-config.interface'; import { FilterVocabularyConfig } from './filter-vocabulary-config'; import { DiscoverySortConfig } from './discovery-sort.config'; -import {QualityAssuranceConfig} from './quality-assurance.config'; +import { QualityAssuranceConfig } from './quality-assurance.config'; interface AppConfig extends Config { ui: UIServerConfig; diff --git a/src/config/default-app-config.ts b/src/config/default-app-config.ts index 902ab68dc3f..3b3eb00ddbf 100644 --- a/src/config/default-app-config.ts +++ b/src/config/default-app-config.ts @@ -14,7 +14,7 @@ import { ServerConfig } from './server-config.interface'; import { SubmissionConfig } from './submission-config.interface'; import { ThemeConfig } from './theme.config'; import { UIServerConfig } from './ui-server-config.interface'; -import {SuggestionConfig} from './layout-config.interfaces'; +import {SuggestionConfig} from './suggestion-config.interfaces'; import { BundleConfig } from './bundle-config.interface'; import { ActuatorsConfig } from './actuators.config'; import { InfoConfig } from './info-config.interface'; @@ -304,6 +304,9 @@ export class DefaultAppConfig implements AppConfig { // source: 'suggestionSource', // collectionId: 'collectionUUID' // } + // This is used as a default fallback in case there aren't collections where to import the suggestion + // If not mapped the user will be allowed to import the suggestions only in the provided options, shown clicking the button "Approve and import" + // If not mapped and no options available for import the user won't be able to import the suggestions. ]; // Theme Config diff --git a/src/config/layout-config.interfaces.ts b/src/config/layout-config.interfaces.ts deleted file mode 100644 index 0b15a06aa92..00000000000 --- a/src/config/layout-config.interfaces.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { Config } from './config.interface'; - -export interface UrnConfig extends Config { - name: string; - baseUrl: string; -} - -export interface CrisRefConfig extends Config { - entityType: string; - icon: string; -} - -export interface CrisLayoutMetadataBoxConfig extends Config { - defaultMetadataLabelColStyle: string; - defaultMetadataValueColStyle: string; -} - -export interface CrisLayoutTypeConfig { - orientation: string; -} - -export interface NavbarConfig extends Config { - showCommunityCollection: boolean; -} - -export interface CrisItemPageConfig extends Config { - [entity: string]: CrisLayoutTypeConfig; - default: CrisLayoutTypeConfig; -} - - -export interface CrisLayoutConfig extends Config { - urn: UrnConfig[]; - crisRef: CrisRefConfig[]; - itemPage: CrisItemPageConfig; - metadataBox: CrisLayoutMetadataBoxConfig; -} - -export interface LayoutConfig extends Config { - navbar: NavbarConfig; -} - -export interface SuggestionConfig extends Config { - source: string; - collectionId: string; -} diff --git a/src/config/suggestion-config.interfaces.ts b/src/config/suggestion-config.interfaces.ts new file mode 100644 index 00000000000..afd3a38c581 --- /dev/null +++ b/src/config/suggestion-config.interfaces.ts @@ -0,0 +1,6 @@ +import { Config } from './config.interface'; + +export interface SuggestionConfig extends Config { + source: string; + collectionId: string; +} diff --git a/src/environments/environment.test.ts b/src/environments/environment.test.ts index 6402209740f..3908dd71dd2 100644 --- a/src/environments/environment.test.ts +++ b/src/environments/environment.test.ts @@ -323,5 +323,5 @@ export const environment: BuildConfig = { vocabulary: 'srsc', enabled: true } - ] + ], }; diff --git a/src/styles/_custom_variables.scss b/src/styles/_custom_variables.scss index fef75b03680..67bee9dac4e 100644 --- a/src/styles/_custom_variables.scss +++ b/src/styles/_custom_variables.scss @@ -106,4 +106,7 @@ --ds-comcol-logo-max-width: 500px; --ds-comcol-logo-max-height: 500px; + + --ds-item-page-img-field-default-inline-height: 24px; + } diff --git a/src/themes/custom/eager-theme.module.ts b/src/themes/custom/eager-theme.module.ts index 31047e239ac..8889c0c3df1 100644 --- a/src/themes/custom/eager-theme.module.ts +++ b/src/themes/custom/eager-theme.module.ts @@ -102,21 +102,24 @@ const DECLARATIONS = [ ]; @NgModule({ - imports: [ - CommonModule, - SharedModule, - RootModule, - NavbarModule, - SharedBrowseByModule, - ResultsBackButtonModule, - ItemPageModule, - ItemSharedModule, - DsoPageModule, - ], - declarations: DECLARATIONS, - providers: [ - ...ENTRY_COMPONENTS.map((component) => ({provide: component})) - ], + imports: [ + CommonModule, + SharedModule, + RootModule, + NavbarModule, + SharedBrowseByModule, + ResultsBackButtonModule, + ItemPageModule, + ItemSharedModule, + DsoPageModule, + ], + declarations: DECLARATIONS, + providers: [ + ...ENTRY_COMPONENTS.map((component) => ({ provide: component })) + ], + exports: [ + ItemSearchResultListElementComponent + ] }) /** * This module is included in the main bundle that gets downloaded at first page load. So it should diff --git a/src/themes/dspace/styles/_theme_css_variable_overrides.scss b/src/themes/dspace/styles/_theme_css_variable_overrides.scss index 516eff9f7e9..8512514a405 100644 --- a/src/themes/dspace/styles/_theme_css_variable_overrides.scss +++ b/src/themes/dspace/styles/_theme_css_variable_overrides.scss @@ -7,5 +7,7 @@ --ds-home-news-link-color: #{$green}; --ds-home-news-link-hover-color: #{darken($green, 15%)}; --ds-header-navbar-border-bottom-color: #{$green}; + --ds-item-page-img-field-default-inline-height: 24px; + --ds-item-page-img-field-ror-inline-height: var(--ds-item-page-img-field-default-inline-height); }