Merge pull request #585 from atmire/List-version-history

List item version history
This commit is contained in:
Tim Donohue
2020-03-20 10:41:39 -05:00
committed by GitHub
24 changed files with 805 additions and 18 deletions

View File

@@ -1005,6 +1005,12 @@
"item.edit.tabs.status.title": "Item Edit - Status", "item.edit.tabs.status.title": "Item Edit - Status",
"item.edit.tabs.versionhistory.head": "Version History",
"item.edit.tabs.versionhistory.title": "Item Edit - Version History",
"item.edit.tabs.versionhistory.under-construction": "Editing or adding new versions is not yet possible in this user interface.",
"item.edit.tabs.view.head": "View Item", "item.edit.tabs.view.head": "View Item",
"item.edit.tabs.view.title": "Item Edit - View", "item.edit.tabs.view.title": "Item Edit - View",
@@ -1084,6 +1090,25 @@
"item.select.table.title": "Title", "item.select.table.title": "Title",
"item.version.history.empty": "There are no other versions for this item yet.",
"item.version.history.head": "Version History",
"item.version.history.return": "Return",
"item.version.history.selected": "Selected version",
"item.version.history.table.version": "Version",
"item.version.history.table.item": "Item",
"item.version.history.table.editor": "Editor",
"item.version.history.table.date": "Date",
"item.version.history.table.summary": "Summary",
"journal.listelement.badge": "Journal", "journal.listelement.badge": "Journal",

View File

@@ -22,6 +22,7 @@ import { EditRelationshipComponent } from './item-relationships/edit-relationshi
import { EditRelationshipListComponent } from './item-relationships/edit-relationship-list/edit-relationship-list.component'; import { EditRelationshipListComponent } from './item-relationships/edit-relationship-list/edit-relationship-list.component';
import { ItemMoveComponent } from './item-move/item-move.component'; import { ItemMoveComponent } from './item-move/item-move.component';
import { VirtualMetadataComponent } from './virtual-metadata/virtual-metadata.component'; import { VirtualMetadataComponent } from './virtual-metadata/virtual-metadata.component';
import { ItemVersionHistoryComponent } from './item-version-history/item-version-history.component';
/** /**
* Module that contains all components related to the Edit Item page administrator functionality * Module that contains all components related to the Edit Item page administrator functionality
@@ -47,6 +48,7 @@ import { VirtualMetadataComponent } from './virtual-metadata/virtual-metadata.co
ItemMetadataComponent, ItemMetadataComponent,
ItemRelationshipsComponent, ItemRelationshipsComponent,
ItemBitstreamsComponent, ItemBitstreamsComponent,
ItemVersionHistoryComponent,
EditInPlaceFieldComponent, EditInPlaceFieldComponent,
EditRelationshipComponent, EditRelationshipComponent,
EditRelationshipListComponent, EditRelationshipListComponent,

View File

@@ -1,4 +1,3 @@
import { ItemPageResolver } from '../item-page.resolver';
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { EditItemPageComponent } from './edit-item-page.component'; import { EditItemPageComponent } from './edit-item-page.component';
@@ -14,6 +13,7 @@ import { ItemCollectionMapperComponent } from './item-collection-mapper/item-col
import { ItemMoveComponent } from './item-move/item-move.component'; import { ItemMoveComponent } from './item-move/item-move.component';
import { ItemRelationshipsComponent } from './item-relationships/item-relationships.component'; import { ItemRelationshipsComponent } from './item-relationships/item-relationships.component';
import { I18nBreadcrumbResolver } from '../../core/breadcrumbs/i18n-breadcrumb.resolver'; import { I18nBreadcrumbResolver } from '../../core/breadcrumbs/i18n-breadcrumb.resolver';
import { ItemVersionHistoryComponent } from './item-version-history/item-version-history.component';
export const ITEM_EDIT_WITHDRAW_PATH = 'withdraw'; export const ITEM_EDIT_WITHDRAW_PATH = 'withdraw';
export const ITEM_EDIT_REINSTATE_PATH = 'reinstate'; export const ITEM_EDIT_REINSTATE_PATH = 'reinstate';
@@ -75,6 +75,11 @@ export const ITEM_EDIT_MOVE_PATH = 'move';
/* TODO - change when curate page exists */ /* TODO - change when curate page exists */
component: ItemBitstreamsComponent, component: ItemBitstreamsComponent,
data: { title: 'item.edit.tabs.curate.title', showBreadcrumbs: true } data: { title: 'item.edit.tabs.curate.title', showBreadcrumbs: true }
},
{
path: 'versionhistory',
component: ItemVersionHistoryComponent,
data: { title: 'item.edit.tabs.versionhistory.title', showBreadcrumbs: true }
} }
] ]
}, },

