[TLC-674] Duplicate detection frontend changes as per feedback

This commit is contained in:
Kim Shepherd
2024-02-28 12:19:08 +13:00
parent 6229966f7a
commit e76b6c962c
10 changed files with 154 additions and 56 deletions

View File

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

View File

@@ -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<Item>
private createData: CreateData<Item>;
private patchData: PatchData<Item>;
private deleteData: DeleteData<Item>;
private searchData: SearchDataImpl<Duplicate>;
protected constructor(
protected linkPath,
@@ -78,7 +74,6 @@ export abstract class BaseItemDataService extends IdentifiableDataService<Item>
this.createData = new CreateDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive);
this.patchData = new PatchDataImpl<Item>(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<Item>
);
}
public findDuplicates(uuid: string, options?: FindListOptions, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<Duplicate>[]): Observable<RemoteData<PaginatedList<Duplicate>>> {
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

View File

@@ -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<Duplicate> {
export class SubmissionDuplicateDataService extends BaseDataService<Duplicate> {
/**
* The ResponseParsingService constructor name

View File

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

View File

@@ -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<ClaimedSearchResultListElementComponent>;
@@ -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, {

View File

@@ -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<Duplicate[]> = new Observable<Duplicate[]>();
public duplicates$: Observable<Duplicate[]>;
/**
* 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<ConfigurationProperty>) => {
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<PaginatedList<Duplicate>>) => {
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)) {

View File

@@ -4,6 +4,7 @@
[showSubmitter]="showSubmitter"
[badgeContext]="badgeContext"
[workflowItem]="workflowitem$.value"></ds-themed-item-list-preview>
<!-- Display duplicate alert, if feature enabled and duplicates detected -->
<ng-container *ngVar="(duplicates$|async)?.length as duplicateCount">
<div [ngClass]="'row'" *ngIf="duplicateCount > 0">

View File

@@ -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<PoolSearchResultListElementComponent>;
@@ -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, {

View File

@@ -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<Duplicate[]> = new Observable<Duplicate[]>();
public duplicates$: Observable<Duplicate[]>;
/**
* The index of this list element
@@ -74,13 +76,16 @@ export class PoolSearchResultListElementComponent extends SearchResultListElemen
*/
showThumbnails: boolean;
enableDetectDuplicates$: Observable<any>;
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<ConfigurationProperty>) => {
return (rd.hasSucceeded && rd.payload && rd.payload.values[0] === 'true');
})
);
(this.dso.workflowitem as Observable<RemoteData<WorkflowItem>>).pipe(
getFirstCompletedRemoteData(),
mergeMap((wfiRD: RemoteData<WorkflowItem>) => {
@@ -111,8 +124,32 @@ export class PoolSearchResultListElementComponent extends SearchResultListElemen
tap((itemRD: RemoteData<Item>) => {
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<ConfigurationProperty>) => {
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<PaginatedList<Duplicate>>) => {
if (remoteData.hasSucceeded) {
@@ -122,12 +159,11 @@ export class PoolSearchResultListElementComponent extends SearchResultListElemen
}
})
);
} else {
return [] as Duplicate[];
}
}),
).subscribe();
this.showThumbnails = this.appConfig.browseBy.showThumbnails;
);
}
ngOnDestroy() {

View File

@@ -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'
}
}
}];