diff --git a/src/app/core/data/feature-authorization/feature-id.ts b/src/app/core/data/feature-authorization/feature-id.ts index 64a4fdc60f..15eba0e5db 100644 --- a/src/app/core/data/feature-authorization/feature-id.ts +++ b/src/app/core/data/feature-authorization/feature-id.ts @@ -22,4 +22,7 @@ export enum FeatureID { CanManagePolicies = 'canManagePolicies', CanMakePrivate = 'canMakePrivate', CanMove = 'canMove', + CanEditVersion = 'canEditVersion', + CanDeleteVersion = 'canDeleteVersion', + CanCreateVersion = 'canCreateVersion', } diff --git a/src/app/core/data/item-data.service.spec.ts b/src/app/core/data/item-data.service.spec.ts index 30a132aeae..26a6b52cc3 100644 --- a/src/app/core/data/item-data.service.spec.ts +++ b/src/app/core/data/item-data.service.spec.ts @@ -31,7 +31,7 @@ describe('ItemDataService', () => { }, removeByHrefSubstring(href: string) { // Do nothing - } + }, }) as RequestService; const rdbService = getMockRemoteDataBuildService(); @@ -184,4 +184,14 @@ describe('ItemDataService', () => { }); }); + describe('when cache is invalidated', () => { + beforeEach(() => { + service = initTestService(); + }); + it('should call setStaleByHrefSubstring', () => { + service.invalidateItemCache('uuid'); + expect(requestService.setStaleByHrefSubstring).toHaveBeenCalledWith('item/uuid'); + }); + }); + }); diff --git a/src/app/core/data/item-data.service.ts b/src/app/core/data/item-data.service.ts index 7a0116fe86..c31b6b3c97 100644 --- a/src/app/core/data/item-data.service.ts +++ b/src/app/core/data/item-data.service.ts @@ -59,6 +59,7 @@ export class ItemDataService extends DataService { * Get the endpoint for browsing items * (When options.sort.field is empty, the default field to browse by will be 'dc.date.issued') * @param {FindListOptions} options + * @param linkPath * @returns {Observable} */ public getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable { @@ -287,4 +288,13 @@ export class ItemDataService extends DataService { switchMap((url: string) => this.halService.getEndpoint('bitstreams', `${url}/${itemId}`)) ); } + + /** + * Invalidate the cache of the item + * @param itemUUID + */ + invalidateItemCache(itemUUID: string) { + this.requestService.setStaleByHrefSubstring('item/' + itemUUID); + } + } diff --git a/src/app/core/data/version-data.service.spec.ts b/src/app/core/data/version-data.service.spec.ts new file mode 100644 index 0000000000..5a8caf31be --- /dev/null +++ b/src/app/core/data/version-data.service.spec.ts @@ -0,0 +1,181 @@ +import { HttpClient } from '@angular/common/http'; +import { of as observableOf } from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; + +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { RequestService } from './request.service'; +import { PageInfo } from '../shared/page-info.model'; +import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { RequestEntry } from './request.reducer'; +import { HrefOnlyDataService } from './href-only-data.service'; +import { getMockHrefOnlyDataService } from '../../shared/mocks/href-only-data.service.mock'; + +import { Store } from '@ngrx/store'; +import { CoreState } from '../core.reducers'; +import { RestResponse } from '../cache/response.models'; +import { cold, getTestScheduler, hot } from 'jasmine-marbles'; +import { Item } from '../shared/item.model'; +import { VersionDataService } from './version-data.service'; +import { Version } from '../shared/version.model'; +import { VersionHistory } from '../shared/version-history.model'; +import { followLink } from '../../shared/utils/follow-link-config.model'; + + +describe('VersionDataService test', () => { + let scheduler: TestScheduler; + let service: VersionDataService; + let requestService: RequestService; + let rdbService: RemoteDataBuildService; + let objectCache: ObjectCacheService; + let halService: HALEndpointService; + let hrefOnlyDataService: HrefOnlyDataService; + let responseCacheEntry: RequestEntry; + + const item = Object.assign(new Item(), { + id: '1234-1234', + uuid: '1234-1234', + bundles: observableOf({}), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'dc.type': [ + { + language: null, + value: 'Article' + } + ], + 'dc.contributor.author': [ + { + language: 'en_US', + value: 'Smith, Donald' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '2015-06-26' + } + ] + } + }); + const itemRD = createSuccessfulRemoteDataObject(item); + + const versionHistory = Object.assign(new VersionHistory(), { + id: '1', + draftVersion: true, + }); + + const mockVersion: Version = Object.assign(new Version(), { + item: createSuccessfulRemoteDataObject$(item), + versionhistory: createSuccessfulRemoteDataObject$(versionHistory), + version: 1, + }); + const mockVersionRD = createSuccessfulRemoteDataObject(mockVersion); + + const endpointURL = `https://rest.api/rest/api/versioning/versions`; + const findByIdRequestURL = `https://rest.api/rest/api/versioning/versions/${mockVersion.id}`; + const findByIdRequestURL$ = observableOf(findByIdRequestURL); + + const requestUUID = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a'; + + objectCache = {} as ObjectCacheService; + const notificationsService = {} as NotificationsService; + const http = {} as HttpClient; + const comparator = {} as any; + const comparatorEntry = {} as any; + const store = {} as Store; + const pageInfo = new PageInfo(); + + function initTestService() { + hrefOnlyDataService = getMockHrefOnlyDataService(); + return new VersionDataService( + requestService, + rdbService, + store, + objectCache, + halService, + notificationsService, + http, + comparatorEntry + ); + } + + describe('', () => { + beforeEach(() => { + + scheduler = getTestScheduler(); + + halService = jasmine.createSpyObj('halService', { + getEndpoint: cold('a', { a: endpointURL }) + }); + responseCacheEntry = new RequestEntry(); + responseCacheEntry.request = { href: 'https://rest.api/' } as any; + responseCacheEntry.response = new RestResponse(true, 200, 'Success'); + + requestService = jasmine.createSpyObj('requestService', { + generateRequestId: requestUUID, + send: true, + removeByHrefSubstring: {}, + getByHref: observableOf(responseCacheEntry), + getByUUID: observableOf(responseCacheEntry), + }); + rdbService = jasmine.createSpyObj('rdbService', { + buildSingle: hot('(a|)', { + a: mockVersionRD + }) + }); + + service = initTestService(); + + spyOn((service as any), 'findByHref').and.callThrough(); + spyOn((service as any), 'getIDHrefObs').and.returnValue(findByIdRequestURL$); + }); + + afterEach(() => { + service = null; + }); + + describe('getHistoryFromVersion', () => { + it('should proxy the call to DataService.findByHref', () => { + scheduler.schedule(() => service.getHistoryFromVersion(mockVersion, true, true)); + scheduler.flush(); + + expect((service as any).findByHref).toHaveBeenCalledWith(findByIdRequestURL$, true, true, followLink('versionhistory')); + }); + + it('should return a VersionHistory', () => { + const result = service.getHistoryFromVersion(mockVersion, true, true); + const expected = cold('(a|)', { + a: versionHistory + }); + expect(result).toBeObservable(expected); + }); + + it('should return an EMPTY observable when version is not given', () => { + const result = service.getHistoryFromVersion(null); + const expected = cold('|'); + expect(result).toBeObservable(expected); + }); + }); + + describe('getHistoryIdFromVersion', () => { + it('should return the version history id', () => { + spyOn((service as any), 'getHistoryFromVersion').and.returnValue(observableOf(versionHistory)); + + const result = service.getHistoryIdFromVersion(mockVersion); + const expected = cold('(a|)', { + a: versionHistory.id + }); + expect(result).toBeObservable(expected); + }); + }); + }); + +}); diff --git a/src/app/core/data/version-data.service.ts b/src/app/core/data/version-data.service.ts index 11a3838eb0..70231122c3 100644 --- a/src/app/core/data/version-data.service.ts +++ b/src/app/core/data/version-data.service.ts @@ -10,10 +10,14 @@ import { HALEndpointService } from '../shared/hal-endpoint.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { HttpClient } from '@angular/common/http'; import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; -import { FindListOptions } from './request.models'; -import { Observable } from 'rxjs'; +import { EMPTY, Observable } from 'rxjs'; import { dataService } from '../cache/builders/build-decorators'; import { VERSION } from '../shared/version.resource-type'; +import { VersionHistory } from '../shared/version-history.model'; +import { followLink } from '../../shared/utils/follow-link-config.model'; +import { getFirstSucceededRemoteDataPayload } from '../shared/operators'; +import { map, switchMap } from 'rxjs/operators'; +import { isNotEmpty } from '../../shared/empty.util'; /** * Service responsible for handling requests related to the Version object @@ -36,9 +40,29 @@ export class VersionDataService extends DataService { } /** - * Get the endpoint for browsing versions + * Get the version history for the given version + * @param version + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale */ - getBrowseEndpoint(options: FindListOptions = {}, linkPath?: string): Observable { - return this.halService.getEndpoint(this.linkPath); + getHistoryFromVersion(version: Version, useCachedVersionIfAvailable = false, reRequestOnStale = true): Observable { + return isNotEmpty(version) ? this.findById(version.id, useCachedVersionIfAvailable, reRequestOnStale, followLink('versionhistory')).pipe( + getFirstSucceededRemoteDataPayload(), + switchMap((res: Version) => res.versionhistory), + getFirstSucceededRemoteDataPayload(), + ) : EMPTY; } + + /** + * Get the ID of the version history for the given version + * @param version + */ + getHistoryIdFromVersion(version: Version): Observable { + return this.getHistoryFromVersion(version).pipe( + map((versionHistory: VersionHistory) => versionHistory.id), + ); + } + } diff --git a/src/app/core/data/version-history-data.service.spec.ts b/src/app/core/data/version-history-data.service.spec.ts index 3a816936de..207093b4d5 100644 --- a/src/app/core/data/version-history-data.service.spec.ts +++ b/src/app/core/data/version-history-data.service.spec.ts @@ -6,6 +6,14 @@ import { NotificationsServiceStub } from '../../shared/testing/notifications-ser import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; import { getMockRequestService } from '../../shared/mocks/request.service.mock'; import { VersionDataService } from './version-data.service'; +import { fakeAsync, waitForAsync } from '@angular/core/testing'; +import { VersionHistory } from '../shared/version-history.model'; +import { Version } from '../shared/version.model'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { createPaginatedList } from '../../shared/testing/utils.test'; +import { Item } from '../shared/item.model'; +import { of } from 'rxjs'; +import SpyObj = jasmine.SpyObj; const url = 'fake-url'; @@ -16,9 +24,97 @@ describe('VersionHistoryDataService', () => { let notificationsService: any; let rdbService: RemoteDataBuildService; let objectCache: ObjectCacheService; - let versionService: VersionDataService; + let versionService: SpyObj; let halService: any; + const versionHistoryId = 'version-history-id'; + const versionHistoryDraftId = 'version-history-draft-id'; + const version1Id = 'version-1-id'; + const version2Id = 'version-1-id'; + const item1Uuid = 'item-1-uuid'; + const item2Uuid = 'item-2-uuid'; + const versionHistory = Object.assign(new VersionHistory(), { + id: versionHistoryId, + draftVersion: false, + }); + const versionHistoryDraft = Object.assign(new VersionHistory(), { + id: versionHistoryDraftId, + draftVersion: true, + }); + const version1 = Object.assign(new Version(), { + id: version1Id, + version: 1, + created: new Date(2020, 1, 1), + summary: 'first version', + versionhistory: createSuccessfulRemoteDataObject$(versionHistory), + _links: { + self: { + href: 'version1-url', + }, + }, + }); + const version2 = Object.assign(new Version(), { + id: version2Id, + version: 2, + summary: 'second version', + created: new Date(2020, 1, 2), + versionhistory: createSuccessfulRemoteDataObject$(versionHistory), + _links: { + self: { + href: 'version2-url', + }, + }, + }); + const versions = [version1, version2]; + versionHistory.versions = createSuccessfulRemoteDataObject$(createPaginatedList(versions)); + const item1 = Object.assign(new Item(), { + uuid: item1Uuid, + handle: '123456789/1', + version: createSuccessfulRemoteDataObject$(version1), + _links: { + self: { + href: '/items/' + item2Uuid, + } + } + }); + const item2 = Object.assign(new Item(), { + uuid: item2Uuid, + handle: '123456789/2', + version: createSuccessfulRemoteDataObject$(version2), + _links: { + self: { + href: '/items/' + item2Uuid, + } + } + }); + const items = [item1, item2]; + version1.item = createSuccessfulRemoteDataObject$(item1); + version2.item = createSuccessfulRemoteDataObject$(item2); + + /** + * Create a VersionHistoryDataService used for testing + * @param requestEntry$ Supply a requestEntry to be returned by the REST API (optional) + */ + function createService(requestEntry$?) { + requestService = getMockRequestService(requestEntry$); + rdbService = jasmine.createSpyObj('rdbService', { + buildList: jasmine.createSpy('buildList'), + buildFromRequestUUID: jasmine.createSpy('buildFromRequestUUID'), + }); + objectCache = jasmine.createSpyObj('objectCache', { + remove: jasmine.createSpy('remove') + }); + versionService = jasmine.createSpyObj('objectCache', { + findByHref: jasmine.createSpy('findByHref'), + findAllByHref: jasmine.createSpy('findAllByHref'), + getHistoryFromVersion: jasmine.createSpy('getHistoryFromVersion'), + }); + halService = new HALEndpointServiceStub(url); + notificationsService = new NotificationsServiceStub(); + + service = new VersionHistoryDataService(requestService, rdbService, null, objectCache, halService, notificationsService, versionService, null, null); + } + beforeEach(() => { createService(); }); @@ -35,24 +131,70 @@ describe('VersionHistoryDataService', () => { }); }); - /** - * Create a VersionHistoryDataService used for testing - * @param requestEntry$ Supply a requestEntry to be returned by the REST API (optional) - */ - function createService(requestEntry$?) { - requestService = getMockRequestService(requestEntry$); - rdbService = jasmine.createSpyObj('rdbService', { - buildList: jasmine.createSpy('buildList') + describe('when getVersions is called', () => { + beforeEach(waitForAsync(() => { + service.getVersions(versionHistoryId); + })); + it('findAllByHref should have been called', () => { + expect(versionService.findAllByHref).toHaveBeenCalled(); }); - objectCache = jasmine.createSpyObj('objectCache', { - remove: jasmine.createSpy('remove') - }); - versionService = jasmine.createSpyObj('objectCache', { - findAllByHref: jasmine.createSpy('findAllByHref') - }); - halService = new HALEndpointServiceStub(url); - notificationsService = new NotificationsServiceStub(); + }); + + describe('when getBrowseEndpoint is called', () => { + it('should return the correct value', () => { + service.getBrowseEndpoint().subscribe((res) => { + expect(res).toBe(url + '/versionhistories'); + }); + }); + }); + + describe('when getVersionsEndpoint is called', () => { + it('should return the correct value', () => { + service.getVersionsEndpoint(versionHistoryId).subscribe((res) => { + expect(res).toBe(url + '/versions'); + }); + }); + }); + + describe('when cache is invalidated', () => { + it('should call setStaleByHrefSubstring', () => { + service.invalidateVersionHistoryCache(versionHistoryId); + expect(requestService.setStaleByHrefSubstring).toHaveBeenCalledWith('versioning/versionhistories/' + versionHistoryId); + }); + }); + + describe('isLatest$', () => { + beforeEach(waitForAsync(() => { + spyOn(service, 'getLatestVersion$').and.returnValue(of(version2)); + })); + it('should return false for version1', () => { + service.isLatest$(version1).subscribe((res) => { + expect(res).toBe(false); + }); + }); + it('should return true for version2', () => { + service.isLatest$(version2).subscribe((res) => { + expect(res).toBe(true); + }); + }); + }); + + describe('hasDraftVersion$', () => { + beforeEach(waitForAsync(() => { + versionService.findByHref.and.returnValue(createSuccessfulRemoteDataObject$(version1)); + })); + it('should return false if draftVersion is false', fakeAsync(() => { + versionService.getHistoryFromVersion.and.returnValue(of(versionHistory)); + service.hasDraftVersion$('href').subscribe((res) => { + expect(res).toBeFalse(); + }); + })); + it('should return true if draftVersion is true', fakeAsync(() => { + versionService.getHistoryFromVersion.and.returnValue(of(versionHistoryDraft)); + service.hasDraftVersion$('href').subscribe((res) => { + expect(res).toBeTrue(); + }); + })); + }); - service = new VersionHistoryDataService(requestService, rdbService, null, objectCache, halService, notificationsService, versionService, null, null); - } }); diff --git a/src/app/core/data/version-history-data.service.ts b/src/app/core/data/version-history-data.service.ts index 8f148f168d..4268516e6b 100644 --- a/src/app/core/data/version-history-data.service.ts +++ b/src/app/core/data/version-history-data.service.ts @@ -8,19 +8,30 @@ import { CoreState } from '../core.reducers'; import { ObjectCacheService } from '../cache/object-cache.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; +import { HttpClient, HttpHeaders } from '@angular/common/http'; import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; -import { FindListOptions } from './request.models'; -import { Observable } from 'rxjs'; +import { FindListOptions, PostRequest, RestRequest } from './request.models'; +import { Observable, of } from 'rxjs'; import { PaginatedSearchOptions } from '../../shared/search/paginated-search-options.model'; import { RemoteData } from './remote-data'; import { PaginatedList } from './paginated-list.model'; import { Version } from '../shared/version.model'; -import { map, switchMap } from 'rxjs/operators'; +import { filter, map, switchMap, take } from 'rxjs/operators'; import { dataService } from '../cache/builders/build-decorators'; import { VERSION_HISTORY } from '../shared/version-history.resource-type'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { VersionDataService } from './version-data.service'; +import { HttpOptions } from '../dspace-rest/dspace-rest.service'; +import { + getAllSucceededRemoteData, + getFirstCompletedRemoteData, + getFirstSucceededRemoteDataPayload, + getRemoteDataPayload, + sendRequest +} from '../shared/operators'; +import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; +import { hasValueOperator } from '../../shared/empty.util'; +import { Item } from '../shared/item.model'; /** * Service responsible for handling requests related to the VersionHistory object @@ -79,4 +90,129 @@ export class VersionHistoryDataService extends DataService { return this.versionDataService.findAllByHref(hrefObs, undefined, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); } + + /** + * Create a new version for an item + * @param itemHref the item for which create a new version + * @param summary the summary of the new version + */ + createVersion(itemHref: string, summary: string): Observable> { + const requestOptions: HttpOptions = Object.create({}); + let requestHeaders = new HttpHeaders(); + requestHeaders = requestHeaders.append('Content-Type', 'text/uri-list'); + requestOptions.headers = requestHeaders; + + return this.halService.getEndpoint(this.versionsEndpoint).pipe( + take(1), + map((endpointUrl: string) => (summary?.length > 0) ? `${endpointUrl}?summary=${summary}` : `${endpointUrl}`), + map((endpointURL: string) => new PostRequest(this.requestService.generateRequestId(), endpointURL, itemHref, requestOptions)), + sendRequest(this.requestService), + switchMap((restRequest: RestRequest) => this.rdbService.buildFromRequestUUID(restRequest.uuid)), + getFirstCompletedRemoteData() + ) as Observable>; + } + + /** + * Get the latest version in a version history + * @param versionHistory + */ + getLatestVersionFromHistory$(versionHistory: VersionHistory): Observable { + + // Pagination options to fetch a single version on the first page (this is the latest version in the history) + const latestVersionOptions = Object.assign(new PaginationComponentOptions(), { + id: 'item-newest-version-options', + currentPage: 1, + pageSize: 1 + }); + + const latestVersionSearch = new PaginatedSearchOptions({pagination: latestVersionOptions}); + + return this.getVersions(versionHistory.id, latestVersionSearch, false, true, followLink('item')).pipe( + getAllSucceededRemoteData(), + getRemoteDataPayload(), + hasValueOperator(), + filter((versions) => versions.page.length > 0), + map((versions) => versions.page[0]) + ); + + } + + /** + * Get the latest version (return null if the specified version is null) + * @param version + */ + getLatestVersion$(version: Version): Observable { + // retrieve again version, including with versionHistory + return version.id ? this.versionDataService.findById(version.id, false, true, followLink('versionhistory')).pipe( + getFirstSucceededRemoteDataPayload(), + switchMap((res) => res.versionhistory), + getFirstSucceededRemoteDataPayload(), + switchMap((versionHistoryRD) => this.getLatestVersionFromHistory$(versionHistoryRD)), + ) : of(null); + } + + /** + * Check if the given version is the latest (return null if `version` is null) + * @param version + * @returns `true` if the specified version is the latest one, `false` otherwise, or `null` if the specified version is null + */ + isLatest$(version: Version): Observable { + return version ? this.getLatestVersion$(version).pipe( + take(1), + switchMap((latestVersion) => of(version.version === latestVersion.version)) + ) : of(null); + } + + /** + * Check if a worskpace item exists in the version history (return null if there is no version history) + * @param versionHref the href of the version + * @returns `true` if a workspace item exists, `false` otherwise, or `null` if a version history does not exist + */ + hasDraftVersion$(versionHref: string): Observable { + return this.versionDataService.findByHref(versionHref, true, true, followLink('versionhistory')).pipe( + getFirstCompletedRemoteData(), + switchMap((res) => { + if (res.hasSucceeded && !res.hasNoContent) { + return of(res).pipe( + getFirstSucceededRemoteDataPayload(), + switchMap((version) => this.versionDataService.getHistoryFromVersion(version)), + map((versionHistory) => versionHistory ? versionHistory.draftVersion : false), + ); + } else { + return of(false); + } + }), + ); + } + + /** + * Get the item of the latest version in a version history + * @param versionHistory + */ + getLatestVersionItemFromHistory$(versionHistory: VersionHistory): Observable { + return this.getLatestVersionFromHistory$(versionHistory).pipe( + switchMap((newLatestVersion) => newLatestVersion.item), + getFirstSucceededRemoteDataPayload(), + ); + } + + /** + * Get the item of the latest version from any version in the version history + * @param version + */ + getVersionHistoryFromVersion$(version: Version): Observable { + return this.versionDataService.getHistoryIdFromVersion(version).pipe( + take(1), + switchMap((res) => this.findById(res)), + getFirstSucceededRemoteDataPayload(), + ); + } + + /** + * Invalidate the cache of the version history + * @param versionHistoryID + */ + invalidateVersionHistoryCache(versionHistoryID: string) { + this.requestService.setStaleByHrefSubstring('versioning/versionhistories/' + versionHistoryID); + } } diff --git a/src/app/core/shared/version-history.model.ts b/src/app/core/shared/version-history.model.ts index 85578f20fc..1e75b8f321 100644 --- a/src/app/core/shared/version-history.model.ts +++ b/src/app/core/shared/version-history.model.ts @@ -22,6 +22,7 @@ export class VersionHistory extends DSpaceObject { _links: { self: HALLink; versions: HALLink; + draftVersion: HALLink; }; /** @@ -30,6 +31,24 @@ export class VersionHistory extends DSpaceObject { @autoserialize id: string; + /** + * The summary of this Version History + */ + @autoserialize + summary: string; + + /** + * The name of the submitter of this Version History + */ + @autoserialize + submitterName: string; + + /** + * Whether exist a workspace item + */ + @autoserialize + draftVersion: boolean; + /** * The list of versions within this history */ diff --git a/src/app/core/submission/workflowitem-data.service.spec.ts b/src/app/core/submission/workflowitem-data.service.spec.ts new file mode 100644 index 0000000000..8a5177118d --- /dev/null +++ b/src/app/core/submission/workflowitem-data.service.spec.ts @@ -0,0 +1,150 @@ +import { HttpClient } from '@angular/common/http'; +import { of as observableOf } from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; + +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { RequestService } from '../data/request.service'; +import { PageInfo } from '../shared/page-info.model'; +import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; +import { RequestEntry } from '../data/request.reducer'; +import { HrefOnlyDataService } from '../data/href-only-data.service'; +import { getMockHrefOnlyDataService } from '../../shared/mocks/href-only-data.service.mock'; +import { Store } from '@ngrx/store'; +import { CoreState } from '../core.reducers'; +import { RestResponse } from '../cache/response.models'; +import { cold, getTestScheduler, hot } from 'jasmine-marbles'; +import { Item } from '../shared/item.model'; +import { WorkflowItemDataService } from './workflowitem-data.service'; +import { WorkflowItem } from './models/workflowitem.model'; + +describe('WorkflowItemDataService test', () => { + let scheduler: TestScheduler; + let service: WorkflowItemDataService; + let requestService: RequestService; + let rdbService: RemoteDataBuildService; + let objectCache: ObjectCacheService; + let halService: HALEndpointService; + let hrefOnlyDataService: HrefOnlyDataService; + let responseCacheEntry: RequestEntry; + + const item = Object.assign(new Item(), { + id: '1234-1234', + uuid: '1234-1234', + bundles: observableOf({}), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'dc.type': [ + { + language: null, + value: 'Article' + } + ], + 'dc.contributor.author': [ + { + language: 'en_US', + value: 'Smith, Donald' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '2015-06-26' + } + ] + } + }); + const itemRD = createSuccessfulRemoteDataObject(item); + const wsi = Object.assign(new WorkflowItem(), { item: observableOf(itemRD), id: '1234', uuid: '1234' }); + const wsiRD = createSuccessfulRemoteDataObject(wsi); + + const endpointURL = `https://rest.api/rest/api/submission/workspaceitems`; + const searchRequestURL = `https://rest.api/rest/api/submission/workspaceitems/search/item?uuid=1234-1234`; + const searchRequestURL$ = observableOf(searchRequestURL); + + const requestUUID = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a'; + + objectCache = {} as ObjectCacheService; + const notificationsService = {} as NotificationsService; + const http = {} as HttpClient; + const comparator = {} as any; + const comparatorEntry = {} as any; + const store = {} as Store; + const pageInfo = new PageInfo(); + + function initTestService() { + hrefOnlyDataService = getMockHrefOnlyDataService(); + return new WorkflowItemDataService( + comparatorEntry, + halService, + http, + notificationsService, + requestService, + rdbService, + objectCache, + store + ); + } + + describe('', () => { + beforeEach(() => { + + scheduler = getTestScheduler(); + + halService = jasmine.createSpyObj('halService', { + getEndpoint: cold('a', { a: endpointURL }) + }); + responseCacheEntry = new RequestEntry(); + responseCacheEntry.request = { href: 'https://rest.api/' } as any; + responseCacheEntry.response = new RestResponse(true, 200, 'Success'); + + requestService = jasmine.createSpyObj('requestService', { + generateRequestId: requestUUID, + send: true, + removeByHrefSubstring: {}, + getByHref: observableOf(responseCacheEntry), + getByUUID: observableOf(responseCacheEntry), + }); + rdbService = jasmine.createSpyObj('rdbService', { + buildSingle: hot('a|', { + a: wsiRD + }) + }); + + service = initTestService(); + + spyOn((service as any), 'findByHref').and.callThrough(); + spyOn((service as any), 'getSearchByHref').and.returnValue(searchRequestURL$); + }); + + afterEach(() => { + service = null; + }); + + describe('findByItem', () => { + it('should proxy the call to DataService.findByHref', () => { + scheduler.schedule(() => service.findByItem('1234-1234', true, true, pageInfo)); + scheduler.flush(); + + expect((service as any).findByHref).toHaveBeenCalledWith(searchRequestURL$, true, true); + }); + + it('should return a RemoteData for the search', () => { + const result = service.findByItem('1234-1234', true, true, pageInfo); + const expected = cold('a|', { + a: wsiRD + }); + expect(result).toBeObservable(expected); + }); + + }); + }); + +}); diff --git a/src/app/core/submission/workflowitem-data.service.ts b/src/app/core/submission/workflowitem-data.service.ts index 099cfa8627..384d477110 100644 --- a/src/app/core/submission/workflowitem-data.service.ts +++ b/src/app/core/submission/workflowitem-data.service.ts @@ -9,7 +9,7 @@ import { DataService } from '../data/data.service'; import { RequestService } from '../data/request.service'; import { WorkflowItem } from './models/workflowitem.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { DeleteByIDRequest } from '../data/request.models'; +import { DeleteByIDRequest, FindListOptions } from '../data/request.models'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; @@ -19,6 +19,9 @@ import { hasValue } from '../../shared/empty.util'; import { RemoteData } from '../data/remote-data'; import { NoContent } from '../shared/NoContent.model'; import { getFirstCompletedRemoteData } from '../shared/operators'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { WorkspaceItem } from './models/workspaceitem.model'; +import { RequestParam } from '../cache/models/request-param.model'; /** * A service that provides methods to make REST requests with workflow items endpoint. @@ -27,6 +30,7 @@ import { getFirstCompletedRemoteData } from '../shared/operators'; @dataService(WorkflowItem.type) export class WorkflowItemDataService extends DataService { protected linkPath = 'workflowitems'; + protected searchByItemLinkPath = 'item'; protected responseMsToLive = 10 * 1000; constructor( @@ -86,4 +90,23 @@ export class WorkflowItemDataService extends DataService { return this.rdbService.buildFromRequestUUID(requestId); } + + /** + * Return the WorkflowItem object found through the UUID of an item + * + * @param uuid The uuid of the item + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param options The {@link FindListOptions} object + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + public findByItem(uuid: string, useCachedVersionIfAvailable = false, reRequestOnStale = true, options: FindListOptions = {}, ...linksToFollow: FollowLinkConfig[]): Observable> { + const findListOptions = new FindListOptions(); + findListOptions.searchParams = [new RequestParam('uuid', encodeURIComponent(uuid))]; + const href$ = this.getSearchByHref(this.searchByItemLinkPath, findListOptions, ...linksToFollow); + return this.findByHref(href$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + } diff --git a/src/app/core/submission/workspaceitem-data.service.spec.ts b/src/app/core/submission/workspaceitem-data.service.spec.ts new file mode 100644 index 0000000000..da7edccda7 --- /dev/null +++ b/src/app/core/submission/workspaceitem-data.service.spec.ts @@ -0,0 +1,150 @@ +import { HttpClient } from '@angular/common/http'; +import { of as observableOf } from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; + +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { RequestService } from '../data/request.service'; +import { PageInfo } from '../shared/page-info.model'; +import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; +import { RequestEntry } from '../data/request.reducer'; +import { HrefOnlyDataService } from '../data/href-only-data.service'; +import { getMockHrefOnlyDataService } from '../../shared/mocks/href-only-data.service.mock'; +import { WorkspaceitemDataService } from './workspaceitem-data.service'; +import { Store } from '@ngrx/store'; +import { CoreState } from '../core.reducers'; +import { RestResponse } from '../cache/response.models'; +import { cold, getTestScheduler, hot } from 'jasmine-marbles'; +import { Item } from '../shared/item.model'; +import { WorkspaceItem } from './models/workspaceitem.model'; + +describe('WorkspaceitemDataService test', () => { + let scheduler: TestScheduler; + let service: WorkspaceitemDataService; + let requestService: RequestService; + let rdbService: RemoteDataBuildService; + let objectCache: ObjectCacheService; + let halService: HALEndpointService; + let hrefOnlyDataService: HrefOnlyDataService; + let responseCacheEntry: RequestEntry; + + const item = Object.assign(new Item(), { + id: '1234-1234', + uuid: '1234-1234', + bundles: observableOf({}), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'dc.type': [ + { + language: null, + value: 'Article' + } + ], + 'dc.contributor.author': [ + { + language: 'en_US', + value: 'Smith, Donald' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '2015-06-26' + } + ] + } + }); + const itemRD = createSuccessfulRemoteDataObject(item); + const wsi = Object.assign(new WorkspaceItem(), { item: observableOf(itemRD), id: '1234', uuid: '1234' }); + const wsiRD = createSuccessfulRemoteDataObject(wsi); + + const endpointURL = `https://rest.api/rest/api/submission/workspaceitems`; + const searchRequestURL = `https://rest.api/rest/api/submission/workspaceitems/search/item?uuid=1234-1234`; + const searchRequestURL$ = observableOf(searchRequestURL); + + const requestUUID = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a'; + + objectCache = {} as ObjectCacheService; + const notificationsService = {} as NotificationsService; + const http = {} as HttpClient; + const comparator = {} as any; + const comparatorEntry = {} as any; + const store = {} as Store; + const pageInfo = new PageInfo(); + + function initTestService() { + hrefOnlyDataService = getMockHrefOnlyDataService(); + return new WorkspaceitemDataService( + comparatorEntry, + halService, + http, + notificationsService, + requestService, + rdbService, + objectCache, + store + ); + } + + describe('', () => { + beforeEach(() => { + + scheduler = getTestScheduler(); + + halService = jasmine.createSpyObj('halService', { + getEndpoint: cold('a', { a: endpointURL }) + }); + responseCacheEntry = new RequestEntry(); + responseCacheEntry.request = { href: 'https://rest.api/' } as any; + responseCacheEntry.response = new RestResponse(true, 200, 'Success'); + + requestService = jasmine.createSpyObj('requestService', { + generateRequestId: requestUUID, + send: true, + removeByHrefSubstring: {}, + getByHref: observableOf(responseCacheEntry), + getByUUID: observableOf(responseCacheEntry), + }); + rdbService = jasmine.createSpyObj('rdbService', { + buildSingle: hot('a|', { + a: wsiRD + }) + }); + + service = initTestService(); + + spyOn((service as any), 'findByHref').and.callThrough(); + spyOn((service as any), 'getSearchByHref').and.returnValue(searchRequestURL$); + }); + + afterEach(() => { + service = null; + }); + + describe('findByItem', () => { + it('should proxy the call to DataService.findByHref', () => { + scheduler.schedule(() => service.findByItem('1234-1234', true, true, pageInfo)); + scheduler.flush(); + + expect((service as any).findByHref).toHaveBeenCalledWith(searchRequestURL$, true, true); + }); + + it('should return a RemoteData for the search', () => { + const result = service.findByItem('1234-1234', true, true, pageInfo); + const expected = cold('a|', { + a: wsiRD + }); + expect(result).toBeObservable(expected); + }); + + }); + }); + +}); diff --git a/src/app/core/submission/workspaceitem-data.service.ts b/src/app/core/submission/workspaceitem-data.service.ts index 2fc95bdd00..2813398bb5 100644 --- a/src/app/core/submission/workspaceitem-data.service.ts +++ b/src/app/core/submission/workspaceitem-data.service.ts @@ -12,6 +12,11 @@ import { NotificationsService } from '../../shared/notifications/notifications.s import { ObjectCacheService } from '../cache/object-cache.service'; import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; import { WorkspaceItem } from './models/workspaceitem.model'; +import { Observable } from 'rxjs'; +import { RemoteData } from '../data/remote-data'; +import { FindListOptions } from '../data/request.models'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { RequestParam } from '../cache/models/request-param.model'; /** * A service that provides methods to make REST requests with workspaceitems endpoint. @@ -20,6 +25,7 @@ import { WorkspaceItem } from './models/workspaceitem.model'; @dataService(WorkspaceItem.type) export class WorkspaceitemDataService extends DataService { protected linkPath = 'workspaceitems'; + protected searchByItemLinkPath = 'item'; constructor( protected comparator: DSOChangeAnalyzer, @@ -33,4 +39,22 @@ export class WorkspaceitemDataService extends DataService { super(); } + /** + * Return the WorkspaceItem object found through the UUID of an item + * + * @param uuid The uuid of the item + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param options The {@link FindListOptions} object + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + public findByItem(uuid: string, useCachedVersionIfAvailable = false, reRequestOnStale = true, options: FindListOptions = {}, ...linksToFollow: FollowLinkConfig[]): Observable> { + const findListOptions = new FindListOptions(); + findListOptions.searchParams = [new RequestParam('uuid', encodeURIComponent(uuid))]; + const href$ = this.getSearchByHref(this.searchByItemLinkPath, findListOptions, ...linksToFollow); + return this.findByHref(href$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + } diff --git a/src/app/item-page/edit-item-page/item-metadata/item-metadata.component.html b/src/app/item-page/edit-item-page/item-metadata/item-metadata.component.html index e154487402..70cd2aaa39 100644 --- a/src/app/item-page/edit-item-page/item-metadata/item-metadata.component.html +++ b/src/app/item-page/edit-item-page/item-metadata/item-metadata.component.html @@ -1,66 +1,69 @@ diff --git a/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.html b/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.html index 4fb5166e4f..8c6d06ef66 100644 --- a/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.html +++ b/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.html @@ -3,6 +3,9 @@
+
diff --git a/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.spec.ts b/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.spec.ts index e70a05a8c8..389468d5f4 100644 --- a/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.spec.ts +++ b/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.spec.ts @@ -29,6 +29,12 @@ import { TruncatePipe } from '../../../../shared/utils/truncate.pipe'; import { GenericItemPageFieldComponent } from '../../field-components/specific-field/generic/generic-item-page-field.component'; import { createRelationshipsObservable } from '../shared/item.component.spec'; import { UntypedItemComponent } from './untyped-item.component'; +import { VersionHistoryDataService } from '../../../../core/data/version-history-data.service'; +import { VersionDataService } from '../../../../core/data/version-data.service'; +import { RouterTestingModule } from '@angular/router/testing'; +import { WorkspaceitemDataService } from '../../../../core/submission/workspaceitem-data.service'; +import { SearchService } from '../../../../core/shared/search/search.service'; +import { ItemVersionsSharedService } from '../../../../shared/item/item-versions/item-versions-shared.service'; const mockItem: Item = Object.assign(new Item(), { bundles: createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])), @@ -47,13 +53,16 @@ describe('UntypedItemComponent', () => { } }; TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot({ - loader: { - provide: TranslateLoader, - useClass: TranslateLoaderMock - } - })], - declarations: [UntypedItemComponent, GenericItemPageFieldComponent, TruncatePipe], + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }), + RouterTestingModule, + ], + declarations: [UntypedItemComponent, GenericItemPageFieldComponent, TruncatePipe ], providers: [ { provide: ItemDataService, useValue: {} }, { provide: TruncatableService, useValue: {} }, @@ -68,9 +77,14 @@ describe('UntypedItemComponent', () => { { provide: HttpClient, useValue: {} }, { provide: DSOChangeAnalyzer, useValue: {} }, { provide: DefaultChangeAnalyzer, useValue: {} }, + { provide: VersionHistoryDataService, useValue: {} }, + { provide: VersionDataService, useValue: {} }, { provide: BitstreamDataService, useValue: mockBitstreamDataService }, + { provide: WorkspaceitemDataService, useValue: {} }, + { provide: SearchService, useValue: {} }, + { provide: ItemDataService, useValue: {} }, + { provide: ItemVersionsSharedService, useValue: {} }, ], - schemas: [NO_ERRORS_SCHEMA] }).overrideComponent(UntypedItemComponent, { set: { changeDetection: ChangeDetectionStrategy.Default } diff --git a/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.ts b/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.ts index 3183c42a28..3ce33dc90a 100644 --- a/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.ts +++ b/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.ts @@ -1,8 +1,8 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; import { Item } from '../../../../core/shared/item.model'; -import { ItemComponent } from '../shared/item.component'; import { ViewMode } from '../../../../core/shared/view-mode.model'; import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; +import { VersionedItemComponent } from '../versioned-item/versioned-item.component'; /** * Component that represents a publication Item page @@ -15,6 +15,6 @@ import { listableObjectComponent } from '../../../../shared/object-collection/sh templateUrl: './untyped-item.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class UntypedItemComponent extends ItemComponent { +export class UntypedItemComponent extends VersionedItemComponent { } diff --git a/src/app/item-page/simple/item-types/versioned-item/versioned-item.component.html b/src/app/item-page/simple/item-types/versioned-item/versioned-item.component.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/item-page/simple/item-types/versioned-item/versioned-item.component.scss b/src/app/item-page/simple/item-types/versioned-item/versioned-item.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/item-page/simple/item-types/versioned-item/versioned-item.component.spec.ts b/src/app/item-page/simple/item-types/versioned-item/versioned-item.component.spec.ts new file mode 100644 index 0000000000..c4dc82f0d9 --- /dev/null +++ b/src/app/item-page/simple/item-types/versioned-item/versioned-item.component.spec.ts @@ -0,0 +1,93 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { VersionedItemComponent } from './versioned-item.component'; +import { VersionHistoryDataService } from '../../../../core/data/version-history-data.service'; +import { TranslateService } from '@ngx-translate/core'; +import { VersionDataService } from '../../../../core/data/version-data.service'; +import { NotificationsService } from '../../../../shared/notifications/notifications.service'; +import { ItemVersionsSharedService } from '../../../../shared/item/item-versions/item-versions-shared.service'; +import { Item } from '../../../../core/shared/item.model'; +import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils'; +import { buildPaginatedList } from '../../../../core/data/paginated-list.model'; +import { PageInfo } from '../../../../core/shared/page-info.model'; +import { MetadataMap } from '../../../../core/shared/metadata.models'; +import { createRelationshipsObservable } from '../shared/item.component.spec'; +import { RouterTestingModule } from '@angular/router/testing'; +import { Component } from '@angular/core'; +import { WorkspaceitemDataService } from '../../../../core/submission/workspaceitem-data.service'; +import { SearchService } from '../../../../core/shared/search/search.service'; +import { ItemDataService } from '../../../../core/data/item-data.service'; +import { Version } from '../../../../core/shared/version.model'; + +const mockItem: Item = Object.assign(new Item(), { + bundles: createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])), + metadata: new MetadataMap(), + relationships: createRelationshipsObservable(), + _links: { + self: { + href: 'item-href' + }, + version: { + href: 'version-href' + } + } +}); + + +@Component({template: ''}) +class DummyComponent { +} + +describe('VersionedItemComponent', () => { + let component: VersionedItemComponent; + let fixture: ComponentFixture; + + let versionService: VersionDataService; + let versionHistoryService: VersionHistoryDataService; + + const versionServiceSpy = jasmine.createSpyObj('versionService', { + findByHref: createSuccessfulRemoteDataObject$(new Version()), + }); + + const versionHistoryServiceSpy = jasmine.createSpyObj('versionHistoryService', { + createVersion: createSuccessfulRemoteDataObject$(new Version()), + }); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [VersionedItemComponent, DummyComponent], + imports: [RouterTestingModule], + providers: [ + { provide: VersionHistoryDataService, useValue: versionHistoryServiceSpy }, + { provide: TranslateService, useValue: {} }, + { provide: VersionDataService, useValue: versionServiceSpy }, + { provide: NotificationsService, useValue: {} }, + { provide: ItemVersionsSharedService, useValue: {} }, + { provide: WorkspaceitemDataService, useValue: {} }, + { provide: SearchService, useValue: {} }, + { provide: ItemDataService, useValue: {} }, + ] + }).compileComponents(); + versionService = TestBed.inject(VersionDataService); + versionHistoryService = TestBed.inject(VersionHistoryDataService); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(VersionedItemComponent); + component = fixture.componentInstance; + component.object = mockItem; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('when onCreateNewVersion() is called', () => { + it('should call versionService.findByHref', () => { + component.onCreateNewVersion(); + expect(versionService.findByHref).toHaveBeenCalledWith('version-href'); + }); + }); + +}); diff --git a/src/app/item-page/simple/item-types/versioned-item/versioned-item.component.ts b/src/app/item-page/simple/item-types/versioned-item/versioned-item.component.ts new file mode 100644 index 0000000000..45c15177e7 --- /dev/null +++ b/src/app/item-page/simple/item-types/versioned-item/versioned-item.component.ts @@ -0,0 +1,78 @@ +import { Component } from '@angular/core'; +import { ItemComponent } from '../shared/item.component'; +import { ItemVersionsSummaryModalComponent } from '../../../../shared/item/item-versions/item-versions-summary-modal/item-versions-summary-modal.component'; +import { getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload } from '../../../../core/shared/operators'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { Version } from '../../../../core/shared/version.model'; +import { switchMap, tap } from 'rxjs/operators'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { VersionHistoryDataService } from '../../../../core/data/version-history-data.service'; +import { TranslateService } from '@ngx-translate/core'; +import { VersionDataService } from '../../../../core/data/version-data.service'; +import { ItemVersionsSharedService } from '../../../../shared/item/item-versions/item-versions-shared.service'; +import { Router } from '@angular/router'; +import { WorkspaceitemDataService } from '../../../../core/submission/workspaceitem-data.service'; +import { SearchService } from '../../../../core/shared/search/search.service'; +import { Item } from '../../../../core/shared/item.model'; +import { ItemDataService } from '../../../../core/data/item-data.service'; +import { WorkspaceItem } from '../../../../core/submission/models/workspaceitem.model'; + +@Component({ + selector: 'ds-versioned-item', + templateUrl: './versioned-item.component.html', + styleUrls: ['./versioned-item.component.scss'] +}) +export class VersionedItemComponent extends ItemComponent { + + constructor( + private modalService: NgbModal, + private versionHistoryService: VersionHistoryDataService, + private translateService: TranslateService, + private versionService: VersionDataService, + private itemVersionShared: ItemVersionsSharedService, + private router: Router, + private workspaceItemDataService: WorkspaceitemDataService, + private searchService: SearchService, + private itemService: ItemDataService, + ) { + super(); + } + + /** + * Open a modal that allows to create a new version starting from the specified item, with optional summary + */ + onCreateNewVersion(): void { + + const item = this.object; + const versionHref = item._links.version.href; + + // Open modal + const activeModal = this.modalService.open(ItemVersionsSummaryModalComponent); + + // Show current version in modal + this.versionService.findByHref(versionHref).pipe(getFirstCompletedRemoteData()).subscribe((res: RemoteData) => { + // if res.hasNoContent then the item is unversioned + activeModal.componentInstance.firstVersion = res.hasNoContent; + activeModal.componentInstance.versionNumber = (res.hasNoContent ? undefined : res.payload.version); + }); + + // On createVersionEvent emitted create new version and notify + activeModal.componentInstance.createVersionEvent.pipe( + switchMap((summary: string) => this.versionHistoryService.createVersion(item._links.self.href, summary)), + getFirstCompletedRemoteData(), + // show success/failure notification + tap((res: RemoteData) => { this.itemVersionShared.notifyCreateNewVersion(res); }), + // get workspace item + getFirstSucceededRemoteDataPayload(), + switchMap((newVersion: Version) => this.itemService.findByHref(newVersion._links.item.href)), + getFirstSucceededRemoteDataPayload(), + switchMap((newVersionItem: Item) => this.workspaceItemDataService.findByItem(newVersionItem.uuid, true, false)), + getFirstSucceededRemoteDataPayload(), + ).subscribe((wsItem) => { + const wsiId = wsItem.id; + const route = 'workspaceitems/' + wsiId + '/edit'; + this.router.navigateByUrl(route); + }); + + } +} diff --git a/src/app/item-page/version-page/version-page/version-page.component.html b/src/app/item-page/version-page/version-page/version-page.component.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/item-page/version-page/version-page/version-page.component.scss b/src/app/item-page/version-page/version-page/version-page.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/item-page/version-page/version-page/version-page.component.spec.ts b/src/app/item-page/version-page/version-page/version-page.component.spec.ts new file mode 100644 index 0000000000..b1dd8bc161 --- /dev/null +++ b/src/app/item-page/version-page/version-page/version-page.component.spec.ts @@ -0,0 +1,68 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { VersionPageComponent } from './version-page.component'; +import { ActivatedRoute } from '@angular/router'; +import { ActivatedRouteStub } from '../../../shared/testing/active-router.stub'; +import { of as observableOf } from 'rxjs'; +import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { Item } from '../../../core/shared/item.model'; +import { createPaginatedList } from '../../../shared/testing/utils.test'; +import { createRelationshipsObservable } from '../../simple/item-types/shared/item.component.spec'; +import { VersionDataService } from '../../../core/data/version-data.service'; +import { AuthService } from '../../../core/auth/auth.service'; +import { Version } from '../../../core/shared/version.model'; +import { RouterTestingModule } from '@angular/router/testing'; +import { Component } from '@angular/core'; + +const mockItem: Item = Object.assign(new Item(), { + bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])), + metadata: [], + relationships: createRelationshipsObservable(), + uuid: 'item-uuid', +}); + +const mockVersion: Version = Object.assign(new Version(), { + item: createSuccessfulRemoteDataObject$(mockItem), + version: 1, +}); + +@Component({ template: '' }) +class DummyComponent { +} + +describe('VersionPageComponent', () => { + let component: VersionPageComponent; + let fixture: ComponentFixture; + let authService: AuthService; + + const mockRoute = Object.assign(new ActivatedRouteStub(), { + data: observableOf({dso: createSuccessfulRemoteDataObject(mockVersion)}) + }); + + beforeEach(waitForAsync(() => { + authService = jasmine.createSpyObj('authService', { + isAuthenticated: observableOf(true), + setRedirectUrl: {} + }); + TestBed.configureTestingModule({ + declarations: [VersionPageComponent, DummyComponent], + imports: [RouterTestingModule.withRoutes([{ path: 'items/item-uuid', component: DummyComponent, pathMatch: 'full' }])], + providers: [ + { provide: ActivatedRoute, useValue: mockRoute }, + { provide: VersionDataService, useValue: {} }, + { provide: AuthService, useValue: authService }, + ], + }).compileComponents(); + })); + + + + beforeEach(() => { + fixture = TestBed.createComponent(VersionPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/item-page/version-page/version-page/version-page.component.ts b/src/app/item-page/version-page/version-page/version-page.component.ts new file mode 100644 index 0000000000..0a2021e06d --- /dev/null +++ b/src/app/item-page/version-page/version-page/version-page.component.ts @@ -0,0 +1,56 @@ +import { Component, OnInit } from '@angular/core'; +import { Observable } from 'rxjs'; +import { RemoteData } from '../../../core/data/remote-data'; +import { ActivatedRoute, Router } from '@angular/router'; +import { AuthService } from '../../../core/auth/auth.service'; +import { map, switchMap } from 'rxjs/operators'; +import { getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload, redirectOn4xx } from '../../../core/shared/operators'; +import { VersionDataService } from '../../../core/data/version-data.service'; +import { Version } from '../../../core/shared/version.model'; +import { Item } from '../../../core/shared/item.model'; +import { getItemPageRoute } from '../../item-page-routing-paths'; +import { getPageNotFoundRoute } from '../../../app-routing-paths'; + +@Component({ + selector: 'ds-version-page', + templateUrl: './version-page.component.html', + styleUrls: ['./version-page.component.scss'] +}) +export class VersionPageComponent implements OnInit { + + versionRD$: Observable>; + itemRD$: Observable>; + + constructor( + protected route: ActivatedRoute, + private router: Router, + private versionService: VersionDataService, + private authService: AuthService, + ) { + } + + ngOnInit(): void { + /* Retrieve version from resolver or redirect on 4xx */ + this.versionRD$ = this.route.data.pipe( + map((data) => data.dso as RemoteData), + redirectOn4xx(this.router, this.authService), + ); + + /* Retrieve item from version and reroute to item's page or handle missing item */ + this.versionRD$.pipe( + getFirstSucceededRemoteDataPayload(), + switchMap((version) => version.item), + redirectOn4xx(this.router, this.authService), + getFirstCompletedRemoteData(), + ).subscribe((itemRD) => { + if (itemRD.hasNoContent) { + this.router.navigateByUrl(getPageNotFoundRoute(), { skipLocationChange: true }); + } else { + const itemUrl = getItemPageRoute(itemRD.payload); + this.router.navigateByUrl(itemUrl); + } + }); + + } + +} diff --git a/src/app/item-page/version-page/version.resolver.ts b/src/app/item-page/version-page/version.resolver.ts new file mode 100644 index 0000000000..8341052468 --- /dev/null +++ b/src/app/item-page/version-page/version.resolver.ts @@ -0,0 +1,54 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, Resolve, Router, RouterStateSnapshot } from '@angular/router'; +import { Observable } from 'rxjs'; +import { RemoteData } from '../../core/data/remote-data'; +import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { getFirstCompletedRemoteData } from '../../core/shared/operators'; +import { Store } from '@ngrx/store'; +import { ResolvedAction } from '../../core/resolving/resolver.actions'; +import { Version } from '../../core/shared/version.model'; +import { VersionDataService } from '../../core/data/version-data.service'; + +/** + * The self links defined in this list are expected to be requested somewhere in the near future + * Requesting them as embeds will limit the number of requests + */ +export const VERSION_PAGE_LINKS_TO_FOLLOW: FollowLinkConfig[] = [ + followLink('item'), +]; + +/** + * This class represents a resolver that requests a specific version before the route is activated + */ +@Injectable() +export class VersionResolver implements Resolve> { + constructor( + protected versionService: VersionDataService, + protected store: Store, + protected router: Router + ) { + } + + /** + * Method for resolving a version based on the parameters in the current route + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + * @param {RouterStateSnapshot} state The current RouterStateSnapshot + * @returns Observable<> Emits the found item based on the parameters in the current route, + * or an error if something went wrong + */ + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable> { + const versionRD$ = this.versionService.findById(route.params.id, + true, + false, + ...VERSION_PAGE_LINKS_TO_FOLLOW + ).pipe( + getFirstCompletedRemoteData(), + ); + + versionRD$.subscribe((versionRD: RemoteData) => { + this.store.dispatch(new ResolvedAction(state.url, versionRD.payload)); + }); + + return versionRD$; + } +} diff --git a/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.ts b/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.ts index c1e67561b2..469f04ffd5 100644 --- a/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.ts +++ b/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.ts @@ -119,7 +119,7 @@ export class MyDSpaceNewSubmissionComponent implements OnDestroy, OnInit { } /** - * Method called on clicking the button "New Submition", It opens a dialog for + * Method called on clicking the button "New Submission", It opens a dialog for * select a collection. */ openDialog() { diff --git a/src/app/shared/dso-page/dso-page-version-button/dso-page-version-button.component.html b/src/app/shared/dso-page/dso-page-version-button/dso-page-version-button.component.html new file mode 100644 index 0000000000..0e2e35dcb7 --- /dev/null +++ b/src/app/shared/dso-page/dso-page-version-button/dso-page-version-button.component.html @@ -0,0 +1,8 @@ + diff --git a/src/app/shared/dso-page/dso-page-version-button/dso-page-version-button.component.scss b/src/app/shared/dso-page/dso-page-version-button/dso-page-version-button.component.scss new file mode 100644 index 0000000000..e8b7d689a3 --- /dev/null +++ b/src/app/shared/dso-page/dso-page-version-button/dso-page-version-button.component.scss @@ -0,0 +1,3 @@ +.btn-dark { + background-color: var(--ds-admin-sidebar-bg); +} diff --git a/src/app/shared/dso-page/dso-page-version-button/dso-page-version-button.component.spec.ts b/src/app/shared/dso-page/dso-page-version-button/dso-page-version-button.component.spec.ts new file mode 100644 index 0000000000..9839507d57 --- /dev/null +++ b/src/app/shared/dso-page/dso-page-version-button/dso-page-version-button.component.spec.ts @@ -0,0 +1,96 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { DsoPageVersionButtonComponent } from './dso-page-version-button.component'; +import { Item } from '../../../core/shared/item.model'; +import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; +import { Observable, of, of as observableOf } from 'rxjs'; +import { TranslateModule } from '@ngx-translate/core'; +import { RouterTestingModule } from '@angular/router/testing'; +import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; +import { By } from '@angular/platform-browser'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { VersionHistoryDataService } from '../../../core/data/version-history-data.service'; + +describe('DsoPageVersionButtonComponent', () => { + let component: DsoPageVersionButtonComponent; + let fixture: ComponentFixture; + + let authorizationService: AuthorizationDataService; + let versionHistoryService: VersionHistoryDataService; + + let dso: Item; + let tooltipMsg: Observable; + + const authorizationServiceSpy = jasmine.createSpyObj('authorizationService', ['isAuthorized']); + + const versionHistoryServiceSpy = jasmine.createSpyObj('versionHistoryService', + ['getVersions', 'getLatestVersionFromHistory$', 'isLatest$', 'hasDraftVersion$'] + ); + + beforeEach(waitForAsync(() => { + dso = Object.assign(new Item(), { + id: 'test-item', + _links: { + self: { href: 'test-item-selflink' }, + version: { href: 'test-item-version-selflink' }, + }, + }); + tooltipMsg = of('tooltip-msg'); + + TestBed.configureTestingModule({ + declarations: [DsoPageVersionButtonComponent], + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NgbModule], + providers: [ + { provide: AuthorizationDataService, useValue: authorizationServiceSpy }, + { provide: VersionHistoryDataService, useValue: versionHistoryServiceSpy }, + ] + }).compileComponents(); + + authorizationService = TestBed.inject(AuthorizationDataService); + versionHistoryService = TestBed.inject(VersionHistoryDataService); + + versionHistoryServiceSpy.hasDraftVersion$.and.returnValue(observableOf(true)); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(DsoPageVersionButtonComponent); + component = fixture.componentInstance; + component.dso = dso; + component.tooltipMsg$ = tooltipMsg; + fixture.detectChanges(); + }); + + it('should check the authorization of the current user', () => { + expect(authorizationService.isAuthorized).toHaveBeenCalledWith(FeatureID.CanCreateVersion, dso.self); + }); + + it('should check if the item has a draft version', () => { + expect(versionHistoryServiceSpy.hasDraftVersion$).toHaveBeenCalledWith(dso._links.version.href); + }); + + describe('when the user is authorized', () => { + beforeEach(() => { + authorizationServiceSpy.isAuthorized.and.returnValue(observableOf(true)); + component.ngOnInit(); + fixture.detectChanges(); + }); + + it('should render a button', () => { + const button = fixture.debugElement.query(By.css('button')); + expect(button).not.toBeNull(); + }); + }); + + describe('when the user is not authorized', () => { + beforeEach(() => { + authorizationServiceSpy.isAuthorized.and.returnValue(observableOf(false)); + component.ngOnInit(); + fixture.detectChanges(); + }); + + it('should render a button', () => { + const button = fixture.debugElement.query(By.css('button')); + expect(button).toBeNull(); + }); + }); + +}); diff --git a/src/app/shared/dso-page/dso-page-version-button/dso-page-version-button.component.ts b/src/app/shared/dso-page/dso-page-version-button/dso-page-version-button.component.ts new file mode 100644 index 0000000000..31844fba00 --- /dev/null +++ b/src/app/shared/dso-page/dso-page-version-button/dso-page-version-button.component.ts @@ -0,0 +1,78 @@ +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; +import { Observable } from 'rxjs/internal/Observable'; +import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; +import { VersionHistoryDataService } from '../../../core/data/version-history-data.service'; +import { Item } from '../../../core/shared/item.model'; +import { map, startWith, switchMap } from 'rxjs/operators'; +import { of } from 'rxjs'; + +@Component({ + selector: 'ds-dso-page-version-button', + templateUrl: './dso-page-version-button.component.html', + styleUrls: ['./dso-page-version-button.component.scss'] +}) +/** + * Display a button linking to the edit page of a DSpaceObject + */ +export class DsoPageVersionButtonComponent implements OnInit { + /** + * The item for which display a button to create a new version + */ + @Input() dso: Item; + + /** + * A message for the tooltip on the button + * Supports i18n keys + */ + @Input() tooltipMsgCreate: string; + + /** + * A message for the tooltip on the button (when is disabled) + * Supports i18n keys + */ + @Input() tooltipMsgHasDraft: string; + + /** + * Emits an event that triggers the creation of the new version + */ + @Output() newVersionEvent = new EventEmitter(); + + /** + * Whether or not the current user is authorized to create a new version of the DSpaceObject + */ + isAuthorized$: Observable; + + disableNewVersionButton$: Observable; + + tooltipMsg$: Observable; + + constructor( + protected authorizationService: AuthorizationDataService, + protected versionHistoryService: VersionHistoryDataService, + ) { + } + + /** + * Creates a new version for the current item + */ + createNewVersion() { + this.newVersionEvent.emit(); + } + + ngOnInit() { + this.isAuthorized$ = this.authorizationService.isAuthorized(FeatureID.CanCreateVersion, this.dso.self); + + this.disableNewVersionButton$ = this.versionHistoryService.hasDraftVersion$(this.dso._links.version.href).pipe( + // button is disabled if hasDraftVersion = true, and enabled if hasDraftVersion = false or null + // (hasDraftVersion is null when a version history does not exist) + map((res) => Boolean(res)), + startWith(true), + ); + + this.tooltipMsg$ = this.disableNewVersionButton$.pipe( + switchMap((hasDraftVersion) => of(hasDraftVersion ? this.tooltipMsgHasDraft : this.tooltipMsgCreate)), + ); + } + +} diff --git a/src/app/shared/item/item-versions/item-versions-delete-modal/item-versions-delete-modal.component.html b/src/app/shared/item/item-versions/item-versions-delete-modal/item-versions-delete-modal.component.html new file mode 100644 index 0000000000..0c0b72272f --- /dev/null +++ b/src/app/shared/item/item-versions/item-versions-delete-modal/item-versions-delete-modal.component.html @@ -0,0 +1,22 @@ +
+ + + +
diff --git a/src/app/shared/item/item-versions/item-versions-delete-modal/item-versions-delete-modal.component.scss b/src/app/shared/item/item-versions/item-versions-delete-modal/item-versions-delete-modal.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/shared/item/item-versions/item-versions-delete-modal/item-versions-delete-modal.component.spec.ts b/src/app/shared/item/item-versions/item-versions-delete-modal/item-versions-delete-modal.component.spec.ts new file mode 100644 index 0000000000..8a0d4a58d9 --- /dev/null +++ b/src/app/shared/item/item-versions/item-versions-delete-modal/item-versions-delete-modal.component.spec.ts @@ -0,0 +1,31 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ItemVersionsDeleteModalComponent } from './item-versions-delete-modal.component'; +import { TranslateModule } from '@ngx-translate/core'; +import { RouterTestingModule } from '@angular/router/testing'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; + +describe('ItemVersionsDeleteModalComponent', () => { + let component: ItemVersionsDeleteModalComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ItemVersionsDeleteModalComponent], + imports: [ TranslateModule.forRoot(), RouterTestingModule.withRoutes([]) ], + providers: [ + { provide: NgbActiveModal }, + ] + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ItemVersionsDeleteModalComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/item/item-versions/item-versions-delete-modal/item-versions-delete-modal.component.ts b/src/app/shared/item/item-versions/item-versions-delete-modal/item-versions-delete-modal.component.ts new file mode 100644 index 0000000000..35618390d9 --- /dev/null +++ b/src/app/shared/item/item-versions/item-versions-delete-modal/item-versions-delete-modal.component.ts @@ -0,0 +1,25 @@ +import { Component } from '@angular/core'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; + +@Component({ + selector: 'ds-item-versions-delete-modal', + templateUrl: './item-versions-delete-modal.component.html', + styleUrls: ['./item-versions-delete-modal.component.scss'] +}) +export class ItemVersionsDeleteModalComponent { + + versionNumber: number; + + constructor( + protected activeModal: NgbActiveModal,) { + } + + onModalClose() { + this.activeModal.dismiss(); + } + + onModalSubmit() { + this.activeModal.close(); + } + +} diff --git a/src/app/shared/item/item-versions/item-versions-shared.service.spec.ts b/src/app/shared/item/item-versions/item-versions-shared.service.spec.ts new file mode 100644 index 0000000000..a9f9596548 --- /dev/null +++ b/src/app/shared/item/item-versions/item-versions-shared.service.spec.ts @@ -0,0 +1,56 @@ +import { TestBed } from '@angular/core/testing'; + +import { ItemVersionsSharedService } from './item-versions-shared.service'; +import { ActivatedRoute } from '@angular/router'; +import { VersionDataService } from '../../../core/data/version-data.service'; +import { AuthService } from '../../../core/auth/auth.service'; +import { NotificationsService } from '../../notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; +import { VersionHistoryDataService } from '../../../core/data/version-history-data.service'; +import { WorkspaceitemDataService } from '../../../core/submission/workspaceitem-data.service'; +import { WorkflowItemDataService } from '../../../core/submission/workflowitem-data.service'; +import { createFailedRemoteDataObject, createSuccessfulRemoteDataObject } from '../../remote-data.utils'; +import { Version } from '../../../core/shared/version.model'; + +describe('ItemVersionsSharedService', () => { + let service: ItemVersionsSharedService; + let notificationService: NotificationsService; + + const successfulVersionRD = createSuccessfulRemoteDataObject(new Version()); + const failedVersionRD = createFailedRemoteDataObject(); + + const notificationsServiceSpy = jasmine.createSpyObj('notificationsServiceSpy', ['success', 'error']); + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + { provide: ActivatedRoute, useValue: {} }, + { provide: VersionDataService, useValue: {} }, + { provide: VersionHistoryDataService, useValue: {} }, + { provide: AuthService, useValue: {} }, + { provide: NotificationsService, useValue: notificationsServiceSpy }, + { provide: TranslateService, useValue: { get: () => undefined, } }, + { provide: WorkspaceitemDataService, useValue: {} }, + { provide: WorkflowItemDataService, useValue: {} }, + ], + }); + service = TestBed.inject(ItemVersionsSharedService); + notificationService = TestBed.inject(NotificationsService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('when notifyCreateNewVersion is called', () => { + it('should notify when successful', () => { + service.notifyCreateNewVersion(successfulVersionRD); + expect(notificationService.success).toHaveBeenCalled(); + }); + it('should notify when not successful', () => { + service.notifyCreateNewVersion(failedVersionRD); + expect(notificationService.error).toHaveBeenCalled(); + }); + }); + +}); diff --git a/src/app/shared/item/item-versions/item-versions-shared.service.ts b/src/app/shared/item/item-versions/item-versions-shared.service.ts new file mode 100644 index 0000000000..996623509c --- /dev/null +++ b/src/app/shared/item/item-versions/item-versions-shared.service.ts @@ -0,0 +1,35 @@ +import { Injectable } from '@angular/core'; +import { NotificationsService } from '../../notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; +import { RemoteData } from '../../../core/data/remote-data'; +import { Version } from '../../../core/shared/version.model'; + +@Injectable({ + providedIn: 'root' +}) +export class ItemVersionsSharedService { + + constructor( + private notificationsService: NotificationsService, + private translateService: TranslateService, + ) { + } + + private static msg(key: string): string { + const translationPrefix = 'item.version.create.notification'; + return translationPrefix + '.' + key; + } + + /** + * Notify success/failure after creating a new version. + * + * @param newVersionRD the new version that has been created + */ + public notifyCreateNewVersion(newVersionRD: RemoteData): void { + const newVersionNumber = newVersionRD?.payload?.version; + newVersionRD.hasSucceeded ? + this.notificationsService.success(null, this.translateService.get(ItemVersionsSharedService.msg('success'), {version: newVersionNumber})) : + this.notificationsService.error(null, this.translateService.get(ItemVersionsSharedService.msg(newVersionRD?.statusCode === 422 ? 'inProgress' : 'failure'))); + } + +} diff --git a/src/app/shared/item/item-versions/item-versions-summary-modal/item-versions-summary-modal.component.html b/src/app/shared/item/item-versions/item-versions-summary-modal/item-versions-summary-modal.component.html new file mode 100644 index 0000000000..e49e257339 --- /dev/null +++ b/src/app/shared/item/item-versions/item-versions-summary-modal/item-versions-summary-modal.component.html @@ -0,0 +1,36 @@ +
+ + + +
diff --git a/src/app/shared/item/item-versions/item-versions-summary-modal/item-versions-summary-modal.component.scss b/src/app/shared/item/item-versions/item-versions-summary-modal/item-versions-summary-modal.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/shared/item/item-versions/item-versions-summary-modal/item-versions-summary-modal.component.spec.ts b/src/app/shared/item/item-versions/item-versions-summary-modal/item-versions-summary-modal.component.spec.ts new file mode 100644 index 0000000000..657e8c0e75 --- /dev/null +++ b/src/app/shared/item/item-versions/item-versions-summary-modal/item-versions-summary-modal.component.spec.ts @@ -0,0 +1,31 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ItemVersionsSummaryModalComponent } from './item-versions-summary-modal.component'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; +import { RouterTestingModule } from '@angular/router/testing'; + +describe('ItemVersionsSummaryModalComponent', () => { + let component: ItemVersionsSummaryModalComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ ItemVersionsSummaryModalComponent ], + imports: [ TranslateModule.forRoot(), RouterTestingModule.withRoutes([]) ], + providers: [ + { provide: NgbActiveModal }, + ] + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ItemVersionsSummaryModalComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/item/item-versions/item-versions-summary-modal/item-versions-summary-modal.component.ts b/src/app/shared/item/item-versions/item-versions-summary-modal/item-versions-summary-modal.component.ts new file mode 100644 index 0000000000..31bb3078c0 --- /dev/null +++ b/src/app/shared/item/item-versions/item-versions-summary-modal/item-versions-summary-modal.component.ts @@ -0,0 +1,31 @@ +import { Component, EventEmitter, Output } from '@angular/core'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; + +@Component({ + selector: 'ds-item-versions-summary-modal', + templateUrl: './item-versions-summary-modal.component.html', + styleUrls: ['./item-versions-summary-modal.component.scss'] +}) +export class ItemVersionsSummaryModalComponent { + + versionNumber: number; + newVersionSummary: string; + firstVersion = true; + + @Output() createVersionEvent: EventEmitter = new EventEmitter(); + + constructor( + protected activeModal: NgbActiveModal, + ) { + } + + onModalClose() { + this.activeModal.dismiss(); + } + + onModalSubmit() { + this.createVersionEvent.emit(this.newVersionSummary); + this.activeModal.close(); + } + +} diff --git a/src/app/shared/item/item-versions/item-versions.component.html b/src/app/shared/item/item-versions/item-versions.component.html index 34764e7925..d8850bc544 100644 --- a/src/app/shared/item/item-versions/item-versions.component.html +++ b/src/app/shared/item/item-versions/item-versions.component.html @@ -2,45 +2,146 @@

{{"item.version.history.head" | translate}}

+ + {{ "item.version.history.selected.alert" | translate : {version: itemVersion.version} }} + - +
- - - - - - - + + + + + + - - - - + + - - - + + + +
{{"item.version.history.table.version" | translate}}{{"item.version.history.table.item" | translate}}{{"item.version.history.table.editor" | translate}}{{"item.version.history.table.date" | translate}}{{"item.version.history.table.summary" | translate}}
{{"item.version.history.table.version" | translate}}{{"item.version.history.table.editor" | translate}}{{"item.version.history.table.date" | translate}}{{"item.version.history.table.summary" | translate}}
{{version?.version}} - - {{item?.handle}} - * - - +
+ + + + +
+ + + + {{version.version}} + + + {{version.version}} + + * + + + {{ "item.version.history.table.workspaceItem" | translate }} + + + + {{ "item.version.history.table.workflowItem" | translate }} + + +
+ +
+ +
+ + + + + + + + + + +
+ +
+ +
+
+
{{eperson?.name}} - {{version?.created}}{{version?.summary}}
+ {{version?.created | date : 'yyyy-MM-dd HH:mm:ss'}} + +
+ + {{version?.summary}} + + + +
+ +
+ + + + + + + + + +
+ + +
* {{"item.version.history.selected" | translate}}
- +
diff --git a/src/app/shared/item/item-versions/item-versions.component.scss b/src/app/shared/item/item-versions/item-versions.component.scss new file mode 100644 index 0000000000..5594e0cafe --- /dev/null +++ b/src/app/shared/item/item-versions/item-versions.component.scss @@ -0,0 +1,9 @@ +.left-column { + float: left; + text-align: left; +} + +.right-column { + float: right; + text-align: right; +} diff --git a/src/app/shared/item/item-versions/item-versions.component.spec.ts b/src/app/shared/item/item-versions/item-versions.component.spec.ts index cc28779537..fff0744aba 100644 --- a/src/app/shared/item/item-versions/item-versions.component.spec.ts +++ b/src/app/shared/item/item-versions/item-versions.component.spec.ts @@ -11,70 +11,138 @@ import { VersionHistoryDataService } from '../../../core/data/version-history-da import { By } from '@angular/platform-browser'; import { createSuccessfulRemoteDataObject$ } from '../../remote-data.utils'; import { createPaginatedList } from '../../testing/utils.test'; -import { PaginationComponentOptions } from '../../pagination/pagination-component-options.model'; -import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model'; -import { of as observableOf } from 'rxjs'; +import { EMPTY, of, of as observableOf } from 'rxjs'; import { PaginationService } from '../../../core/pagination/pagination.service'; import { PaginationServiceStub } from '../../testing/pagination-service.stub'; +import { AuthService } from '../../../core/auth/auth.service'; +import { VersionDataService } from '../../../core/data/version-data.service'; +import { ItemDataService } from '../../../core/data/item-data.service'; +import { FormBuilder } from '@angular/forms'; +import { NotificationsService } from '../../notifications/notifications.service'; +import { NotificationsServiceStub } from '../../testing/notifications-service.stub'; +import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; +import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; +import { WorkspaceitemDataService } from '../../../core/submission/workspaceitem-data.service'; +import { WorkflowItemDataService } from '../../../core/submission/workflowitem-data.service'; describe('ItemVersionsComponent', () => { let component: ItemVersionsComponent; let fixture: ComponentFixture; + let authenticationService: AuthService; + let authorizationService: AuthorizationDataService; + let versionHistoryService: VersionHistoryDataService; + let workspaceItemDataService: WorkspaceitemDataService; + let workflowItemDataService: WorkflowItemDataService; + let versionService: VersionDataService; const versionHistory = Object.assign(new VersionHistory(), { - id: '1' + id: '1', + draftVersion: true, }); + const version1 = Object.assign(new Version(), { id: '1', version: 1, created: new Date(2020, 1, 1), summary: 'first version', - versionhistory: createSuccessfulRemoteDataObject$(versionHistory) + versionhistory: createSuccessfulRemoteDataObject$(versionHistory), + _links: { + self: { + href: 'version2-url', + }, + }, }); const version2 = Object.assign(new Version(), { id: '2', version: 2, summary: 'second version', created: new Date(2020, 1, 2), - versionhistory: createSuccessfulRemoteDataObject$(versionHistory) + versionhistory: createSuccessfulRemoteDataObject$(versionHistory), + _links: { + self: { + href: 'version2-url', + }, + }, }); const versions = [version1, version2]; versionHistory.versions = createSuccessfulRemoteDataObject$(createPaginatedList(versions)); - const item1 = Object.assign(new Item(), { + + const item1 = Object.assign(new Item(), { // is a workspace item uuid: 'item-identifier-1', handle: '123456789/1', - version: createSuccessfulRemoteDataObject$(version1) + version: createSuccessfulRemoteDataObject$(version1), + _links: { + self: { + href: '/items/item-identifier-1' + } + } }); const item2 = Object.assign(new Item(), { uuid: 'item-identifier-2', handle: '123456789/2', - version: createSuccessfulRemoteDataObject$(version2) + version: createSuccessfulRemoteDataObject$(version2), + _links: { + self: { + href: '/items/item-identifier-2' + } + } }); const items = [item1, item2]; version1.item = createSuccessfulRemoteDataObject$(item1); version2.item = createSuccessfulRemoteDataObject$(item2); - const versionHistoryService = jasmine.createSpyObj('versionHistoryService', { - getVersions: createSuccessfulRemoteDataObject$(createPaginatedList(versions)) + + const versionHistoryServiceSpy = jasmine.createSpyObj('versionHistoryService', { + getVersions: createSuccessfulRemoteDataObject$(createPaginatedList(versions)), + }); + const authenticationServiceSpy = jasmine.createSpyObj('authenticationService', { + isAuthenticated: observableOf(true), + setRedirectUrl: {} + }); + const authorizationServiceSpy = jasmine.createSpyObj('authorizationService', ['isAuthorized']); + const workspaceItemDataServiceSpy = jasmine.createSpyObj('workspaceItemDataService', { + findByItem: EMPTY, + }); + const workflowItemDataServiceSpy = jasmine.createSpyObj('workflowItemDataService', { + findByItem: EMPTY, + }); + const versionServiceSpy = jasmine.createSpyObj('versionService', { + findById: EMPTY, }); - const paginationService = new PaginationServiceStub(); - beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ declarations: [ItemVersionsComponent, VarDirective], imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], providers: [ - { provide: VersionHistoryDataService, useValue: versionHistoryService }, - { provide: PaginationService, useValue: paginationService } + {provide: PaginationService, useValue: new PaginationServiceStub()}, + {provide: FormBuilder, useValue: new FormBuilder()}, + {provide: NotificationsService, useValue: new NotificationsServiceStub()}, + {provide: AuthService, useValue: authenticationServiceSpy}, + {provide: AuthorizationDataService, useValue: authorizationServiceSpy}, + {provide: VersionHistoryDataService, useValue: versionHistoryServiceSpy}, + {provide: ItemDataService, useValue: {}}, + {provide: VersionDataService, useValue: versionServiceSpy}, + {provide: WorkspaceitemDataService, useValue: workspaceItemDataServiceSpy}, + {provide: WorkflowItemDataService, useValue: workflowItemDataServiceSpy}, ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); + + versionHistoryService = TestBed.inject(VersionHistoryDataService); + authenticationService = TestBed.inject(AuthService); + authorizationService = TestBed.inject(AuthorizationDataService); + workspaceItemDataService = TestBed.inject(WorkspaceitemDataService); + workflowItemDataService = TestBed.inject(WorkflowItemDataService); + versionService = TestBed.inject(VersionDataService); + })); beforeEach(() => { fixture = TestBed.createComponent(ItemVersionsComponent); component = fixture.componentInstance; component.item = item1; + component.displayActions = true; fixture.detectChanges(); }); @@ -88,26 +156,29 @@ describe('ItemVersionsComponent', () => { it(`should display version ${version.version} in the correct column for version ${version.id}`, () => { const id = fixture.debugElement.query(By.css(`#version-row-${version.id} .version-row-element-version`)); - expect(id.nativeElement.textContent).toEqual('' + version.version); + expect(id.nativeElement.textContent).toContain(version.version.toString()); }); - it(`should display item handle ${versionItem.handle} in the correct column for version ${version.id}`, () => { - const item = fixture.debugElement.query(By.css(`#version-row-${version.id} .version-row-element-item`)); - expect(item.nativeElement.textContent).toContain(versionItem.handle); - }); - - // This version's item is equal to the component's item (the selected item) - // Check if the handle contains an asterisk + // Check if the current version contains an asterisk if (item1.uuid === versionItem.uuid) { - it('should add an asterisk to the handle of the selected item', () => { - const item = fixture.debugElement.query(By.css(`#version-row-${version.id} .version-row-element-item`)); + it('should add an asterisk to the version of the selected item', () => { + const item = fixture.debugElement.query(By.css(`#version-row-${version.id} .version-row-element-version`)); expect(item.nativeElement.textContent).toContain('*'); }); } it(`should display date ${version.created} in the correct column for version ${version.id}`, () => { const date = fixture.debugElement.query(By.css(`#version-row-${version.id} .version-row-element-date`)); - expect(date.nativeElement.textContent).toEqual('' + version.created); + switch (versionItem.uuid) { + case item1.uuid: + expect(date.nativeElement.textContent.trim()).toEqual('2020-02-01 00:00:00'); + break; + case item2.uuid: + expect(date.nativeElement.textContent.trim()).toEqual('2020-02-02 00:00:00'); + break; + default: + throw new Error('Unexpected versionItem'); + } }); it(`should display summary ${version.summary} in the correct column for version ${version.id}`, () => { @@ -115,4 +186,85 @@ describe('ItemVersionsComponent', () => { expect(summary.nativeElement.textContent).toEqual(version.summary); }); }); + + describe('when the user can only delete a version', () => { + beforeAll(waitForAsync(() => { + const canDelete = (featureID: FeatureID, url: string ) => of(featureID === FeatureID.CanDeleteVersion); + authorizationServiceSpy.isAuthorized.and.callFake(canDelete); + })); + it('should not disable the delete button', () => { + const deleteButtons = fixture.debugElement.queryAll(By.css(`.version-row-element-delete`)); + deleteButtons.forEach((btn) => { + expect(btn.nativeElement.disabled).toBe(false); + }); + }); + it('should disable other buttons', () => { + const createButtons = fixture.debugElement.queryAll(By.css(`.version-row-element-create`)); + createButtons.forEach((btn) => { + expect(btn.nativeElement.disabled).toBe(true); + }); + const editButtons = fixture.debugElement.queryAll(By.css(`.version-row-element-create`)); + editButtons.forEach((btn) => { + expect(btn.nativeElement.disabled).toBe(true); + }); + }); + }); + + describe('when page is changed', () => { + it('should call getAllVersions', () => { + spyOn(component, 'getAllVersions'); + component.onPageChange(); + expect(component.getAllVersions).toHaveBeenCalled(); + }); + }); + + describe('when onSummarySubmit() is called', () => { + const id = 'version-being-edited-id'; + beforeEach(() => { + component.versionBeingEditedId = id; + }); + it('should call versionService.findById', () => { + component.onSummarySubmit(); + expect(versionService.findById).toHaveBeenCalledWith(id); + }); + }); + + describe('when editing is enabled for an item', () => { + beforeEach(() => { + component.enableVersionEditing(version1); + }); + it('should set all variables', () => { + expect(component.versionBeingEditedSummary).toEqual('first version'); + expect(component.versionBeingEditedNumber).toEqual(1); + expect(component.versionBeingEditedId).toEqual('1'); + }); + it('isAnyBeingEdited should be true', () => { + expect(component.isAnyBeingEdited()).toBeTrue(); + }); + it('isThisBeingEdited should be true for version1', () => { + expect(component.isThisBeingEdited(version1)).toBeTrue(); + }); + it('isThisBeingEdited should be false for version2', () => { + expect(component.isThisBeingEdited(version2)).toBeFalse(); + }); + }); + + describe('when editing is disabled', () => { + beforeEach(() => { + component.disableVersionEditing(); + }); + it('should unset all variables', () => { + expect(component.versionBeingEditedSummary).toBeUndefined(); + expect(component.versionBeingEditedNumber).toBeUndefined(); + expect(component.versionBeingEditedId).toBeUndefined(); + }); + it('isAnyBeingEdited should be false', () => { + expect(component.isAnyBeingEdited()).toBeFalse(); + }); + it('isThisBeingEdited should be false for all versions', () => { + expect(component.isThisBeingEdited(version1)).toBeFalse(); + expect(component.isThisBeingEdited(version2)).toBeFalse(); + }); + }); + }); diff --git a/src/app/shared/item/item-versions/item-versions.component.ts b/src/app/shared/item/item-versions/item-versions.component.ts index 268c6f00db..e7d65919d6 100644 --- a/src/app/shared/item/item-versions/item-versions.component.ts +++ b/src/app/shared/item/item-versions/item-versions.component.ts @@ -2,14 +2,24 @@ import { Component, Input, OnInit } from '@angular/core'; import { Item } from '../../../core/shared/item.model'; import { Version } from '../../../core/shared/version.model'; import { RemoteData } from '../../../core/data/remote-data'; -import { BehaviorSubject, combineLatest as observableCombineLatest, Observable } from 'rxjs'; +import { + BehaviorSubject, + combineLatest, + combineLatest as observableCombineLatest, + Observable, + of, + Subscription, +} from 'rxjs'; import { VersionHistory } from '../../../core/shared/version-history.model'; import { getAllSucceededRemoteData, getAllSucceededRemoteDataPayload, + getFirstCompletedRemoteData, + getFirstSucceededRemoteData, + getFirstSucceededRemoteDataPayload, getRemoteDataPayload } from '../../../core/shared/operators'; -import { map, startWith, switchMap } from 'rxjs/operators'; +import { map, mergeMap, startWith, switchMap, take, tap } from 'rxjs/operators'; import { PaginatedList } from '../../../core/data/paginated-list.model'; import { PaginationComponentOptions } from '../../pagination/pagination-component-options.model'; import { VersionHistoryDataService } from '../../../core/data/version-history-data.service'; @@ -18,16 +28,38 @@ import { AlertType } from '../../alert/aletr-type'; import { followLink } from '../../utils/follow-link-config.model'; import { hasValue, hasValueOperator } from '../../empty.util'; import { PaginationService } from '../../../core/pagination/pagination.service'; -import { getItemPageRoute } from '../../../item-page/item-page-routing-paths'; +import { + getItemEditVersionhistoryRoute, + getItemPageRoute, + getItemVersionRoute +} from '../../../item-page/item-page-routing-paths'; +import { FormBuilder } from '@angular/forms'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { ItemVersionsSummaryModalComponent } from './item-versions-summary-modal/item-versions-summary-modal.component'; +import { NotificationsService } from '../../notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; +import { ItemVersionsDeleteModalComponent } from './item-versions-delete-modal/item-versions-delete-modal.component'; +import { VersionDataService } from '../../../core/data/version-data.service'; +import { ItemDataService } from '../../../core/data/item-data.service'; +import { Router } from '@angular/router'; +import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; +import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; +import { ItemVersionsSharedService } from './item-versions-shared.service'; +import { WorkspaceItem } from '../../../core/submission/models/workspaceitem.model'; +import { WorkspaceitemDataService } from '../../../core/submission/workspaceitem-data.service'; +import { WorkflowItemDataService } from '../../../core/submission/workflowitem-data.service'; @Component({ selector: 'ds-item-versions', - templateUrl: './item-versions.component.html' + templateUrl: './item-versions.component.html', + styleUrls: ['./item-versions.component.scss'] }) + /** * Component listing all available versions of the history the provided item is a part of */ export class ItemVersionsComponent implements OnInit { + /** * The item to display a version history for */ @@ -45,6 +77,16 @@ export class ItemVersionsComponent implements OnInit { */ @Input() displayTitle = true; + /** + * Whether or not to display the action buttons (delete/create/edit version) + */ + @Input() displayActions: boolean; + + /** + * Array of active subscriptions + */ + subs: Subscription[] = []; + /** * The AlertType enumeration * @type {AlertType} @@ -57,14 +99,19 @@ export class ItemVersionsComponent implements OnInit { versionRD$: Observable>; /** - * The item's full version history + * The item's full version history (remote data) */ versionHistoryRD$: Observable>; + /** + * The item's full version history + */ + versionHistory$: Observable; + /** * The version history's list of versions */ - versionsRD$: Observable>>; + versionsRD$: BehaviorSubject>> = new BehaviorSubject>>(null); /** * Verify if the list of versions has at least one e-person to display @@ -72,6 +119,12 @@ export class ItemVersionsComponent implements OnInit { */ hasEpersons$: Observable; + /** + * Verify if there is an inprogress submission in the version history + * Used to disable the "Create version" button + */ + hasDraftVersion$: Observable; + /** * The amount of versions to display per page */ @@ -81,17 +134,12 @@ export class ItemVersionsComponent implements OnInit { * The page options to use for fetching the versions * Start at page 1 and always use the set page size */ - options = Object.assign(new PaginationComponentOptions(),{ + options = Object.assign(new PaginationComponentOptions(), { id: 'ivo', currentPage: 1, pageSize: this.pageSize }); - /** - * The current page being displayed - */ - currentPage$ = new BehaviorSubject(1); - /** * The routes to the versions their item pages * Key: Item ID @@ -101,9 +149,301 @@ export class ItemVersionsComponent implements OnInit { [itemId: string]: string }>; + /** + * The number of the version whose summary is currently being edited + */ + versionBeingEditedNumber: number; + + /** + * The id of the version whose summary is currently being edited + */ + versionBeingEditedId: string; + + /** + * The summary currently being edited + */ + versionBeingEditedSummary: string; + + canCreateVersion$: Observable; + createVersionTitle$: Observable; + constructor(private versionHistoryService: VersionHistoryDataService, - private paginationService: PaginationService - ) { + private versionService: VersionDataService, + private itemService: ItemDataService, + private paginationService: PaginationService, + private formBuilder: FormBuilder, + private modalService: NgbModal, + private notificationsService: NotificationsService, + private translateService: TranslateService, + private router: Router, + private itemVersionShared: ItemVersionsSharedService, + private authorizationService: AuthorizationDataService, + private workspaceItemDataService: WorkspaceitemDataService, + private workflowItemDataService: WorkflowItemDataService, + ) { + } + + /** + * True when a version is being edited + * (used to disable buttons for other versions) + */ + isAnyBeingEdited(): boolean { + return this.versionBeingEditedNumber != null; + } + + /** + * True if the specified version is being edited + * (used to show input field and to change buttons for specified version) + */ + isThisBeingEdited(version: Version): boolean { + return version?.version === this.versionBeingEditedNumber; + } + + /** + * Enables editing for the specified version + */ + enableVersionEditing(version: Version): void { + this.versionBeingEditedSummary = version?.summary; + this.versionBeingEditedNumber = version?.version; + this.versionBeingEditedId = version?.id; + } + + /** + * Disables editing for the specified version and discards all pending changes + */ + disableVersionEditing(): void { + this.versionBeingEditedSummary = undefined; + this.versionBeingEditedNumber = undefined; + this.versionBeingEditedId = undefined; + } + + /** + * Get the route to the specified version + * @param versionId the ID of the version for which the route will be retrieved + */ + getVersionRoute(versionId: string) { + return getItemVersionRoute(versionId); + } + + /** + * Applies changes to version currently being edited + */ + onSummarySubmit() { + + const successMessageKey = 'item.version.edit.notification.success'; + const failureMessageKey = 'item.version.edit.notification.failure'; + + this.versionService.findById(this.versionBeingEditedId).pipe( + getFirstSucceededRemoteData(), + switchMap((findRes: RemoteData) => { + const payload = findRes.payload; + const summary = {summary: this.versionBeingEditedSummary,}; + const updatedVersion = Object.assign({}, payload, summary); + return this.versionService.update(updatedVersion).pipe(getFirstCompletedRemoteData()); + }), + ).subscribe((updatedVersionRD: RemoteData) => { + if (updatedVersionRD.hasSucceeded) { + this.notificationsService.success(null, this.translateService.get(successMessageKey, {'version': this.versionBeingEditedNumber})); + this.getAllVersions(this.versionHistory$); + } else { + this.notificationsService.warning(null, this.translateService.get(failureMessageKey, {'version': this.versionBeingEditedNumber})); + } + this.disableVersionEditing(); + } + ); + } + + /** + * Delete the item and get the result of the operation + * @param item + */ + deleteItemAndGetResult$(item: Item): Observable { + return this.itemService.delete(item.id).pipe( + getFirstCompletedRemoteData(), + map((deleteItemRes) => deleteItemRes.hasSucceeded), + take(1), + ); + } + + /** + * Deletes the specified version, notify the success/failure and redirect to latest version + * @param version the version to be deleted + * @param redirectToLatest force the redirect to the latest version in the history + */ + deleteVersion(version: Version, redirectToLatest: boolean): void { + const successMessageKey = 'item.version.delete.notification.success'; + const failureMessageKey = 'item.version.delete.notification.failure'; + const versionNumber = version.version; + const versionItem$ = version.item; + + // Open modal + const activeModal = this.modalService.open(ItemVersionsDeleteModalComponent); + activeModal.componentInstance.versionNumber = version.version; + activeModal.componentInstance.firstVersion = false; + + // On modal submit/dismiss + activeModal.result.then(() => { + versionItem$.pipe( + getFirstSucceededRemoteDataPayload(), + // Retrieve version history and invalidate cache + mergeMap((item: Item) => combineLatest([ + of(item), + this.versionHistoryService.getVersionHistoryFromVersion$(version).pipe( + tap((versionHistory: VersionHistory) => { + this.versionHistoryService.invalidateVersionHistoryCache(versionHistory.id); + }) + ) + ])), + // Delete item + mergeMap(([item, versionHistory]: [Item, VersionHistory]) => combineLatest([ + this.deleteItemAndGetResult$(item), + of(versionHistory) + ])), + // Retrieve new latest version + mergeMap(([deleteItemResult, versionHistory]: [boolean, VersionHistory]) => combineLatest([ + of(deleteItemResult), + this.versionHistoryService.getLatestVersionItemFromHistory$(versionHistory).pipe( + tap(() => { + this.getAllVersions(of(versionHistory)); + }), + ) + ])), + ).subscribe(([deleteHasSucceeded, newLatestVersionItem]: [boolean, Item]) => { + // Notify operation result and redirect to latest item + if (deleteHasSucceeded) { + this.notificationsService.success(null, this.translateService.get(successMessageKey, {'version': versionNumber})); + } else { + this.notificationsService.error(null, this.translateService.get(failureMessageKey, {'version': versionNumber})); + } + if (redirectToLatest) { + const path = getItemEditVersionhistoryRoute(newLatestVersionItem); + this.router.navigateByUrl(path); + } + }); + }); + } + + /** + * Creates a new version starting from the specified one + * @param version the version from which a new one will be created + */ + createNewVersion(version: Version) { + const versionNumber = version.version; + + // Open modal and set current version number + const activeModal = this.modalService.open(ItemVersionsSummaryModalComponent); + activeModal.componentInstance.versionNumber = versionNumber; + + // On createVersionEvent emitted create new version and notify + activeModal.componentInstance.createVersionEvent.pipe( + mergeMap((summary: string) => combineLatest([ + of(summary), + version.item.pipe(getFirstSucceededRemoteDataPayload()) + ])), + mergeMap(([summary, item]: [string, Item]) => this.versionHistoryService.createVersion(item._links.self.href, summary)), + // show success/failure notification + tap((newVersionRD: RemoteData) => { + this.itemVersionShared.notifyCreateNewVersion(newVersionRD); + if (newVersionRD.hasSucceeded) { + const versionHistory$ = this.versionService.getHistoryFromVersion(version).pipe( + tap((versionHistory: VersionHistory) => { + this.itemService.invalidateItemCache(this.item.uuid); + this.versionHistoryService.invalidateVersionHistoryCache(versionHistory.id); + }), + ); + this.getAllVersions(versionHistory$); + } + }), + // get workspace item + getFirstSucceededRemoteDataPayload(), + switchMap((newVersion: Version) => this.itemService.findByHref(newVersion._links.item.href)), + getFirstSucceededRemoteDataPayload(), + switchMap((newVersionItem: Item) => this.workspaceItemDataService.findByItem(newVersionItem.uuid, true, false)), + getFirstSucceededRemoteDataPayload(), + ).subscribe((wsItem) => { + const wsiId = wsItem.id; + const route = 'workspaceitems/' + wsiId + '/edit'; + this.router.navigateByUrl(route); + }); + } + + /** + * Check is the current user can edit the version summary + * @param version + */ + canEditVersion$(version: Version): Observable { + return this.authorizationService.isAuthorized(FeatureID.CanEditVersion, version.self); + } + + /** + * Check if the current user can delete the version + * @param version + */ + canDeleteVersion$(version: Version): Observable { + return this.authorizationService.isAuthorized(FeatureID.CanDeleteVersion, version.self); + } + + /** + * Get all versions for the given version history and store them in versionRD$ + * @param versionHistory$ + */ + getAllVersions(versionHistory$: Observable): void { + const currentPagination = this.paginationService.getCurrentPagination(this.options.id, this.options); + observableCombineLatest([versionHistory$, currentPagination]).pipe( + switchMap(([versionHistory, options]: [VersionHistory, PaginationComponentOptions]) => { + return this.versionHistoryService.getVersions(versionHistory.id, + new PaginatedSearchOptions({pagination: Object.assign({}, options, {currentPage: options.currentPage})}), + false, true, followLink('item'), followLink('eperson')); + }), + getFirstCompletedRemoteData(), + ).subscribe((res: RemoteData>) => { + this.versionsRD$.next(res); + }); + } + + /** + * Updates the page + */ + onPageChange() { + this.getAllVersions(this.versionHistory$); + } + + /** + * Get the ID of the workspace item, if present, otherwise return undefined + * @param versionItem the item for which retrieve the workspace item id + */ + getWorkspaceId(versionItem): Observable { + return versionItem.pipe( + getFirstSucceededRemoteDataPayload(), + map((item: Item) => item.uuid), + switchMap((itemUuid: string) => this.workspaceItemDataService.findByItem(itemUuid, true)), + getFirstCompletedRemoteData(), + map((res: RemoteData) => res?.payload?.id ), + ); + } + + /** + * Get the ID of the workflow item, if present, otherwise return undefined + * @param versionItem the item for which retrieve the workspace item id + */ + getWorkflowId(versionItem): Observable { + return versionItem.pipe( + getFirstSucceededRemoteDataPayload(), + map((item: Item) => item.uuid), + switchMap((itemUuid: string) => this.workflowItemDataService.findByItem(itemUuid, true)), + getFirstCompletedRemoteData(), + map((res: RemoteData) => res?.payload?.id ), + ); + } + + /** + * redirect to the edit page of the workspace item + * @param id$ the id of the workspace item + */ + editWorkspaceItem(id$: Observable) { + id$.subscribe((id) => { + this.router.navigateByUrl('workspaceitems/' + id + '/edit'); + }); } /** @@ -116,20 +456,27 @@ export class ItemVersionsComponent implements OnInit { getAllSucceededRemoteData(), getRemoteDataPayload(), hasValueOperator(), - switchMap((version: Version) => version.versionhistory) + switchMap((version: Version) => version.versionhistory), ); - const versionHistory$ = this.versionHistoryRD$.pipe( - getAllSucceededRemoteData(), - getRemoteDataPayload(), + this.versionHistory$ = this.versionHistoryRD$.pipe( + getFirstSucceededRemoteDataPayload(), hasValueOperator(), ); - const currentPagination = this.paginationService.getCurrentPagination(this.options.id, this.options); - this.versionsRD$ = observableCombineLatest(versionHistory$, currentPagination).pipe( - switchMap(([versionHistory, options]: [VersionHistory, PaginationComponentOptions]) => - this.versionHistoryService.getVersions(versionHistory.id, - new PaginatedSearchOptions({pagination: Object.assign({}, options, { currentPage: options.currentPage })}), - true, true, followLink('item'), followLink('eperson'))) + + this.canCreateVersion$ = this.authorizationService.isAuthorized(FeatureID.CanCreateVersion, this.item.self); + + // If there is a draft item in the version history the 'Create version' button is disabled and a different tooltip message is shown + this.hasDraftVersion$ = this.versionHistoryRD$.pipe( + getFirstSucceededRemoteDataPayload(), + map((res) => Boolean(res?.draftVersion)), ); + + this.createVersionTitle$ = this.hasDraftVersion$.pipe( + take(1), + switchMap((res) => of(res ? 'item.version.history.table.action.hasDraft' : 'item.version.history.table.action.newVersion')) + ); + + this.getAllVersions(this.versionHistory$); this.hasEpersons$ = this.versionsRD$.pipe( getAllSucceededRemoteData(), getRemoteDataPayload(), @@ -150,8 +497,15 @@ export class ItemVersionsComponent implements OnInit { } ngOnDestroy(): void { + this.cleanupSubscribes(); this.paginationService.clearPagination(this.options.id); } + /** + * Unsub all subscriptions + */ + cleanupSubscribes() { + this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); + } } diff --git a/src/app/shared/item/item-versions/notice/item-versions-notice.component.html b/src/app/shared/item/item-versions/notice/item-versions-notice.component.html index cec0bdcb04..fb6fa34746 100644 --- a/src/app/shared/item/item-versions/notice/item-versions-notice.component.html +++ b/src/app/shared/item/item-versions/notice/item-versions-notice.component.html @@ -1,4 +1,4 @@ - diff --git a/src/app/shared/item/item-versions/notice/item-versions-notice.component.spec.ts b/src/app/shared/item/item-versions/notice/item-versions-notice.component.spec.ts index f2184b136a..2849ba4909 100644 --- a/src/app/shared/item/item-versions/notice/item-versions-notice.component.spec.ts +++ b/src/app/shared/item/item-versions/notice/item-versions-notice.component.spec.ts @@ -10,10 +10,13 @@ import { VersionHistoryDataService } from '../../../../core/data/version-history import { By } from '@angular/platform-browser'; import { createSuccessfulRemoteDataObject$ } from '../../../remote-data.utils'; import { createPaginatedList } from '../../../testing/utils.test'; +import { of } from 'rxjs'; +import { take } from 'rxjs/operators'; describe('ItemVersionsNoticeComponent', () => { let component: ItemVersionsNoticeComponent; let fixture: ComponentFixture; + let versionHistoryService: VersionHistoryDataService; const versionHistory = Object.assign(new VersionHistory(), { id: '1' @@ -48,19 +51,29 @@ describe('ItemVersionsNoticeComponent', () => { }); firstVersion.item = createSuccessfulRemoteDataObject$(firstItem); latestVersion.item = createSuccessfulRemoteDataObject$(latestItem); - const versionHistoryService = jasmine.createSpyObj('versionHistoryService', { - getVersions: createSuccessfulRemoteDataObject$(createPaginatedList(versions)) - }); + + const versionHistoryServiceSpy = jasmine.createSpyObj('versionHistoryService', + ['getVersions', 'getLatestVersionFromHistory$', 'isLatest$', ] + ); beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ declarations: [ItemVersionsNoticeComponent], imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], providers: [ - { provide: VersionHistoryDataService, useValue: versionHistoryService } + { provide: VersionHistoryDataService, useValue: versionHistoryServiceSpy } ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); + + versionHistoryService = TestBed.inject(VersionHistoryDataService); + + const isLatestFcn = (version: Version) => of((version.version === latestVersion.version)); + + versionHistoryServiceSpy.getVersions.and.returnValue(createSuccessfulRemoteDataObject$(createPaginatedList(versions))); + versionHistoryServiceSpy.getLatestVersionFromHistory$.and.returnValue(of(latestVersion)); + versionHistoryServiceSpy.isLatest$.and.callFake(isLatestFcn); })); describe('when the item is the latest version', () => { @@ -85,6 +98,19 @@ describe('ItemVersionsNoticeComponent', () => { }); }); + describe('isLatest', () => { + it('firstVersion should not be the latest', () => { + versionHistoryService.isLatest$(firstVersion).pipe(take(1)).subscribe((res) => { + expect(res).toBeFalse(); + }); + }); + it('latestVersion should be the latest', () => { + versionHistoryService.isLatest$(latestVersion).pipe(take(1)).subscribe((res) => { + expect(res).toBeTrue(); + }); + }); + }); + function initComponentWithItem(item: Item) { fixture = TestBed.createComponent(ItemVersionsNoticeComponent); component = fixture.componentInstance; diff --git a/src/app/shared/item/item-versions/notice/item-versions-notice.component.ts b/src/app/shared/item/item-versions/notice/item-versions-notice.component.ts index 2fd39b661c..a292ea65c6 100644 --- a/src/app/shared/item/item-versions/notice/item-versions-notice.component.ts +++ b/src/app/shared/item/item-versions/notice/item-versions-notice.component.ts @@ -1,15 +1,16 @@ import { Component, Input, OnInit } from '@angular/core'; import { Item } from '../../../../core/shared/item.model'; -import { PaginationComponentOptions } from '../../../pagination/pagination-component-options.model'; -import { PaginatedSearchOptions } from '../../../search/paginated-search-options.model'; -import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; +import { Observable } from 'rxjs'; import { RemoteData } from '../../../../core/data/remote-data'; import { VersionHistory } from '../../../../core/shared/version-history.model'; import { Version } from '../../../../core/shared/version.model'; import { hasValue, hasValueOperator } from '../../../empty.util'; -import { getAllSucceededRemoteData, getRemoteDataPayload } from '../../../../core/shared/operators'; -import { filter, map, startWith, switchMap } from 'rxjs/operators'; -import { followLink } from '../../../utils/follow-link-config.model'; +import { + getAllSucceededRemoteData, + getFirstSucceededRemoteDataPayload, + getRemoteDataPayload +} from '../../../../core/shared/operators'; +import { map, startWith, switchMap } from 'rxjs/operators'; import { VersionHistoryDataService } from '../../../../core/data/version-history-data.service'; import { AlertType } from '../../../alert/aletr-type'; import { getItemPageRoute } from '../../../../item-page/item-page-routing-paths'; @@ -47,16 +48,11 @@ export class ItemVersionsNoticeComponent implements OnInit { * Is the item's version equal to the latest version from the version history? * This will determine whether or not to display a notice linking to the latest version */ - isLatestVersion$: Observable; + showLatestVersionNotice$: Observable; /** * Pagination options to fetch a single version on the first page (this is the latest version in the history) */ - latestVersionOptions = Object.assign(new PaginationComponentOptions(),{ - id: 'item-newest-version-options', - currentPage: 1, - pageSize: 1 - }); /** * The AlertType enumeration @@ -71,7 +67,6 @@ export class ItemVersionsNoticeComponent implements OnInit { * Initialize the component's observables */ ngOnInit(): void { - const latestVersionSearch = new PaginatedSearchOptions({pagination: this.latestVersionOptions}); if (hasValue(this.item.version)) { this.versionRD$ = this.item.version; this.versionHistoryRD$ = this.versionRD$.pipe( @@ -80,25 +75,17 @@ export class ItemVersionsNoticeComponent implements OnInit { hasValueOperator(), switchMap((version: Version) => version.versionhistory) ); - const versionHistory$ = this.versionHistoryRD$.pipe( - getAllSucceededRemoteData(), - getRemoteDataPayload(), - ); - this.latestVersion$ = versionHistory$.pipe( - switchMap((versionHistory: VersionHistory) => - this.versionHistoryService.getVersions(versionHistory.id, latestVersionSearch, true, true, followLink('item'))), - getAllSucceededRemoteData(), - getRemoteDataPayload(), - hasValueOperator(), - filter((versions) => versions.page.length > 0), - map((versions) => versions.page[0]) + + this.latestVersion$ = this.versionHistoryRD$.pipe( + getFirstSucceededRemoteDataPayload(), + switchMap((vh) => this.versionHistoryService.getLatestVersionFromHistory$(vh)) ); - this.isLatestVersion$ = observableCombineLatest( - this.versionRD$.pipe(getAllSucceededRemoteData(), getRemoteDataPayload()), this.latestVersion$ - ).pipe( - map(([itemVersion, latestVersion]: [Version, Version]) => itemVersion.id === latestVersion.id), - startWith(true) + this.showLatestVersionNotice$ = this.versionRD$.pipe( + getFirstSucceededRemoteDataPayload(), + switchMap((version) => this.versionHistoryService.isLatest$(version)), + map((isLatest) => isLatest != null && !isLatest), + startWith(false), ); } } diff --git a/src/app/shared/pagination/pagination.component.html b/src/app/shared/pagination/pagination.component.html index 5f002e55d3..2a9aa1a062 100644 --- a/src/app/shared/pagination/pagination.component.html +++ b/src/app/shared/pagination/pagination.component.html @@ -16,7 +16,7 @@ - + diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index b492a01fd2..5ab826d9bf 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -211,6 +211,7 @@ import { CollectionSidebarSearchListElementComponent } from './object-list/sideb import { CommunitySidebarSearchListElementComponent } from './object-list/sidebar-search-list-element/community/community-sidebar-search-list-element.component'; import { AuthorizedCollectionSelectorComponent } from './dso-selector/dso-selector/authorized-collection-selector/authorized-collection-selector.component'; import { DsoPageEditButtonComponent } from './dso-page/dso-page-edit-button/dso-page-edit-button.component'; +import { DsoPageVersionButtonComponent } from './dso-page/dso-page-version-button/dso-page-version-button.component'; import { HoverClassDirective } from './hover-class.directive'; import { ValidationSuggestionsComponent } from './input-suggestions/validation-suggestions/validation-suggestions.component'; import { ItemAlertsComponent } from './item/item-alerts/item-alerts.component'; @@ -233,6 +234,8 @@ import { OnClickMenuItemComponent } from './menu/menu-item/onclick-menu-item.com import { TextMenuItemComponent } from './menu/menu-item/text-menu-item.component'; import { ThemedConfigurationSearchPageComponent } from '../search-page/themed-configuration-search-page.component'; import { SearchNavbarComponent } from '../search-navbar/search-navbar.component'; +import { ItemVersionsSummaryModalComponent } from './item/item-versions/item-versions-summary-modal/item-versions-summary-modal.component'; +import { ItemVersionsDeleteModalComponent } from './item/item-versions/item-versions-delete-modal/item-versions-delete-modal.component'; import { ScopeSelectorModalComponent } from './search-form/scope-selector-modal/scope-selector-modal.component'; import { BitstreamRequestACopyPageComponent } from './bitstream-request-a-copy-page/bitstream-request-a-copy-page.component'; @@ -462,7 +465,7 @@ const COMPONENTS = [ CollectionSidebarSearchListElementComponent, CommunitySidebarSearchListElementComponent, SearchNavbarComponent, - ScopeSelectorModalComponent + ScopeSelectorModalComponent, ]; const ENTRY_COMPONENTS = [ @@ -528,7 +531,7 @@ const ENTRY_COMPONENTS = [ LinkMenuItemComponent, OnClickMenuItemComponent, TextMenuItemComponent, - ScopeSelectorModalComponent + ScopeSelectorModalComponent, ]; const SHARED_SEARCH_PAGE_COMPONENTS = [ @@ -540,6 +543,7 @@ const SHARED_ITEM_PAGE_COMPONENTS = [ MetadataFieldWrapperComponent, MetadataValuesComponent, DsoPageEditButtonComponent, + DsoPageVersionButtonComponent, ItemAlertsComponent, GenericItemPageFieldComponent, MetadataRepresentationListComponent, @@ -590,7 +594,9 @@ const DIRECTIVES = [ ...COMPONENTS, ...DIRECTIVES, ...SHARED_ITEM_PAGE_COMPONENTS, - ...SHARED_SEARCH_PAGE_COMPONENTS + ...SHARED_SEARCH_PAGE_COMPONENTS, + ItemVersionsSummaryModalComponent, + ItemVersionsDeleteModalComponent, ], providers: [ ...PROVIDERS diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 61a28dfa25..3b75866ed9 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -2061,6 +2061,10 @@ "item.page.return": "Back", + "item.page.version.create": "Create new version", + + "item.page.version.hasDraft": "A new version cannot be created because there is an inprogress submission in the version history", + "item.preview.dc.identifier.uri": "Identifier:", "item.preview.dc.contributor.author": "Authors:", @@ -2116,6 +2120,8 @@ "item.version.history.selected": "Selected version", + "item.version.history.selected.alert": "You are currently viewing version {{version}} of the item.", + "item.version.history.table.version": "Version", "item.version.history.table.item": "Item", @@ -2126,11 +2132,77 @@ "item.version.history.table.summary": "Summary", + "item.version.history.table.workspaceItem": "Workspace item", + + "item.version.history.table.workflowItem": "Workflow item", + + "item.version.history.table.actions": "Action", + + "item.version.history.table.action.editWorkspaceItem": "Edit workspace item", + + "item.version.history.table.action.editSummary": "Edit summary", + + "item.version.history.table.action.saveSummary": "Save summary edits", + + "item.version.history.table.action.discardSummary": "Discard summary edits", + + "item.version.history.table.action.newVersion": "Create new version from this one", + + "item.version.history.table.action.deleteVersion": "Delete version", + + "item.version.history.table.action.hasDraft": "A new version cannot be created because there is an inprogress submission in the version history", "item.version.notice": "This is not the latest version of this item. The latest version can be found here.", + "item.version.create.modal.header": "New version", + + "item.version.create.modal.text": "Create a new version for this item", + + "item.version.create.modal.text.startingFrom": "starting from version {{version}}", + + "item.version.create.modal.button.confirm": "Create", + + "item.version.create.modal.button.confirm.tooltip": "Create new version", + + "item.version.create.modal.button.cancel": "Cancel", + + "item.version.create.modal.button.cancel.tooltip": "Do not create new version", + + "item.version.create.modal.form.summary.label": "Summary", + + "item.version.create.modal.form.summary.placeholder": "Insert the summary for the new version", + + "item.version.create.notification.success" : "New version has been created with version number {{version}}", + + "item.version.create.notification.failure" : "New version has not been created", + + "item.version.create.notification.inProgress" : "A new version cannot be created because there is an inprogress submission in the version history", + + + "item.version.delete.modal.header": "Delete version", + + "item.version.delete.modal.text": "Do you want to delete version {{version}}?", + + "item.version.delete.modal.button.confirm": "Delete", + + "item.version.delete.modal.button.confirm.tooltip": "Delete this version", + + "item.version.delete.modal.button.cancel": "Cancel", + + "item.version.delete.modal.button.cancel.tooltip": "Do not delete this version", + + "item.version.delete.notification.success" : "Version number {{version}} has been deleted", + + "item.version.delete.notification.failure" : "Version number {{version}} has not been deleted", + + + "item.version.edit.notification.success" : "The summary of version number {{version}} has been changed", + + "item.version.edit.notification.failure" : "The summary of version number {{version}} has not been changed", + + "journal.listelement.badge": "Journal",