diff --git a/config/environment.default.js b/config/environment.default.js index 3c1144fc6f..d46dc10dee 100644 --- a/config/environment.default.js +++ b/config/environment.default.js @@ -59,5 +59,25 @@ module.exports = { // Log directory logDirectory: '.', // NOTE: will log all redux actions and transfers in console - debug: false + debug: false, + // Default Language in which the UI will be rendered if the user's browser language is not an active language + defaultLanguage: 'en', + // Languages. DSpace Angular holds a message catalog for each of the following languages. When set to active, users will be able to switch to the use of this language in the user interface. + languages: [{ + code: 'en', + label: 'English', + active: true, + }, { + code: 'de', + label: 'Deutsch', + active: true, + }, { + code: 'cs', + label: 'Čeština', + active: true, + }, { + code: 'nl', + label: 'Nederlands', + active: false, + }] }; diff --git a/resources/i18n/en.json b/resources/i18n/en.json index bcaccc83a3..bc466c54b0 100644 --- a/resources/i18n/en.json +++ b/resources/i18n/en.json @@ -108,6 +108,120 @@ "simple": "Simple item page", "full": "Full item page" } + }, + "select": { + "table": { + "collection": "Collection", + "author": "Author", + "title": "Title" + }, + "confirm": "Confirm selected" + }, + "edit": { + "head": "Edit Item", + "tabs": { + "status": { + "head": "Item Status", + "description": "Welcome to the item management page. From here you can withdraw, reinstate, move or delete the item. You may also update or add new metadata / bitstreams on the other tabs.", + "labels": { + "id": "Item Internal ID", + "handle": "Handle", + "lastModified": "Last Modified", + "itemPage": "Item Page" + }, + "buttons": { + "authorizations": { + "label": "Edit item's authorization policies", + "button": "Authorizations..." + }, + "withdraw": { + "label": "Withdraw item from the repository", + "button": "Withdraw..." + }, + "reinstate": { + "label": "Reinstate item into the repository", + "button": "Reinstate..." + }, + "move": { + "label": "Move item to another collection", + "button": "Move..." + }, + "private": { + "label": "Make item private", + "button": "Make it private..." + }, + "public": { + "label": "Make item public", + "button": "Make it public..." + }, + "delete": { + "label": "Completely expunge item", + "button": "Permanently delete" + }, + "mappedCollections": { + "label": "Manage mapped collections", + "button": "Mapped collections" + } + } + }, + "bitstreams": { + "head": "Item Bitstreams" + }, + "metadata": { + "head": "Item Metadata" + }, + "view": { + "head": "View Item" + }, + "curate": { + "head": "Curate" + } + }, + "modify.overview": { + "field": "Field", + "value": "Value", + "language": "Language" + }, + "withdraw": { + "header": "Withdraw item: {{ id }}", + "description": "Are you sure this item should be withdrawn from the archive?", + "confirm": "Withdraw", + "cancel": "Cancel", + "success": "The item was withdrawn successfully", + "error": "An error occured while withdrawing the item" + }, + "reinstate": { + "header": "Reinstate item: {{ id }}", + "description": "Are you sure this item should be reinstated to the archive?", + "confirm": "Reinstate", + "cancel": "Cancel", + "success": "The item was reinstated successfully", + "error": "An error occured while reinstating the item" + }, + "private": { + "header": "Make item private: {{ id }}", + "description": "Are you sure this item should be made private in the archive?", + "confirm": "Make it Private", + "cancel": "Cancel", + "success": "The item is now private", + "error": "An error occured while making the item private" + }, + "public": { + "header": "Make item public: {{ id }}", + "description": "Are you sure this item should be made public in the archive?", + "confirm": "Make it Public", + "cancel": "Cancel", + "success": "The item is now public", + "error": "An error occured while making the item public" + }, + "delete": { + "header": "Delete item: {{ id }}", + "description": "Are you sure this item should be completely deleted? Caution: At present, no tombstone would be left.", + "confirm": "Delete", + "cancel": "Cancel", + "success": "The item has been deleted", + "error": "An error occured while deleting the item" + } } }, "nav": { diff --git a/resources/images/dspace-logo.png b/resources/images/dspace-logo.png index 5bb0b36add..40b59feeaa 100644 Binary files a/resources/images/dspace-logo.png and b/resources/images/dspace-logo.png differ diff --git a/src/app/+community-page/community-page.component.ts b/src/app/+community-page/community-page.component.ts index bfaac33c1c..0483143230 100644 --- a/src/app/+community-page/community-page.component.ts +++ b/src/app/+community-page/community-page.component.ts @@ -1,4 +1,4 @@ -import { mergeMap, filter, map, first, tap } from 'rxjs/operators'; +import { mergeMap, filter, map } from 'rxjs/operators'; import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; diff --git a/src/app/+home-page/home-news/home-news.component.html b/src/app/+home-page/home-news/home-news.component.html index ffc88be574..2287329b37 100644 --- a/src/app/+home-page/home-news/home-news.component.html +++ b/src/app/+home-page/home-news/home-news.component.html @@ -1,9 +1,7 @@
-
-
- -
+
+

Welcome to DSpace

DSpace is an open source software platform that enables organisations to:

diff --git a/src/app/+home-page/home-news/home-news.component.scss b/src/app/+home-page/home-news/home-news.component.scss index a1bb43af91..3a0cbc7817 100644 --- a/src/app/+home-page/home-news/home-news.component.scss +++ b/src/app/+home-page/home-news/home-news.component.scss @@ -6,14 +6,11 @@ margin-bottom: -$content-spacing; } -.dspace-logo-container { - margin: 10px 20px 0px 20px; - .display-3 { - word-break: break-word; - } +.display-3 { + word-break: break-word; } -.dspace-logo-container img { - max-height: 110px; - max-width: 110px; +.dspace-logo { + height: 110px; + width: 110px; } diff --git a/src/app/+item-page/edit-item-page/edit-item-operators.spec.ts b/src/app/+item-page/edit-item-page/edit-item-operators.spec.ts new file mode 100644 index 0000000000..8086a62b8f --- /dev/null +++ b/src/app/+item-page/edit-item-page/edit-item-operators.spec.ts @@ -0,0 +1,35 @@ +import {RemoteData} from '../../core/data/remote-data'; +import {hot} from 'jasmine-marbles'; +import {Item} from '../../core/shared/item.model'; +import {findSuccessfulAccordingTo} from './edit-item-operators'; + +describe('findSuccessfulAccordingTo', () => { + let mockItem1; + let mockItem2; + let predicate; + + beforeEach(() => { + mockItem1 = new Item(); + mockItem1.isWithdrawn = true; + + mockItem2 = new Item(); + mockItem1.isWithdrawn = false; + + predicate = (rd: RemoteData) => rd.payload.isWithdrawn; + }); + it('should return first successful RemoteData Observable that complies to predicate', () => { + const testRD = { + a: new RemoteData(false, false, true, null, undefined), + b: new RemoteData(false, false, false, null, mockItem1), + c: new RemoteData(false, false, true, null, mockItem2), + d: new RemoteData(false, false, true, null, mockItem1), + e: new RemoteData(false, false, true, null, mockItem2), + }; + + const source = hot('abcde', testRD); + const result = source.pipe(findSuccessfulAccordingTo(predicate)); + + result.subscribe((value) => expect(value).toEqual(testRD.d)); + }); + +}); diff --git a/src/app/+item-page/edit-item-page/edit-item-operators.ts b/src/app/+item-page/edit-item-page/edit-item-operators.ts new file mode 100644 index 0000000000..26c593cac6 --- /dev/null +++ b/src/app/+item-page/edit-item-page/edit-item-operators.ts @@ -0,0 +1,13 @@ +import {RemoteData} from '../../core/data/remote-data'; +import {Observable} from 'rxjs'; +import {first} from 'rxjs/operators'; +import {getAllSucceededRemoteData} from '../../core/shared/operators'; + +/** + * Return first Observable of a RemoteData object that complies to the provided predicate + * @param predicate + */ +export const findSuccessfulAccordingTo = (predicate: (rd: RemoteData) => boolean) => + (source: Observable>): Observable> => + source.pipe(getAllSucceededRemoteData(), + first(predicate)); diff --git a/src/app/+item-page/edit-item-page/edit-item-page.component.html b/src/app/+item-page/edit-item-page/edit-item-page.component.html new file mode 100644 index 0000000000..001b484c2c --- /dev/null +++ b/src/app/+item-page/edit-item-page/edit-item-page.component.html @@ -0,0 +1,36 @@ +
+
+
+

{{'item.edit.head' | translate}}

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
diff --git a/src/app/+item-page/edit-item-page/edit-item-page.component.ts b/src/app/+item-page/edit-item-page/edit-item-page.component.ts new file mode 100644 index 0000000000..b8d3ca7957 --- /dev/null +++ b/src/app/+item-page/edit-item-page/edit-item-page.component.ts @@ -0,0 +1,35 @@ +import {fadeIn, fadeInOut} from '../../shared/animations/fade'; +import {ChangeDetectionStrategy, Component, OnInit} from '@angular/core'; +import {ActivatedRoute} from '@angular/router'; +import {RemoteData} from '../../core/data/remote-data'; +import {Item} from '../../core/shared/item.model'; +import {Observable} from 'rxjs'; +import {map} from 'rxjs/operators'; + +@Component({ + selector: 'ds-edit-item-page', + templateUrl: './edit-item-page.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + animations: [ + fadeIn, + fadeInOut + ] +}) +/** + * Page component for editing an item + */ +export class EditItemPageComponent implements OnInit { + + /** + * The item to edit + */ + itemRD$: Observable>; + + constructor(private route: ActivatedRoute) { + } + + ngOnInit(): void { + this.itemRD$ = this.route.data.pipe(map((data) => data.item)); + } + +} diff --git a/src/app/+item-page/edit-item-page/edit-item-page.module.ts b/src/app/+item-page/edit-item-page/edit-item-page.module.ts new file mode 100644 index 0000000000..0a7b363d6a --- /dev/null +++ b/src/app/+item-page/edit-item-page/edit-item-page.module.ts @@ -0,0 +1,40 @@ +import {NgModule} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {SharedModule} from '../../shared/shared.module'; +import {EditItemPageRoutingModule} from './edit-item-page.routing.module'; +import {EditItemPageComponent} from './edit-item-page.component'; +import {ItemStatusComponent} from './item-status/item-status.component'; +import {ItemOperationComponent} from './item-operation/item-operation.component'; +import {ModifyItemOverviewComponent} from './modify-item-overview/modify-item-overview.component'; +import {ItemWithdrawComponent} from './item-withdraw/item-withdraw.component'; +import {ItemReinstateComponent} from './item-reinstate/item-reinstate.component'; +import {AbstractSimpleItemActionComponent} from './simple-item-action/abstract-simple-item-action.component'; +import {ItemPrivateComponent} from './item-private/item-private.component'; +import {ItemPublicComponent} from './item-public/item-public.component'; +import {ItemDeleteComponent} from './item-delete/item-delete.component'; + +/** + * Module that contains all components related to the Edit Item page administrator functionality + */ +@NgModule({ + imports: [ + CommonModule, + SharedModule, + EditItemPageRoutingModule + ], + declarations: [ + EditItemPageComponent, + ItemOperationComponent, + AbstractSimpleItemActionComponent, + ModifyItemOverviewComponent, + ItemWithdrawComponent, + ItemReinstateComponent, + ItemPrivateComponent, + ItemPublicComponent, + ItemDeleteComponent, + ItemStatusComponent + ] +}) +export class EditItemPageModule { + +} diff --git a/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts b/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts new file mode 100644 index 0000000000..8ef6f43e17 --- /dev/null +++ b/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts @@ -0,0 +1,72 @@ +import {ItemPageResolver} from '../item-page.resolver'; +import {NgModule} from '@angular/core'; +import {RouterModule} from '@angular/router'; +import {EditItemPageComponent} from './edit-item-page.component'; +import {ItemWithdrawComponent} from './item-withdraw/item-withdraw.component'; +import {ItemReinstateComponent} from './item-reinstate/item-reinstate.component'; +import {ItemPrivateComponent} from './item-private/item-private.component'; +import {ItemPublicComponent} from './item-public/item-public.component'; +import {ItemDeleteComponent} from './item-delete/item-delete.component'; + +const ITEM_EDIT_WITHDRAW_PATH = 'withdraw'; +const ITEM_EDIT_REINSTATE_PATH = 'reinstate'; +const ITEM_EDIT_PRIVATE_PATH = 'private'; +const ITEM_EDIT_PUBLIC_PATH = 'public'; +const ITEM_EDIT_DELETE_PATH = 'delete'; + +/** + * Routing module that handles the routing for the Edit Item page administrator functionality + */ +@NgModule({ + imports: [ + RouterModule.forChild([ + { + path: '', + component: EditItemPageComponent, + resolve: { + item: ItemPageResolver + } + }, + { + path: ITEM_EDIT_WITHDRAW_PATH, + component: ItemWithdrawComponent, + resolve: { + item: ItemPageResolver + } + }, + { + path: ITEM_EDIT_REINSTATE_PATH, + component: ItemReinstateComponent, + resolve: { + item: ItemPageResolver + } + }, + { + path: ITEM_EDIT_PRIVATE_PATH, + component: ItemPrivateComponent, + resolve: { + item: ItemPageResolver + } + }, + { + path: ITEM_EDIT_PUBLIC_PATH, + component: ItemPublicComponent, + resolve: { + item: ItemPageResolver + } + }, + { + path: ITEM_EDIT_DELETE_PATH, + component: ItemDeleteComponent, + resolve: { + item: ItemPageResolver + } + }]) + ], + providers: [ + ItemPageResolver, + ] +}) +export class EditItemPageRoutingModule { + +} diff --git a/src/app/+item-page/edit-item-page/item-delete/item-delete.component.spec.ts b/src/app/+item-page/edit-item-page/item-delete/item-delete.component.spec.ts new file mode 100644 index 0000000000..c03ae7c3fe --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-delete/item-delete.component.spec.ts @@ -0,0 +1,118 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { Item } from '../../../core/shared/item.model'; +import { RouterStub } from '../../../shared/testing/router-stub'; +import { of as observableOf } from 'rxjs'; +import { RemoteData } from '../../../core/data/remote-data'; +import { NotificationsServiceStub } from '../../../shared/testing/notifications-service-stub'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { ActivatedRoute, Router } from '@angular/router'; +import { ItemDataService } from '../../../core/data/item-data.service'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { ItemDeleteComponent } from './item-delete.component'; +import { getItemEditPath } from '../../item-page-routing.module'; +import { RestResponse } from '../../../core/cache/response.models'; + +let comp: ItemDeleteComponent; +let fixture: ComponentFixture; + +let mockItem; +let itemPageUrl; +let routerStub; +let mockItemDataService: ItemDataService; +let routeStub; +let notificationsServiceStub; +let successfulRestResponse; +let failRestResponse; + +describe('ItemDeleteComponent', () => { + beforeEach(async(() => { + + mockItem = Object.assign(new Item(), { + id: 'fake-id', + handle: 'fake/handle', + lastModified: '2018', + isWithdrawn: true + }); + + itemPageUrl = `fake-url/${mockItem.id}`; + routerStub = Object.assign(new RouterStub(), { + url: `${itemPageUrl}/edit` + }); + + mockItemDataService = jasmine.createSpyObj('mockItemDataService', { + delete: observableOf(new RestResponse(true, '200')) + }); + + routeStub = { + data: observableOf({ + item: new RemoteData(false, false, true, null, { + id: 'fake-id' + }) + }) + }; + + notificationsServiceStub = new NotificationsServiceStub(); + + TestBed.configureTestingModule({ + imports: [CommonModule, FormsModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()], + declarations: [ItemDeleteComponent], + providers: [ + {provide: ActivatedRoute, useValue: routeStub}, + {provide: Router, useValue: routerStub}, + {provide: ItemDataService, useValue: mockItemDataService}, + {provide: NotificationsService, useValue: notificationsServiceStub}, + ], schemas: [ + CUSTOM_ELEMENTS_SCHEMA + ] + }).compileComponents(); + })); + + beforeEach(() => { + successfulRestResponse = new RestResponse(true, '200'); + failRestResponse = new RestResponse(false, '500'); + + fixture = TestBed.createComponent(ItemDeleteComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should render a page with messages based on the \'delete\' messageKey', () => { + const header = fixture.debugElement.query(By.css('h2')).nativeElement; + expect(header.innerHTML).toContain('item.edit.delete.header'); + const description = fixture.debugElement.query(By.css('p')).nativeElement; + expect(description.innerHTML).toContain('item.edit.delete.description'); + const confirmButton = fixture.debugElement.query(By.css('button.perform-action')).nativeElement; + expect(confirmButton.innerHTML).toContain('item.edit.delete.confirm'); + const cancelButton = fixture.debugElement.query(By.css('button.cancel')).nativeElement; + expect(cancelButton.innerHTML).toContain('item.edit.delete.cancel'); + }); + + describe('performAction', () => { + it('should call delete function from the ItemDataService', () => { + spyOn(comp, 'processRestResponse'); + comp.performAction(); + + expect(mockItemDataService.delete).toHaveBeenCalledWith(mockItem.id); + expect(comp.processRestResponse).toHaveBeenCalled(); + }); + }); + describe('processRestResponse', () => { + it('should navigate to the homepage on successful deletion of the item', () => { + comp.processRestResponse(successfulRestResponse); + expect(routerStub.navigate).toHaveBeenCalledWith(['']); + }); + }); + describe('processRestResponse', () => { + it('should navigate to the item edit page on failed deletion of the item', () => { + comp.processRestResponse(failRestResponse); + expect(routerStub.navigate).toHaveBeenCalledWith([getItemEditPath('fake-id')]); + }); + }); +}) +; diff --git a/src/app/+item-page/edit-item-page/item-delete/item-delete.component.ts b/src/app/+item-page/edit-item-page/item-delete/item-delete.component.ts new file mode 100644 index 0000000000..95f25c67bc --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-delete/item-delete.component.ts @@ -0,0 +1,43 @@ +import { Component } from '@angular/core'; +import { first } from 'rxjs/operators'; +import { AbstractSimpleItemActionComponent } from '../simple-item-action/abstract-simple-item-action.component'; +import { getItemEditPath } from '../../item-page-routing.module'; +import { RestResponse } from '../../../core/cache/response.models'; + +@Component({ + selector: 'ds-item-delete', + templateUrl: '../simple-item-action/abstract-simple-item-action.component.html' +}) +/** + * Component responsible for rendering the item delete page + */ +export class ItemDeleteComponent extends AbstractSimpleItemActionComponent { + + protected messageKey = 'delete'; + + /** + * Perform the delete action to the item + */ + performAction() { + this.itemDataService.delete(this.item.id).pipe(first()).subscribe( + (response: RestResponse) => { + this.processRestResponse(response); + } + ); + } + + /** + * Process the RestResponse retrieved from the server. + * When the item is successfully delete, navigate to the homepage, otherwise navigate back to the item edit page + * @param response + */ + processRestResponse(response: RestResponse) { + if (response.isSuccessful) { + this.notificationsService.success(this.translateService.get('item.edit.' + this.messageKey + '.success')); + this.router.navigate(['']); + } else { + this.notificationsService.error(this.translateService.get('item.edit.' + this.messageKey + '.error')); + this.router.navigate([getItemEditPath(this.item.id)]); + } + } +} diff --git a/src/app/+item-page/edit-item-page/item-operation/item-operation.component.html b/src/app/+item-page/edit-item-page/item-operation/item-operation.component.html new file mode 100644 index 0000000000..4623195437 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-operation/item-operation.component.html @@ -0,0 +1,15 @@ +
+ + {{'item.edit.tabs.status.buttons.' + operation.operationKey + '.label' | translate}} + +
+ +
+ + {{'item.edit.tabs.status.buttons.' + operation.operationKey + '.button' | translate}} + +
\ No newline at end of file diff --git a/src/app/+item-page/edit-item-page/item-operation/item-operation.component.spec.ts b/src/app/+item-page/edit-item-page/item-operation/item-operation.component.spec.ts new file mode 100644 index 0000000000..1901bf5fb4 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-operation/item-operation.component.spec.ts @@ -0,0 +1,45 @@ +import {ItemOperation} from './itemOperation.model'; +import {async, TestBed} from '@angular/core/testing'; +import {ItemOperationComponent} from './item-operation.component'; +import {TranslateModule} from '@ngx-translate/core'; +import {By} from '@angular/platform-browser'; + +describe('ItemOperationComponent', () => { + let itemOperation: ItemOperation; + + let fixture; + let comp; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [ItemOperationComponent] + }).compileComponents(); + })); + + beforeEach(() => { + itemOperation = new ItemOperation('key1', 'url1'); + + fixture = TestBed.createComponent(ItemOperationComponent); + comp = fixture.componentInstance; + comp.operation = itemOperation; + fixture.detectChanges(); + }); + + it('should render operation row', () => { + const span = fixture.debugElement.query(By.css('span')).nativeElement; + expect(span.textContent).toContain('item.edit.tabs.status.buttons.key1.label'); + const link = fixture.debugElement.query(By.css('a')).nativeElement; + expect(link.href).toContain('url1'); + expect(link.textContent).toContain('item.edit.tabs.status.buttons.key1.button'); + }); + it('should render disabled operation row', () => { + itemOperation.setDisabled(true); + fixture.detectChanges(); + + const span = fixture.debugElement.query(By.css('span')).nativeElement; + expect(span.textContent).toContain('item.edit.tabs.status.buttons.key1.label'); + const span2 = fixture.debugElement.query(By.css('span.btn-danger')).nativeElement; + expect(span2.textContent).toContain('item.edit.tabs.status.buttons.key1.button'); + }); +}); diff --git a/src/app/+item-page/edit-item-page/item-operation/item-operation.component.ts b/src/app/+item-page/edit-item-page/item-operation/item-operation.component.ts new file mode 100644 index 0000000000..76d056df95 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-operation/item-operation.component.ts @@ -0,0 +1,15 @@ +import {Component, Input} from '@angular/core'; +import {ItemOperation} from './itemOperation.model'; + +@Component({ + selector: 'ds-item-operation', + templateUrl: './item-operation.component.html' +}) +/** + * Operation that can be performed on an item + */ +export class ItemOperationComponent { + + @Input() operation: ItemOperation; + +} diff --git a/src/app/+item-page/edit-item-page/item-operation/itemOperation.model.ts b/src/app/+item-page/edit-item-page/item-operation/itemOperation.model.ts new file mode 100644 index 0000000000..105889d42d --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-operation/itemOperation.model.ts @@ -0,0 +1,25 @@ +/** + * Represents an item operation used on the edit item page with a key, an operation URL to which will be navigated + * when performing the action and an option to disable the operation. + */ +export class ItemOperation { + + operationKey: string; + operationUrl: string; + disabled: boolean; + + constructor(operationKey: string, operationUrl: string) { + this.operationKey = operationKey; + this.operationUrl = operationUrl; + this.setDisabled(false); + } + + /** + * Set whether this operation should be disabled + * @param disabled + */ + setDisabled(disabled: boolean): void { + this.disabled = disabled; + } + +} diff --git a/src/app/+item-page/edit-item-page/item-private/item-private.component.spec.ts b/src/app/+item-page/edit-item-page/item-private/item-private.component.spec.ts new file mode 100644 index 0000000000..9f9447704b --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-private/item-private.component.spec.ts @@ -0,0 +1,105 @@ +import {async, ComponentFixture, TestBed} from '@angular/core/testing'; +import {Item} from '../../../core/shared/item.model'; +import {RouterStub} from '../../../shared/testing/router-stub'; +import {of as observableOf} from 'rxjs'; +import {RemoteData} from '../../../core/data/remote-data'; +import {NotificationsServiceStub} from '../../../shared/testing/notifications-service-stub'; +import {CommonModule} from '@angular/common'; +import {FormsModule} from '@angular/forms'; +import {RouterTestingModule} from '@angular/router/testing'; +import {TranslateModule} from '@ngx-translate/core'; +import {NgbModule} from '@ng-bootstrap/ng-bootstrap'; +import {ActivatedRoute, Router} from '@angular/router'; +import {ItemDataService} from '../../../core/data/item-data.service'; +import {NotificationsService} from '../../../shared/notifications/notifications.service'; +import {CUSTOM_ELEMENTS_SCHEMA} from '@angular/core'; +import {By} from '@angular/platform-browser'; +import {ItemPrivateComponent} from './item-private.component'; +import { RestResponse } from '../../../core/cache/response.models'; + +let comp: ItemPrivateComponent; +let fixture: ComponentFixture; + +let mockItem; +let itemPageUrl; +let routerStub; +let mockItemDataService: ItemDataService; +let routeStub; +let notificationsServiceStub; +let successfulRestResponse; +let failRestResponse; + +describe('ItemPrivateComponent', () => { + beforeEach(async(() => { + + mockItem = Object.assign(new Item(), { + id: 'fake-id', + handle: 'fake/handle', + lastModified: '2018', + isWithdrawn: true + }); + + itemPageUrl = `fake-url/${mockItem.id}`; + routerStub = Object.assign(new RouterStub(), { + url: `${itemPageUrl}/edit` + }); + + mockItemDataService = jasmine.createSpyObj('mockItemDataService',{ + setDiscoverable: observableOf(new RestResponse(true, '200')) + }); + + routeStub = { + data: observableOf({ + item: new RemoteData(false, false, true, null, { + id: 'fake-id' + }) + }) + }; + + notificationsServiceStub = new NotificationsServiceStub(); + + TestBed.configureTestingModule({ + imports: [CommonModule, FormsModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()], + declarations: [ItemPrivateComponent], + providers: [ + {provide: ActivatedRoute, useValue: routeStub}, + {provide: Router, useValue: routerStub}, + {provide: ItemDataService, useValue: mockItemDataService}, + {provide: NotificationsService, useValue: notificationsServiceStub}, + ], schemas: [ + CUSTOM_ELEMENTS_SCHEMA + ] + }).compileComponents(); + })); + + beforeEach(() => { + successfulRestResponse = new RestResponse(true, '200'); + failRestResponse = new RestResponse(false, '500'); + + fixture = TestBed.createComponent(ItemPrivateComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should render a page with messages based on the \'private\' messageKey', () => { + const header = fixture.debugElement.query(By.css('h2')).nativeElement; + expect(header.innerHTML).toContain('item.edit.private.header'); + const description = fixture.debugElement.query(By.css('p')).nativeElement; + expect(description.innerHTML).toContain('item.edit.private.description'); + const confirmButton = fixture.debugElement.query(By.css('button.perform-action')).nativeElement; + expect(confirmButton.innerHTML).toContain('item.edit.private.confirm'); + const cancelButton = fixture.debugElement.query(By.css('button.cancel')).nativeElement; + expect(cancelButton.innerHTML).toContain('item.edit.private.cancel'); + }); + + describe('performAction', () => { + it('should call setDiscoverable function from the ItemDataService', () => { + spyOn(comp, 'processRestResponse'); + comp.performAction(); + + expect(mockItemDataService.setDiscoverable).toHaveBeenCalledWith(mockItem.id, false); + expect(comp.processRestResponse).toHaveBeenCalled(); + }); + }); +}) +; diff --git a/src/app/+item-page/edit-item-page/item-private/item-private.component.ts b/src/app/+item-page/edit-item-page/item-private/item-private.component.ts new file mode 100644 index 0000000000..d949e4fa6e --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-private/item-private.component.ts @@ -0,0 +1,30 @@ +import { Component } from '@angular/core'; +import { first } from 'rxjs/operators'; +import { AbstractSimpleItemActionComponent } from '../simple-item-action/abstract-simple-item-action.component'; +import { RemoteData } from '../../../core/data/remote-data'; +import { Item } from '../../../core/shared/item.model'; +import { RestResponse } from '../../../core/cache/response.models'; + +@Component({ + selector: 'ds-item-private', + templateUrl: '../simple-item-action/abstract-simple-item-action.component.html' +}) +/** + * Component responsible for rendering the make item private page + */ +export class ItemPrivateComponent extends AbstractSimpleItemActionComponent { + + protected messageKey = 'private'; + protected predicate = (rd: RemoteData) => !rd.payload.isDiscoverable; + + /** + * Perform the make private action to the item + */ + performAction() { + this.itemDataService.setDiscoverable(this.item.id, false).pipe(first()).subscribe( + (response: RestResponse) => { + this.processRestResponse(response); + } + ); + } +} diff --git a/src/app/+item-page/edit-item-page/item-public/item-public.component.spec.ts b/src/app/+item-page/edit-item-page/item-public/item-public.component.spec.ts new file mode 100644 index 0000000000..97c81681d0 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-public/item-public.component.spec.ts @@ -0,0 +1,105 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { Item } from '../../../core/shared/item.model'; +import { RouterStub } from '../../../shared/testing/router-stub'; +import { of as observableOf } from 'rxjs'; +import { RemoteData } from '../../../core/data/remote-data'; +import { NotificationsServiceStub } from '../../../shared/testing/notifications-service-stub'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { ActivatedRoute, Router } from '@angular/router'; +import { ItemDataService } from '../../../core/data/item-data.service'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { ItemPublicComponent } from './item-public.component'; +import { RestResponse } from '../../../core/cache/response.models'; + +let comp: ItemPublicComponent; +let fixture: ComponentFixture; + +let mockItem; +let itemPageUrl; +let routerStub; +let mockItemDataService: ItemDataService; +let routeStub; +let notificationsServiceStub; +let successfulRestResponse; +let failRestResponse; + +describe('ItemPublicComponent', () => { + beforeEach(async(() => { + + mockItem = Object.assign(new Item(), { + id: 'fake-id', + handle: 'fake/handle', + lastModified: '2018', + isWithdrawn: true + }); + + itemPageUrl = `fake-url/${mockItem.id}`; + routerStub = Object.assign(new RouterStub(), { + url: `${itemPageUrl}/edit` + }); + + mockItemDataService = jasmine.createSpyObj('mockItemDataService',{ + setDiscoverable: observableOf(new RestResponse(true, '200')) + }); + + routeStub = { + data: observableOf({ + item: new RemoteData(false, false, true, null, { + id: 'fake-id' + }) + }) + }; + + notificationsServiceStub = new NotificationsServiceStub(); + + TestBed.configureTestingModule({ + imports: [CommonModule, FormsModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()], + declarations: [ItemPublicComponent], + providers: [ + {provide: ActivatedRoute, useValue: routeStub}, + {provide: Router, useValue: routerStub}, + {provide: ItemDataService, useValue: mockItemDataService}, + {provide: NotificationsService, useValue: notificationsServiceStub}, + ], schemas: [ + CUSTOM_ELEMENTS_SCHEMA + ] + }).compileComponents(); + })); + + beforeEach(() => { + successfulRestResponse = new RestResponse(true, '200'); + failRestResponse = new RestResponse(false, '500'); + + fixture = TestBed.createComponent(ItemPublicComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should render a page with messages based on the \'public\' messageKey', () => { + const header = fixture.debugElement.query(By.css('h2')).nativeElement; + expect(header.innerHTML).toContain('item.edit.public.header'); + const description = fixture.debugElement.query(By.css('p')).nativeElement; + expect(description.innerHTML).toContain('item.edit.public.description'); + const confirmButton = fixture.debugElement.query(By.css('button.perform-action')).nativeElement; + expect(confirmButton.innerHTML).toContain('item.edit.public.confirm'); + const cancelButton = fixture.debugElement.query(By.css('button.cancel')).nativeElement; + expect(cancelButton.innerHTML).toContain('item.edit.public.cancel'); + }); + + describe('performAction', () => { + it('should call setDiscoverable function from the ItemDataService', () => { + spyOn(comp, 'processRestResponse'); + comp.performAction(); + + expect(mockItemDataService.setDiscoverable).toHaveBeenCalledWith(mockItem.id, true); + expect(comp.processRestResponse).toHaveBeenCalled(); + }); + }); +}) +; diff --git a/src/app/+item-page/edit-item-page/item-public/item-public.component.ts b/src/app/+item-page/edit-item-page/item-public/item-public.component.ts new file mode 100644 index 0000000000..272cf9a96f --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-public/item-public.component.ts @@ -0,0 +1,30 @@ +import { Component } from '@angular/core'; +import { first } from 'rxjs/operators'; +import { AbstractSimpleItemActionComponent } from '../simple-item-action/abstract-simple-item-action.component'; +import { RemoteData } from '../../../core/data/remote-data'; +import { Item } from '../../../core/shared/item.model'; +import { RestResponse } from '../../../core/cache/response.models'; + +@Component({ + selector: 'ds-item-public', + templateUrl: '../simple-item-action/abstract-simple-item-action.component.html' +}) +/** + * Component responsible for rendering the make item public page + */ +export class ItemPublicComponent extends AbstractSimpleItemActionComponent { + + protected messageKey = 'public'; + protected predicate = (rd: RemoteData) => rd.payload.isDiscoverable; + + /** + * Perform the make public action to the item + */ + performAction() { + this.itemDataService.setDiscoverable(this.item.id, true).pipe(first()).subscribe( + (response: RestResponse) => { + this.processRestResponse(response); + } + ); + } +} diff --git a/src/app/+item-page/edit-item-page/item-reinstate/item-reinstate.component.spec.ts b/src/app/+item-page/edit-item-page/item-reinstate/item-reinstate.component.spec.ts new file mode 100644 index 0000000000..e89eda736f --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-reinstate/item-reinstate.component.spec.ts @@ -0,0 +1,105 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { Item } from '../../../core/shared/item.model'; +import { RouterStub } from '../../../shared/testing/router-stub'; +import { of as observableOf } from 'rxjs'; +import { RemoteData } from '../../../core/data/remote-data'; +import { NotificationsServiceStub } from '../../../shared/testing/notifications-service-stub'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { ActivatedRoute, Router } from '@angular/router'; +import { ItemDataService } from '../../../core/data/item-data.service'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { ItemReinstateComponent } from './item-reinstate.component'; +import { RestResponse } from '../../../core/cache/response.models'; + +let comp: ItemReinstateComponent; +let fixture: ComponentFixture; + +let mockItem; +let itemPageUrl; +let routerStub; +let mockItemDataService: ItemDataService; +let routeStub; +let notificationsServiceStub; +let successfulRestResponse; +let failRestResponse; + +describe('ItemReinstateComponent', () => { + beforeEach(async(() => { + + mockItem = Object.assign(new Item(), { + id: 'fake-id', + handle: 'fake/handle', + lastModified: '2018', + isWithdrawn: true + }); + + itemPageUrl = `fake-url/${mockItem.id}`; + routerStub = Object.assign(new RouterStub(), { + url: `${itemPageUrl}/edit` + }); + + mockItemDataService = jasmine.createSpyObj('mockItemDataService',{ + setWithDrawn: observableOf(new RestResponse(true, '200')) + }); + + routeStub = { + data: observableOf({ + item: new RemoteData(false, false, true, null, { + id: 'fake-id' + }) + }) + }; + + notificationsServiceStub = new NotificationsServiceStub(); + + TestBed.configureTestingModule({ + imports: [CommonModule, FormsModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()], + declarations: [ItemReinstateComponent], + providers: [ + {provide: ActivatedRoute, useValue: routeStub}, + {provide: Router, useValue: routerStub}, + {provide: ItemDataService, useValue: mockItemDataService}, + {provide: NotificationsService, useValue: notificationsServiceStub}, + ], schemas: [ + CUSTOM_ELEMENTS_SCHEMA + ] + }).compileComponents(); + })); + + beforeEach(() => { + successfulRestResponse = new RestResponse(true, '200'); + failRestResponse = new RestResponse(false, '500'); + + fixture = TestBed.createComponent(ItemReinstateComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should render a page with messages based on the \'reinstate\' messageKey', () => { + const header = fixture.debugElement.query(By.css('h2')).nativeElement; + expect(header.innerHTML).toContain('item.edit.reinstate.header'); + const description = fixture.debugElement.query(By.css('p')).nativeElement; + expect(description.innerHTML).toContain('item.edit.reinstate.description'); + const confirmButton = fixture.debugElement.query(By.css('button.perform-action')).nativeElement; + expect(confirmButton.innerHTML).toContain('item.edit.reinstate.confirm'); + const cancelButton = fixture.debugElement.query(By.css('button.cancel')).nativeElement; + expect(cancelButton.innerHTML).toContain('item.edit.reinstate.cancel'); + }); + + describe('performAction', () => { + it('should call setWithdrawn function from the ItemDataService', () => { + spyOn(comp, 'processRestResponse'); + comp.performAction(); + + expect(mockItemDataService.setWithDrawn).toHaveBeenCalledWith(mockItem.id, false); + expect(comp.processRestResponse).toHaveBeenCalled(); + }); + }); +}) +; diff --git a/src/app/+item-page/edit-item-page/item-reinstate/item-reinstate.component.ts b/src/app/+item-page/edit-item-page/item-reinstate/item-reinstate.component.ts new file mode 100644 index 0000000000..9c0e1c8d05 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-reinstate/item-reinstate.component.ts @@ -0,0 +1,30 @@ +import { Component } from '@angular/core'; +import { first } from 'rxjs/operators'; +import { AbstractSimpleItemActionComponent } from '../simple-item-action/abstract-simple-item-action.component'; +import { RemoteData } from '../../../core/data/remote-data'; +import { Item } from '../../../core/shared/item.model'; +import { RestResponse } from '../../../core/cache/response.models'; + +@Component({ + selector: 'ds-item-reinstate', + templateUrl: '../simple-item-action/abstract-simple-item-action.component.html' +}) +/** + * Component responsible for rendering the Item Reinstate page + */ +export class ItemReinstateComponent extends AbstractSimpleItemActionComponent { + + protected messageKey = 'reinstate'; + protected predicate = (rd: RemoteData) => !rd.payload.isWithdrawn; + + /** + * Perform the reinstate action to the item + */ + performAction() { + this.itemDataService.setWithDrawn(this.item.id, false).pipe(first()).subscribe( + (response: RestResponse) => { + this.processRestResponse(response); + } + ); + } +} diff --git a/src/app/+item-page/edit-item-page/item-status/item-status.component.html b/src/app/+item-page/edit-item-page/item-status/item-status.component.html new file mode 100644 index 0000000000..0f7d9a5607 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-status/item-status.component.html @@ -0,0 +1,21 @@ +

{{'item.edit.tabs.status.description' | translate}}

+
+
+
+ {{'item.edit.tabs.status.labels.' + statusKey | translate}}: +
+
+ {{statusData[statusKey]}} +
+
+
+ {{'item.edit.tabs.status.labels.itemPage' | translate}}: +
+ + +
+ +
+
diff --git a/src/app/+item-page/edit-item-page/item-status/item-status.component.spec.ts b/src/app/+item-page/edit-item-page/item-status/item-status.component.spec.ts new file mode 100644 index 0000000000..319d4c47ae --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-status/item-status.component.spec.ts @@ -0,0 +1,68 @@ +import { ItemStatusComponent } from './item-status.component'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { CommonModule } from '@angular/common'; +import { HostWindowServiceStub } from '../../../shared/testing/host-window-service-stub'; +import { HostWindowService } from '../../../shared/host-window.service'; +import { RouterTestingModule } from '@angular/router/testing'; +import { Router } from '@angular/router'; +import { RouterStub } from '../../../shared/testing/router-stub'; +import { Item } from '../../../core/shared/item.model'; +import { By } from '@angular/platform-browser'; +import {CUSTOM_ELEMENTS_SCHEMA} from '@angular/core'; + +describe('ItemStatusComponent', () => { + let comp: ItemStatusComponent; + let fixture: ComponentFixture; + + const mockItem = Object.assign(new Item(), { + id: 'fake-id', + handle: 'fake/handle', + lastModified: '2018' + }); + + const itemPageUrl = `fake-url/${mockItem.id}`; + const routerStub = Object.assign(new RouterStub(), { + url: `${itemPageUrl}/edit` + }); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()], + declarations: [ItemStatusComponent], + providers: [ + { provide: Router, useValue: routerStub }, + { provide: HostWindowService, useValue: new HostWindowServiceStub(0) } + ], schemas: [CUSTOM_ELEMENTS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ItemStatusComponent); + comp = fixture.componentInstance; + comp.item = mockItem; + fixture.detectChanges(); + }); + + it('should display the item\'s internal id', () => { + const statusId: HTMLElement = fixture.debugElement.query(By.css('.status-data#status-id')).nativeElement; + expect(statusId.textContent).toContain(mockItem.id); + }); + + it('should display the item\'s handle', () => { + const statusHandle: HTMLElement = fixture.debugElement.query(By.css('.status-data#status-handle')).nativeElement; + expect(statusHandle.textContent).toContain(mockItem.handle); + }); + + it('should display the item\'s last modified date', () => { + const statusLastModified: HTMLElement = fixture.debugElement.query(By.css('.status-data#status-lastModified')).nativeElement; + expect(statusLastModified.textContent).toContain(mockItem.lastModified); + }); + + it('should display the item\'s page url', () => { + const statusItemPage: HTMLElement = fixture.debugElement.query(By.css('.status-data#status-itemPage')).nativeElement; + expect(statusItemPage.textContent).toContain(itemPageUrl); + }); + +}); diff --git a/src/app/+item-page/edit-item-page/item-status/item-status.component.ts b/src/app/+item-page/edit-item-page/item-status/item-status.component.ts new file mode 100644 index 0000000000..2b2c7a2ed4 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-status/item-status.component.ts @@ -0,0 +1,95 @@ +import {ChangeDetectionStrategy, Component, Input, OnInit} from '@angular/core'; +import {fadeIn, fadeInOut} from '../../../shared/animations/fade'; +import {Item} from '../../../core/shared/item.model'; +import {Router} from '@angular/router'; +import {ItemOperation} from '../item-operation/itemOperation.model'; + +@Component({ + selector: 'ds-item-status', + templateUrl: './item-status.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + animations: [ + fadeIn, + fadeInOut + ] +}) +/** + * Component for displaying an item's status + */ +export class ItemStatusComponent implements OnInit { + + /** + * The item to display the status for + */ + @Input() item: Item; + + /** + * The data to show in the status + */ + statusData: any; + /** + * The keys of the data (to loop over) + */ + statusDataKeys; + + /** + * The possible actions that can be performed on the item + * key: id value: url to action's component + */ + operations: ItemOperation[]; + /** + * The keys of the actions (to loop over) + */ + actionsKeys; + + constructor(private router: Router) { + } + + ngOnInit(): void { + this.statusData = Object.assign({ + id: this.item.id, + handle: this.item.handle, + lastModified: this.item.lastModified + }); + this.statusDataKeys = Object.keys(this.statusData); + + /* + The key is used to build messages + i18n example: 'item.edit.tabs.status.buttons..label' + The value is supposed to be a href for the button + */ + this.operations = []; + if (this.item.isWithdrawn) { + this.operations.push(new ItemOperation('reinstate', this.getCurrentUrl() + '/reinstate')); + } else { + this.operations.push(new ItemOperation('withdraw', this.getCurrentUrl() + '/withdraw')); + } + if (this.item.isDiscoverable) { + this.operations.push(new ItemOperation('private', this.getCurrentUrl() + '/private')); + } else { + this.operations.push(new ItemOperation('public', this.getCurrentUrl() + '/public')); + } + this.operations.push(new ItemOperation('delete', this.getCurrentUrl() + '/delete')); + } + + /** + * Get the url to the simple item page + * @returns {string} url + */ + getItemPage(): string { + return this.router.url.substr(0, this.router.url.lastIndexOf('/')); + } + + /** + * Get the current url without query params + * @returns {string} url + */ + getCurrentUrl(): string { + if (this.router.url.indexOf('?') > -1) { + return this.router.url.substr(0, this.router.url.indexOf('?')); + } else { + return this.router.url; + } + } + +} diff --git a/src/app/+item-page/edit-item-page/item-withdraw/item-withdraw.component.spec.ts b/src/app/+item-page/edit-item-page/item-withdraw/item-withdraw.component.spec.ts new file mode 100644 index 0000000000..9305459c12 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-withdraw/item-withdraw.component.spec.ts @@ -0,0 +1,105 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { Item } from '../../../core/shared/item.model'; +import { RouterStub } from '../../../shared/testing/router-stub'; +import { of as observableOf } from 'rxjs'; +import { RemoteData } from '../../../core/data/remote-data'; +import { NotificationsServiceStub } from '../../../shared/testing/notifications-service-stub'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { ActivatedRoute, Router } from '@angular/router'; +import { ItemDataService } from '../../../core/data/item-data.service'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { ItemWithdrawComponent } from './item-withdraw.component'; +import { By } from '@angular/platform-browser'; +import { RestResponse } from '../../../core/cache/response.models'; + +let comp: ItemWithdrawComponent; +let fixture: ComponentFixture; + +let mockItem; +let itemPageUrl; +let routerStub; +let mockItemDataService: ItemDataService; +let routeStub; +let notificationsServiceStub; +let successfulRestResponse; +let failRestResponse; + +describe('ItemWithdrawComponent', () => { + beforeEach(async(() => { + + mockItem = Object.assign(new Item(), { + id: 'fake-id', + handle: 'fake/handle', + lastModified: '2018', + isWithdrawn: true + }); + + itemPageUrl = `fake-url/${mockItem.id}`; + routerStub = Object.assign(new RouterStub(), { + url: `${itemPageUrl}/edit` + }); + + mockItemDataService = jasmine.createSpyObj('mockItemDataService',{ + setWithDrawn: observableOf(new RestResponse(true, '200')) + }); + + routeStub = { + data: observableOf({ + item: new RemoteData(false, false, true, null, { + id: 'fake-id' + }) + }) + }; + + notificationsServiceStub = new NotificationsServiceStub(); + + TestBed.configureTestingModule({ + imports: [CommonModule, FormsModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot(),], + declarations: [ItemWithdrawComponent], + providers: [ + {provide: ActivatedRoute, useValue: routeStub}, + {provide: Router, useValue: routerStub}, + {provide: ItemDataService, useValue: mockItemDataService}, + {provide: NotificationsService, useValue: notificationsServiceStub}, + ], schemas: [ + CUSTOM_ELEMENTS_SCHEMA + ] + }).compileComponents(); + })); + + beforeEach(() => { + successfulRestResponse = new RestResponse(true, '200'); + failRestResponse = new RestResponse(false, '500'); + + fixture = TestBed.createComponent(ItemWithdrawComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should render a page with messages based on the \'withdraw\' messageKey', () => { + const header = fixture.debugElement.query(By.css('h2')).nativeElement; + expect(header.innerHTML).toContain('item.edit.withdraw.header'); + const description = fixture.debugElement.query(By.css('p')).nativeElement; + expect(description.innerHTML).toContain('item.edit.withdraw.description'); + const confirmButton = fixture.debugElement.query(By.css('button.perform-action')).nativeElement; + expect(confirmButton.innerHTML).toContain('item.edit.withdraw.confirm'); + const cancelButton = fixture.debugElement.query(By.css('button.cancel')).nativeElement; + expect(cancelButton.innerHTML).toContain('item.edit.withdraw.cancel'); + }); + + describe('performAction', () => { + it('should call setWithdrawn function from the ItemDataService', () => { + spyOn(comp, 'processRestResponse'); + comp.performAction(); + + expect(mockItemDataService.setWithDrawn).toHaveBeenCalledWith(mockItem.id, true); + expect(comp.processRestResponse).toHaveBeenCalled(); + }); + }); +}) +; diff --git a/src/app/+item-page/edit-item-page/item-withdraw/item-withdraw.component.ts b/src/app/+item-page/edit-item-page/item-withdraw/item-withdraw.component.ts new file mode 100644 index 0000000000..1fed1756a4 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-withdraw/item-withdraw.component.ts @@ -0,0 +1,30 @@ +import { Component } from '@angular/core'; +import { first } from 'rxjs/operators'; +import { AbstractSimpleItemActionComponent } from '../simple-item-action/abstract-simple-item-action.component'; +import { RemoteData } from '../../../core/data/remote-data'; +import { Item } from '../../../core/shared/item.model'; +import { RestResponse } from '../../../core/cache/response.models'; + +@Component({ + selector: 'ds-item-withdraw', + templateUrl: '../simple-item-action/abstract-simple-item-action.component.html' +}) +/** + * Component responsible for rendering the Item Withdraw page + */ +export class ItemWithdrawComponent extends AbstractSimpleItemActionComponent { + + protected messageKey = 'withdraw'; + protected predicate = (rd: RemoteData) => rd.payload.isWithdrawn; + + /** + * Perform the withdraw action to the item + */ + performAction() { + this.itemDataService.setWithDrawn(this.item.id, true).pipe(first()).subscribe( + (response: RestResponse) => { + this.processRestResponse(response); + } + ); + } +} diff --git a/src/app/+item-page/edit-item-page/modify-item-overview/modify-item-overview.component.html b/src/app/+item-page/edit-item-page/modify-item-overview/modify-item-overview.component.html new file mode 100644 index 0000000000..d59d29ddbf --- /dev/null +++ b/src/app/+item-page/edit-item-page/modify-item-overview/modify-item-overview.component.html @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + +
{{'item.edit.modify.overview.field'| translate}}{{'item.edit.modify.overview.value'| translate}}{{'item.edit.modify.overview.language'| translate}}
\ No newline at end of file diff --git a/src/app/+item-page/edit-item-page/modify-item-overview/modify-item-overview.component.spec.ts b/src/app/+item-page/edit-item-page/modify-item-overview/modify-item-overview.component.spec.ts new file mode 100644 index 0000000000..942357dc5a --- /dev/null +++ b/src/app/+item-page/edit-item-page/modify-item-overview/modify-item-overview.component.spec.ts @@ -0,0 +1,55 @@ +import {Item} from '../../../core/shared/item.model'; +import {async, ComponentFixture, TestBed} from '@angular/core/testing'; +import {ModifyItemOverviewComponent} from './modify-item-overview.component'; +import {By} from '@angular/platform-browser'; +import {TranslateModule} from '@ngx-translate/core'; + +let comp: ModifyItemOverviewComponent; +let fixture: ComponentFixture; + +const mockItem = Object.assign(new Item(), { + id: 'fake-id', + handle: 'fake/handle', + lastModified: '2018', + metadata: [ + {key: 'dc.title', value: 'Mock item title', language: 'en'}, + {key: 'dc.contributor.author', value: 'Mayer, Ed', language: ''} + ] +}); + +describe('ModifyItemOverviewComponent', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [ModifyItemOverviewComponent], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ModifyItemOverviewComponent); + comp = fixture.componentInstance; + comp.item = mockItem; + + fixture.detectChanges(); + }); + it('should render a table of existing metadata fields in the item', () => { + + const metadataRows = fixture.debugElement.queryAll(By.css('tr.metadata-row')); + expect(metadataRows.length).toEqual(2); + + const titleRow = metadataRows[0].queryAll(By.css('td')); + expect(titleRow.length).toEqual(3); + + expect(titleRow[0].nativeElement.innerHTML).toContain('dc.title'); + expect(titleRow[1].nativeElement.innerHTML).toContain('Mock item title'); + expect(titleRow[2].nativeElement.innerHTML).toContain('en'); + + const authorRow = metadataRows[1].queryAll(By.css('td')); + expect(authorRow.length).toEqual(3); + + expect(authorRow[0].nativeElement.innerHTML).toContain('dc.contributor.author'); + expect(authorRow[1].nativeElement.innerHTML).toContain('Mayer, Ed'); + expect(authorRow[2].nativeElement.innerHTML).toEqual(''); + + }); +}); diff --git a/src/app/+item-page/edit-item-page/modify-item-overview/modify-item-overview.component.ts b/src/app/+item-page/edit-item-page/modify-item-overview/modify-item-overview.component.ts new file mode 100644 index 0000000000..d32a98d5e0 --- /dev/null +++ b/src/app/+item-page/edit-item-page/modify-item-overview/modify-item-overview.component.ts @@ -0,0 +1,20 @@ +import {Component, Input, OnInit} from '@angular/core'; +import {Item} from '../../../core/shared/item.model'; +import {Metadatum} from '../../../core/shared/metadatum.model'; + +@Component({ + selector: 'ds-modify-item-overview', + templateUrl: './modify-item-overview.component.html' +}) +/** + * Component responsible for rendering a table containing the metadatavalues from the to be edited item + */ +export class ModifyItemOverviewComponent implements OnInit { + + @Input() item: Item; + metadata: Metadatum[]; + + ngOnInit(): void { + this.metadata = this.item.metadata; + } +} diff --git a/src/app/+item-page/edit-item-page/simple-item-action/abstract-simple-item-action.component.html b/src/app/+item-page/edit-item-page/simple-item-action/abstract-simple-item-action.component.html new file mode 100644 index 0000000000..fef76231c6 --- /dev/null +++ b/src/app/+item-page/edit-item-page/simple-item-action/abstract-simple-item-action.component.html @@ -0,0 +1,16 @@ +
+
+
+

{{headerMessage | translate: {id: item.handle} }}

+

{{descriptionMessage | translate}}

+ + + + +
+
+ +
\ No newline at end of file diff --git a/src/app/+item-page/edit-item-page/simple-item-action/abstract-simple-item-action.component.spec.ts b/src/app/+item-page/edit-item-page/simple-item-action/abstract-simple-item-action.component.spec.ts new file mode 100644 index 0000000000..1c4cae552e --- /dev/null +++ b/src/app/+item-page/edit-item-page/simple-item-action/abstract-simple-item-action.component.spec.ts @@ -0,0 +1,142 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { Item } from '../../../core/shared/item.model'; +import { RouterStub } from '../../../shared/testing/router-stub'; +import { CommonModule } from '@angular/common'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { ActivatedRoute, Router } from '@angular/router'; +import { NotificationsServiceStub } from '../../../shared/testing/notifications-service-stub'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { ItemDataService } from '../../../core/data/item-data.service'; +import { RemoteData } from '../../../core/data/remote-data'; +import { AbstractSimpleItemActionComponent } from './abstract-simple-item-action.component'; +import { By } from '@angular/platform-browser'; +import { of as observableOf } from 'rxjs'; +import { getItemEditPath } from '../../item-page-routing.module'; +import { RestResponse } from '../../../core/cache/response.models'; + +/** + * Test component that implements the AbstractSimpleItemActionComponent used to test the + * AbstractSimpleItemActionComponent component + */ +@Component({ + selector: 'ds-simple-action', + templateUrl: './abstract-simple-item-action.component.html' +}) +export class MySimpleItemActionComponent extends AbstractSimpleItemActionComponent { + + protected messageKey = 'myEditAction'; + protected predicate = (rd: RemoteData) => rd.payload.isWithdrawn; + + performAction() { + // do nothing + } + +} + +let comp: MySimpleItemActionComponent; +let fixture: ComponentFixture; + +let mockItem; +let itemPageUrl; +let routerStub; +let mockItemDataService; +let routeStub; +let notificationsServiceStub; +let successfulRestResponse; +let failRestResponse; + +describe('AbstractSimpleItemActionComponent', () => { + beforeEach(async(() => { + + mockItem = Object.assign(new Item(), { + id: 'fake-id', + handle: 'fake/handle', + lastModified: '2018', + isWithdrawn: true + }); + + itemPageUrl = `fake-url/${mockItem.id}`; + routerStub = Object.assign(new RouterStub(), { + url: `${itemPageUrl}/edit` + }); + + mockItemDataService = jasmine.createSpyObj({ + findById: observableOf(new RemoteData(false, false, true, undefined, mockItem)) + }); + + routeStub = { + data: observableOf({ + item: new RemoteData(false, false, true, null, { + id: 'fake-id' + }) + }) + }; + + notificationsServiceStub = new NotificationsServiceStub(); + + TestBed.configureTestingModule({ + imports: [CommonModule, FormsModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()], + declarations: [MySimpleItemActionComponent], + providers: [ + {provide: ActivatedRoute, useValue: routeStub}, + {provide: Router, useValue: routerStub}, + {provide: ItemDataService, useValue: mockItemDataService}, + {provide: NotificationsService, useValue: notificationsServiceStub}, + ], schemas: [ + CUSTOM_ELEMENTS_SCHEMA + ] + }).compileComponents(); + })); + + beforeEach(() => { + successfulRestResponse = new RestResponse(true, '200'); + failRestResponse = new RestResponse(false, '500'); + + fixture = TestBed.createComponent(MySimpleItemActionComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should render a page with messages based on the provided messageKey', () => { + const header = fixture.debugElement.query(By.css('h2')).nativeElement; + expect(header.innerHTML).toContain('item.edit.myEditAction.header'); + + const description = fixture.debugElement.query(By.css('p')).nativeElement; + expect(description.innerHTML).toContain('item.edit.myEditAction.description'); + + const confirmButton = fixture.debugElement.query(By.css('button.perform-action')).nativeElement; + expect(confirmButton.innerHTML).toContain('item.edit.myEditAction.confirm'); + + const cancelButton = fixture.debugElement.query(By.css('button.cancel')).nativeElement; + expect(cancelButton.innerHTML).toContain('item.edit.myEditAction.cancel'); + }); + + it('should perform action when the button is clicked', () => { + spyOn(comp, 'performAction'); + const performButton = fixture.debugElement.query(By.css('.perform-action')); + performButton.triggerEventHandler('click', null); + + expect(comp.performAction).toHaveBeenCalled(); + }); + + it('should process a RestResponse to navigate and display success notification', () => { + spyOn(notificationsServiceStub, 'success'); + comp.processRestResponse(successfulRestResponse); + + expect(notificationsServiceStub.success).toHaveBeenCalled(); + expect(routerStub.navigate).toHaveBeenCalledWith([getItemEditPath(mockItem.id)]); + }); + + it('should process a RestResponse to navigate and display success notification', () => { + spyOn(notificationsServiceStub, 'error'); + comp.processRestResponse(failRestResponse); + + expect(notificationsServiceStub.error).toHaveBeenCalled(); + expect(routerStub.navigate).toHaveBeenCalledWith([getItemEditPath(mockItem.id)]); + }); + +}); diff --git a/src/app/+item-page/edit-item-page/simple-item-action/abstract-simple-item-action.component.ts b/src/app/+item-page/edit-item-page/simple-item-action/abstract-simple-item-action.component.ts new file mode 100644 index 0000000000..7773dbb573 --- /dev/null +++ b/src/app/+item-page/edit-item-page/simple-item-action/abstract-simple-item-action.component.ts @@ -0,0 +1,84 @@ +import {Component, OnInit, Predicate} from '@angular/core'; +import {ActivatedRoute, Router} from '@angular/router'; +import {NotificationsService} from '../../../shared/notifications/notifications.service'; +import {ItemDataService} from '../../../core/data/item-data.service'; +import {TranslateService} from '@ngx-translate/core'; +import {Item} from '../../../core/shared/item.model'; +import {RemoteData} from '../../../core/data/remote-data'; +import {Observable} from 'rxjs'; +import {getSucceededRemoteData} from '../../../core/shared/operators'; +import {first, map} from 'rxjs/operators'; +import {findSuccessfulAccordingTo} from '../edit-item-operators'; +import {getItemEditPath} from '../../item-page-routing.module'; +import { RestResponse } from '../../../core/cache/response.models'; + +/** + * Component to render and handle simple item edit actions such as withdrawal and reinstatement. + * This component is not meant to be used itself but to be extended. + */ +@Component({ + selector: 'ds-simple-action', + templateUrl: './abstract-simple-item-action.component.html' +}) +export class AbstractSimpleItemActionComponent implements OnInit { + + itemRD$: Observable>; + item: Item; + + protected messageKey: string; + confirmMessage: string; + cancelMessage: string; + headerMessage: string; + descriptionMessage: string; + + protected predicate: Predicate>; + + constructor(protected route: ActivatedRoute, + protected router: Router, + protected notificationsService: NotificationsService, + protected itemDataService: ItemDataService, + protected translateService: TranslateService) { + } + + ngOnInit(): void { + this.itemRD$ = this.route.data.pipe( + map((data) => data.item), + getSucceededRemoteData() + )as Observable>; + + this.itemRD$.pipe(first()).subscribe((rd) => { + this.item = rd.payload; + } + ); + + this.confirmMessage = 'item.edit.' + this.messageKey + '.confirm'; + this.cancelMessage = 'item.edit.' + this.messageKey + '.cancel'; + this.headerMessage = 'item.edit.' + this.messageKey + '.header'; + this.descriptionMessage = 'item.edit.' + this.messageKey + '.description'; + } + + /** + * Perform the operation linked to this action + */ + performAction() { + // Overwrite in subclasses + }; + + /** + * Process the response obtained during the performAction method and navigate back to the edit page + * @param response from the action in the performAction method + */ + processRestResponse(response: RestResponse) { + if (response.isSuccessful) { + this.itemDataService.findById(this.item.id).pipe( + findSuccessfulAccordingTo(this.predicate)).subscribe(() => { + this.notificationsService.success(this.translateService.get('item.edit.' + this.messageKey + '.success')); + this.router.navigate([getItemEditPath(this.item.id)]); + }); + } else { + this.notificationsService.error(this.translateService.get('item.edit.' + this.messageKey + '.error')); + this.router.navigate([getItemEditPath(this.item.id)]); + } + } + +} diff --git a/src/app/+item-page/item-page-routing.module.ts b/src/app/+item-page/item-page-routing.module.ts index 96158b867e..8c1f317bb7 100644 --- a/src/app/+item-page/item-page-routing.module.ts +++ b/src/app/+item-page/item-page-routing.module.ts @@ -4,6 +4,18 @@ import { RouterModule } from '@angular/router'; import { ItemPageComponent } from './simple/item-page.component'; import { FullItemPageComponent } from './full/full-item-page.component'; import { ItemPageResolver } from './item-page.resolver'; +import { AuthenticatedGuard } from '../core/auth/authenticated.guard'; +import {URLCombiner} from '../core/url-combiner/url-combiner'; +import {getItemModulePath} from '../app-routing.module'; + +export function getItemPageRoute(itemId: string) { + return new URLCombiner(getItemModulePath(), itemId).toString(); +} +export function getItemEditPath(id: string) { + return new URLCombiner(getItemModulePath(),ITEM_EDIT_PATH.replace(/:id/, id)).toString() +} + +const ITEM_EDIT_PATH = ':id/edit'; @NgModule({ imports: [ @@ -22,6 +34,11 @@ import { ItemPageResolver } from './item-page.resolver'; resolve: { item: ItemPageResolver } + }, + { + path: ITEM_EDIT_PATH, + loadChildren: './edit-item-page/edit-item-page.module#EditItemPageModule', + canActivate: [AuthenticatedGuard] } ]) ], diff --git a/src/app/+item-page/item-page.module.ts b/src/app/+item-page/item-page.module.ts index bd801923e3..d383189a9c 100644 --- a/src/app/+item-page/item-page.module.ts +++ b/src/app/+item-page/item-page.module.ts @@ -18,11 +18,13 @@ import { FileSectionComponent } from './simple/field-components/file-section/fil import { CollectionsComponent } from './field-components/collections/collections.component'; import { FullItemPageComponent } from './full/full-item-page.component'; import { FullFileSectionComponent } from './full/field-components/file-section/full-file-section.component'; +import { EditItemPageModule } from './edit-item-page/edit-item-page.module'; @NgModule({ imports: [ CommonModule, SharedModule, + EditItemPageModule, ItemPageRoutingModule ], declarations: [ diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.ts b/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.ts index faaf3b9fb5..fd5a75e7d1 100644 --- a/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.ts +++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.ts @@ -6,7 +6,7 @@ import { Subject, Subscription } from 'rxjs'; -import { switchMap, distinctUntilChanged, first, map, take } from 'rxjs/operators'; +import { switchMap, distinctUntilChanged, map, take } from 'rxjs/operators'; import { animate, state, style, transition, trigger } from '@angular/animations'; import { Component, Inject, OnDestroy, OnInit } from '@angular/core'; import { Router } from '@angular/router'; diff --git a/src/app/+search-page/search-filters/search-filter/search-filter.component.ts b/src/app/+search-page/search-filters/search-filter/search-filter.component.ts index ec239e3628..dcc01f2b46 100644 --- a/src/app/+search-page/search-filters/search-filter/search-filter.component.ts +++ b/src/app/+search-page/search-filters/search-filter/search-filter.component.ts @@ -1,5 +1,5 @@ -import { first, take } from 'rxjs/operators'; +import { take } from 'rxjs/operators'; import { Component, Input, OnInit } from '@angular/core'; import { SearchFilterConfig } from '../../search-service/search-filter-config.model'; import { SearchFilterService } from './search-filter.service'; diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 7de83651ff..e7ea10598d 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -3,6 +3,10 @@ import { RouterModule } from '@angular/router'; import { PageNotFoundComponent } from './pagenotfound/pagenotfound.component'; +const ITEM_MODULE_PATH = 'items'; +export function getItemModulePath() { + return `/${ITEM_MODULE_PATH}`; +} @NgModule({ imports: [ RouterModule.forRoot([ @@ -10,7 +14,7 @@ import { PageNotFoundComponent } from './pagenotfound/pagenotfound.component'; { path: 'home', loadChildren: './+home-page/home-page.module#HomePageModule' }, { path: 'communities', loadChildren: './+community-page/community-page.module#CommunityPageModule' }, { path: 'collections', loadChildren: './+collection-page/collection-page.module#CollectionPageModule' }, - { path: 'items', loadChildren: './+item-page/item-page.module#ItemPageModule' }, + { path: ITEM_MODULE_PATH, loadChildren: './+item-page/item-page.module#ItemPageModule' }, { path: 'search', loadChildren: './+search-page/search-page.module#SearchPageModule' }, { path: 'browse', loadChildren: './+browse-by/browse-by.module#BrowseByModule' }, { path: 'admin', loadChildren: './+admin/admin.module#AdminModule' }, diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 98e0d614ae..10c6643fbb 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,4 +1,4 @@ -import { filter, first, map, take } from 'rxjs/operators'; +import { filter, map, take } from 'rxjs/operators'; import { AfterViewInit, ChangeDetectionStrategy, @@ -60,10 +60,18 @@ export class AppComponent implements OnInit, AfterViewInit { private menuService: MenuService, private windowService: HostWindowService ) { - // this language will be used as a fallback when a translation isn't found in the current language - translate.setDefaultLang('en'); - // the lang to use, if the lang isn't available, it will use the current loader to get them - translate.use('en'); + // Load all the languages that are defined as active from the config file + translate.addLangs(config.languages.filter((LangConfig) => LangConfig.active === true).map((a) => a.code)); + + // Load the default language from the config file + translate.setDefaultLang(config.defaultLanguage); + + // Attempt to get the browser language from the user + if (translate.getLangs().includes(translate.getBrowserLang())) { + translate.use(translate.getBrowserLang()); + } else { + translate.use(config.defaultLanguage); + } metadata.listenForRouteChange(); diff --git a/src/app/core/auth/auth-request.service.ts b/src/app/core/auth/auth-request.service.ts index f957d807c1..6d782cbbe2 100644 --- a/src/app/core/auth/auth-request.service.ts +++ b/src/app/core/auth/auth-request.service.ts @@ -25,8 +25,6 @@ export class AuthRequestService { protected fetchRequest(request: RestRequest): Observable { return this.requestService.getByUUID(request.uuid).pipe( getResponseFromEntry(), - // TODO to review when https://github.com/DSpace/dspace-angular/issues/217 will be fixed - // tap(() => this.responseCache.remove(request.href)), mergeMap((response) => { if (response.isSuccessful && isNotEmpty(response)) { return observableOf((response as AuthStatusResponse).response); diff --git a/src/app/core/auth/auth.effects.ts b/src/app/core/auth/auth.effects.ts index 56a5411ef2..1e68802af8 100644 --- a/src/app/core/auth/auth.effects.ts +++ b/src/app/core/auth/auth.effects.ts @@ -1,6 +1,6 @@ import { of as observableOf, Observable } from 'rxjs'; -import { filter, debounceTime, switchMap, take, tap, catchError, map, first } from 'rxjs/operators'; +import { filter, debounceTime, switchMap, take, tap, catchError, map } from 'rxjs/operators'; import { Injectable } from '@angular/core'; // import @ngrx diff --git a/src/app/core/auth/server-auth.service.ts b/src/app/core/auth/server-auth.service.ts index e11800233e..868d444c26 100644 --- a/src/app/core/auth/server-auth.service.ts +++ b/src/app/core/auth/server-auth.service.ts @@ -1,4 +1,4 @@ -import { first, map, switchMap, take } from 'rxjs/operators'; +import { map, switchMap, take } from 'rxjs/operators'; import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; diff --git a/src/app/core/cache/builders/data-build.service.ts b/src/app/core/cache/builders/normalized-object-build.service.ts similarity index 63% rename from src/app/core/cache/builders/data-build.service.ts rename to src/app/core/cache/builders/normalized-object-build.service.ts index 8ba3ebee0c..9d97ccda75 100644 --- a/src/app/core/cache/builders/data-build.service.ts +++ b/src/app/core/cache/builders/normalized-object-build.service.ts @@ -3,24 +3,38 @@ import { NormalizedObject } from '../models/normalized-object.model'; import { CacheableObject } from '../object-cache.reducer'; import { getRelationships } from './build-decorators'; import { NormalizedObjectFactory } from '../models/normalized-object-factory'; -import { map, take } from 'rxjs/operators'; import { hasValue, isNotEmpty } from '../../../shared/empty.util'; -import { PaginatedList } from '../../data/paginated-list'; -export function isRestDataObject(halObj: any) { +/** + * Return true if halObj has a value for `_links.self` + * + * @param {any} halObj The object to test + */ +export function isRestDataObject(halObj: any): boolean { return isNotEmpty(halObj._links) && hasValue(halObj._links.self); } -export function isRestPaginatedList(halObj: any) { +/** + * Return true if halObj has a value for `page` and `_embedded` + * + * @param {any} halObj The object to test + */ +export function isRestPaginatedList(halObj: any): boolean { return hasValue(halObj.page) && hasValue(halObj._embedded); } -export function isPaginatedList(halObj: any) { - return hasValue(halObj.page) && hasValue(halObj.pageInfo); -} - +/** + * A service to turn domain models in to their normalized + * counterparts. + */ @Injectable() -export class DataBuildService { +export class NormalizedObjectBuildService { + + /** + * Returns the normalized model that corresponds to the given domain model + * + * @param {TDomain} domainModel a domain model + */ normalize(domainModel: TDomain): TNormalized { const normalizedConstructor = NormalizedObjectFactory.getConstructor(domainModel.type); const relationships = getRelationships(normalizedConstructor) || []; diff --git a/src/app/core/cache/object-cache.reducer.ts b/src/app/core/cache/object-cache.reducer.ts index 991ae5466e..982c77341e 100644 --- a/src/app/core/cache/object-cache.reducer.ts +++ b/src/app/core/cache/object-cache.reducer.ts @@ -17,8 +17,18 @@ export enum DirtyType { Deleted = 'Deleted' } +/** + * An interface to represent a JsonPatch + */ export interface Patch { + /** + * The identifier for this Patch + */ uuid?: string; + + /** + * the list of operations this Patch is composed of + */ operations: Operation[]; } diff --git a/src/app/core/cache/object-cache.service.ts b/src/app/core/cache/object-cache.service.ts index 40f41be14d..af30646f53 100644 --- a/src/app/core/cache/object-cache.service.ts +++ b/src/app/core/cache/object-cache.service.ts @@ -1,6 +1,6 @@ import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; -import { distinctUntilChanged, filter, first, map, mergeMap, take, } from 'rxjs/operators'; +import { distinctUntilChanged, filter, map, mergeMap, take, } from 'rxjs/operators'; import { Injectable } from '@angular/core'; import { MemoizedSelector, select, Store } from '@ngrx/store'; import { IndexName } from '../index/index.reducer'; diff --git a/src/app/core/cache/server-sync-buffer.effects.ts b/src/app/core/cache/server-sync-buffer.effects.ts index d0a194705b..0d7392e555 100644 --- a/src/app/core/cache/server-sync-buffer.effects.ts +++ b/src/app/core/cache/server-sync-buffer.effects.ts @@ -1,4 +1,4 @@ -import { delay, exhaustMap, first, map, switchMap, take, tap } from 'rxjs/operators'; +import { delay, exhaustMap, map, switchMap, take } from 'rxjs/operators'; import { Inject, Injectable } from '@angular/core'; import { Actions, Effect, ofType } from '@ngrx/effects'; import { diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index b1904782bd..0868773550 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -65,8 +65,8 @@ import { BrowseItemsResponseParsingService } from './data/browse-items-response- import { DSpaceObjectDataService } from './data/dspace-object-data.service'; import { CSSVariableService } from '../shared/sass-helper/sass-helper.service'; import { MenuService } from '../shared/menu/menu.service'; -import { DataBuildService } from './cache/builders/data-build.service'; -import { DSOUpdateComparator } from './data/dso-update-comparator'; +import { NormalizedObjectBuildService } from './cache/builders/normalized-object-build.service'; +import { DSOChangeAnalyzer } from './data/dso-change-analyzer.service'; const IMPORTS = [ CommonModule, @@ -103,7 +103,7 @@ const PROVIDERS = [ ObjectCacheService, PaginationComponentOptions, RegistryService, - DataBuildService, + NormalizedObjectBuildService, RemoteDataBuildService, RequestService, EndpointMapResponseParsingService, @@ -131,7 +131,7 @@ const PROVIDERS = [ UploaderService, UUIDService, DSpaceObjectDataService, - DSOUpdateComparator, + DSOChangeAnalyzer, CSSVariableService, MenuService, // register AuthInterceptor as HttpInterceptor diff --git a/src/app/core/data/base-response-parsing.service.ts b/src/app/core/data/base-response-parsing.service.ts index d5c1c58296..925caa495c 100644 --- a/src/app/core/data/base-response-parsing.service.ts +++ b/src/app/core/data/base-response-parsing.service.ts @@ -8,7 +8,7 @@ import { GenericConstructor } from '../shared/generic-constructor'; import { PaginatedList } from './paginated-list'; import { ResourceType } from '../shared/resource-type'; import { RESTURLCombiner } from '../url-combiner/rest-url-combiner'; -import { isRestDataObject, isRestPaginatedList } from '../cache/builders/data-build.service'; +import { isRestDataObject, isRestPaginatedList } from '../cache/builders/normalized-object-build.service'; /* tslint:disable:max-classes-per-file */ export abstract class BaseResponseParsingService { diff --git a/src/app/core/data/change-analyzer.ts b/src/app/core/data/change-analyzer.ts new file mode 100644 index 0000000000..caf9e38c7c --- /dev/null +++ b/src/app/core/data/change-analyzer.ts @@ -0,0 +1,20 @@ +import { NormalizedObject } from '../cache/models/normalized-object.model'; +import { Operation } from 'fast-json-patch/lib/core'; + +/** + * An interface to determine what differs between two + * NormalizedObjects + */ +export interface ChangeAnalyzer { + + /** + * Compare two objects and return their differences as a + * JsonPatch Operation Array + * + * @param {NormalizedObject} object1 + * The first object to compare + * @param {NormalizedObject} object2 + * The second object to compare + */ + diff(object1: TNormalized, object2: TNormalized): Operation[]; +} diff --git a/src/app/core/data/collection-data.service.ts b/src/app/core/data/collection-data.service.ts index b08b1005b7..e8a682ba0e 100644 --- a/src/app/core/data/collection-data.service.ts +++ b/src/app/core/data/collection-data.service.ts @@ -11,8 +11,8 @@ import { RequestService } from './request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { HttpClient } from '@angular/common/http'; -import { DataBuildService } from '../cache/builders/data-build.service'; -import { DSOUpdateComparator } from './dso-update-comparator'; +import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; +import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; @Injectable() export class CollectionDataService extends ComColDataService { @@ -21,14 +21,14 @@ export class CollectionDataService extends ComColDataService, protected cds: CommunityDataService, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, protected notificationsService: NotificationsService, protected http: HttpClient, - protected comparator: DSOUpdateComparator + protected comparator: DSOChangeAnalyzer ) { super(); } diff --git a/src/app/core/data/comcol-data.service.spec.ts b/src/app/core/data/comcol-data.service.spec.ts index 8bdc5904b0..4c20a4cfeb 100644 --- a/src/app/core/data/comcol-data.service.spec.ts +++ b/src/app/core/data/comcol-data.service.spec.ts @@ -16,8 +16,8 @@ import { RequestEntry } from './request.reducer'; import { of as observableOf } from 'rxjs'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { HttpClient } from '@angular/common/http'; -import { DataBuildService } from '../cache/builders/data-build.service'; -import { DSOUpdateComparator } from './dso-update-comparator'; +import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; +import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; const LINK_NAME = 'test'; @@ -30,7 +30,7 @@ class TestService extends ComColDataService { constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected dataBuildService: DataBuildService, + protected dataBuildService: NormalizedObjectBuildService, protected store: Store, protected EnvConfig: GlobalConfig, protected cds: CommunityDataService, @@ -38,7 +38,7 @@ class TestService extends ComColDataService { protected halService: HALEndpointService, protected notificationsService: NotificationsService, protected http: HttpClient, - protected comparator: DSOUpdateComparator, + protected comparator: DSOChangeAnalyzer, protected linkPath: string ) { super(); @@ -61,7 +61,7 @@ describe('ComColDataService', () => { const notificationsService = {} as NotificationsService; const http = {} as HttpClient; const comparator = {} as any; - const dataBuildService = {} as DataBuildService; + const dataBuildService = {} as NormalizedObjectBuildService; const scopeID = 'd9d30c0c-69b7-4369-8397-ca67c888974d'; const options = Object.assign(new FindAllOptions(), { diff --git a/src/app/core/data/community-data.service.ts b/src/app/core/data/community-data.service.ts index 63fbe3a21a..d09a0b9757 100644 --- a/src/app/core/data/community-data.service.ts +++ b/src/app/core/data/community-data.service.ts @@ -17,8 +17,8 @@ import { Observable } from 'rxjs'; import { PaginatedList } from './paginated-list'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { HttpClient } from '@angular/common/http'; -import { DataBuildService } from '../cache/builders/data-build.service'; -import { DSOUpdateComparator } from './dso-update-comparator'; +import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; +import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; @Injectable() export class CommunityDataService extends ComColDataService { @@ -29,13 +29,13 @@ export class CommunityDataService extends ComColDataService, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, protected notificationsService: NotificationsService, protected http: HttpClient, - protected comparator: DSOUpdateComparator + protected comparator: DSOChangeAnalyzer ) { super(); } diff --git a/src/app/core/data/data.service.spec.ts b/src/app/core/data/data.service.spec.ts index 9a690e3c4b..9b39f0b68e 100644 --- a/src/app/core/data/data.service.spec.ts +++ b/src/app/core/data/data.service.spec.ts @@ -11,9 +11,9 @@ import { SortDirection, SortOptions } from '../cache/models/sort-options.model'; import { ObjectCacheService } from '../cache/object-cache.service'; import { Operation } from '../../../../node_modules/fast-json-patch'; import { DSpaceObject } from '../shared/dspace-object.model'; -import { UpdateComparator } from './update-comparator'; +import { ChangeAnalyzer } from './change-analyzer'; import { HttpClient } from '@angular/common/http'; -import { DataBuildService } from '../cache/builders/data-build.service'; +import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { compare } from 'fast-json-patch'; @@ -27,14 +27,14 @@ class TestService extends DataService { constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected dataBuildService: DataBuildService, + protected dataBuildService: NormalizedObjectBuildService, protected store: Store, protected linkPath: string, protected halService: HALEndpointService, protected objectCache: ObjectCacheService, protected notificationsService: NotificationsService, protected http: HttpClient, - protected comparator: UpdateComparator + protected comparator: ChangeAnalyzer ) { super(); } @@ -44,8 +44,8 @@ class TestService extends DataService { } } -class DummyComparator implements UpdateComparator { - compare(object1: NormalizedTestObject, object2: NormalizedTestObject): Operation[] { +class DummyChangeAnalyzer implements ChangeAnalyzer { + diff(object1: NormalizedTestObject, object2: NormalizedTestObject): Operation[] { return compare((object1 as any).metadata, (object2 as any).metadata); } @@ -58,10 +58,10 @@ describe('DataService', () => { const rdbService = {} as RemoteDataBuildService; const notificationsService = {} as NotificationsService; const http = {} as HttpClient; - const comparator = new DummyComparator() as any; + const comparator = new DummyChangeAnalyzer() as any; const dataBuildService = { normalize: (object) => object - } as DataBuildService; + } as NormalizedObjectBuildService; const objectCache = { addPatch: () => { /* empty */ diff --git a/src/app/core/data/data.service.ts b/src/app/core/data/data.service.ts index 069af4937e..f4d7bcdf4f 100644 --- a/src/app/core/data/data.service.ts +++ b/src/app/core/data/data.service.ts @@ -1,12 +1,12 @@ import { - delay, distinctUntilChanged, filter, find, - switchMap, + first, map, - take, - tap, first, mergeMap + mergeMap, + switchMap, + take } from 'rxjs/operators'; import { Observable } from 'rxjs'; import { Store } from '@ngrx/store'; @@ -18,44 +18,41 @@ import { URLCombiner } from '../url-combiner/url-combiner'; import { PaginatedList } from './paginated-list'; import { RemoteData } from './remote-data'; import { - CreateRequest, DeleteByIDRequest, + CreateRequest, + DeleteByIDRequest, FindAllOptions, FindAllRequest, FindByIDRequest, - GetRequest, RestRequest + GetRequest } from './request.models'; import { RequestService } from './request.service'; import { NormalizedObject } from '../cache/models/normalized-object.model'; -import { compare, Operation } from 'fast-json-patch'; +import { Operation } from 'fast-json-patch'; import { ObjectCacheService } from '../cache/object-cache.service'; import { DSpaceObject } from '../shared/dspace-object.model'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { HttpClient } from '@angular/common/http'; -import { - configureRequest, - filterSuccessfulResponses, getFinishedRemoteData, getResourceLinksFromResponse, - getResponseFromEntry -} from '../shared/operators'; -import { DSOSuccessResponse, ErrorResponse, RestResponse } from '../cache/response.models'; +import { configureRequest, getResponseFromEntry } from '../shared/operators'; +import { ErrorResponse, RestResponse } from '../cache/response.models'; import { NotificationOptions } from '../../shared/notifications/models/notification-options.model'; import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; import { NormalizedObjectFactory } from '../cache/models/normalized-object-factory'; import { CacheableObject } from '../cache/object-cache.reducer'; -import { DataBuildService } from '../cache/builders/data-build.service'; -import { UpdateComparator } from './update-comparator'; import { RequestEntry } from './request.reducer'; +import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; +import { ChangeAnalyzer } from './change-analyzer'; export abstract class DataService { protected abstract requestService: RequestService; protected abstract rdbService: RemoteDataBuildService; - protected abstract dataBuildService: DataBuildService; + protected abstract dataBuildService: NormalizedObjectBuildService; protected abstract store: Store; protected abstract linkPath: string; protected abstract halService: HALEndpointService; protected abstract objectCache: ObjectCacheService; protected abstract notificationsService: NotificationsService; protected abstract http: HttpClient; - protected abstract comparator: UpdateComparator; + protected abstract comparator: ChangeAnalyzer; public abstract getBrowseEndpoint(options: FindAllOptions, linkPath?: string): Observable @@ -139,7 +136,7 @@ export abstract class DataService { const newVersion = this.dataBuildService.normalize(object); - const operations = this.comparator.compare(oldVersion, newVersion); + const operations = this.comparator.diff(oldVersion, newVersion); if (isNotEmpty(operations)) { this.objectCache.addPatch(object.self, operations); } @@ -149,6 +146,15 @@ export abstract class DataService> { const requestId = this.requestService.generateRequestId(); const endpoint$ = this.halService.getEndpoint(this.linkPath).pipe( diff --git a/src/app/core/data/dso-change-analyzer.service.ts b/src/app/core/data/dso-change-analyzer.service.ts new file mode 100644 index 0000000000..a47359e5c0 --- /dev/null +++ b/src/app/core/data/dso-change-analyzer.service.ts @@ -0,0 +1,26 @@ +import { Operation } from 'fast-json-patch/lib/core'; +import { compare } from 'fast-json-patch'; +import { ChangeAnalyzer } from './change-analyzer'; +import { NormalizedDSpaceObject } from '../cache/models/normalized-dspace-object.model'; +import { Injectable } from '@angular/core'; + +/** + * A class to determine what differs between two + * DSpaceObjects + */ +@Injectable() +export class DSOChangeAnalyzer implements ChangeAnalyzer { + + /** + * Compare the metadata of two DSpaceObjects and return the differences as + * a JsonPatch Operation Array + * + * @param {NormalizedDSpaceObject} object1 + * The first object to compare + * @param {NormalizedDSpaceObject} object2 + * The second object to compare + */ + diff(object1: NormalizedDSpaceObject, object2: NormalizedDSpaceObject): Operation[] { + return compare(object1.metadata, object2.metadata).map((operation: Operation) => Object.assign({}, operation, { path: '/metadata' + operation.path })); + } +} diff --git a/src/app/core/data/dso-update-comparator.ts b/src/app/core/data/dso-update-comparator.ts deleted file mode 100644 index 245fbfaef0..0000000000 --- a/src/app/core/data/dso-update-comparator.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Operation } from 'fast-json-patch/lib/core'; -import { compare } from 'fast-json-patch'; -import { UpdateComparator } from './update-comparator'; -import { NormalizedDSpaceObject } from '../cache/models/normalized-dspace-object.model'; -import { Injectable } from '@angular/core'; - -@Injectable() -export class DSOUpdateComparator implements UpdateComparator { - compare(object1: NormalizedDSpaceObject, object2: NormalizedDSpaceObject): Operation[] { - return compare(object1.metadata, object2.metadata).map((operation: Operation) => Object.assign({}, operation, { path: '/metadata' + operation.path })); - } -} diff --git a/src/app/core/data/dspace-object-data.service.spec.ts b/src/app/core/data/dspace-object-data.service.spec.ts index 2d478b8f73..7047db6065 100644 --- a/src/app/core/data/dspace-object-data.service.spec.ts +++ b/src/app/core/data/dspace-object-data.service.spec.ts @@ -9,7 +9,7 @@ import { DSpaceObjectDataService } from './dspace-object-data.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { HttpClient } from '@angular/common/http'; -import { DataBuildService } from '../cache/builders/data-build.service'; +import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; describe('DSpaceObjectDataService', () => { let scheduler: TestScheduler; @@ -46,7 +46,7 @@ describe('DSpaceObjectDataService', () => { const notificationsService = {} as NotificationsService; const http = {} as HttpClient; const comparator = {} as any; - const dataBuildService = {} as DataBuildService; + const dataBuildService = {} as NormalizedObjectBuildService; service = new DSpaceObjectDataService( requestService, diff --git a/src/app/core/data/dspace-object-data.service.ts b/src/app/core/data/dspace-object-data.service.ts index 15438d60b7..86ce9be7a9 100644 --- a/src/app/core/data/dspace-object-data.service.ts +++ b/src/app/core/data/dspace-object-data.service.ts @@ -13,8 +13,8 @@ import { FindAllOptions } from './request.models'; import { ObjectCacheService } from '../cache/object-cache.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { HttpClient } from '@angular/common/http'; -import { DataBuildService } from '../cache/builders/data-build.service'; -import { DSOUpdateComparator } from './dso-update-comparator'; +import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; +import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; /* tslint:disable:max-classes-per-file */ class DataServiceImpl extends DataService { @@ -23,13 +23,13 @@ class DataServiceImpl extends DataService constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected dataBuildService: DataBuildService, + protected dataBuildService: NormalizedObjectBuildService, protected store: Store, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, protected notificationsService: NotificationsService, protected http: HttpClient, - protected comparator: DSOUpdateComparator) { + protected comparator: DSOChangeAnalyzer) { super(); } @@ -50,12 +50,12 @@ export class DSpaceObjectDataService { constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected dataBuildService: DataBuildService, + protected dataBuildService: NormalizedObjectBuildService, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, protected notificationsService: NotificationsService, protected http: HttpClient, - protected comparator: DSOUpdateComparator) { + protected comparator: DSOChangeAnalyzer) { this.dataService = new DataServiceImpl(requestService, rdbService, dataBuildService, null, objectCache, halService, notificationsService, http, comparator); } diff --git a/src/app/core/data/item-data.service.spec.ts b/src/app/core/data/item-data.service.spec.ts index 1be361cb9d..6cf7e503d3 100644 --- a/src/app/core/data/item-data.service.spec.ts +++ b/src/app/core/data/item-data.service.spec.ts @@ -7,21 +7,42 @@ import { CoreState } from '../core.reducers'; import { ItemDataService } from './item-data.service'; import { RequestService } from './request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { FindAllOptions, RestRequest } from './request.models'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { FindAllOptions } from './request.models'; +import { Observable } from 'rxjs'; +import { RestResponse } from '../cache/response.models'; import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; import { HttpClient } from '@angular/common/http'; -import { DataBuildService } from '../cache/builders/data-build.service'; +import { RequestEntry } from './request.reducer'; +import { of as observableOf } from 'rxjs'; describe('ItemDataService', () => { let scheduler: TestScheduler; let service: ItemDataService; let bs: BrowseService; - const requestService = {} as RequestService; + const requestService = { + generateRequestId(): string { + return scopeID; + }, + configure(request: RestRequest) { + // Do nothing + }, + getByHref(requestHref: string) { + const responseCacheEntry = new RequestEntry(); + responseCacheEntry.response = new RestResponse(true, '200'); + return observableOf(responseCacheEntry); + } + } as RequestService; const rdbService = {} as RemoteDataBuildService; - const objectCache = {} as ObjectCacheService; + const store = {} as Store; - const halEndpointService = {} as HALEndpointService; + const objectCache = {} as ObjectCacheService; + const halEndpointService = { + getEndpoint(linkPath: string): Observable { + return cold('a', {a: itemEndpoint}); + } + } as HALEndpointService; const scopeID = '4af28e99-6a9c-4036-a199-e1b587046d39'; const options = Object.assign(new FindAllOptions(), { @@ -40,7 +61,9 @@ describe('ItemDataService', () => { const notificationsService = {} as NotificationsService; const http = {} as HttpClient; const comparator = {} as any; - const dataBuildService = {} as DataBuildService; + const dataBuildService = {} as NormalizedObjectBuildService; + const itemEndpoint = 'https://rest.api/core/items'; + const ScopedItemEndpoint = `https://rest.api/core/items/${scopeID}`; function initMockBrowseService(isSuccessful: boolean) { const obs = isSuccessful ? @@ -94,4 +117,70 @@ describe('ItemDataService', () => { }); }); }); + + describe('getItemWithdrawEndpoint', () => { + beforeEach(() => { + scheduler = getTestScheduler(); + service = initTestService(); + + }); + + it('should return the endpoint to withdraw and reinstate items', () => { + const result = service.getItemWithdrawEndpoint(scopeID); + const expected = cold('a', {a: ScopedItemEndpoint}); + + expect(result).toBeObservable(expected); + }); + + it('should setWithDrawn', () => { + const expected = new RestResponse(true, '200'); + const result = service.setWithDrawn(scopeID, true); + result.subscribe((v) => expect(v).toEqual(expected)); + + }); + }); + + describe('getItemDiscoverableEndpoint', () => { + beforeEach(() => { + scheduler = getTestScheduler(); + service = initTestService(); + + }); + + it('should return the endpoint to make an item private or public', () => { + const result = service.getItemDiscoverableEndpoint(scopeID); + const expected = cold('a', {a: ScopedItemEndpoint}); + + expect(result).toBeObservable(expected); + }); + + it('should setDiscoverable', () => { + const expected = new RestResponse(true, '200'); + const result = service.setDiscoverable(scopeID, false); + result.subscribe((v) => expect(v).toEqual(expected)); + + }); + }); + + describe('getItemDeleteEndpoint', () => { + beforeEach(() => { + scheduler = getTestScheduler(); + service = initTestService(); + }); + + it('should return the endpoint to make an item private or public', () => { + const result = service.getItemDeleteEndpoint(scopeID); + const expected = cold('a', {a: ScopedItemEndpoint}); + + expect(result).toBeObservable(expected); + }); + + it('should delete the item', () => { + const expected = new RestResponse(true, '200'); + const result = service.delete(scopeID); + result.subscribe((v) => expect(v).toEqual(expected)); + + }); + }); + }); diff --git a/src/app/core/data/item-data.service.ts b/src/app/core/data/item-data.service.ts index 2fb2c017dc..c67b49f70d 100644 --- a/src/app/core/data/item-data.service.ts +++ b/src/app/core/data/item-data.service.ts @@ -14,12 +14,14 @@ import { URLCombiner } from '../url-combiner/url-combiner'; import { DataService } from './data.service'; import { RequestService } from './request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { FindAllOptions } from './request.models'; +import { DeleteRequest, FindAllOptions, PatchRequest, RestRequest } from './request.models'; import { ObjectCacheService } from '../cache/object-cache.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { HttpClient } from '@angular/common/http'; -import { DataBuildService } from '../cache/builders/data-build.service'; -import { DSOUpdateComparator } from './dso-update-comparator'; +import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; +import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; +import { configureRequest, getRequestFromRequestHref } from '../shared/operators'; +import { RequestEntry } from './request.reducer'; @Injectable() export class ItemDataService extends DataService { @@ -28,14 +30,14 @@ export class ItemDataService extends DataService { constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected dataBuildService: DataBuildService, + protected dataBuildService: NormalizedObjectBuildService, protected store: Store, private bs: BrowseService, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, protected notificationsService: NotificationsService, protected http: HttpClient, - protected comparator: DSOUpdateComparator) { + protected comparator: DSOChangeAnalyzer) { super(); } @@ -56,4 +58,93 @@ export class ItemDataService extends DataService { distinctUntilChanged(),); } + /** + * Get the endpoint for item withdrawal and reinstatement + * @param itemId + */ + public getItemWithdrawEndpoint(itemId: string): Observable { + return this.halService.getEndpoint(this.linkPath).pipe( + map((endpoint: string) => this.getFindByIDHref(endpoint, itemId)) + ); + } + + /** + * Get the endpoint to make item private and public + * @param itemId + */ + public getItemDiscoverableEndpoint(itemId: string): Observable { + return this.halService.getEndpoint(this.linkPath).pipe( + map((endpoint: string) => this.getFindByIDHref(endpoint, itemId)) + ); + } + + /** + * Get the endpoint to delete the item + * @param itemId + */ + public getItemDeleteEndpoint(itemId: string): Observable { + return this.halService.getEndpoint(this.linkPath).pipe( + map((endpoint: string) => this.getFindByIDHref(endpoint, itemId)) + ); + } + + /** + * Set the isWithdrawn state of an item to a specified state + * @param itemId + * @param withdrawn + */ + public setWithDrawn(itemId: string, withdrawn: boolean) { + const patchOperation = [{ + op: 'replace', path: '/withdrawn', value: withdrawn + }]; + return this.getItemWithdrawEndpoint(itemId).pipe( + distinctUntilChanged(), + map((endpointURL: string) => + new PatchRequest(this.requestService.generateRequestId(), endpointURL, patchOperation) + ), + configureRequest(this.requestService), + map((request: RestRequest) => request.href), + getRequestFromRequestHref(this.requestService), + map((requestEntry: RequestEntry) => requestEntry.response) + ); + } + + /** + * Set the isDiscoverable state of an item to a specified state + * @param itemId + * @param discoverable + */ + public setDiscoverable(itemId: string, discoverable: boolean) { + const patchOperation = [{ + op: 'replace', path: '/discoverable', value: discoverable + }]; + return this.getItemDiscoverableEndpoint(itemId).pipe( + distinctUntilChanged(), + map((endpointURL: string) => + new PatchRequest(this.requestService.generateRequestId(), endpointURL, patchOperation) + ), + configureRequest(this.requestService), + map((request: RestRequest) => request.href), + getRequestFromRequestHref(this.requestService), + map((requestEntry: RequestEntry) => requestEntry.response) + ); + } + + /** + * Delete the item + * @param itemId + */ + public delete(itemId: string) { + return this.getItemDeleteEndpoint(itemId).pipe( + distinctUntilChanged(), + map((endpointURL: string) => + new DeleteRequest(this.requestService.generateRequestId(), endpointURL) + ), + configureRequest(this.requestService), + map((request: RestRequest) => request.href), + getRequestFromRequestHref(this.requestService), + map((requestEntry: RequestEntry) => requestEntry.response) + ); + } + } diff --git a/src/app/core/data/request.reducer.ts b/src/app/core/data/request.reducer.ts index a680de2d6b..27b5a4188a 100644 --- a/src/app/core/data/request.reducer.ts +++ b/src/app/core/data/request.reducer.ts @@ -86,7 +86,17 @@ function completeRequest(state: RequestState, action: RequestCompleteAction): Re }); } -function resetResponseTimestamps(state: RequestState, action: ResetResponseTimestampsAction) { +/** + * Reset the timeAdded property of all responses + * + * @param state + * the current state + * @param action + * a RequestCompleteAction + * @return RequestState + * the new state, with the timeAdded property reset + */ +function resetResponseTimestamps(state: RequestState, action: ResetResponseTimestampsAction): RequestState { const newState = Object.create(null); Object.keys(state).forEach((key) => { newState[key] = Object.assign({}, state[key], diff --git a/src/app/core/data/update-comparator.ts b/src/app/core/data/update-comparator.ts deleted file mode 100644 index f064d7f3f2..0000000000 --- a/src/app/core/data/update-comparator.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { NormalizedObject } from '../cache/models/normalized-object.model'; -import { Operation } from 'fast-json-patch/lib/core'; - -export interface UpdateComparator { - compare(object1: TNormalized, object2: TNormalized): Operation[]; -} diff --git a/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts b/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts index 59038228bb..20d6b1dfb3 100644 --- a/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts +++ b/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts @@ -79,6 +79,14 @@ export class DSpaceRESTv2Service { })); } + /** + * Create a FormData object from a DSpaceObject + * + * @param {DSpaceObject} dso + * the DSpaceObject + * @return {FormData} + * the result + */ buildFormData(dso: DSpaceObject): FormData { const form: FormData = new FormData(); form.append('name', dso.name); diff --git a/src/app/core/metadata/metadata.service.spec.ts b/src/app/core/metadata/metadata.service.spec.ts index ef1d69b8b5..90c811db7a 100644 --- a/src/app/core/metadata/metadata.service.spec.ts +++ b/src/app/core/metadata/metadata.service.spec.ts @@ -35,8 +35,8 @@ import { AuthService } from '../auth/auth.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { HttpClient } from '@angular/common/http'; import { EmptyError } from 'rxjs/internal-compatibility'; -import { DataBuildService } from '../cache/builders/data-build.service'; -import { DSOUpdateComparator } from '../data/dso-update-comparator'; +import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; +import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; /* tslint:disable:max-classes-per-file */ @Component({ @@ -119,8 +119,8 @@ describe('MetadataService', () => { { provide: AuthService, useValue: {} }, { provide: NotificationsService, useValue: {} }, { provide: HttpClient, useValue: {} }, - { provide: DataBuildService, useValue: {} }, - { provide: DSOUpdateComparator, useValue: {} }, + { provide: NormalizedObjectBuildService, useValue: {} }, + { provide: DSOChangeAnalyzer, useValue: {} }, Meta, Title, ItemDataService, diff --git a/src/app/core/shared/operators.spec.ts b/src/app/core/shared/operators.spec.ts index 6aeec230c4..1e92f718ae 100644 --- a/src/app/core/shared/operators.spec.ts +++ b/src/app/core/shared/operators.spec.ts @@ -7,9 +7,15 @@ import { RequestService } from '../data/request.service'; import { configureRequest, filterSuccessfulResponses, - getRemoteDataPayload, getRequestFromRequestHref, getRequestFromRequestUUID, - getResourceLinksFromResponse, getResponseFromEntry, + getAllSucceededRemoteData, + getRemoteDataPayload, + getRequestFromRequestHref, + getRequestFromRequestUUID, + getResourceLinksFromResponse, + getResponseFromEntry, + getSucceededRemoteData } from './operators'; +import { RemoteData } from '../data/remote-data'; describe('Core Module - RxJS Operators', () => { let scheduler: TestScheduler; @@ -48,7 +54,7 @@ describe('Core Module - RxJS Operators', () => { const result = source.pipe(getRequestFromRequestHref(requestService)); const expected = cold('a', { a: new RequestEntry() }); - expect(result).toBeObservable(expected) + expect(result).toBeObservable(expected); }); it('should use the requestService to fetch the request by its self link', () => { @@ -68,7 +74,7 @@ describe('Core Module - RxJS Operators', () => { const result = source.pipe(getRequestFromRequestHref(requestService)); const expected = cold('-'); - expect(result).toBeObservable(expected) + expect(result).toBeObservable(expected); }); }); @@ -81,7 +87,7 @@ describe('Core Module - RxJS Operators', () => { const result = source.pipe(getRequestFromRequestUUID(requestService)); const expected = cold('a', { a: new RequestEntry() }); - expect(result).toBeObservable(expected) + expect(result).toBeObservable(expected); }); it('should use the requestService to fetch the request by its request uuid', () => { @@ -101,7 +107,7 @@ describe('Core Module - RxJS Operators', () => { const result = source.pipe(getRequestFromRequestUUID(requestService)); const expected = cold('-'); - expect(result).toBeObservable(expected) + expect(result).toBeObservable(expected); }); }); @@ -111,7 +117,7 @@ describe('Core Module - RxJS Operators', () => { const result = source.pipe(filterSuccessfulResponses()); const expected = cold('a--d-', testResponses); - expect(result).toBeObservable(expected) + expect(result).toBeObservable(expected); }); }); @@ -124,7 +130,7 @@ describe('Core Module - RxJS Operators', () => { d: testRCEs.d.response.resourceSelfLinks }); - expect(result).toBeObservable(expected) + expect(result).toBeObservable(expected); }); }); @@ -136,7 +142,7 @@ describe('Core Module - RxJS Operators', () => { scheduler.schedule(() => source.pipe(configureRequest(requestService)).subscribe()); scheduler.flush(); - expect(requestService.configure).toHaveBeenCalledWith(testRequest) + expect(requestService.configure).toHaveBeenCalledWith(testRequest); }); }); @@ -149,7 +155,25 @@ describe('Core Module - RxJS Operators', () => { a: testRD.a.payload, }); - expect(result).toBeObservable(expected) + expect(result).toBeObservable(expected); + }); + }); + + describe('getSucceededRemoteData', () => { + it('should return the first() hasSucceeded RemoteData Observable', () => { + const testRD = { + a: new RemoteData(false, false, true, null, undefined), + b: new RemoteData(false, false, false, null, 'b'), + c: new RemoteData(false, false, undefined, null, 'c'), + d: new RemoteData(false, false, true, null, 'd'), + e: new RemoteData(false, false, true, null, 'e'), + }; + const source = hot('abcde', testRD); + const result = source.pipe(getSucceededRemoteData()); + + result.subscribe((value) => expect(value) + .toEqual(new RemoteData(false, false, true, null, 'd'))); + }); }); @@ -168,4 +192,23 @@ describe('Core Module - RxJS Operators', () => { expect(result).toBeObservable(expected) }); }); + + describe('getAllSucceededRemoteData', () => { + it('should return all hasSucceeded RemoteData Observables', () => { + const testRD = { + a: new RemoteData(false, false, true, null, undefined), + b: new RemoteData(false, false, false, null, 'b'), + c: new RemoteData(false, false, undefined, null, 'c'), + d: new RemoteData(false, false, true, null, 'd'), + e: new RemoteData(false, false, true, null, 'e'), + }; + const source = hot('abcde', testRD); + const result = source.pipe(getAllSucceededRemoteData()); + const expected = cold('---de', testRD); + + expect(result).toBeObservable(expected); + + }); + + }); }); diff --git a/src/app/core/shared/operators.ts b/src/app/core/shared/operators.ts index 09cb86e010..3b92c71433 100644 --- a/src/app/core/shared/operators.ts +++ b/src/app/core/shared/operators.ts @@ -1,5 +1,5 @@ import { Observable } from 'rxjs'; -import { filter, find, first, flatMap, map, tap } from 'rxjs/operators'; +import { filter, find, flatMap, map, tap } from 'rxjs/operators'; import { hasValue, hasValueOperator, isNotEmpty } from '../../shared/empty.util'; import { DSOSuccessResponse, RestResponse } from '../cache/response.models'; import { RemoteData } from '../data/remote-data'; @@ -66,6 +66,10 @@ export const getFinishedRemoteData = () => (source: Observable>): Observable> => source.pipe(find((rd: RemoteData) => !rd.isLoading)); +export const getAllSucceededRemoteData = () => + (source: Observable>): Observable> => + source.pipe(filter((rd: RemoteData) => rd.hasSucceeded)); + export const toDSpaceObjectListRD = () => (source: Observable>>>): Observable>> => source.pipe( diff --git a/src/app/header/header.component.html b/src/app/header/header.component.html index 461f809357..402eb7a44d 100644 --- a/src/app/header/header.component.html +++ b/src/app/header/header.component.html @@ -6,7 +6,7 @@