View File

@@ -0,0 +1,6 @@
<div class="mt-4">
<ds-alert [content]="'item.edit.tabs.versionhistory.under-construction'" [type]="AlertTypeEnum.Warning"></ds-alert>
</div>
<div class="mt-2" *ngVar="(itemRD$ | async)?.payload as item">
<ds-item-versions *ngIf="item" [item]="item" [displayWhenEmpty]="true" [displayTitle]="false"></ds-item-versions>
</div>

View File

@@ -0,0 +1,44 @@
import { ItemVersionHistoryComponent } from './item-version-history.component';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { VarDirective } from '../../../shared/utils/var.directive';
import { RouterTestingModule } from '@angular/router/testing';
import { TranslateModule } from '@ngx-translate/core';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { Item } from '../../../core/shared/item.model';
import { ActivatedRoute } from '@angular/router';
import { of as observableOf } from 'rxjs';
import { createSuccessfulRemoteDataObject } from '../../../shared/testing/utils';
describe('ItemVersionHistoryComponent', () => {
let component: ItemVersionHistoryComponent;
let fixture: ComponentFixture<ItemVersionHistoryComponent>;
const item = Object.assign(new Item(), {
uuid: 'item-identifier-1',
handle: '123456789/1',
});
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ItemVersionHistoryComponent, VarDirective],
imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])],
providers: [
{ provide: ActivatedRoute, useValue: { parent: { data: observableOf({ item: createSuccessfulRemoteDataObject(item) }) } } }
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ItemVersionHistoryComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should initialize the itemRD$ from the route\'s data', (done) => {
component.itemRD$.subscribe((itemRD) => {
expect(itemRD.payload).toBe(item);
done();
});
});
});

View File

@@ -0,0 +1,35 @@
import { Component } from '@angular/core';
import { Observable } from 'rxjs/internal/Observable';
import { RemoteData } from '../../../core/data/remote-data';
import { Item } from '../../../core/shared/item.model';
import { map } from 'rxjs/operators';
import { getSucceededRemoteData } from '../../../core/shared/operators';
import { ActivatedRoute } from '@angular/router';
import { AlertType } from '../../../shared/alert/aletr-type';
@Component({
selector: 'ds-item-version-history',
templateUrl: './item-version-history.component.html'
})
/**
* Component for listing and managing an item's version history
*/
export class ItemVersionHistoryComponent {
/**
* The item to display the version history for
*/
itemRD$: Observable<RemoteData<Item>>;
/**
* The AlertType enumeration
* @type {AlertType}
*/
AlertTypeEnum = AlertType;
constructor(private route: ActivatedRoute) {
}
ngOnInit(): void {
this.itemRD$ = this.route.parent.data.pipe(map((data) => data.item)).pipe(getSucceededRemoteData()) as Observable<RemoteData<Item>>;
}
}

View File

@@ -21,6 +21,7 @@
</table> </table>
<ds-item-page-full-file-section [item]="item"></ds-item-page-full-file-section> <ds-item-page-full-file-section [item]="item"></ds-item-page-full-file-section>
<ds-item-page-collections [item]="item"></ds-item-page-collections> <ds-item-page-collections [item]="item"></ds-item-page-collections>
<ds-item-versions class="mt-2" [item]="item"></ds-item-versions>
</div> </div>
</div> </div>
<ds-error *ngIf="itemRD?.hasFailed" message="{{'error.item' | translate}}"></ds-error> <ds-error *ngIf="itemRD?.hasFailed" message="{{'error.item' | translate}}"></ds-error>

View File

