mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-07 01:54:15 +00:00
Merge remote-tracking branch 'github/main' into coar-notify-7-part-two
This commit is contained in:
@@ -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
|
||||
|
@@ -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,
|
||||
|
@@ -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[]
|
||||
}
|
@@ -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;
|
||||
|
||||
|
||||
|
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
139
src/app/core/submission/submission-duplicate-data.service.ts
Normal file
139
src/app/core/submission/submission-duplicate-data.service.ts
Normal file
@@ -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<Duplicate> implements SearchData<Duplicate> {
|
||||
|
||||
/**
|
||||
* The ResponseParsingService constructor name
|
||||
*/
|
||||
private parser: GenericConstructor<ResponseParsingService> = SearchResponseParsingService;
|
||||
|
||||
/**
|
||||
* The RestRequest constructor name
|
||||
*/
|
||||
private request: GenericConstructor<RestRequest> = GetRequest;
|
||||
|
||||
/**
|
||||
* SearchData interface to implement
|
||||
* @private
|
||||
*/
|
||||
private searchData: SearchData<Duplicate>;
|
||||
|
||||
/**
|
||||
* 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<Duplicate>[]): Observable<RemoteData<PaginatedList<Duplicate>>> {
|
||||
return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to get the duplicates endpoint
|
||||
* @protected
|
||||
*/
|
||||
protected getEndpoint(): Observable<string> {
|
||||
return this.halService.getEndpoint(this.linkPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to set service options
|
||||
* @param {GenericConstructor<ResponseParsingService>} parser The ResponseParsingService constructor name
|
||||
* @param {boolean} request The RestRequest constructor name
|
||||
*/
|
||||
setServiceOptions(parser: GenericConstructor<ResponseParsingService>, request: GenericConstructor<RestRequest>) {
|
||||
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<Duplicate> 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<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 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();
|
||||
}
|
||||
}
|
||||
}
|
@@ -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,
|
||||
|
57
src/app/shared/object-list/duplicate-data/duplicate.model.ts
Normal file
57
src/app/shared/object-list/duplicate-data/duplicate.model.ts
Normal file
@@ -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;
|
||||
};
|
||||
}
|
@@ -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');
|
@@ -4,7 +4,17 @@
|
||||
[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">
|
||||
<div [ngClass]="'col-2'"></div>
|
||||
<div [ngClass]="'col-10'">
|
||||
<div class="d-flex alert alert-warning w-100">
|
||||
{{ duplicateCount }} {{ 'submission.workflow.tasks.duplicates' | translate }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
<div class="row">
|
||||
<div [ngClass]="showThumbnails ? 'offset-3 offset-md-2 pl-3' : ''">
|
||||
<ds-claimed-task-actions [item]="item$.value"
|
||||
|
@@ -15,7 +15,7 @@ import { Item } from '../../../../core/shared/item.model';
|
||||
import { ClaimedSearchResultListElementComponent } from './claimed-search-result-list-element.component';
|
||||
import { ClaimedTask } from '../../../../core/tasks/models/claimed-task-object.model';
|
||||
import { WorkflowItem } from '../../../../core/submission/models/workflowitem.model';
|
||||
import { createSuccessfulRemoteDataObject } from '../../../remote-data.utils';
|
||||
import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../../remote-data.utils';
|
||||
import { ClaimedTaskSearchResult } from '../../../object-collection/shared/claimed-task-search-result.model';
|
||||
import { TruncatableService } from '../../../truncatable/truncatable.service';
|
||||
import { VarDirective } from '../../../utils/var.directive';
|
||||
@@ -28,6 +28,10 @@ import { APP_CONFIG } from '../../../../../config/app-config.interface';
|
||||
import { environment } from '../../../../../environments/environment';
|
||||
import { ObjectCacheService } from '../../../../core/cache/object-cache.service';
|
||||
import { Context } from '../../../../core/shared/context.model';
|
||||
import { createPaginatedList } from '../../../testing/utils.test';
|
||||
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>;
|
||||
@@ -35,6 +39,21 @@ let fixture: ComponentFixture<ClaimedSearchResultListElementComponent>;
|
||||
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, {
|
||||
|
@@ -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<WorkflowItem> = new BehaviorSubject<WorkflowItem>(null);
|
||||
|
||||
/**
|
||||
* The potential duplicates of this item
|
||||
*/
|
||||
public duplicates$: Observable<Duplicate[]>;
|
||||
|
||||
/**
|
||||
* 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<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() {
|
||||
|
@@ -4,6 +4,19 @@
|
||||
[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">
|
||||
<div [ngClass]="'col-2'"></div>
|
||||
<div [ngClass]="'col-10'">
|
||||
<div class="d-flex alert alert-warning w-100">
|
||||
{{ duplicateCount }} {{ 'submission.workflow.tasks.duplicates' | translate }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<div class="row">
|
||||
<div [ngClass]="showThumbnails ? 'offset-3 offset-md-2 pl-3' : ''">
|
||||
<ds-pool-task-actions id="actions"
|
||||
|
@@ -15,7 +15,7 @@ import { Item } from '../../../../core/shared/item.model';
|
||||
import { PoolSearchResultListElementComponent } from './pool-search-result-list-element.component';
|
||||
import { PoolTask } from '../../../../core/tasks/models/pool-task-object.model';
|
||||
import { WorkflowItem } from '../../../../core/submission/models/workflowitem.model';
|
||||
import { createSuccessfulRemoteDataObject } from '../../../remote-data.utils';
|
||||
import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../../remote-data.utils';
|
||||
import { PoolTaskSearchResult } from '../../../object-collection/shared/pool-task-search-result.model';
|
||||
import { TruncatableService } from '../../../truncatable/truncatable.service';
|
||||
import { VarDirective } from '../../../utils/var.directive';
|
||||
@@ -27,6 +27,10 @@ import { DSONameServiceMock } from '../../../mocks/dso-name.service.mock';
|
||||
import { APP_CONFIG } from '../../../../../config/app-config.interface';
|
||||
import { ObjectCacheService } from '../../../../core/cache/object-cache.service';
|
||||
import { Context } from '../../../../core/shared/context.model';
|
||||
import { createPaginatedList } from '../../../testing/utils.test';
|
||||
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>;
|
||||
@@ -34,7 +38,23 @@ let fixture: ComponentFixture<PoolSearchResultListElementComponent>;
|
||||
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, {
|
||||
|
@@ -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<WorkflowItem> = new BehaviorSubject<WorkflowItem>(null);
|
||||
|
||||
/**
|
||||
* The potential duplicates of this workflow item
|
||||
*/
|
||||
public duplicates$: Observable<Duplicate[]>;
|
||||
|
||||
/**
|
||||
* 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<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() {
|
||||
|
@@ -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
|
||||
|
@@ -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);
|
||||
}
|
||||
|
@@ -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();
|
||||
});
|
||||
|
||||
});
|
||||
|
@@ -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;
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,20 @@
|
||||
<!--
|
||||
Template for the detect duplicates submission section component
|
||||
@author Kim Shepherd
|
||||
-->
|
||||
<div class="text-sm-left" *ngVar="(this.getDuplicateData() | async) as data">
|
||||
<ng-container *ngIf="data?.potentialDuplicates.length == 0">
|
||||
<div class="alert alert-success w-100">{{ 'submission.sections.duplicates.none' | translate }}</div>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="data?.potentialDuplicates.length > 0">
|
||||
<div class="alert alert-warning w-100">{{ 'submission.sections.duplicates.detected' | translate }}</div>
|
||||
<div *ngFor="let dupe of data?.potentialDuplicates" class="ds-duplicate">
|
||||
<a target="_blank" [href]="getItemLink(dupe.uuid)">{{dupe.title}}</a>
|
||||
<div *ngFor="let metadatum of Metadata.toViewModelList(dupe.metadata)">
|
||||
{{('item.preview.' + metadatum.key) | translate}} {{metadatum.value}}
|
||||
</div>
|
||||
<p *ngIf="dupe.workspaceItemId">{{ 'submission.sections.duplicates.in-workspace' | translate }}</p>
|
||||
<p *ngIf="dupe.workflowItemId">{{ 'submission.sections.duplicates.in-workflow' | translate }}</p>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
@@ -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<SubmissionSectionDuplicatesComponent>;
|
||||
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<TestComponent>;
|
||||
|
||||
// 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 {
|
||||
|
||||
}
|
@@ -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<boolean>
|
||||
* the section status
|
||||
*/
|
||||
public getSectionStatus(): Observable<boolean> {
|
||||
return observableOf(!this.isLoading);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get duplicate data as observable from the section data
|
||||
*/
|
||||
public getDuplicateData(): Observable<WorkspaceitemSectionDuplicatesObject> {
|
||||
return this.sectionService.getSectionData(this.submissionId, this.sectionData.id, this.sectionData.sectionType) as
|
||||
Observable<WorkspaceitemSectionDuplicatesObject>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
|
||||
|
||||
}
|
@@ -9,5 +9,6 @@ export enum SectionsType {
|
||||
SherpaPolicies = 'sherpaPolicy',
|
||||
Identifiers = 'identifiers',
|
||||
Collection = 'collection',
|
||||
CoarNotify = 'coarnotify'
|
||||
CoarNotify = 'coarnotify',
|
||||
Duplicates = 'duplicates'
|
||||
}
|
||||
|
@@ -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,
|
||||
|
@@ -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.",
|
||||
|
@@ -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');
|
||||
|
@@ -157,6 +157,9 @@ export class DefaultAppConfig implements AppConfig {
|
||||
*/
|
||||
timer: 0
|
||||
},
|
||||
duplicateDetection: {
|
||||
alwaysShowSection: false
|
||||
},
|
||||
typeBind: {
|
||||
field: 'dc.type'
|
||||
},
|
||||
|
@@ -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;
|
||||
}
|
||||
|
@@ -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'
|
||||
},
|
||||
|
Reference in New Issue
Block a user