From d9a393c8e65c11233fade7553749ee7853ca7f94 Mon Sep 17 00:00:00 2001 From: Yana De Pauw Date: Tue, 18 Dec 2018 16:52:11 +0100 Subject: [PATCH 1/9] Add Item Status Edit Actions Add the Item Withdraw and Reistate action Add the make Item Private and Public action Add the Permanently Delete action --- resources/i18n/en.json | 114 +++++++++++++++ .../edit-item-operators.spec.ts | 35 +++++ .../edit-item-page/edit-item-operators.ts | 13 ++ .../edit-item-page.component.html | 36 +++++ .../edit-item-page.component.ts | 35 +++++ .../edit-item-page/edit-item-page.module.ts | 37 +++++ .../edit-item-page.routing.module.ts | 69 +++++++++ .../item-delete/item-delete.component.spec.ts | 118 +++++++++++++++ .../item-delete/item-delete.component.ts | 43 ++++++ .../item-operation.component.html | 15 ++ .../item-operation.component.spec.ts | 45 ++++++ .../item-operation.component.ts | 15 ++ .../item-operation/itemOperation.model.ts | 21 +++ .../item-private.component.spec.ts | 105 +++++++++++++ .../item-private/item-private.component.ts | 30 ++++ .../item-public/item-public.component.spec.ts | 105 +++++++++++++ .../item-public/item-public.component.ts | 30 ++++ .../item-reinstate.component.spec.ts | 105 +++++++++++++ .../item-reinstate.component.ts | 30 ++++ .../item-status/item-status.component.html | 21 +++ .../item-status/item-status.component.spec.ts | 68 +++++++++ .../item-status/item-status.component.ts | 97 ++++++++++++ .../item-withdraw.component.spec.ts | 105 +++++++++++++ .../item-withdraw/item-withdraw.component.ts | 30 ++++ .../modify-item-overview.component.html | 16 ++ .../modify-item-overview.component.spec.ts | 55 +++++++ .../modify-item-overview.component.ts | 20 +++ ...abstract-simple-item-action.component.html | 16 ++ ...tract-simple-item-action.component.spec.ts | 138 ++++++++++++++++++ .../abstract-simple-item-action.component.ts | 84 +++++++++++ .../+item-page/item-page-routing.module.ts | 17 +++ src/app/+item-page/item-page.module.ts | 2 + src/app/app-routing.module.ts | 6 +- src/app/core/data/item-data.service.spec.ts | 120 +++++++++++++-- src/app/core/data/item-data.service.ts | 124 +++++++++++++--- src/app/core/shared/operators.spec.ts | 120 ++++++++++----- src/app/core/shared/operators.ts | 6 +- src/app/shared/testing/test-module.ts | 14 +- 38 files changed, 1984 insertions(+), 76 deletions(-) create mode 100644 src/app/+item-page/edit-item-page/edit-item-operators.spec.ts create mode 100644 src/app/+item-page/edit-item-page/edit-item-operators.ts create mode 100644 src/app/+item-page/edit-item-page/edit-item-page.component.html create mode 100644 src/app/+item-page/edit-item-page/edit-item-page.component.ts create mode 100644 src/app/+item-page/edit-item-page/edit-item-page.module.ts create mode 100644 src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts create mode 100644 src/app/+item-page/edit-item-page/item-delete/item-delete.component.spec.ts create mode 100644 src/app/+item-page/edit-item-page/item-delete/item-delete.component.ts create mode 100644 src/app/+item-page/edit-item-page/item-operation/item-operation.component.html create mode 100644 src/app/+item-page/edit-item-page/item-operation/item-operation.component.spec.ts create mode 100644 src/app/+item-page/edit-item-page/item-operation/item-operation.component.ts create mode 100644 src/app/+item-page/edit-item-page/item-operation/itemOperation.model.ts create mode 100644 src/app/+item-page/edit-item-page/item-private/item-private.component.spec.ts create mode 100644 src/app/+item-page/edit-item-page/item-private/item-private.component.ts create mode 100644 src/app/+item-page/edit-item-page/item-public/item-public.component.spec.ts create mode 100644 src/app/+item-page/edit-item-page/item-public/item-public.component.ts create mode 100644 src/app/+item-page/edit-item-page/item-reinstate/item-reinstate.component.spec.ts create mode 100644 src/app/+item-page/edit-item-page/item-reinstate/item-reinstate.component.ts create mode 100644 src/app/+item-page/edit-item-page/item-status/item-status.component.html create mode 100644 src/app/+item-page/edit-item-page/item-status/item-status.component.spec.ts create mode 100644 src/app/+item-page/edit-item-page/item-status/item-status.component.ts create mode 100644 src/app/+item-page/edit-item-page/item-withdraw/item-withdraw.component.spec.ts create mode 100644 src/app/+item-page/edit-item-page/item-withdraw/item-withdraw.component.ts create mode 100644 src/app/+item-page/edit-item-page/modify-item-overview/modify-item-overview.component.html create mode 100644 src/app/+item-page/edit-item-page/modify-item-overview/modify-item-overview.component.spec.ts create mode 100644 src/app/+item-page/edit-item-page/modify-item-overview/modify-item-overview.component.ts create mode 100644 src/app/+item-page/edit-item-page/simple-item-action/abstract-simple-item-action.component.html create mode 100644 src/app/+item-page/edit-item-page/simple-item-action/abstract-simple-item-action.component.spec.ts create mode 100644 src/app/+item-page/edit-item-page/simple-item-action/abstract-simple-item-action.component.ts diff --git a/resources/i18n/en.json b/resources/i18n/en.json index 169ff0efe9..483e8987d7 100644 --- a/resources/i18n/en.json +++ b/resources/i18n/en.json @@ -43,6 +43,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/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..9cc2ffe84d --- /dev/null +++ b/src/app/+item-page/edit-item-page/edit-item-page.module.ts @@ -0,0 +1,37 @@ +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'; + +@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..3d86aab741 --- /dev/null +++ b/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts @@ -0,0 +1,69 @@ +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'; + +@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..50c494128a --- /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 {RestResponse} from '../../../core/cache/response-cache.models'; +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'; + +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..68c5738f7d --- /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 {RestResponse} from '../../../core/cache/response-cache.models'; +import {AbstractSimpleItemActionComponent} from '../simple-item-action/abstract-simple-item-action.component'; +import {getItemEditPath} from '../../item-page-routing.module'; + +@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..0104dfbdb3 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-operation/itemOperation.model.ts @@ -0,0 +1,21 @@ +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..5b99ced743 --- /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 {RestResponse} from '../../../core/cache/response-cache.models'; +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'; + +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..f1e7600c18 --- /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 {RestResponse} from '../../../core/cache/response-cache.models'; +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'; + +@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..182d3ffabe --- /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 {RestResponse} from '../../../core/cache/response-cache.models'; +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'; + +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..d65d0f171d --- /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 {RestResponse} from '../../../core/cache/response-cache.models'; +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'; + +@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..dbea7f3e69 --- /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 {RestResponse} from '../../../core/cache/response-cache.models'; +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'; + +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..5c710b0a81 --- /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 {RestResponse} from '../../../core/cache/response-cache.models'; +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'; + +@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..82c00a1d06 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-status/item-status.component.ts @@ -0,0 +1,97 @@ +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 = []; + this.operations.push(new ItemOperation('mappedCollections', this.getCurrentUrl() + '/')); + this.operations.push(new ItemOperation('move', this.getCurrentUrl() + '/move')); + 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..e1de52a506 --- /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 {RestResponse} from '../../../core/cache/response-cache.models'; +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'; + +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..6924124efc --- /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 {RestResponse} from '../../../core/cache/response-cache.models'; +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'; + +@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..9343e68655 --- /dev/null +++ b/src/app/+item-page/edit-item-page/simple-item-action/abstract-simple-item-action.component.spec.ts @@ -0,0 +1,138 @@ +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 {RestResponse} from '../../../core/cache/response-cache.models'; +import {of as observableOf} from 'rxjs'; +import {getItemEditPath} from '../../item-page-routing.module'; + +@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..743b52921f --- /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 {RestResponse} from '../../../core/cache/response-cache.models'; +import {findSuccessfulAccordingTo} from '../edit-item-operators'; +import {getItemEditPath} from '../../item-page-routing.module'; + +/** + * 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/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/core/data/item-data.service.spec.ts b/src/app/core/data/item-data.service.spec.ts index 8e2db15921..f1ad737aa1 100644 --- a/src/app/core/data/item-data.service.spec.ts +++ b/src/app/core/data/item-data.service.spec.ts @@ -1,24 +1,44 @@ -import { Store } from '@ngrx/store'; -import { cold, getTestScheduler } from 'jasmine-marbles'; -import { TestScheduler } from 'rxjs/testing'; -import { BrowseService } from '../browse/browse.service'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { ResponseCacheService } from '../cache/response-cache.service'; -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 } from './request.models'; +import {Store} from '@ngrx/store'; +import {cold, getTestScheduler} from 'jasmine-marbles'; +import {TestScheduler} from 'rxjs/testing'; +import {BrowseService} from '../browse/browse.service'; +import {RemoteDataBuildService} from '../cache/builders/remote-data-build.service'; +import {ResponseCacheService} from '../cache/response-cache.service'; +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 {Observable, of as observableOf} from 'rxjs'; +import {ResponseCacheEntry} from '../cache/response-cache.reducer'; +import {RestResponse} from '../cache/response-cache.models'; describe('ItemDataService', () => { let scheduler: TestScheduler; let service: ItemDataService; let bs: BrowseService; - const requestService = {} as RequestService; - const responseCache = {} as ResponseCacheService; + const requestService = { + generateRequestId(): string { + return scopeID; + }, + configure(request: RestRequest) { + // Do nothing + } + } as RequestService; + const responseCache = { + get(href: string) { + const responseCacheEntry = new ResponseCacheEntry(); + responseCacheEntry.response = new RestResponse(true, '200'); + return observableOf(responseCacheEntry); + } + } as ResponseCacheService; const rdbService = {} as RemoteDataBuildService; const store = {} as Store; - const halEndpointService = {} as HALEndpointService; + 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(), { @@ -34,10 +54,12 @@ describe('ItemDataService', () => { const scopedEndpoint = `${itemBrowseEndpoint}?scope=${scopeID}`; const serviceEndpoint = `https://rest.api/core/items`; const browseError = new Error('getBrowseURL failed'); + const itemEndpoint = 'https://rest.api/core/items'; + const ScopedItemEndpoint = `https://rest.api/core/items/${scopeID}`; function initMockBrowseService(isSuccessful: boolean) { const obs = isSuccessful ? - cold('--a-', { a: itemBrowseEndpoint }) : + cold('--a-', {a: itemBrowseEndpoint}) : cold('--#-', undefined, browseError); return jasmine.createSpyObj('bs', { getBrowseURLFor: obs @@ -65,7 +87,7 @@ describe('ItemDataService', () => { service = initTestService(); const result = service.getBrowseEndpoint(options); - const expected = cold('--b-', { b: scopedEndpoint }); + const expected = cold('--b-', {b: scopedEndpoint}); expect(result).toBeObservable(expected); }); @@ -83,4 +105,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 89b2eef568..bb0de530ff 100644 --- a/src/app/core/data/item-data.service.ts +++ b/src/app/core/data/item-data.service.ts @@ -1,21 +1,22 @@ +import {distinctUntilChanged, filter, map} from 'rxjs/operators'; +import {Injectable} from '@angular/core'; +import {Store} from '@ngrx/store'; +import {Observable} from 'rxjs'; +import {isNotEmpty} from '../../shared/empty.util'; +import {BrowseService} from '../browse/browse.service'; +import {RemoteDataBuildService} from '../cache/builders/remote-data-build.service'; +import {NormalizedItem} from '../cache/models/normalized-item.model'; +import {ResponseCacheService} from '../cache/response-cache.service'; +import {CoreState} from '../core.reducers'; +import {Item} from '../shared/item.model'; +import {URLCombiner} from '../url-combiner/url-combiner'; -import {distinctUntilChanged, map, filter} from 'rxjs/operators'; -import { Injectable } from '@angular/core'; -import { Store } from '@ngrx/store'; -import { Observable } from 'rxjs'; -import { isNotEmpty } from '../../shared/empty.util'; -import { BrowseService } from '../browse/browse.service'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { NormalizedItem } from '../cache/models/normalized-item.model'; -import { ResponseCacheService } from '../cache/response-cache.service'; -import { CoreState } from '../core.reducers'; -import { Item } from '../shared/item.model'; -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 {DataService} from './data.service'; +import {RequestService} from './request.service'; +import {HALEndpointService} from '../shared/hal-endpoint.service'; +import {DeleteRequest, FindAllOptions, PatchRequest, RestRequest} from './request.models'; +import {configureRequest, getResponseFromSelflink} from '../shared/operators'; +import {ResponseCacheEntry} from '../cache/response-cache.reducer'; @Injectable() export class ItemDataService extends DataService { @@ -48,4 +49,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), + getResponseFromSelflink(this.responseCache), + map((responseCacheEntry: ResponseCacheEntry) => responseCacheEntry.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), + getResponseFromSelflink(this.responseCache), + map((responseCacheEntry: ResponseCacheEntry) => responseCacheEntry.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), + getResponseFromSelflink(this.responseCache), + map((responseCacheEntry: ResponseCacheEntry) => responseCacheEntry.response) + ); + } + } diff --git a/src/app/core/shared/operators.spec.ts b/src/app/core/shared/operators.spec.ts index 16bf633705..33331e4a7d 100644 --- a/src/app/core/shared/operators.spec.ts +++ b/src/app/core/shared/operators.spec.ts @@ -1,18 +1,23 @@ -import { cold, getTestScheduler, hot } from 'jasmine-marbles'; -import { TestScheduler } from 'rxjs/testing'; -import { getMockRequestService } from '../../shared/mocks/mock-request.service'; -import { getMockResponseCacheService } from '../../shared/mocks/mock-response-cache.service'; -import { ResponseCacheEntry } from '../cache/response-cache.reducer'; -import { ResponseCacheService } from '../cache/response-cache.service'; -import { GetRequest, RestRequest } from '../data/request.models'; -import { RequestEntry } from '../data/request.reducer'; -import { RequestService } from '../data/request.service'; +import {cold, getTestScheduler, hot} from 'jasmine-marbles'; +import {TestScheduler} from 'rxjs/testing'; +import {getMockRequestService} from '../../shared/mocks/mock-request.service'; +import {getMockResponseCacheService} from '../../shared/mocks/mock-response-cache.service'; +import {ResponseCacheEntry} from '../cache/response-cache.reducer'; +import {ResponseCacheService} from '../cache/response-cache.service'; +import {GetRequest} from '../data/request.models'; +import {RequestEntry} from '../data/request.reducer'; +import {RequestService} from '../data/request.service'; import { configureRequest, - filterSuccessfulResponses, getRemoteDataPayload, - getRequestFromSelflink, getResourceLinksFromResponse, - getResponseFromSelflink + filterSuccessfulResponses, + getAllSucceededRemoteData, + getRemoteDataPayload, + getRequestFromSelflink, + getResourceLinksFromResponse, + getResponseFromSelflink, + getSucceededRemoteData } from './operators'; +import {RemoteData} from '../data/remote-data'; describe('Core Module - RxJS Operators', () => { let scheduler: TestScheduler; @@ -20,11 +25,11 @@ describe('Core Module - RxJS Operators', () => { const testSelfLink = 'https://rest.api/'; const testRCEs = { - a: { response: { isSuccessful: true, resourceSelfLinks: ['a', 'b', 'c', 'd'] } }, - b: { response: { isSuccessful: false, resourceSelfLinks: ['e', 'f'] } }, - c: { response: { isSuccessful: undefined, resourceSelfLinks: ['g', 'h', 'i'] } }, - d: { response: { isSuccessful: true, resourceSelfLinks: ['j', 'k', 'l', 'm', 'n'] } }, - e: { response: { isSuccessful: 1, resourceSelfLinks: [] } } + a: {response: {isSuccessful: true, resourceSelfLinks: ['a', 'b', 'c', 'd']}}, + b: {response: {isSuccessful: false, resourceSelfLinks: ['e', 'f']}}, + c: {response: {isSuccessful: undefined, resourceSelfLinks: ['g', 'h', 'i']}}, + d: {response: {isSuccessful: true, resourceSelfLinks: ['j', 'k', 'l', 'm', 'n']}}, + e: {response: {isSuccessful: 1, resourceSelfLinks: []}} }; beforeEach(() => { @@ -36,31 +41,31 @@ describe('Core Module - RxJS Operators', () => { it('should return the RequestEntry corresponding to the self link in the source', () => { requestService = getMockRequestService(); - const source = hot('a', { a: testSelfLink }); + const source = hot('a', {a: testSelfLink}); const result = source.pipe(getRequestFromSelflink(requestService)); - const expected = cold('a', { a: new RequestEntry()}); + 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', () => { requestService = getMockRequestService(); - const source = hot('a', { a: testSelfLink }); + const source = hot('a', {a: testSelfLink}); scheduler.schedule(() => source.pipe(getRequestFromSelflink(requestService)).subscribe()); scheduler.flush(); - expect(requestService.getByHref).toHaveBeenCalledWith(testSelfLink) + expect(requestService.getByHref).toHaveBeenCalledWith(testSelfLink); }); it('shouldn\'t return anything if there is no request matching the self link', () => { - requestService = getMockRequestService(cold('a', { a: undefined })); + requestService = getMockRequestService(cold('a', {a: undefined})); - const source = hot('a', { a: testSelfLink }); + const source = hot('a', {a: testSelfLink}); const result = source.pipe(getRequestFromSelflink(requestService)); const expected = cold('-'); - expect(result).toBeObservable(expected) + expect(result).toBeObservable(expected); }); }); @@ -74,31 +79,31 @@ describe('Core Module - RxJS Operators', () => { it('should return the ResponseCacheEntry corresponding to the self link in the source', () => { responseCacheService = getMockResponseCacheService(); - const source = hot('a', { a: testSelfLink }); + const source = hot('a', {a: testSelfLink}); const result = source.pipe(getResponseFromSelflink(responseCacheService)); - const expected = cold('a', { a: new ResponseCacheEntry()}); + const expected = cold('a', {a: new ResponseCacheEntry()}); - expect(result).toBeObservable(expected) + expect(result).toBeObservable(expected); }); it('should use the responseCacheService to fetch the response by the request\'s link', () => { responseCacheService = getMockResponseCacheService(); - const source = hot('a', { a: testSelfLink }); + const source = hot('a', {a: testSelfLink}); scheduler.schedule(() => source.pipe(getResponseFromSelflink(responseCacheService)).subscribe()); scheduler.flush(); - expect(responseCacheService.get).toHaveBeenCalledWith(testSelfLink) + expect(responseCacheService.get).toHaveBeenCalledWith(testSelfLink); }); it('shouldn\'t return anything if there is no response matching the request\'s link', () => { - responseCacheService = getMockResponseCacheService(undefined, cold('a', { a: undefined })); + responseCacheService = getMockResponseCacheService(undefined, cold('a', {a: undefined})); - const source = hot('a', { a: testSelfLink }); + const source = hot('a', {a: testSelfLink}); const result = source.pipe(getResponseFromSelflink(responseCacheService)); const expected = cold('-'); - expect(result).toBeObservable(expected) + expect(result).toBeObservable(expected); }); }); @@ -108,7 +113,7 @@ describe('Core Module - RxJS Operators', () => { const result = source.pipe(filterSuccessfulResponses()); const expected = cold('a--d-', testRCEs); - expect(result).toBeObservable(expected) + expect(result).toBeObservable(expected); }); }); @@ -121,7 +126,7 @@ describe('Core Module - RxJS Operators', () => { d: testRCEs.d.response.resourceSelfLinks }); - expect(result).toBeObservable(expected) + expect(result).toBeObservable(expected); }); }); @@ -129,24 +134,61 @@ describe('Core Module - RxJS Operators', () => { it('should call requestService.configure with the source request', () => { requestService = getMockRequestService(); const testRequest = new GetRequest('6b789e31-f026-4ff8-8993-4eb3b730c841', testSelfLink); - const source = hot('a', { a: testRequest }); + const source = hot('a', {a: testRequest}); scheduler.schedule(() => source.pipe(configureRequest(requestService)).subscribe()); scheduler.flush(); - expect(requestService.configure).toHaveBeenCalledWith(testRequest) + expect(requestService.configure).toHaveBeenCalledWith(testRequest); }); }); describe('getRemoteDataPayload', () => { it('should return the payload of the source RemoteData', () => { - const testRD = { a: { payload: 'a' } }; + const testRD = {a: {payload: 'a'}}; const source = hot('a', testRD); const result = source.pipe(getRemoteDataPayload()); const expected = cold('a', { 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'))); + + }); + + }); + 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 9622bb91e1..5434a4f04c 100644 --- a/src/app/core/shared/operators.ts +++ b/src/app/core/shared/operators.ts @@ -54,12 +54,16 @@ export const getSucceededRemoteData = () => (source: Observable>): Observable> => source.pipe(first((rd: RemoteData) => rd.hasSucceeded)); +export const getAllSucceededRemoteData = () => + (source: Observable>): Observable> => + source.pipe(filter((rd: RemoteData) => rd.hasSucceeded)); + export const toDSpaceObjectListRD = () => (source: Observable>>>): Observable>> => source.pipe( map((rd: RemoteData>>) => { const dsoPage: T[] = rd.payload.page.map((searchResult: SearchResult) => searchResult.dspaceObject); - const payload = Object.assign(rd.payload, { page: dsoPage }) as any; + const payload = Object.assign(rd.payload, {page: dsoPage}) as any; return Object.assign(rd, {payload: payload}); }) ); diff --git a/src/app/shared/testing/test-module.ts b/src/app/shared/testing/test-module.ts index 03d22640d3..b81b86575c 100644 --- a/src/app/shared/testing/test-module.ts +++ b/src/app/shared/testing/test-module.ts @@ -1,5 +1,8 @@ -import { NgModule } from '@angular/core'; +import {CUSTOM_ELEMENTS_SCHEMA, NgModule} from '@angular/core'; import { QueryParamsDirectiveStub } from './query-params-directive-stub'; +import { MySimpleItemActionComponent } from '../../+item-page/edit-item-page/simple-item-action/abstract-simple-item-action.component.spec'; +import {CommonModule} from '@angular/common'; +import {SharedModule} from '../shared.module'; /** * This module isn't used. It serves to prevent the AoT compiler @@ -8,8 +11,15 @@ import { QueryParamsDirectiveStub } from './query-params-directive-stub'; * See https://github.com/angular/angular/issues/13590 */ @NgModule({ + imports: [ + CommonModule, + SharedModule + ], declarations: [ - QueryParamsDirectiveStub + QueryParamsDirectiveStub, + MySimpleItemActionComponent + ], schemas: [ + CUSTOM_ELEMENTS_SCHEMA ] }) export class TestModule {} From b9ea5133e39a82c83fafe425896ff1d5b1034e8a Mon Sep 17 00:00:00 2001 From: Yana De Pauw Date: Wed, 19 Dec 2018 12:40:25 +0100 Subject: [PATCH 2/9] Remove edit page menu options not handled in this PR --- .../edit-item-page/item-status/item-status.component.ts | 2 -- 1 file changed, 2 deletions(-) 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 index 82c00a1d06..2b2c7a2ed4 100644 --- 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 @@ -59,8 +59,6 @@ export class ItemStatusComponent implements OnInit { The value is supposed to be a href for the button */ this.operations = []; - this.operations.push(new ItemOperation('mappedCollections', this.getCurrentUrl() + '/')); - this.operations.push(new ItemOperation('move', this.getCurrentUrl() + '/move')); if (this.item.isWithdrawn) { this.operations.push(new ItemOperation('reinstate', this.getCurrentUrl() + '/reinstate')); } else { From b305462449537abd3b218972fcfe01add224ae2a Mon Sep 17 00:00:00 2001 From: courtneypattison Date: Wed, 19 Dec 2018 13:53:19 -0800 Subject: [PATCH 3/9] Made home news responsive --- resources/images/dspace-logo.png | Bin 7709 -> 8609 bytes .../home-news/home-news.component.html | 6 ++---- .../home-news/home-news.component.scss | 9 --------- 3 files changed, 2 insertions(+), 13 deletions(-) diff --git a/resources/images/dspace-logo.png b/resources/images/dspace-logo.png index 5bb0b36addcc7bb68c8419881fa1f72e0e297d3e..40b59feeaaf5ab7d9b885f02820956ddda24e431 100644 GIT binary patch literal 8609 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 Fe*l0irBwg` 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..aa3b6e914d 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 4f4c6df128..dfcc293e21 100644 --- a/src/app/+home-page/home-news/home-news.component.scss +++ b/src/app/+home-page/home-news/home-news.component.scss @@ -5,12 +5,3 @@ margin-top: -$content-spacing; margin-bottom: -$content-spacing; } - -.dspace-logo-container { - margin: 10px 20px 0px 20px; -} - -.dspace-logo-container img { - max-height: 110px; - max-width: 110px; -} From 2e40b007ae16a881a214f98dabf824c0948929c6 Mon Sep 17 00:00:00 2001 From: Yana De Pauw Date: Thu, 20 Dec 2018 11:43:04 +0100 Subject: [PATCH 4/9] Add additional typedocs to Edit Item classes --- src/app/+item-page/edit-item-page/edit-item-page.module.ts | 3 +++ .../edit-item-page/edit-item-page.routing.module.ts | 3 +++ .../edit-item-page/item-operation/itemOperation.model.ts | 4 ++++ .../abstract-simple-item-action.component.spec.ts | 4 ++++ 4 files changed, 14 insertions(+) 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 index 9cc2ffe84d..0a7b363d6a 100644 --- 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 @@ -13,6 +13,9 @@ 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, 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 index 3d86aab741..8ef6f43e17 100644 --- 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 @@ -14,6 +14,9 @@ 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([ 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 index 0104dfbdb3..105889d42d 100644 --- 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 @@ -1,3 +1,7 @@ +/** + * 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; 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 index 9343e68655..2da671517c 100644 --- 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 @@ -18,6 +18,10 @@ import {RestResponse} from '../../../core/cache/response-cache.models'; import {of as observableOf} from 'rxjs'; import {getItemEditPath} from '../../item-page-routing.module'; +/** + * Test component that implements the AbstractSimpleItemActionComponent used to test the + * AbstractSimpleItemActionComponent component + */ @Component({ selector: 'ds-simple-action', templateUrl: './abstract-simple-item-action.component.html' From d3a3461651418b3f552acaabd1ff6a090564ae5d Mon Sep 17 00:00:00 2001 From: courtneypattison Date: Fri, 21 Dec 2018 11:47:35 -0800 Subject: [PATCH 5/9] Added logo dimensions to css --- src/app/+home-page/home-news/home-news.component.html | 2 +- src/app/+home-page/home-news/home-news.component.scss | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) 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 752f6171f6..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,7 +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 173f20ece4..3a0cbc7817 100644 --- a/src/app/+home-page/home-news/home-news.component.scss +++ b/src/app/+home-page/home-news/home-news.component.scss @@ -9,3 +9,8 @@ .display-3 { word-break: break-word; } + +.dspace-logo { + height: 110px; + width: 110px; +} From 3fcc74cd364ec7b8f68cf3f881947986ff61a0f2 Mon Sep 17 00:00:00 2001 From: Yana De Pauw Date: Thu, 10 Jan 2019 15:41:24 +0100 Subject: [PATCH 6/9] Fix small merge issue --- src/app/shared/testing/test-module.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/shared/testing/test-module.ts b/src/app/shared/testing/test-module.ts index 603acc5878..8f59d76c87 100644 --- a/src/app/shared/testing/test-module.ts +++ b/src/app/shared/testing/test-module.ts @@ -19,7 +19,7 @@ import { NgComponentOutletDirectiveStub } from './ng-component-outlet-directive- ], declarations: [ QueryParamsDirectiveStub, - MySimpleItemActionComponent + MySimpleItemActionComponent, RouterLinkDirectiveStub, NgComponentOutletDirectiveStub ], schemas: [ From 17be7a34e0c253d06b8d31c2a7a0c55db447517b Mon Sep 17 00:00:00 2001 From: Bram Luyten Date: Sun, 18 Nov 2018 08:21:51 +0100 Subject: [PATCH 7/9] Issue 298 Language switch --- config/environment.default.js | 22 ++- src/app/app.component.ts | 16 +- src/app/header/header.component.html | 3 +- .../lang-switch/lang-switch.component.html | 13 ++ .../lang-switch/lang-switch.component.scss | 0 .../lang-switch/lang-switch.component.spec.ts | 163 ++++++++++++++++++ .../lang-switch/lang-switch.component.ts | 49 ++++++ src/app/shared/shared.module.ts | 2 + src/config/global-config.interface.ts | 3 + src/config/lang-config.interface.ts | 12 ++ 10 files changed, 277 insertions(+), 6 deletions(-) create mode 100644 src/app/shared/lang-switch/lang-switch.component.html create mode 100644 src/app/shared/lang-switch/lang-switch.component.scss create mode 100644 src/app/shared/lang-switch/lang-switch.component.spec.ts create mode 100644 src/app/shared/lang-switch/lang-switch.component.ts create mode 100644 src/config/lang-config.interface.ts diff --git a/config/environment.default.js b/config/environment.default.js index a6ef738f41..d3758e66bd 100644 --- a/config/environment.default.js +++ b/config/environment.default.js @@ -52,5 +52,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/src/app/app.component.ts b/src/app/app.component.ts index a7b9fd59bf..ab66d738b9 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -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/header/header.component.html b/src/app/header/header.component.html index 461f809357..e975ec0cc1 100644 --- a/src/app/header/header.component.html +++ b/src/app/header/header.component.html @@ -6,7 +6,8 @@