@@ -59,7 +59,7 @@ import { AbstractIncrementalListComponent } from './simple/abstract-incremental-
MetadataRepresentationListComponent, MetadataRepresentationListComponent,
RelatedEntitiesSearchComponent, RelatedEntitiesSearchComponent,
TabbedRelatedEntitiesSearchComponent, TabbedRelatedEntitiesSearchComponent,
AbstractIncrementalListComponent AbstractIncrementalListComponent,
], ],
exports: [ exports: [
ItemComponent, ItemComponent,

View File

@@ -28,6 +28,7 @@ export class ItemPageResolver implements Resolve<RemoteData<Item>> {
followLink('owningCollection'), followLink('owningCollection'),
followLink('bundles'), followLink('bundles'),
followLink('relationships'), followLink('relationships'),
followLink('version', undefined, true, followLink('versionhistory')),
).pipe( ).pipe(
find((RD) => hasValue(RD.error) || RD.hasSucceeded), find((RD) => hasValue(RD.error) || RD.hasSucceeded),
); );

View File

@@ -3,6 +3,7 @@
<div *ngIf="itemRD?.payload as item"> <div *ngIf="itemRD?.payload as item">
<ds-view-tracker [object]="item"></ds-view-tracker> <ds-view-tracker [object]="item"></ds-view-tracker>
<ds-listable-object-component-loader [object]="item" [viewMode]="viewMode"></ds-listable-object-component-loader> <ds-listable-object-component-loader [object]="item" [viewMode]="viewMode"></ds-listable-object-component-loader>
<ds-item-versions class="mt-2" [item]="item"></ds-item-versions>
</div> </div>
</div> </div>
<ds-error *ngIf="itemRD?.hasFailed" message="{{'error.item' | translate}}"></ds-error> <ds-error *ngIf="itemRD?.hasFailed" message="{{'error.item' | translate}}"></ds-error>

View File

@@ -142,6 +142,10 @@ import { PoolTask } from './tasks/models/pool-task-object.model';
import { TaskObject } from './tasks/models/task-object.model'; import { TaskObject } from './tasks/models/task-object.model';
import { PoolTaskDataService } from './tasks/pool-task-data.service'; import { PoolTaskDataService } from './tasks/pool-task-data.service';
import { TaskResponseParsingService } from './tasks/task-response-parsing.service'; import { TaskResponseParsingService } from './tasks/task-response-parsing.service';
import { VersionDataService } from './data/version-data.service';
import { VersionHistoryDataService } from './data/version-history-data.service';
import { Version } from './shared/version.model';
import { VersionHistory } from './shared/version-history.model';
/** /**
* When not in production, endpoint responses can be mocked for testing purposes * When not in production, endpoint responses can be mocked for testing purposes
@@ -255,6 +259,8 @@ const PROVIDERS = [
RelationshipTypeService, RelationshipTypeService,
ExternalSourceService, ExternalSourceService,
LookupRelationService, LookupRelationService,
VersionDataService,
VersionHistoryDataService,
LicenseDataService, LicenseDataService,
ItemTypeDataService, ItemTypeDataService,
// register AuthInterceptor as HttpInterceptor // register AuthInterceptor as HttpInterceptor
@@ -305,6 +311,8 @@ export const models =
ItemType, ItemType,
ExternalSource, ExternalSource,
ExternalSourceEntry, ExternalSourceEntry,
Version,
VersionHistory
]; ];
@NgModule({ @NgModule({

View File

@@ -0,0 +1,44 @@
import { Injectable } from '@angular/core';
import { DataService } from './data.service';
import { Version } from '../shared/version.model';
import { RequestService } from './request.service';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { Store } from '@ngrx/store';
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 { DefaultChangeAnalyzer } from './default-change-analyzer.service';
import { FindListOptions } from './request.models';
import { Observable } from 'rxjs/internal/Observable';
import { dataService } from '../cache/builders/build-decorators';
import { VERSION } from '../shared/version.resource-type';
/**
* Service responsible for handling requests related to the Version object
*/
@Injectable()
@dataService(VERSION)
export class VersionDataService extends DataService<Version> {
protected linkPath = 'versions';
constructor(
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected store: Store<CoreState>,
protected objectCache: ObjectCacheService,
protected halService: HALEndpointService,
protected notificationsService: NotificationsService,
protected http: HttpClient,
protected comparator: DefaultChangeAnalyzer<Version>) {
super();
}
/**
* Get the endpoint for browsing versions
*/
getBrowseEndpoint(options: FindListOptions = {}, linkPath?: string): Observable<string> {
return this.halService.getEndpoint(this.linkPath);
}
}

View File

@@ -0,0 +1,54 @@
import { RequestService } from './request.service';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { ObjectCacheService } from '../cache/object-cache.service';
import { VersionHistoryDataService } from './version-history-data.service';
import { NotificationsServiceStub } from '../../shared/testing/notifications-service-stub';
import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub';
import { getMockRequestService } from '../../shared/mocks/mock-request.service';
import { GetRequest } from './request.models';
const url = 'fake-url';
describe('VersionHistoryDataService', () => {
let service: VersionHistoryDataService;
let requestService: RequestService;
let notificationsService: any;
let rdbService: RemoteDataBuildService;
let objectCache: ObjectCacheService;
let halService: any;
beforeEach(() => {
createService();
});
describe('getVersions', () => {
let result;
beforeEach(() => {
result = service.getVersions('1');
});
it('should configure a GET request', () => {
expect(requestService.configure).toHaveBeenCalledWith(jasmine.any(GetRequest));
});
});
/**
* 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')
});
objectCache = jasmine.createSpyObj('objectCache', {
remove: jasmine.createSpy('remove')
});
halService = new HALEndpointServiceStub(url);
notificationsService = new NotificationsServiceStub();
service = new VersionHistoryDataService(requestService, rdbService, null, objectCache, halService, notificationsService, null, null);
}
});

View File

@@ -0,0 +1,81 @@
import { DataService } from './data.service';
import { VersionHistory } from '../shared/version-history.model';
import { Injectable } from '@angular/core';
import { RequestService } from './request.service';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { Store } from '@ngrx/store';
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 { DefaultChangeAnalyzer } from './default-change-analyzer.service';
import { FindListOptions, GetRequest } from './request.models';
import { Observable } from 'rxjs/internal/Observable';
import { PaginatedSearchOptions } from '../../shared/search/paginated-search-options.model';
import { RemoteData } from './remote-data';
import { PaginatedList } from './paginated-list';
import { Version } from '../shared/version.model';
import { map, switchMap, take } from 'rxjs/operators';
import { dataService } from '../cache/builders/build-decorators';
import { VERSION_HISTORY } from '../shared/version-history.resource-type';
import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
/**
* Service responsible for handling requests related to the VersionHistory object
*/
@Injectable()
@dataService(VERSION_HISTORY)
export class VersionHistoryDataService extends DataService<VersionHistory> {
protected linkPath = 'versionhistories';
protected versionsEndpoint = 'versions';
constructor(
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected store: Store<CoreState>,
protected objectCache: ObjectCacheService,
protected halService: HALEndpointService,
protected notificationsService: NotificationsService,
protected http: HttpClient,
protected comparator: DefaultChangeAnalyzer<VersionHistory>) {
super();
}
/**
* Get the endpoint for browsing versions
*/
getBrowseEndpoint(options: FindListOptions = {}, linkPath?: string): Observable<string> {
return this.halService.getEndpoint(this.linkPath);
}
/**
* Get the versions endpoint for a version history
* @param versionHistoryId
*/
getVersionsEndpoint(versionHistoryId: string): Observable<string> {
return this.getBrowseEndpoint().pipe(
switchMap((href: string) => this.halService.getEndpoint(this.versionsEndpoint, `${href}/${versionHistoryId}`))
);
}
/**
* Get a version history's versions using paginated search options
* @param versionHistoryId The version history's ID
* @param searchOptions The search options to use
* @param linksToFollow HAL Links to follow on the Versions
*/
getVersions(versionHistoryId: string, searchOptions?: PaginatedSearchOptions, ...linksToFollow: Array<FollowLinkConfig<Version>>): Observable<RemoteData<PaginatedList<Version>>> {
const hrefObs = this.getVersionsEndpoint(versionHistoryId).pipe(
map((href) => searchOptions ? searchOptions.toRestUrl(href) : href)
);
hrefObs.pipe(
take(1)
).subscribe((href) => {
const request = new GetRequest(this.requestService.generateRequestId(), href);
this.requestService.configure(request);
});
return this.rdbService.buildList<Version>(hrefObs, ...linksToFollow);
}
}

View File

@@ -18,6 +18,8 @@ import { Relationship } from './item-relationships/relationship.model';
import { RELATIONSHIP } from './item-relationships/relationship.resource-type'; import { RELATIONSHIP } from './item-relationships/relationship.resource-type';
import { ITEM } from './item.resource-type'; import { ITEM } from './item.resource-type';
import { ChildHALResource } from './child-hal-resource.model'; import { ChildHALResource } from './child-hal-resource.model';
import { Version } from './version.model';
import { VERSION } from './version.resource-type';
/** /**
* Class representing a DSpace Item * Class representing a DSpace Item
@@ -67,6 +69,7 @@ export class Item extends DSpaceObject implements ChildHALResource {
bundles: HALLink; bundles: HALLink;
owningCollection: HALLink; owningCollection: HALLink;
templateItemOf: HALLink; templateItemOf: HALLink;
version: HALLink;
self: HALLink; self: HALLink;
}; };
@@ -77,6 +80,13 @@ export class Item extends DSpaceObject implements ChildHALResource {
@link(COLLECTION) @link(COLLECTION)
owningCollection?: Observable<RemoteData<Collection>>; owningCollection?: Observable<RemoteData<Collection>>;
/**
* The version this item represents in its history
* Will be undefined unless the version {@link HALLink} has been resolved.
*/
@link(VERSION)
version?: Observable<RemoteData<Version>>;
/** /**
* The list of Bundles inside this Item * The list of Bundles inside this Item
* Will be undefined unless the bundles {@link HALLink} has been resolved. * Will be undefined unless the bundles {@link HALLink} has been resolved.

View File

@@ -0,0 +1,39 @@
import { deserialize, autoserialize, inheritSerialization } from 'cerialize';
import { excludeFromEquals } from '../utilities/equals.decorators';
import { Observable } from 'rxjs/internal/Observable';
import { RemoteData } from '../data/remote-data';
import { PaginatedList } from '../data/paginated-list';
import { Version } from './version.model';
import { VERSION_HISTORY } from './version-history.resource-type';
import { link, typedObject } from '../cache/builders/build-decorators';
import { DSpaceObject } from './dspace-object.model';
import { HALLink } from './hal-link.model';
import { VERSION } from './version.resource-type';
/**
* Class representing a DSpace Version History
*/
@typedObject
@inheritSerialization(DSpaceObject)
export class VersionHistory extends DSpaceObject {
static type = VERSION_HISTORY;
@deserialize
_links: {
self: HALLink;
versions: HALLink;
};
/**
* The identifier of this Version History
*/
@autoserialize
id: string;
/**
* The list of versions within this history
*/
@excludeFromEquals
@link(VERSION, true)
versions: Observable<RemoteData<PaginatedList<Version>>>;
}

View File

@@ -0,0 +1,9 @@
import { ResourceType } from './resource-type';
/**
* The resource type for VersionHistory
*
* Needs to be in a separate file to prevent circular
* dependencies in webpack.
*/
export const VERSION_HISTORY = new ResourceType('versionhistory');

View File

@@ -0,0 +1,76 @@
import { deserialize, autoserialize, inheritSerialization } from 'cerialize';
import { excludeFromEquals } from '../utilities/equals.decorators';
import { Item } from './item.model';
import { RemoteData } from '../data/remote-data';
import { Observable } from 'rxjs/internal/Observable';
import { VersionHistory } from './version-history.model';
import { EPerson } from '../eperson/models/eperson.model';
import { VERSION } from './version.resource-type';
import { HALLink } from './hal-link.model';
import { link, typedObject } from '../cache/builders/build-decorators';
import { VERSION_HISTORY } from './version-history.resource-type';
import { ITEM } from './item.resource-type';
import { EPERSON } from '../eperson/models/eperson.resource-type';
import { DSpaceObject } from './dspace-object.model';
/**
* Class representing a DSpace Version
*/
@typedObject
@inheritSerialization(DSpaceObject)
export class Version extends DSpaceObject {
static type = VERSION;
@deserialize
_links: {
self: HALLink;
item: HALLink;
versionhistory: HALLink;
eperson: HALLink;
};
/**
* The identifier of this Version
*/
@autoserialize
id: string;
/**
* The version number of the version's history this version represents
*/
@autoserialize
version: number;
/**
* The summary for the changes made in this version
*/
@autoserialize
summary: string;
/**
* The Date this version was created
*/
@deserialize
created: Date;
/**
* The full version history this version is apart of
*/
@excludeFromEquals
@link(VERSION_HISTORY)
versionhistory: Observable<RemoteData<VersionHistory>>;
/**
* The item this version represents
*/
@excludeFromEquals
@link(ITEM)
item: Observable<RemoteData<Item>>;
/**
* The e-person who created this version
*/
@excludeFromEquals
@link(EPERSON)
eperson: Observable<RemoteData<EPerson>>;
}

View File

@@ -0,0 +1,9 @@
import { ResourceType } from './resource-type';
/**
* The resource type for Version
*
* Needs to be in a separate file to prevent circular
* dependencies in webpack.
*/
export const VERSION = new ResourceType('version');

View File

@@ -0,0 +1,47 @@
<div *ngVar="(versionsRD$ | async)?.payload as versions">
<div *ngVar="(versionRD$ | async)?.payload as itemVersion">
<div class="mb-2" *ngIf="versions?.page?.length > 0 || displayWhenEmpty">
<h2 *ngIf="displayTitle">{{"item.version.history.head" | translate}}</h2>
<ds-pagination *ngIf="versions?.page?.length > 0"
[hideGear]="true"
[hidePagerWhenSinglePage]="true"
[paginationOptions]="options"
[pageInfoState]="versions"
[collectionSize]="versions?.totalElements"
[disableRouteParameterUpdate]="true"
(pageChange)="switchPage($event)">
<table class="table table-striped my-2">
<thead>
<tr>
<th scope="col">{{"item.version.history.table.version" | translate}}</th>
<th scope="col">{{"item.version.history.table.item" | translate}}</th>
<th scope="col" *ngIf="(hasEpersons$ | async)">{{"item.version.history.table.editor" | translate}}</th>
<th scope="col">{{"item.version.history.table.date" | translate}}</th>
<th scope="col">{{"item.version.history.table.summary" | translate}}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let version of versions?.page" [id]="'version-row-' + version.id">
<td class="version-row-element-version">{{version?.version}}</td>
<td class="version-row-element-item">
<span *ngVar="(version?.item | async)?.payload as item">
<a *ngIf="item" [routerLink]="['/items', item?.id]">{{item?.handle}}</a>
<span *ngIf="version?.id === itemVersion?.id">*</span>
</span>
</td>
<td *ngIf="(hasEpersons$ | async)" class="version-row-element-editor">
<span *ngVar="(version?.eperson | async)?.payload as eperson">
<a *ngIf="eperson" [href]="'mailto:' + eperson?.email">{{eperson?.name}}</a>
</span>
</td>
<td class="version-row-element-date">{{version?.created}}</td>
<td class="version-row-element-summary">{{version?.summary}}</td>
</tr>
</tbody>
</table>
<div>*&nbsp;{{"item.version.history.selected" | translate}}</div>
</ds-pagination>
<ds-alert *ngIf="!itemVersion || versions?.page?.length === 0" [content]="'item.version.history.empty'" [type]="AlertTypeEnum.Info"></ds-alert>
</div>
</div>
</div>

View File

@@ -0,0 +1,121 @@
import { ItemVersionsComponent } from './item-versions.component';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { VarDirective } from '../../utils/var.directive';
import { TranslateModule } from '@ngx-translate/core';
import { RouterTestingModule } from '@angular/router/testing';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { Item } from '../../../core/shared/item.model';
import { Version } from '../../../core/shared/version.model';
import { VersionHistory } from '../../../core/shared/version-history.model';
import { createPaginatedList, createSuccessfulRemoteDataObject$ } from '../../testing/utils';
import { VersionHistoryDataService } from '../../../core/data/version-history-data.service';
import { By } from '@angular/platform-browser';
describe('ItemVersionsComponent', () => {
let component: ItemVersionsComponent;
let fixture: ComponentFixture<ItemVersionsComponent>;
const versionHistory = Object.assign(new VersionHistory(), {
id: '1'
});
const version1 = Object.assign(new Version(), {
id: '1',
version: 1,
created: new Date(2020, 1, 1),
summary: 'first version',
versionhistory: createSuccessfulRemoteDataObject$(versionHistory)
});
const version2 = Object.assign(new Version(), {
id: '2',
version: 2,
summary: 'second version',
created: new Date(2020, 1, 2),
versionhistory: createSuccessfulRemoteDataObject$(versionHistory)
});
const versions = [version1, version2];
versionHistory.versions = createSuccessfulRemoteDataObject$(createPaginatedList(versions));
const item1 = Object.assign(new Item(), {
uuid: 'item-identifier-1',
handle: '123456789/1',
version: createSuccessfulRemoteDataObject$(version1)
});
const item2 = Object.assign(new Item(), {
uuid: 'item-identifier-2',
handle: '123456789/2',
version: createSuccessfulRemoteDataObject$(version2)
});
const items = [item1, item2];
version1.item = createSuccessfulRemoteDataObject$(item1);
version2.item = createSuccessfulRemoteDataObject$(item2);
const versionHistoryService = jasmine.createSpyObj('versionHistoryService', {
getVersions: createSuccessfulRemoteDataObject$(createPaginatedList(versions))
});
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ItemVersionsComponent, VarDirective],
imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])],
providers: [
{ provide: VersionHistoryDataService, useValue: versionHistoryService }
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ItemVersionsComponent);
component = fixture.componentInstance;
component.item = item1;
fixture.detectChanges();
});
it(`should display ${versions.length} rows`, () => {
const rows = fixture.debugElement.queryAll(By.css('tbody tr'));
expect(rows.length).toBe(versions.length);
});
versions.forEach((version: Version, index: number) => {
const versionItem = items[index];
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);
});
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
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`));
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);
});
it(`should display summary ${version.summary} in the correct column for version ${version.id}`, () => {
const summary = fixture.debugElement.query(By.css(`#version-row-${version.id} .version-row-element-summary`));
expect(summary.nativeElement.textContent).toEqual(version.summary);
});
});
describe('switchPage', () => {
const page = 5;
beforeEach(() => {
component.switchPage(page);
});
it('should set the option\'s currentPage to the new page', () => {
expect(component.options.currentPage).toEqual(page);
});
});
});

