diff --git a/config/config.example.yml b/config/config.example.yml index 8752cd04ed4..36d6a009d35 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -131,6 +131,10 @@ submission: # NOTE: after how many time (milliseconds) submission is saved automatically # eg. timer: 5 * (1000 * 60); // 5 minutes timer: 0 + # Always show the duplicate detection section if enabled, even if there are no potential duplicates detected + # (a message will be displayed to indicate no matches were found) + duplicateDetection: + alwaysShowSection: false icons: metadata: # NOTE: example of configuration diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 28e0d3e6e39..2efd4c478cc 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -199,6 +199,7 @@ import { LdnService } from '../admin/admin-ldn-services/ldn-services-model/ldn-s 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 { AdminNotifyMessage } from '../admin/admin-notify-dashboard/models/admin-notify-message.model'; +import { SubmissionDuplicateDataService } from './submission/submission-duplicate-data.service'; /** * When not in production, endpoint responses can be mocked for testing purposes @@ -235,6 +236,7 @@ const PROVIDERS = [ HALEndpointService, HostWindowService, ItemDataService, + SubmissionDuplicateDataService, MetadataService, ObjectCacheService, PaginationComponentOptions, diff --git a/src/app/core/submission/models/workspaceitem-section-duplicates.model.ts b/src/app/core/submission/models/workspaceitem-section-duplicates.model.ts new file mode 100644 index 00000000000..f9441fa7905 --- /dev/null +++ b/src/app/core/submission/models/workspaceitem-section-duplicates.model.ts @@ -0,0 +1,8 @@ +/* + * Object model for the data returned by the REST API to present potential duplicates in a submission section + */ +import { Duplicate } from '../../../shared/object-list/duplicate-data/duplicate.model'; + +export interface WorkspaceitemSectionDuplicatesObject { + potentialDuplicates?: Duplicate[] +} diff --git a/src/app/core/submission/models/workspaceitem-sections.model.ts b/src/app/core/submission/models/workspaceitem-sections.model.ts index dd19c3fb8b4..4c90f3ede86 100644 --- a/src/app/core/submission/models/workspaceitem-sections.model.ts +++ b/src/app/core/submission/models/workspaceitem-sections.model.ts @@ -3,8 +3,9 @@ import { WorkspaceitemSectionFormObject } from './workspaceitem-section-form.mod import { WorkspaceitemSectionLicenseObject } from './workspaceitem-section-license.model'; import { WorkspaceitemSectionUploadObject } from './workspaceitem-section-upload.model'; import { WorkspaceitemSectionCcLicenseObject } from './workspaceitem-section-cc-license.model'; -import {WorkspaceitemSectionIdentifiersObject} from './workspaceitem-section-identifiers.model'; +import { WorkspaceitemSectionIdentifiersObject } from './workspaceitem-section-identifiers.model'; import { WorkspaceitemSectionSherpaPoliciesObject } from './workspaceitem-section-sherpa-policies.model'; +import { WorkspaceitemSectionDuplicatesObject } from './workspaceitem-section-duplicates.model'; /** * An interface to represent submission's section object. @@ -25,6 +26,7 @@ export type WorkspaceitemSectionDataType | WorkspaceitemSectionAccessesObject | WorkspaceitemSectionSherpaPoliciesObject | WorkspaceitemSectionIdentifiersObject + | WorkspaceitemSectionDuplicatesObject | string; diff --git a/src/app/core/submission/submission-duplicate-data.service.spec.ts b/src/app/core/submission/submission-duplicate-data.service.spec.ts new file mode 100644 index 00000000000..fff4f3a0bc5 --- /dev/null +++ b/src/app/core/submission/submission-duplicate-data.service.spec.ts @@ -0,0 +1,30 @@ +import { SubmissionDuplicateDataService } from './submission-duplicate-data.service'; +import { FindListOptions } from '../data/find-list-options.model'; +import { RequestParam } from '../cache/models/request-param.model'; + +/** + * Basic tests for the submission-duplicate-data.service.ts service + */ +describe('SubmissionDuplicateDataService', () => { + const duplicateDataService = new SubmissionDuplicateDataService(null, null, null, null); + + // Test the findDuplicates method to make sure that a call results in an expected + // call to searchBy, using the 'findByItem' search method + describe('findDuplicates', () => { + beforeEach(() => { + spyOn(duplicateDataService, 'searchBy'); + }); + + it('should call searchBy with the correct arguments', () => { + // Set up expected search parameters and find options + const searchParams = []; + searchParams.push(new RequestParam('uuid', 'test')); + let findListOptions = new FindListOptions(); + findListOptions.searchParams = searchParams; + // Perform test search using uuid 'test' using the findDuplicates method + const result = duplicateDataService.findDuplicates('test', new FindListOptions(), true, true); + // Expect searchBy('findByItem'...) to have been used as SearchData impl with the expected options (uuid=test) + expect(duplicateDataService.searchBy).toHaveBeenCalledWith('findByItem', findListOptions, true, true); + }); + }); +}); diff --git a/src/app/core/submission/submission-duplicate-data.service.ts b/src/app/core/submission/submission-duplicate-data.service.ts new file mode 100644 index 00000000000..7e0e97e80b8 --- /dev/null +++ b/src/app/core/submission/submission-duplicate-data.service.ts @@ -0,0 +1,139 @@ +/* eslint-disable max-classes-per-file */ +import { Observable } from 'rxjs'; +import { Injectable } from '@angular/core'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +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 '../data/search-response-parsing.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +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 '../data/paginated-list.model'; +import { RequestParam } from '../cache/models/request-param.model'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { SearchData, SearchDataImpl } from '../data/base/search-data'; +import { DUPLICATE } from '../../shared/object-list/duplicate-data/duplicate.resource-type'; +import { dataService } from '../data/base/data-service.decorator'; + + +/** + * 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() +@dataService(DUPLICATE) +export class SubmissionDuplicateDataService extends BaseDataService implements SearchData { + + /** + * The ResponseParsingService constructor name + */ + private parser: GenericConstructor = SearchResponseParsingService; + + /** + * The RestRequest constructor name + */ + private request: GenericConstructor = GetRequest; + + /** + * SearchData interface to implement + * @private + */ + private searchData: SearchData; + + /** + * Subscription to unsubscribe from + */ + private sub; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + ) { + super('duplicates', requestService, rdbService, objectCache, halService); + this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + } + + /** + * Implement the searchBy method to return paginated lists of Duplicate resources + * + * @param searchMethod the search method name + * @param options find list options + * @param useCachedVersionIfAvailable whether to use cached version if available + * @param reRequestOnStale whether to rerequest results on stale + * @param linksToFollow links to follow in results + */ + searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * Helper method to get the duplicates endpoint + * @protected + */ + protected getEndpoint(): Observable { + return this.halService.getEndpoint(this.linkPath); + } + + /** + * Method to set service options + * @param {GenericConstructor} parser The ResponseParsingService constructor name + * @param {boolean} request The RestRequest constructor name + */ + setServiceOptions(parser: GenericConstructor, request: GenericConstructor) { + if (parser) { + this.parser = parser; + } + if (request) { + this.request = request; + } + } + + /** + * Find duplicates for a given item UUID. Locates and returns results from the /api/submission/duplicates/search/findByItem + * SearchRestMethod, which is why this implements SearchData and searchBy + * + * @param uuid the item UUID + * @param options any find list options e.g. paging + * @param useCachedVersionIfAvailable whether to use cached version if available + * @param reRequestOnStale whether to rerequest results on stale + * @param linksToFollow links to follow in results + */ + 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 actual search/findByItem results + return this.searchBy('findByItem', findListOptions, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + + } + + /** + * Unsubscribe from the subscription + */ + ngOnDestroy(): void { + if (this.sub !== undefined) { + this.sub.unsubscribe(); + } + } +} diff --git a/src/app/shared/mocks/submission.mock.ts b/src/app/shared/mocks/submission.mock.ts index bec08013c35..385df9dff61 100644 --- a/src/app/shared/mocks/submission.mock.ts +++ b/src/app/shared/mocks/submission.mock.ts @@ -1114,7 +1114,10 @@ export const mockSubmissionState: SubmissionObjectState = Object.assign({}, { isLoading: false, isValid: false, removePending: false - } as any + } as any, + 'duplicates': { + potentialDuplicates: [] + } as any, }, isLoading: false, savePending: false, diff --git a/src/app/shared/object-list/duplicate-data/duplicate.model.ts b/src/app/shared/object-list/duplicate-data/duplicate.model.ts new file mode 100644 index 00000000000..a165b81bab4 --- /dev/null +++ b/src/app/shared/object-list/duplicate-data/duplicate.model.ts @@ -0,0 +1,57 @@ +import {autoserialize, deserialize} from 'cerialize'; +import { MetadataMap } from '../../../core/shared/metadata.models'; +import { HALLink} from '../../../core/shared/hal-link.model'; +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; + + /** + * The item title + */ + @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 preview item (e.g. dc.title) + */ + @autoserialize + metadata: MetadataMap; + + @autoserialize + type: ResourceType; + + /** + * The {@link HALLink}s for the URL that generated this item (in context of search results) + */ + @deserialize + _links: { + self: HALLink; + }; +} diff --git a/src/app/shared/object-list/duplicate-data/duplicate.resource-type.ts b/src/app/shared/object-list/duplicate-data/duplicate.resource-type.ts new file mode 100644 index 00000000000..588ca2da55e --- /dev/null +++ b/src/app/shared/object-list/duplicate-data/duplicate.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from 'src/app/core/shared/resource-type'; + +/** + * The resource type for Duplicate preview stubs + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const DUPLICATE = new ResourceType('duplicate'); diff --git a/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.html b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.html index 42af008cdd7..9cdeb49e5c6 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.html +++ b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.html @@ -4,7 +4,17 @@ [showSubmitter]="showSubmitter" [badgeContext]="badgeContext" [workflowItem]="workflowitem$.value"> - + + +
+
+
+
+ {{ duplicateCount }} {{ 'submission.workflow.tasks.duplicates' | translate }} +
+
+
+
; @@ -35,6 +39,21 @@ let fixture: ComponentFixture; const mockResultObject: ClaimedTaskSearchResult = new ClaimedTaskSearchResult(); mockResultObject.hitHighlights = {}; +const emptyList = createSuccessfulRemoteDataObject(createPaginatedList([])); + +const configurationDataService = jasmine.createSpyObj('configurationDataService', { + findByPropertyName: createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), { + name: 'duplicate.enable', + values: [ + 'true' + ] + })) +}); +const duplicateDataServiceStub = { + findListByHref: () => observableOf(emptyList), + findDuplicates: () => createSuccessfulRemoteDataObject$({}), +}; + const item = Object.assign(new Item(), { bundles: observableOf({}), metadata: { @@ -83,7 +102,9 @@ describe('ClaimedSearchResultListElementComponent', () => { { provide: LinkService, useValue: linkService }, { provide: DSONameService, useClass: DSONameServiceMock }, { provide: APP_CONFIG, useValue: environment }, - { provide: ObjectCacheService, useValue: objectCacheServiceMock } + { provide: ObjectCacheService, useValue: objectCacheServiceMock }, + { 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 18148b6a8c4..2dd87ec1a1a 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'; @@ -18,9 +18,14 @@ import { APP_CONFIG, AppConfig } from '../../../../../config/app-config.interfac import { ObjectCacheService } from '../../../../core/cache/object-cache.service'; import { getFirstCompletedRemoteData } from '../../../../core/shared/operators'; import { Item } from '../../../../core/shared/item.model'; -import { mergeMap, tap } from 'rxjs/operators'; +import { map, mergeMap, tap } from 'rxjs/operators'; import { isNotEmpty, hasValue } from '../../../empty.util'; import { Context } from '../../../../core/shared/context.model'; +import { Duplicate } from '../../duplicate-data/duplicate.model'; +import { PaginatedList } from '../../../../core/data/paginated-list.model'; +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', @@ -50,6 +55,11 @@ export class ClaimedSearchResultListElementComponent extends SearchResultListEle */ public workflowitem$: BehaviorSubject = new BehaviorSubject(null); + /** + * The potential duplicates of this item + */ + public duplicates$: Observable; + /** * Display thumbnails if required by configuration */ @@ -60,6 +70,8 @@ export class ClaimedSearchResultListElementComponent extends SearchResultListEle protected truncatableService: TruncatableService, public dsoNameService: DSONameService, protected objectCache: ObjectCacheService, + protected configService: ConfigurationDataService, + protected duplicateDataService: SubmissionDuplicateDataService, @Inject(APP_CONFIG) protected appConfig: AppConfig ) { super(truncatableService, dsoNameService, appConfig); @@ -93,8 +105,43 @@ export class ClaimedSearchResultListElementComponent extends SearchResultListEle } }) ).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) { + if (remoteData.payload.page) { + return remoteData.payload.page; + } + } + }) + ); + } else { + return [] as Duplicate[]; + } + }), + ); } ngOnDestroy() { 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 9fe6e37c9e3..ab7d7a7d8a3 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,19 @@ [showSubmitter]="showSubmitter" [badgeContext]="badgeContext" [workflowItem]="workflowitem$.value"> + + + +
+
+
+
+ {{ duplicateCount }} {{ 'submission.workflow.tasks.duplicates' | translate }} +
+
+
+
+
; @@ -34,7 +38,23 @@ let fixture: ComponentFixture; const mockResultObject: PoolTaskSearchResult = new PoolTaskSearchResult(); mockResultObject.hitHighlights = {}; +const emptyList = createSuccessfulRemoteDataObject(createPaginatedList([])); + +const configurationDataService = jasmine.createSpyObj('configurationDataService', { + findByPropertyName: createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), { + name: 'duplicate.enable', + values: [ + 'true' + ] + })) +}); +const duplicateDataServiceStub = { + findListByHref: () => observableOf(emptyList), + findDuplicates: () => createSuccessfulRemoteDataObject$({}), +}; + const item = Object.assign(new Item(), { + duplicates: observableOf([]), bundles: observableOf({}), metadata: { 'dc.title': [ @@ -89,7 +109,9 @@ describe('PoolSearchResultListElementComponent', () => { { provide: LinkService, useValue: linkService }, { provide: DSONameService, useClass: DSONameServiceMock }, { provide: APP_CONFIG, useValue: environmentUseThumbs }, - { provide: ObjectCacheService, useValue: objectCacheServiceMock } + { provide: ObjectCacheService, useValue: objectCacheServiceMock }, + { 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 19723a7e494..087f234fd36 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,7 +1,7 @@ import { Component, Inject, OnDestroy, OnInit } from '@angular/core'; -import { BehaviorSubject, EMPTY, Observable } from 'rxjs'; -import { mergeMap, tap } from 'rxjs/operators'; +import {BehaviorSubject, combineLatest, EMPTY, Observable } from 'rxjs'; +import { map, mergeMap, tap } from 'rxjs/operators'; import { ViewMode } from '../../../../core/shared/view-mode.model'; import { RemoteData } from '../../../../core/data/remote-data'; @@ -22,6 +22,11 @@ import { getFirstCompletedRemoteData } from '../../../../core/shared/operators'; import { Item } from '../../../../core/shared/item.model'; import { isNotEmpty, hasValue } from '../../../empty.util'; import { Context } from '../../../../core/shared/context.model'; +import { PaginatedList } from '../../../../core/data/paginated-list.model'; +import { Duplicate } from '../../duplicate-data/duplicate.model'; +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. @@ -55,6 +60,11 @@ export class PoolSearchResultListElementComponent extends SearchResultListElemen */ public workflowitem$: BehaviorSubject = new BehaviorSubject(null); + /** + * The potential duplicates of this workflow item + */ + public duplicates$: Observable; + /** * The index of this list element */ @@ -70,6 +80,8 @@ export class PoolSearchResultListElementComponent extends SearchResultListElemen protected truncatableService: TruncatableService, public dsoNameService: DSONameService, protected objectCache: ObjectCacheService, + protected configService: ConfigurationDataService, + protected duplicateDataService: SubmissionDuplicateDataService, @Inject(APP_CONFIG) protected appConfig: AppConfig ) { super(truncatableService, dsoNameService, appConfig); @@ -101,10 +113,45 @@ export class PoolSearchResultListElementComponent extends SearchResultListElemen if (isNotEmpty(itemRD) && itemRD.hasSucceeded) { this.item$.next(itemRD.payload); } - }) + }), ).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) { + if (remoteData.payload.page) { + return remoteData.payload.page; + } + } + }) + ); + } else { + return [] as Duplicate[]; + } + }), + ); } ngOnDestroy() { diff --git a/src/app/submission/objects/submission-objects.actions.ts b/src/app/submission/objects/submission-objects.actions.ts index 86d90f05f30..91309180b90 100644 --- a/src/app/submission/objects/submission-objects.actions.ts +++ b/src/app/submission/objects/submission-objects.actions.ts @@ -56,6 +56,9 @@ export const SubmissionObjectActionTypes = { DISCARD_SUBMISSION_SUCCESS: type('dspace/submission/DISCARD_SUBMISSION_SUCCESS'), DISCARD_SUBMISSION_ERROR: type('dspace/submission/DISCARD_SUBMISSION_ERROR'), + // Clearing active section types + CLEAN_DUPLICATE_DETECTION: type('dspace/submission/CLEAN_DUPLICATE_DETECTION'), + // Upload file types NEW_FILE: type('dspace/submission/NEW_FILE'), EDIT_FILE_DATA: type('dspace/submission/EDIT_FILE_DATA'), @@ -240,6 +243,25 @@ export class UpdateSectionDataAction implements Action { } } +/** + * Removes data and makes 'detect-duplicate' section not visible. + */ +export class CleanDuplicateDetectionAction implements Action { + type = SubmissionObjectActionTypes.CLEAN_DUPLICATE_DETECTION; + payload: { + submissionId: string; + }; + + /** + * creates a new CleanDetectDuplicateAction + * + * @param submissionId Id of the submission on which perform the action + */ + constructor(submissionId: string ) { + this.payload = { submissionId }; + } +} + export class UpdateSectionDataSuccessAction implements Action { type = SubmissionObjectActionTypes.UPDATE_SECTION_DATA_SUCCESS; } @@ -845,6 +867,7 @@ export type SubmissionObjectAction = DisableSectionAction | InitSubmissionFormAction | ResetSubmissionFormAction | CancelSubmissionFormAction + | CleanDuplicateDetectionAction | CompleteInitSubmissionFormAction | ChangeSubmissionCollectionAction | SaveAndDepositSubmissionAction diff --git a/src/app/submission/objects/submission-objects.effects.ts b/src/app/submission/objects/submission-objects.effects.ts index 2c19224336d..8fd7fba5012 100644 --- a/src/app/submission/objects/submission-objects.effects.ts +++ b/src/app/submission/objects/submission-objects.effects.ts @@ -1,25 +1,25 @@ -import { Injectable } from '@angular/core'; -import { Actions, createEffect, ofType } from '@ngrx/effects'; -import { Store } from '@ngrx/store'; -import { TranslateService } from '@ngx-translate/core'; +import {Injectable} from '@angular/core'; +import {Actions, createEffect, ofType} from '@ngrx/effects'; +import {Store} from '@ngrx/store'; +import {TranslateService} from '@ngx-translate/core'; import findKey from 'lodash/findKey'; import isEqual from 'lodash/isEqual'; import union from 'lodash/union'; -import { from as observableFrom, Observable, of as observableOf } from 'rxjs'; -import { catchError, filter, map, mergeMap, switchMap, take, tap, withLatestFrom } from 'rxjs/operators'; -import { SubmissionObject } from '../../core/submission/models/submission-object.model'; -import { WorkflowItem } from '../../core/submission/models/workflowitem.model'; -import { WorkspaceitemSectionUploadObject } from '../../core/submission/models/workspaceitem-section-upload.model'; -import { WorkspaceitemSectionsObject } from '../../core/submission/models/workspaceitem-sections.model'; -import { WorkspaceItem } from '../../core/submission/models/workspaceitem.model'; -import { SubmissionJsonPatchOperationsService } from '../../core/submission/submission-json-patch-operations.service'; -import { isEmpty, isNotEmpty, isNotUndefined } from '../../shared/empty.util'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { SectionsType } from '../sections/sections-type'; -import { SectionsService } from '../sections/sections.service'; -import { SubmissionState } from '../submission.reducers'; -import { SubmissionService } from '../submission.service'; +import {from as observableFrom, Observable, of as observableOf} from 'rxjs'; +import {catchError, filter, map, mergeMap, switchMap, take, tap, withLatestFrom} from 'rxjs/operators'; +import {SubmissionObject} from '../../core/submission/models/submission-object.model'; +import {WorkflowItem} from '../../core/submission/models/workflowitem.model'; +import {WorkspaceitemSectionUploadObject} from '../../core/submission/models/workspaceitem-section-upload.model'; +import {WorkspaceitemSectionsObject} from '../../core/submission/models/workspaceitem-sections.model'; +import {WorkspaceItem} from '../../core/submission/models/workspaceitem.model'; +import {SubmissionJsonPatchOperationsService} from '../../core/submission/submission-json-patch-operations.service'; +import {isEmpty, isNotEmpty, isNotUndefined} from '../../shared/empty.util'; +import {NotificationsService} from '../../shared/notifications/notifications.service'; +import {SectionsType} from '../sections/sections-type'; +import {SectionsService} from '../sections/sections.service'; +import {SubmissionState} from '../submission.reducers'; +import {SubmissionService} from '../submission.service'; import parseSectionErrors from '../utils/parseSectionErrors'; import { CompleteInitSubmissionFormAction, @@ -43,18 +43,23 @@ import { SubmissionObjectAction, SubmissionObjectActionTypes, UpdateSectionDataAction, - UpdateSectionDataSuccessAction + UpdateSectionDataSuccessAction, + CleanDuplicateDetectionAction } from './submission-objects.actions'; -import { SubmissionObjectEntry } from './submission-objects.reducer'; -import { Item } from '../../core/shared/item.model'; -import { RemoteData } from '../../core/data/remote-data'; -import { getFirstSucceededRemoteDataPayload } from '../../core/shared/operators'; -import { SubmissionObjectDataService } from '../../core/submission/submission-object-data.service'; -import { followLink } from '../../shared/utils/follow-link-config.model'; -import parseSectionErrorPaths, { SectionErrorPath } from '../utils/parseSectionErrorPaths'; -import { FormState } from '../../shared/form/form.reducer'; -import { SubmissionSectionObject } from './submission-section-object.model'; -import { SubmissionSectionError } from './submission-section-error.model'; +import {SubmissionObjectEntry} from './submission-objects.reducer'; +import {Item} from '../../core/shared/item.model'; +import {RemoteData} from '../../core/data/remote-data'; +import {getFirstSucceededRemoteDataPayload} from '../../core/shared/operators'; +import {SubmissionObjectDataService} from '../../core/submission/submission-object-data.service'; +import {followLink} from '../../shared/utils/follow-link-config.model'; +import parseSectionErrorPaths, {SectionErrorPath} from '../utils/parseSectionErrorPaths'; +import {FormState} from '../../shared/form/form.reducer'; +import {SubmissionSectionObject} from './submission-section-object.model'; +import {SubmissionSectionError} from './submission-section-error.model'; +import { + WorkspaceitemSectionDuplicatesObject +} from '../../core/submission/models/workspaceitem-section-duplicates.model'; +import { environment } from '../../../environments/environment'; @Injectable() export class SubmissionObjectEffects { @@ -71,7 +76,12 @@ export class SubmissionObjectEffects { const selfLink = sectionDefinition._links.self.href || sectionDefinition._links.self; const sectionId = selfLink.substr(selfLink.lastIndexOf('/') + 1); const config = sectionDefinition._links.config ? (sectionDefinition._links.config.href || sectionDefinition._links.config) : ''; - const enabled = (sectionDefinition.mandatory) || (isNotEmpty(action.payload.sections) && action.payload.sections.hasOwnProperty(sectionId)); + // A section is enabled if it is mandatory or contains data in its section payload + // except for detect duplicate steps which will be hidden with no data unless overridden in config, even if mandatory + const enabled = (sectionDefinition.mandatory && (sectionDefinition.sectionType !== SectionsType.Duplicates)) + || (isNotEmpty(action.payload.sections) && action.payload.sections.hasOwnProperty(sectionId) + && (sectionDefinition.sectionType === SectionsType.Duplicates && (alwaysDisplayDuplicates() || isNotEmpty((action.payload.sections[sectionId] as WorkspaceitemSectionDuplicatesObject).potentialDuplicates))) + ); let sectionData; if (sectionDefinition.sectionType !== SectionsType.SubmissionForm) { sectionData = (isNotUndefined(action.payload.sections) && isNotUndefined(action.payload.sections[sectionId])) ? action.payload.sections[sectionId] : Object.create(null); @@ -434,8 +444,16 @@ export class SubmissionObjectEffects { && isEmpty(sections[sherpaPoliciesSectionId])) { mappedActions.push(new UpdateSectionDataAction(submissionId, sherpaPoliciesSectionId, null, [], [])); } - }); + // When Duplicate Detection step is enabled, add it only if there are duplicates in the response section data + // or if configuration overrides this behaviour + if (!alwaysDisplayDuplicates()) { + const duplicatesSectionId = findKey(currentState.sections, (section) => section.sectionType === SectionsType.Duplicates); + if (isNotUndefined(duplicatesSectionId) && sections.hasOwnProperty(duplicatesSectionId) && isEmpty((sections[duplicatesSectionId] as WorkspaceitemSectionDuplicatesObject).potentialDuplicates)) { + mappedActions.push(new CleanDuplicateDetectionAction(submissionId)); + } + } + }); } return mappedActions; } @@ -481,3 +499,7 @@ function filterErrors(sectionForm: FormState, sectionErrors: SubmissionSectionEr }); return filteredErrors; } + +function alwaysDisplayDuplicates(): boolean { + return (environment.submission.duplicateDetection.alwaysShowSection); +} diff --git a/src/app/submission/objects/submission-objects.reducer.spec.ts b/src/app/submission/objects/submission-objects.reducer.spec.ts index 2a24afae19c..35eb281d608 100644 --- a/src/app/submission/objects/submission-objects.reducer.spec.ts +++ b/src/app/submission/objects/submission-objects.reducer.spec.ts @@ -1,7 +1,7 @@ import { submissionObjectReducer, SubmissionObjectState } from './submission-objects.reducer'; import { CancelSubmissionFormAction, - ChangeSubmissionCollectionAction, + ChangeSubmissionCollectionAction, CleanDuplicateDetectionAction, CompleteInitSubmissionFormAction, DeleteSectionErrorsAction, DeleteUploadedFileAction, @@ -273,7 +273,7 @@ describe('submissionReducer test suite', () => { expect(newState[826].sections.traditionalpagetwo.enabled).toBeTruthy(); }); - it('should enable submission section properly', () => { + it('should disable submission section properly', () => { let action: SubmissionObjectAction = new EnableSectionAction(submissionId, 'traditionalpagetwo'); let newState = submissionObjectReducer(initState, action); @@ -644,4 +644,20 @@ describe('submissionReducer test suite', () => { expect(newState[826].sections.upload.data).toEqual(expectedState); }); + it('should enable duplicates section properly', () => { + + let action: SubmissionObjectAction = new EnableSectionAction(submissionId, 'duplicates'); + let newState = submissionObjectReducer(initState, action); + + expect(newState[826].sections.duplicates.enabled).toBeTruthy(); + }); + + it('should clean duplicates section properly', () => { + + let action = new CleanDuplicateDetectionAction(submissionId); + let newState = submissionObjectReducer(initState, action); + + expect(newState[826].sections.duplicates.enabled).toBeFalsy(); + }); + }); diff --git a/src/app/submission/objects/submission-objects.reducer.ts b/src/app/submission/objects/submission-objects.reducer.ts index 4970e25d325..268329460ee 100644 --- a/src/app/submission/objects/submission-objects.reducer.ts +++ b/src/app/submission/objects/submission-objects.reducer.ts @@ -5,7 +5,7 @@ import isEqual from 'lodash/isEqual'; import uniqWith from 'lodash/uniqWith'; import { - ChangeSubmissionCollectionAction, + ChangeSubmissionCollectionAction, CleanDuplicateDetectionAction, CompleteInitSubmissionFormAction, DeleteSectionErrorsAction, DeleteUploadedFileAction, @@ -229,6 +229,10 @@ export function submissionObjectReducer(state = initialState, action: Submission return removeSectionErrors(state, action as RemoveSectionErrorsAction); } + case SubmissionObjectActionTypes.CLEAN_DUPLICATE_DETECTION: { + return cleanDuplicateDetectionSection(state, action as CleanDuplicateDetectionAction); + } + default: { return state; } @@ -856,3 +860,20 @@ function deleteFile(state: SubmissionObjectState, action: DeleteUploadedFileActi } return state; } + +function cleanDuplicateDetectionSection(state: SubmissionObjectState, action: CleanDuplicateDetectionAction): SubmissionObjectState { + if (isNotEmpty(state[ action.payload.submissionId ]) && state[action.payload.submissionId].sections.hasOwnProperty('duplicates')) { + return Object.assign({}, state, { + [ action.payload.submissionId ]: Object.assign({}, state[ action.payload.submissionId ], { + sections: Object.assign({}, state[ action.payload.submissionId ].sections, { + [ 'duplicates' ]: Object.assign({}, state[ action.payload.submissionId ].sections.duplicates, { + enabled: false, + data: { potentialDuplicates: [] } + }) + }) + }) + }); + } else { + return state; + } +} diff --git a/src/app/submission/sections/duplicates/section-duplicates.component.html b/src/app/submission/sections/duplicates/section-duplicates.component.html new file mode 100644 index 00000000000..78c9e5df282 --- /dev/null +++ b/src/app/submission/sections/duplicates/section-duplicates.component.html @@ -0,0 +1,20 @@ + +
+ +
{{ 'submission.sections.duplicates.none' | translate }}
+
+ +
{{ 'submission.sections.duplicates.detected' | translate }}
+
+ {{dupe.title}} +
+ {{('item.preview.' + metadatum.key) | translate}} {{metadatum.value}} +
+

{{ 'submission.sections.duplicates.in-workspace' | translate }}

+

{{ 'submission.sections.duplicates.in-workflow' | translate }}

+
+
+
diff --git a/src/app/submission/sections/duplicates/section-duplicates.component.spec.ts b/src/app/submission/sections/duplicates/section-duplicates.component.spec.ts new file mode 100644 index 00000000000..2c581fee97e --- /dev/null +++ b/src/app/submission/sections/duplicates/section-duplicates.component.spec.ts @@ -0,0 +1,248 @@ +import { ChangeDetectorRef, Component, NO_ERRORS_SCHEMA } from '@angular/core'; +import { BrowserModule } from '@angular/platform-browser'; +import { CommonModule } from '@angular/common'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +import { NgxPaginationModule } from 'ngx-pagination'; +import { cold } from 'jasmine-marbles'; +import { of as observableOf } from 'rxjs'; +import { TranslateModule } from '@ngx-translate/core'; + +import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub'; +import { SubmissionService } from '../../submission.service'; +import { SubmissionServiceStub } from '../../../shared/testing/submission-service.stub'; +import { SectionsService } from '../sections.service'; +import { SectionsServiceStub } from '../../../shared/testing/sections-service.stub'; +import { FormBuilderService } from '../../../shared/form/builder/form-builder.service'; +import { getMockFormOperationsService } from '../../../shared/mocks/form-operations-service.mock'; +import { getMockFormService } from '../../../shared/mocks/form-service.mock'; +import { FormService } from '../../../shared/form/form.service'; +import { SubmissionFormsConfigDataService } from '../../../core/config/submission-forms-config-data.service'; +import { SectionsType } from '../sections-type'; +import { mockSubmissionCollectionId, mockSubmissionId } from '../../../shared/mocks/submission.mock'; +import { JsonPatchOperationPathCombiner } from '../../../core/json-patch/builder/json-patch-operation-path-combiner'; +import { SubmissionSectionDuplicatesComponent } from './section-duplicates.component'; +import { CollectionDataService } from '../../../core/data/collection-data.service'; +import { JsonPatchOperationsBuilder } from '../../../core/json-patch/builder/json-patch-operations-builder'; +import { SectionFormOperationsService } from '../form/section-form-operations.service'; +import { SubmissionScopeType } from '../../../core/submission/submission-scope-type'; +import { License } from '../../../core/shared/license.model'; +import { Collection } from '../../../core/shared/collection.model'; +import { ObjNgFor } from '../../../shared/utils/object-ngfor.pipe'; +import { VarDirective } from '../../../shared/utils/var.directive'; +import { PaginationService } from '../../../core/pagination/pagination.service'; +import { PaginationServiceStub } from '../../../shared/testing/pagination-service.stub'; +import { Duplicate} from '../../../shared/object-list/duplicate-data/duplicate.model'; +import { MetadataValue } from '../../../core/shared/metadata.models'; +import { defaultUUID } from '../../../shared/mocks/uuid.service.mock'; +import { DUPLICATE } from '../../../shared/object-list/duplicate-data/duplicate.resource-type'; + +function getMockSubmissionFormsConfigService(): SubmissionFormsConfigDataService { + return jasmine.createSpyObj('FormOperationsService', { + getConfigAll: jasmine.createSpy('getConfigAll'), + getConfigByHref: jasmine.createSpy('getConfigByHref'), + getConfigByName: jasmine.createSpy('getConfigByName'), + getConfigBySearch: jasmine.createSpy('getConfigBySearch') + }); +} + +function getMockCollectionDataService(): CollectionDataService { + return jasmine.createSpyObj('CollectionDataService', { + findById: jasmine.createSpy('findById'), + findByHref: jasmine.createSpy('findByHref') + }); +} + +const duplicates: Duplicate[] = [{ + title: 'Unique title', + uuid: defaultUUID, + workflowItemId: 1, + workspaceItemId: 2, + owningCollection: 'Test Collection', + metadata: { + 'dc.title': [ + Object.assign(new MetadataValue(), { + 'value': 'Unique title', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0 + })] + }, + type: DUPLICATE, + _links: { + self: { + href: 'http://localhost:8080/server/api/core/submission/duplicates/search?uuid=testid' + } + } + }]; + +const sectionObject = { + header: 'submission.sections.submit.progressbar.duplicates', + mandatory: true, + opened: true, + data: {potentialDuplicates: duplicates}, + errorsToShow: [], + serverValidationErrors: [], + id: 'duplicates', + sectionType: SectionsType.Duplicates, + sectionVisibility: null +}; + +describe('SubmissionSectionDuplicatesComponent test suite', () => { + let comp: SubmissionSectionDuplicatesComponent; + let compAsAny: any; + let fixture: ComponentFixture; + let submissionServiceStub: any = new SubmissionServiceStub(); + const sectionsServiceStub: any = new SectionsServiceStub(); + let formService: any; + let formOperationsService: any; + let formBuilderService: any; + let collectionDataService: any; + + const submissionId = mockSubmissionId; + const collectionId = mockSubmissionCollectionId; + const jsonPatchOpBuilder: any = jasmine.createSpyObj('jsonPatchOpBuilder', { + add: jasmine.createSpy('add'), + replace: jasmine.createSpy('replace'), + remove: jasmine.createSpy('remove'), + }); + + const licenseText = 'License text'; + const mockCollection = Object.assign(new Collection(), { + name: 'Community 1-Collection 1', + id: collectionId, + metadata: [ + { + key: 'dc.title', + language: 'en_US', + value: 'Community 1-Collection 1' + }], + license: createSuccessfulRemoteDataObject$(Object.assign(new License(), { text: licenseText })) + }); + const paginationService = new PaginationServiceStub(); + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + BrowserModule, + CommonModule, + FormsModule, + ReactiveFormsModule, + NgxPaginationModule, + NoopAnimationsModule, + TranslateModule.forRoot(), + ], + declarations: [ + SubmissionSectionDuplicatesComponent, + TestComponent, + ObjNgFor, + VarDirective, + ], + providers: [ + { provide: CollectionDataService, useValue: getMockCollectionDataService() }, + { provide: SectionFormOperationsService, useValue: getMockFormOperationsService() }, + { provide: FormService, useValue: getMockFormService() }, + { provide: JsonPatchOperationsBuilder, useValue: jsonPatchOpBuilder }, + { provide: SubmissionFormsConfigDataService, useValue: getMockSubmissionFormsConfigService() }, + { provide: NotificationsService, useClass: NotificationsServiceStub }, + { provide: SectionsService, useClass: SectionsServiceStub }, + { provide: SubmissionService, useClass: SubmissionServiceStub }, + { provide: 'collectionIdProvider', useValue: collectionId }, + { provide: 'sectionDataProvider', useValue: sectionObject }, + { provide: 'submissionIdProvider', useValue: submissionId }, + { provide: PaginationService, useValue: paginationService }, + ChangeDetectorRef, + FormBuilderService + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents().then(); + })); + + // First test to check the correct component creation + describe('', () => { + let testComp: TestComponent; + let testFixture: ComponentFixture; + + // synchronous beforeEach + beforeEach(() => { + sectionsServiceStub.isSectionReadOnly.and.returnValue(observableOf(false)); + sectionsServiceStub.getSectionErrors.and.returnValue(observableOf([])); + sectionsServiceStub.getSectionData.and.returnValue(observableOf(sectionObject)); + testFixture = TestBed.createComponent(SubmissionSectionDuplicatesComponent); + testComp = testFixture.componentInstance; + + }); + + afterEach(() => { + testFixture.destroy(); + }); + + it('should create SubmissionSectionDuplicatesComponent', () => { + expect(testComp).toBeTruthy(); + }); + }); + + describe('', () => { + beforeEach(() => { + fixture = TestBed.createComponent(SubmissionSectionDuplicatesComponent); + comp = fixture.componentInstance; + compAsAny = comp; + submissionServiceStub = TestBed.inject(SubmissionService); + formService = TestBed.inject(FormService); + formBuilderService = TestBed.inject(FormBuilderService); + formOperationsService = TestBed.inject(SectionFormOperationsService); + collectionDataService = TestBed.inject(CollectionDataService); + compAsAny.pathCombiner = new JsonPatchOperationPathCombiner('sections', sectionObject.id); + }); + + afterEach(() => { + fixture.destroy(); + comp = null; + compAsAny = null; + }); + + // Test initialisation of the submission section + it('Should init section properly', () => { + collectionDataService.findById.and.returnValue(createSuccessfulRemoteDataObject$(mockCollection)); + sectionsServiceStub.getSectionErrors.and.returnValue(observableOf([])); + sectionsServiceStub.isSectionReadOnly.and.returnValue(observableOf(false)); + compAsAny.submissionService.getSubmissionScope.and.returnValue(SubmissionScopeType.WorkspaceItem); + spyOn(comp, 'getSectionStatus').and.returnValue(observableOf(true)); + spyOn(comp, 'getDuplicateData').and.returnValue(observableOf({potentialDuplicates: duplicates})); + expect(comp.isLoading).toBeTruthy(); + comp.onSectionInit(); + fixture.detectChanges(); + expect(comp.isLoading).toBeFalsy(); + }); + + // The following tests look for proper logic in the getSectionStatus() implementation + // These are very simple as we don't really have a 'false' state unless we're still loading + it('Should return TRUE if the isLoading is FALSE', () => { + compAsAny.isLoading = false; + expect(compAsAny.getSectionStatus()).toBeObservable(cold('(a|)', { + a: true + })); + }); + it('Should return FALSE', () => { + compAsAny.isLoadin = true; + expect(compAsAny.getSectionStatus()).toBeObservable(cold('(a|)', { + a: false + })); + }); + }); + +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { + +} diff --git a/src/app/submission/sections/duplicates/section-duplicates.component.ts b/src/app/submission/sections/duplicates/section-duplicates.component.ts new file mode 100644 index 00000000000..c929ab9ce2f --- /dev/null +++ b/src/app/submission/sections/duplicates/section-duplicates.component.ts @@ -0,0 +1,124 @@ +import {ChangeDetectionStrategy, Component, Inject } from '@angular/core'; + +import { Observable, of as observableOf, Subscription } from 'rxjs'; +import { TranslateService } from '@ngx-translate/core'; +import { SectionsType } from '../sections-type'; +import { SectionModelComponent } from '../models/section.model'; +import { renderSectionFor } from '../sections-decorator'; +import { SectionDataObject } from '../models/section-data.model'; +import { SubmissionService } from '../../submission.service'; +import { AlertType } from '../../../shared/alert/alert-type'; +import { SectionsService } from '../sections.service'; +import { + WorkspaceitemSectionDuplicatesObject +} from '../../../core/submission/models/workspaceitem-section-duplicates.model'; +import { Metadata } from '../../../core/shared/metadata.utils'; +import { URLCombiner } from '../../../core/url-combiner/url-combiner'; +import { getItemModuleRoute } from '../../../item-page/item-page-routing-paths'; + +/** + * Detect duplicates step + * + * @author Kim Shepherd + */ +@Component({ + selector: 'ds-submission-section-duplicates', + templateUrl: './section-duplicates.component.html', + changeDetection: ChangeDetectionStrategy.Default +}) + +@renderSectionFor(SectionsType.Duplicates) +export class SubmissionSectionDuplicatesComponent extends SectionModelComponent { + protected readonly Metadata = Metadata; + /** + * The Alert categories. + * @type {AlertType} + */ + public AlertTypeEnum = AlertType; + + /** + * Variable to track if the section is loading. + * @type {boolean} + */ + public isLoading = true; + + /** + * Array to track all subscriptions and unsubscribe them onDestroy + * @type {Array} + */ + protected subs: Subscription[] = []; + + /** + * Initialize instance variables. + * + * @param {TranslateService} translate + * @param {SectionsService} sectionService + * @param {SubmissionService} submissionService + * @param {string} injectedCollectionId + * @param {SectionDataObject} injectedSectionData + * @param {string} injectedSubmissionId + */ + constructor(protected translate: TranslateService, + protected sectionService: SectionsService, + protected submissionService: SubmissionService, + @Inject('collectionIdProvider') public injectedCollectionId: string, + @Inject('sectionDataProvider') public injectedSectionData: SectionDataObject, + @Inject('submissionIdProvider') public injectedSubmissionId: string) { + super(injectedCollectionId, injectedSectionData, injectedSubmissionId); + } + + ngOnInit() { + super.ngOnInit(); + } + + /** + * Initialize all instance variables and retrieve configuration. + */ + onSectionInit() { + this.isLoading = false; + } + + /** + * Check if identifier section has read-only visibility + */ + isReadOnly(): boolean { + return true; + } + + /** + * Unsubscribe from all subscriptions, if needed. + */ + onSectionDestroy(): void { + return; + } + + /** + * Get section status. Because this simple component never requires human interaction, this is basically + * always going to be the opposite of "is this section still loading". This is not the place for API response + * error checking but determining whether the step can 'proceed'. + * + * @return Observable + * the section status + */ + public getSectionStatus(): Observable { + return observableOf(!this.isLoading); + } + + /** + * Get duplicate data as observable from the section data + */ + public getDuplicateData(): Observable { + return this.sectionService.getSectionData(this.submissionId, this.sectionData.id, this.sectionData.sectionType) as + Observable; + } + + /** + * Construct and return an item link for use with a preview item stub + * @param uuid + */ + public getItemLink(uuid: any) { + return new URLCombiner(getItemModuleRoute(), uuid).toString(); + } + + +} diff --git a/src/app/submission/sections/sections-type.ts b/src/app/submission/sections/sections-type.ts index 5f71d1731d5..50d15427d2b 100644 --- a/src/app/submission/sections/sections-type.ts +++ b/src/app/submission/sections/sections-type.ts @@ -9,5 +9,6 @@ export enum SectionsType { SherpaPolicies = 'sherpaPolicy', Identifiers = 'identifiers', Collection = 'collection', - CoarNotify = 'coarnotify' + CoarNotify = 'coarnotify', + Duplicates = 'duplicates' } diff --git a/src/app/submission/submission.module.ts b/src/app/submission/submission.module.ts index d839565f8d6..db4b8c66748 100644 --- a/src/app/submission/submission.module.ts +++ b/src/app/submission/submission.module.ts @@ -66,7 +66,8 @@ import { MetadataInformationComponent } from './sections/sherpa-policies/metadata-information/metadata-information.component'; import { SectionFormOperationsService } from './sections/form/section-form-operations.service'; -import {SubmissionSectionIdentifiersComponent} from './sections/identifiers/section-identifiers.component'; +import { SubmissionSectionIdentifiersComponent } from './sections/identifiers/section-identifiers.component'; +import { SubmissionSectionDuplicatesComponent } from './sections/duplicates/section-duplicates.component'; import { SubmissionSectionCoarNotifyComponent } from './sections/section-coar-notify/section-coar-notify.component'; import { CoarNotifyConfigDataService @@ -81,7 +82,8 @@ const ENTRY_COMPONENTS = [ SubmissionSectionCcLicensesComponent, SubmissionSectionAccessesComponent, SubmissionSectionSherpaPoliciesComponent, - SubmissionSectionCoarNotifyComponent + SubmissionSectionCoarNotifyComponent, + SubmissionSectionDuplicatesComponent ]; const DECLARATIONS = [ @@ -102,6 +104,7 @@ const DECLARATIONS = [ SubmissionSectionUploadFileEditComponent, SubmissionSectionUploadFileViewComponent, SubmissionSectionIdentifiersComponent, + SubmissionSectionDuplicatesComponent, SubmissionImportExternalComponent, ThemedSubmissionImportExternalComponent, SubmissionImportExternalSearchbarComponent, diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 888c8788f5e..541bb4f39d3 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -2826,6 +2826,8 @@ "item.preview.organization.legalName": "Legal Name", + "item.preview.dspace.entity.type": "Entity Type:", + "item.select.confirm": "Confirm selected", "item.select.empty": "No items to show", @@ -5106,7 +5108,7 @@ "submission.sections.submit.progressbar.describe.steptwo": "Describe", - "submission.sections.submit.progressbar.detect-duplicate": "Potential duplicates", + "submission.sections.submit.progressbar.duplicates": "Potential duplicates", "submission.sections.submit.progressbar.identifiers": "Identifiers", @@ -5238,6 +5240,14 @@ "submission.sections.accesses.form.until-placeholder": "Until", + "submission.sections.duplicates.none": "No duplicates were detected.", + + "submission.sections.duplicates.detected": "Potential duplicates were detected. Please review the list below.", + + "submission.sections.duplicates.in-workspace": "This item is in workspace", + + "submission.sections.duplicates.in-workflow": "This item is in workflow", + "submission.sections.license.granted-label": "I confirm the license above", "submission.sections.license.required": "You must accept the license", @@ -5364,6 +5374,8 @@ "submission.workflow.tasks.pool.show-detail": "Show detail", + "submission.workflow.tasks.duplicates": "potential duplicates were detected for this item. Claim and edit this item to see details.", + "submission.workspace.generic.view": "View", "submission.workspace.generic.view-help": "Select this option to view the item's metadata.", diff --git a/src/config/config.util.spec.ts b/src/config/config.util.spec.ts index 4dc2b67260e..cbf3bb329f3 100644 --- a/src/config/config.util.spec.ts +++ b/src/config/config.util.spec.ts @@ -13,6 +13,7 @@ describe('Config Util', () => { expect(appConfig.ui.useProxies).toEqual(true); expect(appConfig.submission.autosave.metadata).toEqual([]); + expect(appConfig.submission.duplicateDetection.alwaysShowSection).toEqual(false); expect(appConfig.themes.length).toEqual(1); expect(appConfig.themes[0].name).toEqual('dspace'); diff --git a/src/config/default-app-config.ts b/src/config/default-app-config.ts index 48350850f03..3ba87d735f4 100644 --- a/src/config/default-app-config.ts +++ b/src/config/default-app-config.ts @@ -157,6 +157,9 @@ export class DefaultAppConfig implements AppConfig { */ timer: 0 }, + duplicateDetection: { + alwaysShowSection: false + }, typeBind: { field: 'dc.type' }, diff --git a/src/config/submission-config.interface.ts b/src/config/submission-config.interface.ts index b0d1df900fa..afc81a39e25 100644 --- a/src/config/submission-config.interface.ts +++ b/src/config/submission-config.interface.ts @@ -5,6 +5,10 @@ interface AutosaveConfig extends Config { timer: number; } +interface DuplicateDetectionConfig extends Config { + alwaysShowSection: boolean; +} + interface TypeBindConfig extends Config { field: string; } @@ -29,6 +33,7 @@ export interface ConfidenceIconConfig extends Config { export interface SubmissionConfig extends Config { autosave: AutosaveConfig; + duplicateDetection: DuplicateDetectionConfig; typeBind: TypeBindConfig; icons: IconsConfig; } diff --git a/src/environments/environment.test.ts b/src/environments/environment.test.ts index ef5a46d9c6c..20491e5c19f 100644 --- a/src/environments/environment.test.ts +++ b/src/environments/environment.test.ts @@ -121,6 +121,9 @@ export const environment: BuildConfig = { // NOTE: every how many minutes submission is saved automatically timer: 5 }, + duplicateDetection: { + alwaysShowSection: false + }, typeBind: { field: 'dc.type' },