diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 8a0edcf802..48e48738bf 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -198,7 +198,7 @@ import { NotifyRequestsStatus } from '../item-page/simple/notify-requests-status import { LdnService } from '../admin/admin-ldn-services/ldn-services-model/ldn-services.model'; import { Itemfilter } from '../admin/admin-ldn-services/ldn-services-model/ldn-service-itemfilters'; import { SubmissionCoarNotifyConfig } from '../submission/sections/section-coar-notify/submission-coar-notify.config'; -import { DuplicateDataService } from './data/duplicate-search.service'; +import { SubmissionDuplicateDataService } from './submission/submission-duplicate-data.service'; /** * When not in production, endpoint responses can be mocked for testing purposes @@ -235,7 +235,7 @@ const PROVIDERS = [ HALEndpointService, HostWindowService, ItemDataService, - DuplicateDataService, + SubmissionDuplicateDataService, MetadataService, ObjectCacheService, PaginationComponentOptions, diff --git a/src/app/core/data/item-data.service.ts b/src/app/core/data/item-data.service.ts index 1a0d181ca1..c3fa84dd6c 100644 --- a/src/app/core/data/item-data.service.ts +++ b/src/app/core/data/item-data.service.ts @@ -46,9 +46,6 @@ import { RestRequestMethod } from './rest-request-method'; import { CreateData, CreateDataImpl } from './base/create-data'; import { RequestParam } from '../cache/models/request-param.model'; import { dataService } from './base/data-service.decorator'; -import { Duplicate } from '../../shared/object-list/duplicate-data/duplicate.model'; -import { SearchDataImpl } from './base/search-data'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; /** * An abstract service for CRUD operations on Items @@ -59,7 +56,6 @@ export abstract class BaseItemDataService extends IdentifiableDataService private createData: CreateData; private patchData: PatchData; private deleteData: DeleteData; - private searchData: SearchDataImpl; protected constructor( protected linkPath, @@ -78,7 +74,6 @@ export abstract class BaseItemDataService extends IdentifiableDataService 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.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); } /** @@ -247,20 +242,6 @@ export abstract class BaseItemDataService extends IdentifiableDataService ); } - public findDuplicates(uuid: string, options?: FindListOptions, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { - const searchParams = [new RequestParam('uuid', uuid)]; - let findListOptions = new FindListOptions(); - if (options) { - findListOptions = Object.assign(new FindListOptions(), options); - } - if (findListOptions.searchParams) { - findListOptions.searchParams = [...findListOptions.searchParams, ...searchParams]; - } else { - findListOptions.searchParams = searchParams; - } - return this.searchData.searchBy('findDuplicates', findListOptions, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); - } - /** * Get the endpoint to move the item * @param itemId diff --git a/src/app/core/data/duplicate-search.service.ts b/src/app/core/submission/submission-duplicate-data.service.ts similarity index 72% rename from src/app/core/data/duplicate-search.service.ts rename to src/app/core/submission/submission-duplicate-data.service.ts index f33188119a..5410e51332 100644 --- a/src/app/core/data/duplicate-search.service.ts +++ b/src/app/core/submission/submission-duplicate-data.service.ts @@ -3,28 +3,34 @@ import { Observable } from 'rxjs'; import { Injectable } from '@angular/core'; import { map } from 'rxjs/operators'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { ResponseParsingService } from './parsing.service'; -import { RemoteData } from './remote-data'; -import { GetRequest } from './request.models'; -import { RequestService } from './request.service'; +import { ResponseParsingService } from '../data/parsing.service'; +import { RemoteData } from '../data/remote-data'; +import { GetRequest } from '../data/request.models'; +import { RequestService } from '../data/request.service'; import { GenericConstructor } from '../shared/generic-constructor'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { SearchResponseParsingService } from './search-response-parsing.service'; +import { SearchResponseParsingService } from '../data/search-response-parsing.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { RestRequest } from './rest-request.model'; -import { BaseDataService } from './base/base-data.service'; -import { FindListOptions } from './find-list-options.model'; +import { RestRequest } from '../data/rest-request.model'; +import { BaseDataService } from '../data/base/base-data.service'; +import { FindListOptions } from '../data/find-list-options.model'; import { Duplicate } from '../../shared/object-list/duplicate-data/duplicate.model'; -import { PaginatedList } from './paginated-list.model'; +import { PaginatedList } from '../data/paginated-list.model'; import { RequestParam } from '../cache/models/request-param.model'; import { ObjectCacheService } from '../cache/object-cache.service'; /** - * Service that performs all general actions that have to do with the search page + * Service that handles search requests for potential duplicate items. + * This uses the /api/submission/duplicates endpoint to look for other archived or in-progress items (if user + * has READ permission) that match the item (for the given uuid). + * Matching is configured in the backend in dspace/config/modulesduplicate-detection.cfg + * The returned results are small preview 'stubs' of items, and displayed in either a submission section + * or the workflow pooled/claimed task page. + * */ @Injectable() -export class DuplicateDataService extends BaseDataService { +export class SubmissionDuplicateDataService extends BaseDataService { /** * The ResponseParsingService constructor name diff --git a/src/app/shared/object-list/duplicate-data/duplicate.model.ts b/src/app/shared/object-list/duplicate-data/duplicate.model.ts index cbcff155e1..a165b81bab 100644 --- a/src/app/shared/object-list/duplicate-data/duplicate.model.ts +++ b/src/app/shared/object-list/duplicate-data/duplicate.model.ts @@ -5,6 +5,10 @@ import { CacheableObject } from '../../../core/cache/cacheable-object.model'; import { DUPLICATE } from './duplicate.resource-type'; import { ResourceType } from '../../../core/shared/resource-type'; +/** + * This implements the model of a duplicate preview stub, to be displayed to submitters or reviewers + * if duplicate detection is enabled. The metadata map is configurable in the backend at duplicate-detection.cfg + */ export class Duplicate implements CacheableObject { static type = DUPLICATE; @@ -14,17 +18,28 @@ export class Duplicate implements CacheableObject { */ @autoserialize title: string; + /** + * The item uuid + */ @autoserialize uuid: string; + /** + * The workfow item ID, if any + */ @autoserialize workflowItemId: number; + /** + * The workspace item ID, if any + */ @autoserialize workspaceItemId: number; + /** + * The owning collection of the item + */ @autoserialize owningCollection: string; - /** - * Metadata for the bitstream (e.g. dc.description) + * Metadata for the preview item (e.g. dc.title) */ @autoserialize metadata: MetadataMap; @@ -33,7 +48,7 @@ export class Duplicate implements CacheableObject { type: ResourceType; /** - * The {@link HALLink}s for this Bitstream + * The {@link HALLink}s for the URL that generated this item (in context of search results) */ @deserialize _links: { diff --git a/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.spec.ts b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.spec.ts index 349ac88a01..7e513319a1 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.spec.ts +++ b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.spec.ts @@ -30,7 +30,9 @@ import { ObjectCacheService } from '../../../../core/cache/object-cache.service' import { Context } from '../../../../core/shared/context.model'; import { createPaginatedList } from '../../../testing/utils.test'; import { ItemDataService } from '../../../../core/data/item-data.service'; -import { DuplicateDataService } from '../../../../core/data/duplicate-search.service'; +import { SubmissionDuplicateDataService } from '../../../../core/submission/submission-duplicate-data.service'; +import { ConfigurationProperty } from '../../../../core/shared/configuration-property.model'; +import { ConfigurationDataService } from '../../../../core/data/configuration-data.service'; let component: ClaimedSearchResultListElementComponent; let fixture: ComponentFixture; @@ -41,8 +43,15 @@ mockResultObject.hitHighlights = {}; const emptyList = createSuccessfulRemoteDataObject(createPaginatedList([])); const itemDataServiceStub = { findListByHref: () => observableOf(emptyList), - }; +const configurationDataService = jasmine.createSpyObj('configurationDataService', { + findByPropertyName: createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), { + name: 'duplicate.enable', + values: [ + 'true' + ] + })) +}); const duplicateDataServiceStub = { findListByHref: () => observableOf(emptyList), findDuplicates: () => createSuccessfulRemoteDataObject$({}), @@ -98,7 +107,8 @@ describe('ClaimedSearchResultListElementComponent', () => { { provide: APP_CONFIG, useValue: environment }, { provide: ObjectCacheService, useValue: objectCacheServiceMock }, { provide: ItemDataService, useValue: itemDataServiceStub }, - { provide: DuplicateDataService, useValue: duplicateDataServiceStub }, + { provide: ConfigurationDataService, useValue: configurationDataService }, + { provide: SubmissionDuplicateDataService, useValue: duplicateDataServiceStub }, ], schemas: [NO_ERRORS_SCHEMA] }).overrideComponent(ClaimedSearchResultListElementComponent, { diff --git a/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.ts b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.ts index 92adbc28ca..b4a61a6290 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.ts +++ b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.ts @@ -5,7 +5,7 @@ import { listableObjectComponent } from '../../../object-collection/shared/lista import { ClaimedTaskSearchResult } from '../../../object-collection/shared/claimed-task-search-result.model'; import { LinkService } from '../../../../core/cache/builders/link.service'; import { TruncatableService } from '../../../truncatable/truncatable.service'; -import { BehaviorSubject, EMPTY, Observable } from 'rxjs'; +import {BehaviorSubject, combineLatest, EMPTY, Observable} from 'rxjs'; import { RemoteData } from '../../../../core/data/remote-data'; import { WorkflowItem } from '../../../../core/submission/models/workflowitem.model'; import { followLink } from '../../../utils/follow-link-config.model'; @@ -24,7 +24,9 @@ import { Context } from '../../../../core/shared/context.model'; import { Duplicate } from '../../duplicate-data/duplicate.model'; import { PaginatedList } from '../../../../core/data/paginated-list.model'; import { ItemDataService } from '../../../../core/data/item-data.service'; -import { DuplicateDataService } from '../../../../core/data/duplicate-search.service'; +import { SubmissionDuplicateDataService } from '../../../../core/submission/submission-duplicate-data.service'; +import { ConfigurationProperty } from '../../../../core/shared/configuration-property.model'; +import { ConfigurationDataService } from '../../../../core/data/configuration-data.service'; @Component({ selector: 'ds-claimed-search-result-list-element', @@ -57,7 +59,7 @@ export class ClaimedSearchResultListElementComponent extends SearchResultListEle /** * The potential duplicates of this item */ - public duplicates$: Observable = new Observable(); + public duplicates$: Observable; /** * Display thumbnails if required by configuration @@ -70,7 +72,8 @@ export class ClaimedSearchResultListElementComponent extends SearchResultListEle public dsoNameService: DSONameService, protected objectCache: ObjectCacheService, protected itemDataService: ItemDataService, - protected duplicateDataService: DuplicateDataService, + protected configService: ConfigurationDataService, + protected duplicateDataService: SubmissionDuplicateDataService, @Inject(APP_CONFIG) protected appConfig: AppConfig ) { super(truncatableService, dsoNameService, appConfig); @@ -114,10 +117,45 @@ export class ClaimedSearchResultListElementComponent extends SearchResultListEle } }) ).subscribe(); - + // Initialise duplicates, if enabled + this.duplicates$ = this.initializeDuplicateDetectionIfEnabled(); this.showThumbnails = this.appConfig.browseBy.showThumbnails; } + /** + * Initialize and set the duplicates observable based on whether the configuration in REST is enabled + * and the results returned + */ + initializeDuplicateDetectionIfEnabled() { + return combineLatest([ + this.configService.findByPropertyName('duplicate.enable').pipe( + getFirstCompletedRemoteData(), + map((remoteData: RemoteData) => { + return (remoteData.isSuccess && remoteData.payload && remoteData.payload.values[0] === 'true'); + }) + ), + this.item$.pipe(), + ] + ).pipe( + map(([enabled, rd]) => { + if (enabled) { + this.duplicates$ = this.duplicateDataService.findDuplicates(rd.uuid).pipe( + getFirstCompletedRemoteData(), + map((remoteData: RemoteData>) => { + if (remoteData.hasSucceeded) { + if (remoteData.payload.page) { + return remoteData.payload.page; + } + } + }) + ); + } else { + return [] as Duplicate[]; + } + }), + ); + } + ngOnDestroy() { // This ensures the object is removed from cache, when action is performed on task if (hasValue(this.dso)) { diff --git a/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.html b/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.html index a2b73eb761..ab7d7a7d8a 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.html +++ b/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.html @@ -4,6 +4,7 @@ [showSubmitter]="showSubmitter" [badgeContext]="badgeContext" [workflowItem]="workflowitem$.value"> +
diff --git a/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.spec.ts b/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.spec.ts index 73cf09eb77..555449a3a4 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.spec.ts +++ b/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.spec.ts @@ -29,7 +29,9 @@ import { ObjectCacheService } from '../../../../core/cache/object-cache.service' import { Context } from '../../../../core/shared/context.model'; import { createPaginatedList } from '../../../testing/utils.test'; import { ItemDataService } from '../../../../core/data/item-data.service'; -import { DuplicateDataService } from '../../../../core/data/duplicate-search.service'; +import { SubmissionDuplicateDataService } from '../../../../core/submission/submission-duplicate-data.service'; +import { ConfigurationProperty } from '../../../../core/shared/configuration-property.model'; +import { ConfigurationDataService } from '../../../../core/data/configuration-data.service'; let component: PoolSearchResultListElementComponent; let fixture: ComponentFixture; @@ -41,6 +43,14 @@ const emptyList = createSuccessfulRemoteDataObject(createPaginatedList([])); const itemDataServiceStub = { findListByHref: () => observableOf(emptyList), }; +const configurationDataService = jasmine.createSpyObj('configurationDataService', { + findByPropertyName: createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), { + name: 'duplicate.enable', + values: [ + 'true' + ] + })) +}); const duplicateDataServiceStub = { findListByHref: () => observableOf(emptyList), findDuplicates: () => createSuccessfulRemoteDataObject$({}), @@ -104,7 +114,8 @@ describe('PoolSearchResultListElementComponent', () => { { provide: APP_CONFIG, useValue: environmentUseThumbs }, { provide: ObjectCacheService, useValue: objectCacheServiceMock }, { provide: ItemDataService, useValue: itemDataServiceStub }, - { provide: DuplicateDataService, useValue: duplicateDataServiceStub } + { provide: ConfigurationDataService, useValue: configurationDataService }, + { provide: SubmissionDuplicateDataService, useValue: duplicateDataServiceStub } ], schemas: [NO_ERRORS_SCHEMA] }).overrideComponent(PoolSearchResultListElementComponent, { diff --git a/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.ts b/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.ts index 151fd1fe56..5800d58d1d 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.ts +++ b/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.ts @@ -1,6 +1,6 @@ import { Component, Inject, OnDestroy, OnInit } from '@angular/core'; -import { BehaviorSubject, EMPTY, Observable } from 'rxjs'; +import {BehaviorSubject, combineLatest, EMPTY, Observable } from 'rxjs'; import { map, mergeMap, tap } from 'rxjs/operators'; import { ViewMode } from '../../../../core/shared/view-mode.model'; @@ -25,7 +25,9 @@ import { Context } from '../../../../core/shared/context.model'; import { PaginatedList } from '../../../../core/data/paginated-list.model'; import { Duplicate } from '../../duplicate-data/duplicate.model'; import { ItemDataService } from '../../../../core/data/item-data.service'; -import { DuplicateDataService } from '../../../../core/data/duplicate-search.service'; +import { SubmissionDuplicateDataService } from '../../../../core/submission/submission-duplicate-data.service'; +import { ConfigurationDataService } from '../../../../core/data/configuration-data.service'; +import { ConfigurationProperty } from '../../../../core/shared/configuration-property.model'; /** * This component renders pool task object for the search result in the list view. @@ -62,7 +64,7 @@ export class PoolSearchResultListElementComponent extends SearchResultListElemen /** * The potential duplicates of this workflow item */ - public duplicates$: Observable = new Observable(); + public duplicates$: Observable; /** * The index of this list element @@ -74,13 +76,16 @@ export class PoolSearchResultListElementComponent extends SearchResultListElemen */ showThumbnails: boolean; + enableDetectDuplicates$: Observable; + constructor( protected linkService: LinkService, protected truncatableService: TruncatableService, public dsoNameService: DSONameService, protected objectCache: ObjectCacheService, protected itemDataService: ItemDataService, - protected duplicateDataService: DuplicateDataService, + protected configService: ConfigurationDataService, + protected duplicateDataService: SubmissionDuplicateDataService, @Inject(APP_CONFIG) protected appConfig: AppConfig ) { super(truncatableService, dsoNameService, appConfig); @@ -96,6 +101,14 @@ export class PoolSearchResultListElementComponent extends SearchResultListElemen followLink('submitter') ), followLink('action')); + // Get configuration for duplicate detection feature + this.enableDetectDuplicates$ = this.configService.findByPropertyName('duplicate.enable').pipe( + getFirstCompletedRemoteData(), + map((rd: RemoteData) => { + return (rd.hasSucceeded && rd.payload && rd.payload.values[0] === 'true'); + }) + ); + (this.dso.workflowitem as Observable>).pipe( getFirstCompletedRemoteData(), mergeMap((wfiRD: RemoteData) => { @@ -111,8 +124,32 @@ export class PoolSearchResultListElementComponent extends SearchResultListElemen tap((itemRD: RemoteData) => { if (isNotEmpty(itemRD) && itemRD.hasSucceeded) { this.item$.next(itemRD.payload); - // Find duplicates for this item - this.duplicates$ = this.duplicateDataService.findDuplicates(itemRD.payload.uuid).pipe( + } + }), + ).subscribe(); + this.showThumbnails = this.appConfig.browseBy.showThumbnails; + // Initialise duplicates, if enabled + this.duplicates$ = this.initializeDuplicateDetectionIfEnabled(); + } + + /** + * Initialize and set the duplicates observable based on whether the configuration in REST is enabled + * and the results returned + */ + initializeDuplicateDetectionIfEnabled() { + return combineLatest([ + this.configService.findByPropertyName('duplicate.enable').pipe( + getFirstCompletedRemoteData(), + map((remoteData: RemoteData) => { + return (remoteData.isSuccess && remoteData.payload && remoteData.payload.values[0] === 'true'); + }) + ), + this.item$.pipe(), + ] + ).pipe( + map(([enabled, rd]) => { + if (enabled) { + this.duplicates$ = this.duplicateDataService.findDuplicates(rd.uuid).pipe( getFirstCompletedRemoteData(), map((remoteData: RemoteData>) => { if (remoteData.hasSucceeded) { @@ -122,12 +159,11 @@ export class PoolSearchResultListElementComponent extends SearchResultListElemen } }) ); + } else { + return [] as Duplicate[]; } }), - ).subscribe(); - - this.showThumbnails = this.appConfig.browseBy.showThumbnails; - + ); } ngOnDestroy() { diff --git a/src/app/submission/sections/duplicates/section-duplicates.component.spec.ts b/src/app/submission/sections/duplicates/section-duplicates.component.spec.ts index 5d00db4159..2c581fee97 100644 --- a/src/app/submission/sections/duplicates/section-duplicates.component.spec.ts +++ b/src/app/submission/sections/duplicates/section-duplicates.component.spec.ts @@ -76,7 +76,7 @@ const duplicates: Duplicate[] = [{ type: DUPLICATE, _links: { self: { - href: 'http://localhost:8080/server/api/core/items/search/findDuplicates?uuid=testid' + href: 'http://localhost:8080/server/api/core/submission/duplicates/search?uuid=testid' } } }];