View File

@@ -0,0 +1,130 @@
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 { Observable } from 'rxjs/internal/Observable';
import { VersionHistory } from '../../../core/shared/version-history.model';
import { getAllSucceededRemoteData, getRemoteDataPayload } from '../../../core/shared/operators';
import { map, startWith, switchMap } from 'rxjs/operators';
import { combineLatest as observableCombineLatest } from 'rxjs';
import { PaginatedList } from '../../../core/data/paginated-list';
import { PaginationComponentOptions } from '../../pagination/pagination-component-options.model';
import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject';
import { VersionHistoryDataService } from '../../../core/data/version-history-data.service';
import { PaginatedSearchOptions } from '../../search/paginated-search-options.model';
import { AlertType } from '../../alert/aletr-type';
import { followLink } from '../../utils/follow-link-config.model';
@Component({
selector: 'ds-item-versions',
templateUrl: './item-versions.component.html'
})
/**
* 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
*/
@Input() item: Item;
/**
* An option to display the list of versions, even when there aren't any.
* Instead of the table, an alert will be displayed, notifying the user there are no other versions present
* for the current item.
*/
@Input() displayWhenEmpty = false;
/**
* Whether or not to display the title
*/
@Input() displayTitle = true;
/**
* The AlertType enumeration
* @type {AlertType}
*/
AlertTypeEnum = AlertType;
/**
* The item's version
*/
versionRD$: Observable<RemoteData<Version>>;
/**
* The item's full version history
*/
versionHistoryRD$: Observable<RemoteData<VersionHistory>>;
/**
* The version history's list of versions
*/
versionsRD$: Observable<RemoteData<PaginatedList<Version>>>;
/**
* Verify if the list of versions has at least one e-person to display
* Used to hide the "Editor" column when no e-persons are present to display
*/
hasEpersons$: Observable<boolean>;
/**
* The amount of versions to display per page
*/
pageSize = 10;
/**
* 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(),{
id: 'item-versions-options',
currentPage: 1,
pageSize: this.pageSize
});
/**
* The current page being displayed
*/
currentPage$ = new BehaviorSubject<number>(1);
constructor(private versionHistoryService: VersionHistoryDataService) {
}
/**
* Initialize all observables
*/
ngOnInit(): void {
this.versionRD$ = this.item.version;
this.versionHistoryRD$ = this.versionRD$.pipe(
getAllSucceededRemoteData(),
getRemoteDataPayload(),
switchMap((version: Version) => version.versionhistory)
);
const versionHistory$ = this.versionHistoryRD$.pipe(
getAllSucceededRemoteData(),
getRemoteDataPayload(),
);
this.versionsRD$ = observableCombineLatest(versionHistory$, this.currentPage$).pipe(
switchMap(([versionHistory, page]: [VersionHistory, number]) =>
this.versionHistoryService.getVersions(versionHistory.id,
new PaginatedSearchOptions({pagination: Object.assign({}, this.options, { currentPage: page })}),
followLink('item'), followLink('eperson')))
);
this.hasEpersons$ = this.versionsRD$.pipe(
getAllSucceededRemoteData(),
getRemoteDataPayload(),
map((versions: PaginatedList<Version>) => versions.page.filter((version: Version) => version.eperson !== undefined).length > 0),
startWith(false)
);
}
/**
* Update the current page
* @param page
*/
switchPage(page: number) {
this.options.currentPage = page;
this.currentPage$.next(page);
}
}

View File

@@ -99,6 +99,13 @@ export class PaginationComponent implements OnDestroy, OnInit {
*/ */
@Input() public hidePagerWhenSinglePage = true; @Input() public hidePagerWhenSinglePage = true;
/**
* Option for disabling updating and reading route parameters on pagination changes
* In other words, changing pagination won't add or update the url parameters on the current page, and the url
* parameters won't affect the pagination of this component
*/
@Input() public disableRouteParameterUpdate = false;
/** /**
* Current page. * Current page.
*/ */
@@ -173,20 +180,35 @@ export class PaginationComponent implements OnDestroy, OnInit {
this.checkConfig(this.paginationOptions); this.checkConfig(this.paginationOptions);
this.initializeConfig(); this.initializeConfig();
// Listen to changes // Listen to changes
this.subs.push(this.route.queryParams if (!this.disableRouteParameterUpdate) {
.subscribe((queryParams) => { this.subs.push(this.route.queryParams
if (this.isEmptyPaginationParams(queryParams)) { .subscribe((queryParams) => {
this.initializeConfig(queryParams); this.initializeParams(queryParams);
}));
}
}
/**
* Initialize the route and current parameters
* This method will fix any invalid or missing parameters
* @param params
*/
private initializeParams(params) {
if (this.isEmptyPaginationParams(params)) {
this.initializeConfig(params);
} else {
this.currentQueryParams = params;
const fixedProperties = this.validateParams(params);
if (isNotEmpty(fixedProperties)) {
if (!this.disableRouteParameterUpdate) {
this.fixRoute(fixedProperties);
} else { } else {
this.currentQueryParams = queryParams; this.initializeParams(fixedProperties);
const fixedProperties = this.validateParams(queryParams);
if (isNotEmpty(fixedProperties)) {
this.fixRoute(fixedProperties);
} else {
this.setFields();
}
} }
})); } else {
this.setFields();
}
}
} }
private fixRoute(fixedProperties) { private fixRoute(fixedProperties) {
@@ -247,7 +269,7 @@ export class PaginationComponent implements OnDestroy, OnInit {
* The page being navigated to. * The page being navigated to.
*/ */
public doPageChange(page: number) { public doPageChange(page: number) {
this.updateRoute({ pageId: this.id, page: page.toString() }); this.updateParams(Object.assign({}, this.currentQueryParams, { pageId: this.id, page: page.toString() }));
} }
/** /**
@@ -257,7 +279,7 @@ export class PaginationComponent implements OnDestroy, OnInit {
* The page size being navigated to. * The page size being navigated to.
*/ */
public doPageSizeChange(pageSize: number) { public doPageSizeChange(pageSize: number) {
this.updateRoute({ pageId: this.id, page: 1, pageSize: pageSize }); this.updateParams(Object.assign({}, this.currentQueryParams,{ pageId: this.id, page: 1, pageSize: pageSize }));
} }
/** /**
@@ -267,7 +289,7 @@ export class PaginationComponent implements OnDestroy, OnInit {
* The sort direction being navigated to. * The sort direction being navigated to.
*/ */
public doSortDirectionChange(sortDirection: SortDirection) { public doSortDirectionChange(sortDirection: SortDirection) {
this.updateRoute({ pageId: this.id, page: 1, sortDirection: sortDirection }); this.updateParams(Object.assign({}, this.currentQueryParams,{ pageId: this.id, page: 1, sortDirection: sortDirection }));
} }
/** /**
@@ -277,7 +299,7 @@ export class PaginationComponent implements OnDestroy, OnInit {
* The sort field being navigated to. * The sort field being navigated to.
*/ */
public doSortFieldChange(field: string) { public doSortFieldChange(field: string) {
this.updateRoute({ pageId: this.id, page: 1, sortField: field }); this.updateParams(Object.assign(this.currentQueryParams,{ pageId: this.id, page: 1, sortField: field }));
} }
/** /**
@@ -347,6 +369,20 @@ export class PaginationComponent implements OnDestroy, OnInit {
}) })
} }
/**
* Update the current query params and optionally update the route
* @param params
*/
private updateParams(params: {}) {
if (isNotEmpty(difference(params, this.currentQueryParams))) {
if (!this.disableRouteParameterUpdate) {
this.updateRoute(params);
} else {
this.initializeParams(params);
}
}
}
/** /**
* Method to update the route parameters * Method to update the route parameters
*/ */

View File

@@ -176,6 +176,7 @@ import { ExternalSourceEntryImportModalComponent } from './form/builder/ds-dynam
import { ImportableListItemControlComponent } from './object-collection/shared/importable-list-item-control/importable-list-item-control.component'; import { ImportableListItemControlComponent } from './object-collection/shared/importable-list-item-control/importable-list-item-control.component';
import { DragDropModule } from '@angular/cdk/drag-drop'; import { DragDropModule } from '@angular/cdk/drag-drop';
import { ExistingMetadataListElementComponent } from './form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component'; import { ExistingMetadataListElementComponent } from './form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component';
import { ItemVersionsComponent } from './item/item-versions/item-versions.component';
import { SortablejsModule } from 'ngx-sortablejs'; import { SortablejsModule } from 'ngx-sortablejs';
import { MissingTranslationHelper } from './translate/missing-translation.helper'; import { MissingTranslationHelper } from './translate/missing-translation.helper';
@@ -344,6 +345,7 @@ const COMPONENTS = [
ExternalSourceEntryImportModalComponent, ExternalSourceEntryImportModalComponent,
ImportableListItemControlComponent, ImportableListItemControlComponent,
ExistingMetadataListElementComponent, ExistingMetadataListElementComponent,
ItemVersionsComponent,
PublicationSearchResultListElementComponent, PublicationSearchResultListElementComponent,
]; ];
@@ -408,6 +410,7 @@ const ENTRY_COMPONENTS = [
DsDynamicLookupRelationSelectionTabComponent, DsDynamicLookupRelationSelectionTabComponent,
DsDynamicLookupRelationExternalSourceTabComponent, DsDynamicLookupRelationExternalSourceTabComponent,
ExternalSourceEntryImportModalComponent, ExternalSourceEntryImportModalComponent,
ItemVersionsComponent,
]; ];
const SHARED_ITEM_PAGE_COMPONENTS = [ const SHARED_ITEM_PAGE_COMPONENTS = [