diff --git a/config/environment.default.js b/config/environment.default.js index e3b5aef21db..f70f132fa45 100644 --- a/config/environment.default.js +++ b/config/environment.default.js @@ -48,6 +48,68 @@ module.exports = { // NOTE: 'fade' | 'fromTop' | 'fromRight' | 'fromBottom' | 'fromLeft' | 'rotate' | 'scale' animate: 'scale' }, + // Submission settings + submission: { + autosave: { + // NOTE: which metadata trigger an autosave + metadata: ['dc.title', 'dc.identifier.doi', 'dc.identifier.pmid', 'dc.identifier.arxiv'], + // NOTE: every how many minutes submission is saved automatically + timer: 5 + }, + icons: { + metadata: [ + /** + * NOTE: example of configuration + * { + * // NOTE: metadata name + * name: 'dc.author', + * // NOTE: fontawesome (v5.x) icon classes and bootstrap utility classes can be used + * style: 'fa-user' + * } + */ + { + name: 'dc.author', + style: 'fas fa-user' + }, + // default configuration + { + name: 'default', + style: '' + } + ], + authority: { + confidence: [ + /** + * NOTE: example of configuration + * { + * // NOTE: confidence value + * value: 'dc.author', + * // NOTE: fontawesome (v4.x) icon classes and bootstrap utility classes can be used + * style: 'fa-user' + * } + */ + { + value: 600, + style: 'text-success' + }, + { + value: 500, + style: 'text-info' + }, + { + value: 400, + style: 'text-warning' + }, + // default configuration + { + value: 'default', + style: 'text-muted' + }, + + ] + } + } + }, // Angular Universal settings universal: { preboot: true, diff --git a/package.json b/package.json index fd037de3f73..4a1fe8884e4 100644 --- a/package.json +++ b/package.json @@ -75,8 +75,8 @@ "@angular/router": "^6.1.4", "@angularclass/bootloader": "1.0.1", "@ng-bootstrap/ng-bootstrap": "^2.0.0", - "@ng-dynamic-forms/core": "6.0.9", - "@ng-dynamic-forms/ui-ng-bootstrap": "6.0.9", + "@ng-dynamic-forms/core": "6.2.0", + "@ng-dynamic-forms/ui-ng-bootstrap": "6.2.0", "@ngrx/effects": "^6.1.0", "@ngrx/router-store": "^6.1.0", "@ngrx/store": "^6.1.0", @@ -97,6 +97,7 @@ "express": "4.16.2", "express-session": "1.15.6", "fast-json-patch": "^2.0.7", + "file-saver": "^1.3.8", "font-awesome": "4.7.0", "fork-ts-checker-webpack-plugin": "^0.4.10", "http-server": "0.11.1", @@ -142,6 +143,7 @@ "@types/deep-freeze": "0.1.1", "@types/express": "^4.11.1", "@types/express-serve-static-core": "4.16.0", + "@types/file-saver": "^1.3.0", "@types/hammerjs": "2.0.35", "@types/jasmine": "^2.8.6", "@types/js-cookie": "2.1.0", diff --git a/resources/i18n/en.json b/resources/i18n/en.json index 502c638b0e5..42847f8ec8c 100644 --- a/resources/i18n/en.json +++ b/resources/i18n/en.json @@ -623,13 +623,25 @@ "license": { "notgranted": "You must grant this license to complete your submission. If you are unable to grant this license at this time you may save your work and return later or remove the submission." } + }, + "submission": { + "sections": { + "init-form-error": "An error occurred during section initialize, please check your input-form configuration. Details are below :

" + } } }, "form": { "submit": "Submit", "cancel": "Cancel", "search": "Search", + "search-help": "Click here to looking for an existing correspondence", "remove": "Remove", + "clear": "Clear", + "clear-help": "Click here to remove the selected value", + "edit": "Edit", + "edit-help": "Click here to edit the selected value", + "save": "Save", + "save-help": "Save changes", "first-name": "First name", "last-name": "Last name", "loading": "Loading...", @@ -638,7 +650,9 @@ "group-collapse": "Collapse", "group-expand": "Expand", "group-collapse-help": "Click here to collapse", - "group-expand-help": "Click here to expand and add more elements" + "group-expand-help": "Click here to expand and add more element", + "other-information": { + } }, "login": { "title": "Login", @@ -668,5 +682,97 @@ }, "chips": { "remove": "Remove chip" + }, + "submission": { + "general":{ + "cannot_submit": "You have not the privilege to make a new submission.", + "deposit": "Deposit", + "discard": { + "submit": "Discard", + "confirm": { + "cancel": "Cancel", + "submit": "Yes, I'm sure", + "title": "Discard submission", + "info": "This operation can't be undone. Are you sure?" + } + }, + "save": "Save", + "save-later": "Save for later" + }, + "submit": { + "title": "Submission" + }, + "edit": { + "title": "Edit Submission" + }, + "mydspace": { + + }, + "sections": { + + "general": { + "add-more": "Add more", + "no-sections": "No options available", + "sections_not_valid": "There are incomplete sections.", + "collection": "Collection", + "no-collection": "No collection found", + "search-collection": "Search for a collection", + "save_error_notice": "There was an issue when saving the item, please try again later.", + "deposit_success_notice": "Submission deposited successfully.", + "deposit_error_notice": "There was an issue when submitting the item, please try again later.", + "discard_success_notice": "Submission discarded successfully.", + "discard_error_notice": "There was an issue when discarding the item, please try again later.", + "save_success_notice": "Submission saved successfully.", + "metadata-extracted": "New metadata have been extracted and added to the {{sectionId}} section.", + "metadata-extracted-new-section": "New {{sectionId}} section has been added to submission." + }, + "submit.progressbar.describe.stepone": "Describe", + "submit.progressbar.describe.steptwo": "Describe", + "submit.progressbar.describe.stepcustom": "Describe", + "submit.progressbar.describe.recycle": "Recycle", + "submit.progressbar.upload": "Upload files", + "submit.progressbar.license": "Deposit license", + "submit.progressbar.cclicense": "Creative commons license", + "submit.progressbar.detect-duplicate": "Potential duplicates", + + "upload": { + "no-entry": "No", + "no-file-uploaded": "No file uploaded yet.", + "info": "Here you will find all the files currently in the item. You can update the fle metadata and access conditions or upload additional files just dragging & dropping them everywhere in the page", + "drop-message": "Drop files to attach them to the item", + "upload-successful": "Upload successful", + "upload-failed": "Upload failed", + "header.policy.default.nolist": "Uploaded files in the {{collectionName}} collection will be accessible according to the following group(s):", + "header.policy.default.withlist": "Please note that uploaded files in the {{collectionName}} collection will be accessible, in addition to what is explicity decided for the single file, with the following group(s):", + "form": { + "access-condition-label": "Access condition type", + "from-label": "Access grant from", + "from-placeholder": "From", + "until-label": "Access grant until", + "until-placeholder": "Until", + "group-label": "Group", + "group-required": "Group is required.", + "date-required": "Date is required." + }, + "save-metadata": "Save metadata", + "undo": "Cancel", + "delete": { + "submit": "Delete", + "confirm": { + "cancel": "Cancel", + "submit": "Yes, I'm sure", + "title": "Delete bitstream", + "info": "This operation can't be undone. Are you sure?" + } + } + } + } + }, + "uploader": { + "drag-message": "Drag & Drop your files here", + "or": ", or", + "browse": "browse", + "queue-lenght": "Queue length", + "processing": "Processing" } } diff --git a/src/app/+admin/admin-registries/metadata-registry/metadata-registry.component.spec.ts b/src/app/+admin/admin-registries/metadata-registry/metadata-registry.component.spec.ts index 66088236a4a..d641c973522 100644 --- a/src/app/+admin/admin-registries/metadata-registry/metadata-registry.component.spec.ts +++ b/src/app/+admin/admin-registries/metadata-registry/metadata-registry.component.spec.ts @@ -1,5 +1,5 @@ import { MetadataRegistryComponent } from './metadata-registry.component'; -import { async, ComponentFixture, fakeAsync, inject, TestBed, tick } from '@angular/core/testing'; +import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; import { of as observableOf } from 'rxjs'; import { RemoteData } from '../../../core/data/remote-data'; import { PaginatedList } from '../../../core/data/paginated-list'; @@ -44,7 +44,7 @@ describe('MetadataRegistryComponent', () => { getSelectedMetadataSchemas: () => observableOf([]), editMetadataSchema: (schema) => {}, cancelEditMetadataSchema: () => {}, - deleteMetadataSchema: () => observableOf(new RestResponse(true, '200')), + deleteMetadataSchema: () => observableOf(new RestResponse(true, 200, 'OK')), deselectAllMetadataSchema: () => {}, clearMetadataSchemaRequests: () => observableOf(undefined) }; diff --git a/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.spec.ts b/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.spec.ts index 37fb51e5c70..674798848b0 100644 --- a/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.spec.ts +++ b/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.spec.ts @@ -84,7 +84,7 @@ describe('MetadataSchemaComponent', () => { getSelectedMetadataFields: () => observableOf([]), editMetadataField: (schema) => {}, cancelEditMetadataField: () => {}, - deleteMetadataField: () => observableOf(new RestResponse(true, '200')), + deleteMetadataField: () => observableOf(new RestResponse(true, 200, 'OK')), deselectAllMetadataField: () => {}, clearMetadataFieldRequests: () => observableOf(undefined) }; diff --git a/src/app/+collection-page/collection-page.component.html b/src/app/+collection-page/collection-page.component.html index 6e411cb29d9..6265b223d88 100644 --- a/src/app/+collection-page/collection-page.component.html +++ b/src/app/+collection-page/collection-page.component.html @@ -32,7 +32,7 @@ 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 index 9f9447704b4..651bebde587 100644 --- 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 @@ -1,20 +1,20 @@ -import {async, ComponentFixture, TestBed} from '@angular/core/testing'; -import {Item} from '../../../core/shared/item.model'; -import {RouterStub} from '../../../shared/testing/router-stub'; -import {of as observableOf} from 'rxjs'; -import {RemoteData} from '../../../core/data/remote-data'; -import {NotificationsServiceStub} from '../../../shared/testing/notifications-service-stub'; -import {CommonModule} from '@angular/common'; -import {FormsModule} from '@angular/forms'; -import {RouterTestingModule} from '@angular/router/testing'; -import {TranslateModule} from '@ngx-translate/core'; -import {NgbModule} from '@ng-bootstrap/ng-bootstrap'; -import {ActivatedRoute, Router} from '@angular/router'; -import {ItemDataService} from '../../../core/data/item-data.service'; -import {NotificationsService} from '../../../shared/notifications/notifications.service'; -import {CUSTOM_ELEMENTS_SCHEMA} from '@angular/core'; -import {By} from '@angular/platform-browser'; -import {ItemPrivateComponent} from './item-private.component'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { Item } from '../../../core/shared/item.model'; +import { RouterStub } from '../../../shared/testing/router-stub'; +import { of as observableOf } from 'rxjs'; +import { RemoteData } from '../../../core/data/remote-data'; +import { NotificationsServiceStub } from '../../../shared/testing/notifications-service-stub'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { ActivatedRoute, Router } from '@angular/router'; +import { ItemDataService } from '../../../core/data/item-data.service'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { ItemPrivateComponent } from './item-private.component'; import { RestResponse } from '../../../core/cache/response.models'; let comp: ItemPrivateComponent; @@ -44,8 +44,8 @@ describe('ItemPrivateComponent', () => { url: `${itemPageUrl}/edit` }); - mockItemDataService = jasmine.createSpyObj('mockItemDataService',{ - setDiscoverable: observableOf(new RestResponse(true, '200')) + mockItemDataService = jasmine.createSpyObj('mockItemDataService', { + setDiscoverable: observableOf(new RestResponse(true, 200, 'OK')) }); routeStub = { @@ -62,10 +62,10 @@ describe('ItemPrivateComponent', () => { 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}, + { provide: ActivatedRoute, useValue: routeStub }, + { provide: Router, useValue: routerStub }, + { provide: ItemDataService, useValue: mockItemDataService }, + { provide: NotificationsService, useValue: notificationsServiceStub }, ], schemas: [ CUSTOM_ELEMENTS_SCHEMA ] @@ -73,8 +73,8 @@ describe('ItemPrivateComponent', () => { })); beforeEach(() => { - successfulRestResponse = new RestResponse(true, '200'); - failRestResponse = new RestResponse(false, '500'); + successfulRestResponse = new RestResponse(true, 200, 'OK'); + failRestResponse = new RestResponse(false, 500, 'Internal Server Error'); fixture = TestBed.createComponent(ItemPrivateComponent); comp = fixture.componentInstance; 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 index 97c81681d0d..7516a842658 100644 --- 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 @@ -44,8 +44,8 @@ describe('ItemPublicComponent', () => { url: `${itemPageUrl}/edit` }); - mockItemDataService = jasmine.createSpyObj('mockItemDataService',{ - setDiscoverable: observableOf(new RestResponse(true, '200')) + mockItemDataService = jasmine.createSpyObj('mockItemDataService', { + setDiscoverable: observableOf(new RestResponse(true, 200, 'OK')) }); routeStub = { @@ -62,10 +62,10 @@ describe('ItemPublicComponent', () => { 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}, + { provide: ActivatedRoute, useValue: routeStub }, + { provide: Router, useValue: routerStub }, + { provide: ItemDataService, useValue: mockItemDataService }, + { provide: NotificationsService, useValue: notificationsServiceStub }, ], schemas: [ CUSTOM_ELEMENTS_SCHEMA ] @@ -73,8 +73,8 @@ describe('ItemPublicComponent', () => { })); beforeEach(() => { - successfulRestResponse = new RestResponse(true, '200'); - failRestResponse = new RestResponse(false, '500'); + successfulRestResponse = new RestResponse(true, 200, 'OK'); + failRestResponse = new RestResponse(false, 500, 'Internal Server Error'); fixture = TestBed.createComponent(ItemPublicComponent); comp = fixture.componentInstance; 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 index e89eda736fe..f606fb4a83a 100644 --- 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 @@ -44,8 +44,8 @@ describe('ItemReinstateComponent', () => { url: `${itemPageUrl}/edit` }); - mockItemDataService = jasmine.createSpyObj('mockItemDataService',{ - setWithDrawn: observableOf(new RestResponse(true, '200')) + mockItemDataService = jasmine.createSpyObj('mockItemDataService', { + setWithDrawn: observableOf(new RestResponse(true, 200, 'OK')) }); routeStub = { @@ -62,10 +62,10 @@ describe('ItemReinstateComponent', () => { 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}, + { provide: ActivatedRoute, useValue: routeStub }, + { provide: Router, useValue: routerStub }, + { provide: ItemDataService, useValue: mockItemDataService }, + { provide: NotificationsService, useValue: notificationsServiceStub }, ], schemas: [ CUSTOM_ELEMENTS_SCHEMA ] @@ -73,8 +73,8 @@ describe('ItemReinstateComponent', () => { })); beforeEach(() => { - successfulRestResponse = new RestResponse(true, '200'); - failRestResponse = new RestResponse(false, '500'); + successfulRestResponse = new RestResponse(true, 200, 'OK'); + failRestResponse = new RestResponse(false, 500, 'Internal Server Error'); fixture = TestBed.createComponent(ItemReinstateComponent); comp = fixture.componentInstance; 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 index 9305459c121..ac49eee7e72 100644 --- 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 @@ -44,8 +44,8 @@ describe('ItemWithdrawComponent', () => { url: `${itemPageUrl}/edit` }); - mockItemDataService = jasmine.createSpyObj('mockItemDataService',{ - setWithDrawn: observableOf(new RestResponse(true, '200')) + mockItemDataService = jasmine.createSpyObj('mockItemDataService', { + setWithDrawn: observableOf(new RestResponse(true, 200, 'OK')) }); routeStub = { @@ -62,10 +62,10 @@ describe('ItemWithdrawComponent', () => { 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}, + { provide: ActivatedRoute, useValue: routeStub }, + { provide: Router, useValue: routerStub }, + { provide: ItemDataService, useValue: mockItemDataService }, + { provide: NotificationsService, useValue: notificationsServiceStub }, ], schemas: [ CUSTOM_ELEMENTS_SCHEMA ] @@ -73,8 +73,8 @@ describe('ItemWithdrawComponent', () => { })); beforeEach(() => { - successfulRestResponse = new RestResponse(true, '200'); - failRestResponse = new RestResponse(false, '500'); + successfulRestResponse = new RestResponse(true, 200, 'OK'); + failRestResponse = new RestResponse(false, 500, 'Internal Server Error'); fixture = TestBed.createComponent(ItemWithdrawComponent); comp = fixture.componentInstance; 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 1c4cae552e4..32acdef4679 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 @@ -82,10 +82,10 @@ describe('AbstractSimpleItemActionComponent', () => { 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}, + { provide: ActivatedRoute, useValue: routeStub }, + { provide: Router, useValue: routerStub }, + { provide: ItemDataService, useValue: mockItemDataService }, + { provide: NotificationsService, useValue: notificationsServiceStub }, ], schemas: [ CUSTOM_ELEMENTS_SCHEMA ] @@ -93,14 +93,19 @@ describe('AbstractSimpleItemActionComponent', () => { })); beforeEach(() => { - successfulRestResponse = new RestResponse(true, '200'); - failRestResponse = new RestResponse(false, '500'); + successfulRestResponse = new RestResponse(true, 200, 'OK'); + failRestResponse = new RestResponse(false, 500, 'Internal Server Error'); fixture = TestBed.createComponent(MySimpleItemActionComponent); comp = fixture.componentInstance; fixture.detectChanges(); }); + afterEach(() => { + fixture.destroy(); + comp = null; + }); + 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'); @@ -124,7 +129,6 @@ describe('AbstractSimpleItemActionComponent', () => { }); it('should process a RestResponse to navigate and display success notification', () => { - spyOn(notificationsServiceStub, 'success'); comp.processRestResponse(successfulRestResponse); expect(notificationsServiceStub.success).toHaveBeenCalled(); @@ -132,7 +136,6 @@ describe('AbstractSimpleItemActionComponent', () => { }); it('should process a RestResponse to navigate and display success notification', () => { - spyOn(notificationsServiceStub, 'error'); comp.processRestResponse(failRestResponse); expect(notificationsServiceStub.error).toHaveBeenCalled(); diff --git a/src/app/+item-page/item-page.module.ts b/src/app/+item-page/item-page.module.ts index d383189a9c0..c60f9d35832 100644 --- a/src/app/+item-page/item-page.module.ts +++ b/src/app/+item-page/item-page.module.ts @@ -1,7 +1,7 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { SharedModule } from './../shared/shared.module'; +import { SharedModule } from '../shared/shared.module'; import { ItemPageComponent } from './simple/item-page.component'; import { ItemPageRoutingModule } from './item-page-routing.module'; diff --git a/src/app/+login-page/login-page-routing.module.ts b/src/app/+login-page/login-page-routing.module.ts index 4e932c50ced..d3c6425dd38 100644 --- a/src/app/+login-page/login-page-routing.module.ts +++ b/src/app/+login-page/login-page-routing.module.ts @@ -6,7 +6,7 @@ import { LoginPageComponent } from './login-page.component'; @NgModule({ imports: [ RouterModule.forChild([ - { path: '', component: LoginPageComponent, data: { title: 'login.title' } } + { path: '', pathMatch: 'full', component: LoginPageComponent, data: { title: 'login.title' } } ]) ] }) diff --git a/src/app/+login-page/login-page.component.spec.ts b/src/app/+login-page/login-page.component.spec.ts index 234435a410b..74ce5d4f9a9 100644 --- a/src/app/+login-page/login-page.component.spec.ts +++ b/src/app/+login-page/login-page.component.spec.ts @@ -1,15 +1,20 @@ import { NO_ERRORS_SCHEMA } from '@angular/core'; import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; import { Store } from '@ngrx/store'; import { TranslateModule } from '@ngx-translate/core'; import { of as observableOf } from 'rxjs'; import { LoginPageComponent } from './login-page.component'; +import { ActivatedRouteStub } from '../shared/testing/active-router-stub'; describe('LoginPageComponent', () => { let comp: LoginPageComponent; let fixture: ComponentFixture; + const activatedRouteStub = Object.assign(new ActivatedRouteStub(), { + params: observableOf({}) + }); const store: Store = jasmine.createSpyObj('store', { /* tslint:disable:no-empty */ @@ -25,9 +30,8 @@ describe('LoginPageComponent', () => { ], declarations: [LoginPageComponent], providers: [ - { - provide: Store, useValue: store - } + { provide: ActivatedRoute, useValue: activatedRouteStub }, + { provide: Store, useValue: store } ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); diff --git a/src/app/+login-page/login-page.component.ts b/src/app/+login-page/login-page.component.ts index 27529731303..1f1cf7cf04e 100644 --- a/src/app/+login-page/login-page.component.ts +++ b/src/app/+login-page/login-page.component.ts @@ -1,20 +1,61 @@ -import { Component, OnDestroy } from '@angular/core'; +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { combineLatest as observableCombineLatest, Observable, Subscription } from 'rxjs'; +import { filter, take } from 'rxjs/operators'; import { Store } from '@ngrx/store'; import { AppState } from '../app.reducer'; -import { ResetAuthenticationMessagesAction } from '../core/auth/auth.actions'; +import { + AddAuthenticationMessageAction, + AuthenticatedAction, + AuthenticationSuccessAction, + ResetAuthenticationMessagesAction +} from '../core/auth/auth.actions'; +import { hasValue, isNotEmpty } from '../shared/empty.util'; +import { AuthTokenInfo } from '../core/auth/models/auth-token-info.model'; +import { isAuthenticated } from '../core/auth/selectors'; @Component({ selector: 'ds-login-page', styleUrls: ['./login-page.component.scss'], templateUrl: './login-page.component.html' }) -export class LoginPageComponent implements OnDestroy { +export class LoginPageComponent implements OnDestroy, OnInit { + sub: Subscription; - constructor(private store: Store) {} + constructor(private route: ActivatedRoute, + private store: Store) {} + + ngOnInit() { + const queryParamsObs = this.route.queryParams; + const authenticated = this.store.select(isAuthenticated); + this.sub = observableCombineLatest(queryParamsObs, authenticated).pipe( + filter(([params, auth]) => isNotEmpty(params.token) || isNotEmpty(params.expired)), + take(1) + ).subscribe(([params, auth]) => { + const token = params.token; + let authToken: AuthTokenInfo; + if (!auth) { + if (isNotEmpty(token)) { + authToken = new AuthTokenInfo(token); + this.store.dispatch(new AuthenticatedAction(authToken)); + } else if (isNotEmpty(params.expired)) { + this.store.dispatch(new AddAuthenticationMessageAction('auth.messages.expired')); + } + } else { + if (isNotEmpty(token)) { + authToken = new AuthTokenInfo(token); + this.store.dispatch(new AuthenticationSuccessAction(authToken)); + } + } + }) + } ngOnDestroy() { + if (hasValue(this.sub)) { + this.sub.unsubscribe(); + } // Clear all authentication messages when leaving login page this.store.dispatch(new ResetAuthenticationMessagesAction()); } diff --git a/src/app/+search-page/search-service/search.service.spec.ts b/src/app/+search-page/search-service/search.service.spec.ts index 4af0ffcb2eb..ca48b02aa72 100644 --- a/src/app/+search-page/search-service/search.service.spec.ts +++ b/src/app/+search-page/search-service/search.service.spec.ts @@ -155,7 +155,7 @@ describe('SearchService', () => { const endPoint = 'http://endpoint.com/test/test'; const searchOptions = new PaginatedSearchOptions({}); const queryResponse = Object.assign(new SearchQueryResponse(), { objects: [] }); - const response = new SearchSuccessResponse(queryResponse, '200'); + const response = new SearchSuccessResponse(queryResponse, 200, 'OK'); beforeEach(() => { spyOn((searchService as any).halService, 'getEndpoint').and.returnValue(observableOf(endPoint)); /* tslint:disable:no-empty */ @@ -180,7 +180,7 @@ describe('SearchService', () => { describe('when getConfig is called without a scope', () => { const endPoint = 'http://endpoint.com/test/config'; const filterConfig = [new SearchFilterConfig()]; - const response = new FacetConfigSuccessResponse(filterConfig, '200'); + const response = new FacetConfigSuccessResponse(filterConfig, 200, 'OK'); beforeEach(() => { spyOn((searchService as any).halService, 'getEndpoint').and.returnValue(observableOf(endPoint)); /* tslint:disable:no-empty */ @@ -207,7 +207,7 @@ describe('SearchService', () => { const scope = 'test'; const requestUrl = endPoint + '?scope=' + scope; const filterConfig = [new SearchFilterConfig()]; - const response = new FacetConfigSuccessResponse(filterConfig, '200'); + const response = new FacetConfigSuccessResponse(filterConfig, 200, 'OK'); beforeEach(() => { spyOn((searchService as any).halService, 'getEndpoint').and.returnValue(observableOf(endPoint)); /* tslint:disable:no-empty */ diff --git a/src/app/+submit-page/submit-page-routing.module.ts b/src/app/+submit-page/submit-page-routing.module.ts new file mode 100644 index 00000000000..7a123bfc31b --- /dev/null +++ b/src/app/+submit-page/submit-page-routing.module.ts @@ -0,0 +1,23 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; + +import { AuthenticatedGuard } from '../core/auth/authenticated.guard'; +import { SubmissionSubmitComponent } from '../submission/submit/submission-submit.component'; + +@NgModule({ + imports: [ + RouterModule.forChild([ + { + canActivate: [AuthenticatedGuard], + path: '', + pathMatch: 'full', + component: SubmissionSubmitComponent, + data: { title: 'submission.submit.title' } + } + ]) + ] +}) +/** + * This module defines the default component to load when navigating to the submit page path. + */ +export class SubmitPageRoutingModule { } diff --git a/src/app/+submit-page/submit-page.module.ts b/src/app/+submit-page/submit-page.module.ts new file mode 100644 index 00000000000..e43d9d36aaf --- /dev/null +++ b/src/app/+submit-page/submit-page.module.ts @@ -0,0 +1,20 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { SharedModule } from '../shared/shared.module'; +import { SubmitPageRoutingModule } from './submit-page-routing.module'; +import { SubmissionModule } from '../submission/submission.module'; + +@NgModule({ + imports: [ + SubmitPageRoutingModule, + CommonModule, + SharedModule, + SubmissionModule, + ], +}) +/** + * This module handles all modules that need to access the submit page. + */ +export class SubmitPageModule { + +} diff --git a/src/app/+workflowitems-edit-page/workflowitems-edit-page-routing.module.ts b/src/app/+workflowitems-edit-page/workflowitems-edit-page-routing.module.ts new file mode 100644 index 00000000000..aa182eb2911 --- /dev/null +++ b/src/app/+workflowitems-edit-page/workflowitems-edit-page-routing.module.ts @@ -0,0 +1,23 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; + +import { AuthenticatedGuard } from '../core/auth/authenticated.guard'; +import { SubmissionEditComponent } from '../submission/edit/submission-edit.component'; + +@NgModule({ + imports: [ + RouterModule.forChild([ + { path: '', redirectTo: '/home', pathMatch: 'full' }, + { + canActivate: [AuthenticatedGuard], + path: ':id/edit', + component: SubmissionEditComponent, + data: { title: 'submission.edit.title' } + } + ]) + ] +}) +/** + * This module defines the default component to load when navigating to the workflowitems edit page path. + */ +export class WorkflowitemsEditPageRoutingModule { } diff --git a/src/app/+workflowitems-edit-page/workflowitems-edit-page.module.ts b/src/app/+workflowitems-edit-page/workflowitems-edit-page.module.ts new file mode 100644 index 00000000000..fbb53d8dcc8 --- /dev/null +++ b/src/app/+workflowitems-edit-page/workflowitems-edit-page.module.ts @@ -0,0 +1,21 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { SharedModule } from '../shared/shared.module'; +import { WorkflowitemsEditPageRoutingModule } from './workflowitems-edit-page-routing.module'; +import { SubmissionModule } from '../submission/submission.module'; + +@NgModule({ + imports: [ + WorkflowitemsEditPageRoutingModule, + CommonModule, + SharedModule, + SubmissionModule, + ], + declarations: [] +}) +/** + * This module handles all modules that need to access the workflowitems edit page. + */ +export class WorkflowitemsEditPageModule { + +} diff --git a/src/app/+workspaceitems-edit-page/workspaceitems-edit-page-routing.module.ts b/src/app/+workspaceitems-edit-page/workspaceitems-edit-page-routing.module.ts new file mode 100644 index 00000000000..d10c53e1381 --- /dev/null +++ b/src/app/+workspaceitems-edit-page/workspaceitems-edit-page-routing.module.ts @@ -0,0 +1,23 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; + +import { AuthenticatedGuard } from '../core/auth/authenticated.guard'; +import { SubmissionEditComponent } from '../submission/edit/submission-edit.component'; + +@NgModule({ + imports: [ + RouterModule.forChild([ + { path: '', redirectTo: '/home', pathMatch: 'full' }, + { + canActivate: [AuthenticatedGuard], + path: ':id/edit', + component: SubmissionEditComponent, + data: { title: 'submission.edit.title' } + } + ]) + ] +}) +/** + * This module defines the default component to load when navigating to the workspaceitems edit page path + */ +export class WorkspaceitemsEditPageRoutingModule { } diff --git a/src/app/+workspaceitems-edit-page/workspaceitems-edit-page.module.ts b/src/app/+workspaceitems-edit-page/workspaceitems-edit-page.module.ts new file mode 100644 index 00000000000..65a40f3f7c8 --- /dev/null +++ b/src/app/+workspaceitems-edit-page/workspaceitems-edit-page.module.ts @@ -0,0 +1,21 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { SharedModule } from '../shared/shared.module'; +import { WorkspaceitemsEditPageRoutingModule } from './workspaceitems-edit-page-routing.module'; +import { SubmissionModule } from '../submission/submission.module'; + +@NgModule({ + imports: [ + WorkspaceitemsEditPageRoutingModule, + CommonModule, + SharedModule, + SubmissionModule, + ], + declarations: [] +}) +/** + * This module handles all modules that need to access the workspaceitems edit page. + */ +export class WorkspaceitemsEditPageModule { + +} diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 1476aa5dade..03edd698fd7 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -21,6 +21,9 @@ export function getItemModulePath() { { path: 'admin', loadChildren: './+admin/admin.module#AdminModule', canActivate: [AuthenticatedGuard] }, { path: 'login', loadChildren: './+login-page/login-page.module#LoginPageModule' }, { path: 'logout', loadChildren: './+logout-page/logout-page.module#LogoutPageModule' }, + { path: 'submit', loadChildren: './+submit-page/submit-page.module#SubmitPageModule' }, + { path: 'workspaceitems', loadChildren: './+workspaceitems-edit-page/workspaceitems-edit-page.module#WorkspaceitemsEditPageModule' }, + { path: 'workflowitems', loadChildren: './+workflowitems-edit-page/workflowitems-edit-page.module#WorkflowitemsEditPageModule' }, { path: '**', pathMatch: 'full', component: PageNotFoundComponent }, ]) ], diff --git a/src/app/app.component.scss b/src/app/app.component.scss index c90d35678d0..fa7e7a873ad 100644 --- a/src/app/app.component.scss +++ b/src/app/app.component.scss @@ -30,11 +30,16 @@ body { .main-content { z-index: $main-z-index; - flex: 1 0 auto; + flex: 1 1 100%; margin-top: $content-spacing; margin-bottom: $content-spacing; } +.alert.hide { + padding: 0; + margin: 0; +} + ds-header-navbar-wrapper { z-index: $nav-z-index; } diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index e079400e854..bd2d832c675 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -34,13 +34,16 @@ import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; import { AngularticsMock } from './shared/mocks/mock-angulartics.service'; import { AuthServiceMock } from './shared/mocks/mock-auth.service'; import { AuthService } from './core/auth/auth.service'; -import { Router } from '@angular/router'; import { MenuService } from './shared/menu/menu.service'; import { CSSVariableService } from './shared/sass-helper/sass-helper.service'; import { CSSVariableServiceStub } from './shared/testing/css-variable-service-stub'; import { MenuServiceStub } from './shared/testing/menu-service-stub'; import { HostWindowService } from './shared/host-window.service'; import { HostWindowServiceStub } from './shared/testing/host-window-service-stub'; +import { ActivatedRoute, Router } from '@angular/router'; +import { RouteService } from './shared/services/route.service'; +import { MockActivatedRoute } from './shared/mocks/mock-active-router'; +import { MockRouter } from './shared/mocks/mock-router'; let comp: AppComponent; let fixture: ComponentFixture; @@ -70,11 +73,13 @@ describe('App component', () => { { provide: MetadataService, useValue: new MockMetadataService() }, { provide: Angulartics2GoogleAnalytics, useValue: new AngularticsMock() }, { provide: AuthService, useValue: new AuthServiceMock() }, - { provide: Router, useValue: {} }, + { provide: Router, useValue: new MockRouter() }, + { provide: ActivatedRoute, useValue: new MockActivatedRoute() }, { provide: MenuService, useValue: menuService }, { provide: CSSVariableService, useClass: CSSVariableServiceStub }, { provide: HostWindowService, useValue: new HostWindowServiceStub(800) }, - AppComponent + AppComponent, + RouteService ], schemas: [CUSTOM_ELEMENTS_SCHEMA] }) diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 10c6643fbbd..da01b1297a7 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -23,6 +23,7 @@ import { NativeWindowRef, NativeWindowService } from './shared/services/window.s import { isAuthenticated } from './core/auth/selectors'; import { AuthService } from './core/auth/auth.service'; import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; +import { RouteService } from './shared/services/route.service'; import variables from '../styles/_exposed_variables.scss'; import { CSSVariableService } from './shared/sass-helper/sass-helper.service'; import { MenuService } from './shared/menu/menu.service'; @@ -56,6 +57,7 @@ export class AppComponent implements OnInit, AfterViewInit { private angulartics2GoogleAnalytics: Angulartics2GoogleAnalytics, private authService: AuthService, private router: Router, + private routeService: RouteService, private cssService: CSSVariableService, private menuService: MenuService, private windowService: HostWindowService @@ -75,6 +77,8 @@ export class AppComponent implements OnInit, AfterViewInit { metadata.listenForRouteChange(); + routeService.saveRouting(); + if (config.debug) { console.info(config); } @@ -83,7 +87,6 @@ export class AppComponent implements OnInit, AfterViewInit { } ngOnInit() { - const env: string = this.config.production ? 'Production' : 'Development'; const color: string = this.config.production ? 'red' : 'green'; console.info(`Environment: %c${env}`, `color: ${color}; font-weight: bold;`); diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 8c4126f8ede..f9d6e50dcc6 100755 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -31,6 +31,7 @@ import { DSpaceRouterStateSerializer } from './shared/ngrx/dspace-router-state-s import { NotificationsBoardComponent } from './shared/notifications/notifications-board/notifications-board.component'; import { NotificationComponent } from './shared/notifications/notification/notification.component'; import { SharedModule } from './shared/shared.module'; +import { ScrollToModule } from '@nicky-lenaers/ngx-scroll-to'; import { HeaderNavbarWrapperComponent } from './header-nav-wrapper/header-navbar-wrapper.component'; import { AdminSidebarComponent } from './+admin/admin-sidebar/admin-sidebar.component'; import { AdminSidebarSectionComponent } from './+admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component'; @@ -57,6 +58,7 @@ const IMPORTS = [ HttpClientModule, AppRoutingModule, CoreModule.forRoot(), + ScrollToModule.forRoot(), NgbModule.forRoot(), TranslateModule.forRoot(), EffectsModule.forRoot(appEffects), diff --git a/src/app/app.reducer.ts b/src/app/app.reducer.ts index 756bcb6f233..ea2512a9744 100644 --- a/src/app/app.reducer.ts +++ b/src/app/app.reducer.ts @@ -22,9 +22,11 @@ import { import { hasValue } from './shared/empty.util'; import { cssVariablesReducer, CSSVariablesState } from './shared/sass-helper/sass-helper.reducer'; import { menusReducer, MenusState } from './shared/menu/menu.reducer'; +import { historyReducer, HistoryState } from './shared/history/history.reducer'; export interface AppState { router: fromRouter.RouterReducerState; + history: HistoryState; hostWindow: HostWindowState; forms: FormState; metadataRegistry: MetadataRegistryState; @@ -38,6 +40,7 @@ export interface AppState { export const appReducers: ActionReducerMap = { router: fromRouter.routerReducer, + history: historyReducer, hostWindow: hostWindowReducer, forms: formReducer, metadataRegistry: metadataRegistryReducer, diff --git a/src/app/core/auth/auth-response-parsing.service.spec.ts b/src/app/core/auth/auth-response-parsing.service.spec.ts index ee9f2e571bb..0b2c32fc047 100644 --- a/src/app/core/auth/auth-response-parsing.service.spec.ts +++ b/src/app/core/auth/auth-response-parsing.service.spec.ts @@ -1,20 +1,36 @@ -import { AuthStatusResponse } from '../cache/response.models'; +import { async, TestBed } from '@angular/core/testing'; + +import { Store, StoreModule } from '@ngrx/store'; +import { GlobalConfig } from '../../../config/global-config.interface'; +import { AuthStatusResponse } from '../cache/response.models'; import { ObjectCacheService } from '../cache/object-cache.service'; import { AuthStatus } from './models/auth-status.model'; import { AuthResponseParsingService } from './auth-response-parsing.service'; import { AuthGetRequest, AuthPostRequest } from '../data/request.models'; import { MockStore } from '../../shared/testing/mock-store'; -import { ObjectCacheState } from '../cache/object-cache.reducer'; describe('AuthResponseParsingService', () => { let service: AuthResponseParsingService; - const EnvConfig = { cache: { msToLive: 1000 } } as any; - const store = new MockStore({}); - const objectCacheService = new ObjectCacheService(store as any); + const EnvConfig: GlobalConfig = { cache: { msToLive: 1000 } } as any; + let store: any; + let objectCacheService: ObjectCacheService; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({}), + ], + providers: [ + { provide: Store, useClass: MockStore } + ] + }).compileComponents(); + })); beforeEach(() => { + store = TestBed.get(Store); + objectCacheService = new ObjectCacheService(store as any); service = new AuthResponseParsingService(EnvConfig, objectCacheService); }); @@ -38,12 +54,14 @@ describe('AuthResponseParsingService', () => { expires: 1526318322000 }, } as AuthStatus, - statusCode: '200' + statusCode: 200, + statusText: '200' }; const validResponse1 = { payload: {}, - statusCode: '404' + statusCode: 404, + statusText: '404' }; const validResponse2 = { @@ -102,7 +120,9 @@ describe('AuthResponseParsingService', () => { } } }, - statusCode: '200' + statusCode: 200, + statusText: '200' + }; it('should return a AuthStatusResponse if data contains a valid AuthStatus object as payload', () => { diff --git a/src/app/core/auth/auth-response-parsing.service.ts b/src/app/core/auth/auth-response-parsing.service.ts index 61559991ec6..3cb00789f6f 100644 --- a/src/app/core/auth/auth-response-parsing.service.ts +++ b/src/app/core/auth/auth-response-parsing.service.ts @@ -26,11 +26,11 @@ export class AuthResponseParsingService extends BaseResponseParsingService imple } parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { - if (isNotEmpty(data.payload) && isNotEmpty(data.payload._links) && (data.statusCode === '200' || data.statusCode === 'OK')) { + if (isNotEmpty(data.payload) && isNotEmpty(data.payload._links) && (data.statusCode === 200)) { const response = this.process(data.payload, request.uuid); - return new AuthStatusResponse(response, data.statusCode); + return new AuthStatusResponse(response, data.statusCode, data.statusText); } else { - return new AuthStatusResponse(data.payload as AuthStatus, data.statusCode); + return new AuthStatusResponse(data.payload as AuthStatus, data.statusCode, data.statusText); } } diff --git a/src/app/core/auth/auth.effects.spec.ts b/src/app/core/auth/auth.effects.spec.ts index 0dc8abf8609..8c2b4026e07 100644 --- a/src/app/core/auth/auth.effects.spec.ts +++ b/src/app/core/auth/auth.effects.spec.ts @@ -22,7 +22,7 @@ import { } from './auth.actions'; import { AuthServiceStub } from '../../shared/testing/auth-service-stub'; import { AuthService } from './auth.service'; -import { TruncatablesState } from '../../shared/truncatable/truncatable.reducer'; +import { AuthState } from './auth.reducer'; import { EPersonMock } from '../../shared/testing/eperson-mock'; @@ -30,7 +30,7 @@ describe('AuthEffects', () => { let authEffects: AuthEffects; let actions: Observable; let authServiceStub; - const store: Store = jasmine.createSpyObj('store', { + const store: Store = jasmine.createSpyObj('store', { /* tslint:disable:no-empty */ dispatch: {}, /* tslint:enable:no-empty */ diff --git a/src/app/core/auth/auth.interceptor.ts b/src/app/core/auth/auth.interceptor.ts index dd9e3fb5e75..da760b8faa2 100644 --- a/src/app/core/auth/auth.interceptor.ts +++ b/src/app/core/auth/auth.interceptor.ts @@ -17,7 +17,7 @@ import { AppState } from '../../app.reducer'; import { AuthService } from './auth.service'; import { AuthStatus } from './models/auth-status.model'; import { AuthTokenInfo } from './models/auth-token-info.model'; -import { isNotEmpty, isUndefined } from '../../shared/empty.util'; +import { isNotEmpty, isUndefined, isNotNull } from '../../shared/empty.util'; import { RedirectWhenTokenExpiredAction, RefreshTokenAction } from './auth.actions'; import { Store } from '@ngrx/store'; import { Router } from '@angular/router'; @@ -142,7 +142,7 @@ export class AuthInterceptor implements HttpInterceptor { url: error.url }); return observableOf(authResponse); - } else if (this.isUnauthorized(error)) { + } else if (this.isUnauthorized(error) && isNotNull(token) && authService.isTokenExpired()) { // The access token provided is expired, revoked, malformed, or invalid for other reasons // Redirect to the login route this.store.dispatch(new RedirectWhenTokenExpiredAction('auth.messages.expired')); diff --git a/src/app/core/auth/auth.service.spec.ts b/src/app/core/auth/auth.service.spec.ts index d39c0a45901..c461148eeaf 100644 --- a/src/app/core/auth/auth.service.spec.ts +++ b/src/app/core/auth/auth.service.spec.ts @@ -73,7 +73,7 @@ describe('AuthService test', () => { { provide: REQUEST, useValue: {} }, { provide: Router, useValue: routerStub }, { provide: ActivatedRoute, useValue: routeStub }, - {provide: Store, useValue: mockStore}, + { provide: Store, useValue: mockStore }, { provide: RemoteDataBuildService, useValue: rdbService }, CookieService, AuthService diff --git a/src/app/core/browse/browse.service.spec.ts b/src/app/core/browse/browse.service.spec.ts index 14b023e362b..725b371c144 100644 --- a/src/app/core/browse/browse.service.spec.ts +++ b/src/app/core/browse/browse.service.spec.ts @@ -114,7 +114,7 @@ describe('BrowseService', () => { scheduler.schedule(() => service.getBrowseDefinitions().subscribe()); scheduler.flush(); - expect(requestService.configure).toHaveBeenCalledWith(expected); + expect(requestService.configure).toHaveBeenCalledWith(expected, undefined); }); it('should call RemoteDataBuildService to create the RemoteData Observable', () => { @@ -155,7 +155,7 @@ describe('BrowseService', () => { scheduler.schedule(() => service.getBrowseEntriesFor(new BrowseEntrySearchOptions(browseDefinitions[1].id)).subscribe()); scheduler.flush(); - expect(requestService.configure).toHaveBeenCalledWith(expected); + expect(requestService.configure).toHaveBeenCalledWith(expected, undefined); }); it('should call RemoteDataBuildService to create the RemoteData Observable', () => { @@ -174,7 +174,7 @@ describe('BrowseService', () => { scheduler.schedule(() => service.getBrowseItemsFor(mockAuthorName, new BrowseEntrySearchOptions(browseDefinitions[1].id)).subscribe()); scheduler.flush(); - expect(requestService.configure).toHaveBeenCalledWith(expected); + expect(requestService.configure).toHaveBeenCalledWith(expected, undefined); }); it('should call RemoteDataBuildService to create the RemoteData Observable', () => { @@ -190,7 +190,7 @@ describe('BrowseService', () => { it('should throw an Error', () => { const definitionID = 'invalidID'; - const expected = cold('--#-', undefined, new Error(`No metadata browse definition could be found for id '${definitionID}'`)) + const expected = cold('--#-', undefined, new Error(`No metadata browse definition could be found for id '${definitionID}'`)); expect(service.getBrowseEntriesFor(new BrowseEntrySearchOptions(definitionID))).toBeObservable(expected); }); @@ -303,7 +303,7 @@ describe('BrowseService', () => { scheduler.schedule(() => service.getFirstItemFor(browseDefinitions[1].id).subscribe()); scheduler.flush(); - expect(requestService.configure).toHaveBeenCalledWith(expected); + expect(requestService.configure).toHaveBeenCalledWith(expected, undefined); }); it('should call RemoteDataBuildService to create the RemoteData Observable', () => { diff --git a/src/app/core/cache/builders/remote-data-build.service.ts b/src/app/core/cache/builders/remote-data-build.service.ts index 7cfbcdd252c..e30b8c99556 100644 --- a/src/app/core/cache/builders/remote-data-build.service.ts +++ b/src/app/core/cache/builders/remote-data-build.service.ts @@ -1,19 +1,15 @@ -import { - combineLatest as observableCombineLatest, - Observable, - of as observableOf, - race as observableRace -} from 'rxjs'; import { Injectable } from '@angular/core'; + +import { combineLatest as observableCombineLatest, Observable, of as observableOf, race as observableRace } from 'rxjs'; import { distinctUntilChanged, flatMap, map, startWith, switchMap } from 'rxjs/operators'; -import { hasValue, hasValueOperator, isEmpty, isNotEmpty } from '../../../shared/empty.util'; + +import { hasValue, hasValueOperator, isEmpty, isNotEmpty, isNotUndefined } from '../../../shared/empty.util'; import { PaginatedList } from '../../data/paginated-list'; import { RemoteData } from '../../data/remote-data'; import { RemoteDataError } from '../../data/remote-data-error'; import { GetRequest } from '../../data/request.models'; import { RequestEntry } from '../../data/request.reducer'; import { RequestService } from '../../data/request.service'; - import { NormalizedObject } from '../models/normalized-object.model'; import { ObjectCacheService } from '../object-cache.service'; import { DSOSuccessResponse, ErrorResponse } from '../response.models'; @@ -93,7 +89,11 @@ export class RemoteDataBuildService { isSuccessful = reqEntry.response.isSuccessful; const errorMessage = isSuccessful === false ? (reqEntry.response as ErrorResponse).errorMessage : undefined; if (hasValue(errorMessage)) { - error = new RemoteDataError(reqEntry.response.statusCode, errorMessage); + error = new RemoteDataError( + (reqEntry.response as ErrorResponse).statusCode, + (reqEntry.response as ErrorResponse).statusText, + errorMessage + ); } } return new RemoteData( @@ -226,16 +226,25 @@ export class RemoteDataBuildService { }).filter((e: string) => hasValue(e)) .join(', '); - const statusCode: string = arr + const statusText: string = arr .map((d: RemoteData) => d.error) .map((e: RemoteDataError, idx: number) => { if (hasValue(e)) { - return `[${idx}]: ${e.statusCode}`; + return `[${idx}]: ${e.statusText}`; } }).filter((c: string) => hasValue(c)) .join(', '); - const error = new RemoteDataError(statusCode, errorMessage); + const statusCode: number = arr + .map((d: RemoteData) => d.error) + .map((e: RemoteDataError, idx: number) => { + if (hasValue(e)) { + return e.statusCode; + } + }).filter((c: number) => hasValue(c)) + .reduce((acc, status) => status, undefined); + + const error = new RemoteDataError(statusCode, statusText, errorMessage); const payload: T[] = arr.map((d: RemoteData) => d.payload); @@ -254,8 +263,10 @@ export class RemoteDataBuildService { map((rd: RemoteData>) => { if (Array.isArray(rd.payload)) { return Object.assign(rd, { payload: new PaginatedList(pageInfo, rd.payload) }) - } else { + } else if (isNotUndefined(rd.payload)) { return Object.assign(rd, { payload: new PaginatedList(pageInfo, rd.payload.page) }); + } else { + return Object.assign(rd, { payload: new PaginatedList(pageInfo, []) }); } }) ); diff --git a/src/app/core/cache/models/normalized-collection.model.ts b/src/app/core/cache/models/normalized-collection.model.ts index 4eb6e1027b1..ddfcc29a2cb 100644 --- a/src/app/core/cache/models/normalized-collection.model.ts +++ b/src/app/core/cache/models/normalized-collection.model.ts @@ -18,6 +18,20 @@ export class NormalizedCollection extends NormalizedDSpaceObject { @autoserialize handle: string; + /** + * The Bitstream that represents the license of this Collection + */ + @autoserialize + @relationship(ResourceType.License, false) + license: string; + + /** + * The Bitstream that represents the default Access Conditions of this Collection + */ + @autoserialize + @relationship(ResourceType.ResourcePolicy, false) + defaultAccessConditions: string; + /** * The Bitstream that represents the logo of this Collection */ diff --git a/src/app/core/cache/models/normalized-dspace-object.model.ts b/src/app/core/cache/models/normalized-dspace-object.model.ts index 2248b625099..e12faa4a779 100644 --- a/src/app/core/cache/models/normalized-dspace-object.model.ts +++ b/src/app/core/cache/models/normalized-dspace-object.model.ts @@ -31,9 +31,6 @@ export class NormalizedDSpaceObject extends NormalizedOb /** * The universally unique identifier of this DSpaceObject - * - * Repeated here to make the serialization work, - * inheritSerialization doesn't seem to work for more than one level */ @autoserializeAs(String) uuid: string; diff --git a/src/app/core/cache/models/normalized-license.model.ts b/src/app/core/cache/models/normalized-license.model.ts new file mode 100644 index 00000000000..02bd1808c88 --- /dev/null +++ b/src/app/core/cache/models/normalized-license.model.ts @@ -0,0 +1,24 @@ +import { autoserialize, inheritSerialization } from 'cerialize'; +import { mapsTo } from '../builders/build-decorators'; +import { NormalizedDSpaceObject } from './normalized-dspace-object.model'; +import { License } from '../../shared/license.model'; + +/** + * Normalized model class for a Collection License + */ +@mapsTo(License) +@inheritSerialization(NormalizedDSpaceObject) +export class NormalizedLicense extends NormalizedDSpaceObject { + + /** + * A boolean representing if this License is custom or not + */ + @autoserialize + custom: boolean; + + /** + * The text of the license + */ + @autoserialize + text: string; +} diff --git a/src/app/core/cache/models/normalized-object-factory.ts b/src/app/core/cache/models/normalized-object-factory.ts index 681dbea9842..53d7f475fcd 100644 --- a/src/app/core/cache/models/normalized-object-factory.ts +++ b/src/app/core/cache/models/normalized-object-factory.ts @@ -6,12 +6,18 @@ import { GenericConstructor } from '../../shared/generic-constructor'; import { NormalizedCommunity } from './normalized-community.model'; import { ResourceType } from '../../shared/resource-type'; import { NormalizedObject } from './normalized-object.model'; -import { NormalizedBitstreamFormat } from './normalized-bitstream-format.model'; +import { NormalizedLicense } from './normalized-license.model'; import { NormalizedResourcePolicy } from './normalized-resource-policy.model'; +import { NormalizedWorkspaceItem } from '../../submission/models/normalized-workspaceitem.model'; import { NormalizedEPerson } from '../../eperson/models/normalized-eperson.model'; import { NormalizedGroup } from '../../eperson/models/normalized-group.model'; +import { NormalizedWorkflowItem } from '../../submission/models/normalized-workflowitem.model'; +import { NormalizedBitstreamFormat } from './normalized-bitstream-format.model'; import { NormalizedMetadataSchema } from '../../metadata/normalized-metadata-schema.model'; import { CacheableObject } from '../object-cache.reducer'; +import { NormalizedSubmissionDefinitionsModel } from '../../config/models/normalized-config-submission-definitions.model'; +import { NormalizedSubmissionFormsModel } from '../../config/models/normalized-config-submission-forms.model'; +import { NormalizedSubmissionSectionModel } from '../../config/models/normalized-config-submission-section.model'; export class NormalizedObjectFactory { public static getConstructor(type: ResourceType): GenericConstructor> { @@ -34,6 +40,9 @@ export class NormalizedObjectFactory { case ResourceType.BitstreamFormat: { return NormalizedBitstreamFormat } + case ResourceType.License: { + return NormalizedLicense + } case ResourceType.ResourcePolicy: { return NormalizedResourcePolicy } @@ -49,6 +58,24 @@ export class NormalizedObjectFactory { case ResourceType.MetadataField: { return NormalizedGroup } + case ResourceType.Workspaceitem: { + return NormalizedWorkspaceItem + } + case ResourceType.Workflowitem: { + return NormalizedWorkflowItem + } + case ResourceType.SubmissionDefinition: + case ResourceType.SubmissionDefinitions: { + return NormalizedSubmissionDefinitionsModel + } + case ResourceType.SubmissionForm: + case ResourceType.SubmissionForms: { + return NormalizedSubmissionFormsModel + } + case ResourceType.SubmissionSection: + case ResourceType.SubmissionSections: { + return NormalizedSubmissionSectionModel + } default: { return undefined; } diff --git a/src/app/core/cache/models/normalized-object.model.ts b/src/app/core/cache/models/normalized-object.model.ts index de04572dcd6..6ac8985d64c 100644 --- a/src/app/core/cache/models/normalized-object.model.ts +++ b/src/app/core/cache/models/normalized-object.model.ts @@ -13,11 +13,8 @@ export abstract class NormalizedObject implements Cac self: string; /** - * The universally unique identifier of this Object + * A string representing the kind of DSpaceObject, e.g. community, item, … */ - @autoserialize - uuid: string; - @autoserialize type: ResourceType; diff --git a/src/app/core/cache/models/normalized-resource-policy.model.ts b/src/app/core/cache/models/normalized-resource-policy.model.ts index b6c8c1369a0..9438c1da0ab 100644 --- a/src/app/core/cache/models/normalized-resource-policy.model.ts +++ b/src/app/core/cache/models/normalized-resource-policy.model.ts @@ -1,10 +1,9 @@ import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize'; import { ResourcePolicy } from '../../shared/resource-policy.model'; -import { mapsTo, relationship } from '../builders/build-decorators'; +import { mapsTo } from '../builders/build-decorators'; import { NormalizedObject } from './normalized-object.model'; import { IDToUUIDSerializer } from '../id-to-uuid-serializer'; -import { ResourceType } from '../../shared/resource-type'; import { ActionType } from './action-type.model'; /** @@ -17,6 +16,7 @@ export class NormalizedResourcePolicy extends NormalizedObject { /** * The action that is allowed by this Resource Policy */ + @autoserialize action: ActionType; /** @@ -28,9 +28,8 @@ export class NormalizedResourcePolicy extends NormalizedObject { /** * The uuid of the Group this Resource Policy applies to */ - @relationship(ResourceType.Group, false) - @autoserializeAs(String, 'groupUUID') - group: string; + @autoserialize + groupUUID: string; /** * Identifier for this Resource Policy @@ -46,4 +45,5 @@ export class NormalizedResourcePolicy extends NormalizedObject { */ @autoserializeAs(new IDToUUIDSerializer('resource-policy'), 'id') uuid: string; + } diff --git a/src/app/core/cache/models/search-param.model.ts b/src/app/core/cache/models/search-param.model.ts new file mode 100644 index 00000000000..a33bbee5e6a --- /dev/null +++ b/src/app/core/cache/models/search-param.model.ts @@ -0,0 +1,9 @@ + +/** + * Class representing a query parameter (query?fieldName=fieldValue) used in FindAllOptions object + */ +export class SearchParam { + constructor(public fieldName: string, public fieldValue: any) { + + } +} diff --git a/src/app/core/cache/response.models.ts b/src/app/core/cache/response.models.ts index bb150b3bcb6..a734eba8126 100644 --- a/src/app/core/cache/response.models.ts +++ b/src/app/core/cache/response.models.ts @@ -1,7 +1,7 @@ import { SearchQueryResponse } from '../../+search-page/search-service/search-query-response.model'; import { RequestError } from '../data/request.models'; import { PageInfo } from '../shared/page-info.model'; -import { ConfigObject } from '../shared/config/config.model'; +import { ConfigObject } from '../config/models/config.model'; import { FacetValue } from '../../+search-page/search-service/facet-value.model'; import { SearchFilterConfig } from '../../+search-page/search-service/search-filter-config.model'; import { IntegrationModel } from '../integration/models/integration.model'; @@ -11,14 +11,19 @@ import { RegistryBitstreamformatsResponse } from '../registry/registry-bitstream import { AuthStatus } from '../auth/models/auth-status.model'; import { MetadataSchema } from '../metadata/metadataschema.model'; import { MetadataField } from '../metadata/metadatafield.model'; +import { PaginatedList } from '../data/paginated-list'; +import { SubmissionObject } from '../submission/models/submission-object.model'; +import { DSpaceObject } from '../shared/dspace-object.model'; /* tslint:disable:max-classes-per-file */ export class RestResponse { + public toCache = true; public timeAdded: number; constructor( public isSuccessful: boolean, - public statusCode: string, + public statusCode: number, + public statusText: string ) { } } @@ -26,10 +31,11 @@ export class RestResponse { export class DSOSuccessResponse extends RestResponse { constructor( public resourceSelfLinks: string[], - public statusCode: string, + public statusCode: number, + public statusText: string, public pageInfo?: PageInfo ) { - super(true, statusCode); + super(true, statusCode, statusText); } } @@ -39,10 +45,11 @@ export class DSOSuccessResponse extends RestResponse { export class RegistryMetadataschemasSuccessResponse extends RestResponse { constructor( public metadataschemasResponse: RegistryMetadataschemasResponse, - public statusCode: string, + public statusCode: number, + public statusText: string, public pageInfo?: PageInfo ) { - super(true, statusCode); + super(true, statusCode, statusText); } } @@ -52,10 +59,11 @@ export class RegistryMetadataschemasSuccessResponse extends RestResponse { export class RegistryMetadatafieldsSuccessResponse extends RestResponse { constructor( public metadatafieldsResponse: RegistryMetadatafieldsResponse, - public statusCode: string, + public statusCode: number, + public statusText: string, public pageInfo?: PageInfo ) { - super(true, statusCode); + super(true, statusCode, statusText); } } @@ -65,10 +73,11 @@ export class RegistryMetadatafieldsSuccessResponse extends RestResponse { export class RegistryBitstreamformatsSuccessResponse extends RestResponse { constructor( public bitstreamformatsResponse: RegistryBitstreamformatsResponse, - public statusCode: string, + public statusCode: number, + public statusText: string, public pageInfo?: PageInfo ) { - super(true, statusCode); + super(true, statusCode, statusText); } } @@ -78,9 +87,10 @@ export class RegistryBitstreamformatsSuccessResponse extends RestResponse { export class MetadataschemaSuccessResponse extends RestResponse { constructor( public metadataschema: MetadataSchema, - public statusCode: string + public statusCode: number, + public statusText: string, ) { - super(true, statusCode); + super(true, statusCode, statusText); } } @@ -90,28 +100,31 @@ export class MetadataschemaSuccessResponse extends RestResponse { export class MetadatafieldSuccessResponse extends RestResponse { constructor( public metadatafield: MetadataField, - public statusCode: string + public statusCode: number, + public statusText: string, ) { - super(true, statusCode); + super(true, statusCode, statusText); } } export class SearchSuccessResponse extends RestResponse { constructor( public results: SearchQueryResponse, - public statusCode: string, + public statusCode: number, + public statusText: string, public pageInfo?: PageInfo ) { - super(true, statusCode); + super(true, statusCode, statusText); } } export class FacetConfigSuccessResponse extends RestResponse { constructor( public results: SearchFilterConfig[], - public statusCode: string + public statusCode: number, + public statusText: string, ) { - super(true, statusCode); + super(true, statusCode, statusText); } } @@ -122,18 +135,20 @@ export class FacetValueMap { export class FacetValueSuccessResponse extends RestResponse { constructor( public results: FacetValue[], - public statusCode: string, + public statusCode: number, + public statusText: string, public pageInfo?: PageInfo) { - super(true, statusCode); + super(true, statusCode, statusText); } } export class FacetValueMapSuccessResponse extends RestResponse { constructor( public results: FacetValueMap, - public statusCode: string, + public statusCode: number, + public statusText: string ) { - super(true, statusCode); + super(true, statusCode, statusText); } } @@ -144,19 +159,21 @@ export class EndpointMap { export class EndpointMapSuccessResponse extends RestResponse { constructor( public endpointMap: EndpointMap, - public statusCode: string, + public statusCode: number, + public statusText: string ) { - super(true, statusCode); + super(true, statusCode, statusText); } } export class GenericSuccessResponse extends RestResponse { constructor( public payload: T, - public statusCode: string, + public statusCode: number, + public statusText: string, public pageInfo?: PageInfo ) { - super(true, statusCode); + super(true, statusCode, statusText); } } @@ -164,7 +181,7 @@ export class ErrorResponse extends RestResponse { errorMessage: string; constructor(error: RequestError) { - super(false, error.statusText); + super(false, error.statusCode, error.statusText); console.error(error); this.errorMessage = error.message; } @@ -172,11 +189,12 @@ export class ErrorResponse extends RestResponse { export class ConfigSuccessResponse extends RestResponse { constructor( - public configDefinition: ConfigObject[], - public statusCode: string, + public configDefinition: ConfigObject, + public statusCode: number, + public statusText: string, public pageInfo?: PageInfo ) { - super(true, statusCode); + super(true, statusCode, statusText); } } @@ -185,19 +203,54 @@ export class AuthStatusResponse extends RestResponse { constructor( public response: AuthStatus, - public statusCode: string + public statusCode: number, + public statusText: string, ) { - super(true, statusCode); + super(true, statusCode, statusText); } } export class IntegrationSuccessResponse extends RestResponse { constructor( - public dataDefinition: IntegrationModel[], - public statusCode: string, + public dataDefinition: PaginatedList, + public statusCode: number, + public statusText: string, public pageInfo?: PageInfo ) { - super(true, statusCode); + super(true, statusCode, statusText); + } +} + +export class PostPatchSuccessResponse extends RestResponse { + constructor( + public dataDefinition: any, + public statusCode: number, + public statusText: string, + public pageInfo?: PageInfo + ) { + super(true, statusCode, statusText); + } +} + +export class SubmissionSuccessResponse extends RestResponse { + constructor( + public dataDefinition: Array, + public statusCode: number, + public statusText: string, + public pageInfo?: PageInfo + ) { + super(true, statusCode, statusText); + } +} + +export class EpersonSuccessResponse extends RestResponse { + constructor( + public epersonDefinition: DSpaceObject[], + public statusCode: number, + public statusText: string, + public pageInfo?: PageInfo + ) { + super(true, statusCode, statusText); } } diff --git a/src/app/core/cache/server-sync-buffer.effects.spec.ts b/src/app/core/cache/server-sync-buffer.effects.spec.ts index 0a8d50107e0..724a4b1c6b4 100644 --- a/src/app/core/cache/server-sync-buffer.effects.spec.ts +++ b/src/app/core/cache/server-sync-buffer.effects.spec.ts @@ -1,20 +1,17 @@ import { TestBed } from '@angular/core/testing'; + import { Observable, of as observableOf } from 'rxjs'; import { provideMockActions } from '@ngrx/effects/testing'; import { cold, hot } from 'jasmine-marbles'; + import { ServerSyncBufferEffects } from './server-sync-buffer.effects'; import { GLOBAL_CONFIG } from '../../../config'; -import { - CommitSSBAction, - EmptySSBAction, - ServerSyncBufferActionTypes -} from './server-sync-buffer.actions'; +import { CommitSSBAction, EmptySSBAction, ServerSyncBufferActionTypes } from './server-sync-buffer.actions'; import { RestRequestMethod } from '../data/rest-request-method'; -import { Store } from '@ngrx/store'; +import { Store, StoreModule } from '@ngrx/store'; import { RequestService } from '../data/request.service'; import { ObjectCacheService } from './object-cache.service'; import { MockStore } from '../../shared/testing/mock-store'; -import { ObjectCacheState } from './object-cache.reducer'; import * as operators from 'rxjs/operators'; import { spyOnOperator } from '../../shared/testing/utils'; import { DSpaceObject } from '../shared/dspace-object.model'; @@ -38,8 +35,10 @@ describe('ServerSyncBufferEffects', () => { let store; beforeEach(() => { - store = new MockStore({}); TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({}), + ], providers: [ ServerSyncBufferEffects, provideMockActions(() => actions), @@ -54,11 +53,12 @@ describe('ServerSyncBufferEffects', () => { } } }, - { provide: Store, useValue: store } + { provide: Store, useClass: MockStore } // other providers ], }); + store = TestBed.get(Store); ssbEffects = TestBed.get(ServerSyncBufferEffects); }); diff --git a/src/app/core/config/config-data.ts b/src/app/core/config/config-data.ts index efcdb7eed44..cb40514e455 100644 --- a/src/app/core/config/config-data.ts +++ b/src/app/core/config/config-data.ts @@ -1,5 +1,5 @@ import { PageInfo } from '../shared/page-info.model'; -import { ConfigObject } from '../shared/config/config.model'; +import { ConfigObject } from './models/config.model'; /** * A class to represent the data retrieved by a configuration service @@ -7,7 +7,7 @@ import { ConfigObject } from '../shared/config/config.model'; export class ConfigData { constructor( public pageInfo: PageInfo, - public payload: ConfigObject[] + public payload: ConfigObject ) { } } diff --git a/src/app/core/data/config-response-parsing.service.spec.ts b/src/app/core/config/config-response-parsing.service.spec.ts similarity index 70% rename from src/app/core/data/config-response-parsing.service.spec.ts rename to src/app/core/config/config-response-parsing.service.spec.ts index a2c5cbbadcd..7c69f1bdb3d 100644 --- a/src/app/core/data/config-response-parsing.service.spec.ts +++ b/src/app/core/config/config-response-parsing.service.spec.ts @@ -2,13 +2,14 @@ import { ConfigSuccessResponse, ErrorResponse } from '../cache/response.models'; import { ConfigResponseParsingService } from './config-response-parsing.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { GlobalConfig } from '../../../config/global-config.interface'; -import { ConfigRequest } from './request.models'; +import { ConfigRequest } from '../data/request.models'; import { Store } from '@ngrx/store'; import { CoreState } from '../core.reducers'; -import { SubmissionDefinitionsModel } from '../shared/config/config-submission-definitions.model'; -import { PaginatedList } from './paginated-list'; +import { PaginatedList } from '../data/paginated-list'; import { PageInfo } from '../shared/page-info.model'; +import { NormalizedSubmissionDefinitionsModel } from './models/normalized-config-submission-definitions.model'; +import { NormalizedSubmissionSectionModel } from './models/normalized-config-submission-section.model'; describe('ConfigResponseParsingService', () => { let service: ConfigResponseParsingService; @@ -119,7 +120,8 @@ describe('ConfigResponseParsingService', () => { } } }, - statusCode: '200' + statusCode: 200, + statusText: 'OK' }; }); @@ -128,7 +130,8 @@ describe('ConfigResponseParsingService', () => { const invalidResponse1 = { payload: {}, - statusCode: '200' + statusCode: 200, + statusText: 'OK' }; const invalidResponse2 = { @@ -152,14 +155,15 @@ describe('ConfigResponseParsingService', () => { } } }, - statusCode: '200' + statusCode: 200, + statusText: 'OK' }; const invalidResponse3 = { payload: { _links: { self: { href: 'https://rest.api/config/submissiondefinitions/traditional' } }, page: { size: 20, totalElements: 2, totalPages: 1, number: 0 } - }, statusCode: '500' + }, statusCode: 500, statusText: 'Internal Server Error' }; const pageinfo = Object.assign(new PageInfo(), { elementsPerPage: 4, @@ -169,7 +173,7 @@ describe('ConfigResponseParsingService', () => { self: 'https://rest.api/config/submissiondefinitions/traditional/sections' }); const definitions = - Object.assign(new SubmissionDefinitionsModel(), { + Object.assign(new NormalizedSubmissionDefinitionsModel(), { isDefault: true, name: 'traditional', type: 'submissiondefinition', @@ -179,10 +183,65 @@ describe('ConfigResponseParsingService', () => { }, self: 'https://rest.api/config/submissiondefinitions/traditional', sections: new PaginatedList(pageinfo, [ - 'https://rest.api/config/submissionsections/traditionalpageone', - 'https://rest.api/config/submissionsections/traditionalpagetwo', - 'https://rest.api/config/submissionsections/upload', - 'https://rest.api/config/submissionsections/license' + Object.assign(new NormalizedSubmissionSectionModel(), { + header: 'submit.progressbar.describe.stepone', + mandatory: true, + sectionType: 'submission-form', + visibility:{ + main:null, + other:'READONLY' + }, + type: 'submissionsection', + _links: { + self: 'https://rest.api/config/submissionsections/traditionalpageone', + config: 'https://rest.api/config/submissionforms/traditionalpageone' + }, + self: 'https://rest.api/config/submissionsections/traditionalpageone', + }), + Object.assign(new NormalizedSubmissionSectionModel(), { + header: 'submit.progressbar.describe.steptwo', + mandatory: true, + sectionType: 'submission-form', + visibility:{ + main:null, + other:'READONLY' + }, + type: 'submissionsection', + _links: { + self: 'https://rest.api/config/submissionsections/traditionalpagetwo', + config: 'https://rest.api/config/submissionforms/traditionalpagetwo' + }, + self: 'https://rest.api/config/submissionsections/traditionalpagetwo', + }), + Object.assign(new NormalizedSubmissionSectionModel(), { + header: 'submit.progressbar.upload', + mandatory: false, + sectionType: 'upload', + visibility:{ + main:null, + other:'READONLY' + }, + type: 'submissionsection', + _links: { + self: 'https://rest.api/config/submissionsections/upload', + config: 'https://rest.api/config/submissionuploads/upload' + }, + self: 'https://rest.api/config/submissionsections/upload', + }), + Object.assign(new NormalizedSubmissionSectionModel(), { + header: 'submit.progressbar.license', + mandatory: true, + sectionType: 'license', + visibility:{ + main:null, + other:'READONLY' + }, + type: 'submissionsection', + _links: { + self: 'https://rest.api/config/submissionsections/license' + }, + self: 'https://rest.api/config/submissionsections/license', + }) ]) }); diff --git a/src/app/core/data/config-response-parsing.service.ts b/src/app/core/config/config-response-parsing.service.ts similarity index 69% rename from src/app/core/data/config-response-parsing.service.ts rename to src/app/core/config/config-response-parsing.service.ts index 50303d0a093..b81dc07624b 100644 --- a/src/app/core/data/config-response-parsing.service.ts +++ b/src/app/core/config/config-response-parsing.service.ts @@ -1,15 +1,15 @@ import { Inject, Injectable } from '@angular/core'; -import { ResponseParsingService } from './parsing.service'; -import { RestRequest } from './request.models'; +import { ResponseParsingService } from '../data/parsing.service'; +import { RestRequest } from '../data/request.models'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; import { ConfigSuccessResponse, ErrorResponse, RestResponse } from '../cache/response.models'; import { isNotEmpty } from '../../shared/empty.util'; -import { ConfigObjectFactory } from '../shared/config/config-object-factory'; +import { ConfigObjectFactory } from './models/config-object-factory'; -import { ConfigObject } from '../shared/config/config.model'; -import { ConfigType } from '../shared/config/config-type'; -import { BaseResponseParsingService } from './base-response-parsing.service'; +import { ConfigObject } from './models/config.model'; +import { ConfigType } from './models/config-type'; +import { BaseResponseParsingService } from '../data/base-response-parsing.service'; import { GLOBAL_CONFIG } from '../../../config'; import { GlobalConfig } from '../../../config/global-config.interface'; import { ObjectCacheService } from '../cache/object-cache.service'; @@ -27,14 +27,14 @@ export class ConfigResponseParsingService extends BaseResponseParsingService imp } parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { - if (isNotEmpty(data.payload) && isNotEmpty(data.payload._links) && (data.statusCode === '201' || data.statusCode === '200' || data.statusCode === 'OK')) { + if (isNotEmpty(data.payload) && isNotEmpty(data.payload._links) && (data.statusCode === 201 || data.statusCode === 200)) { const configDefinition = this.process(data.payload, request.uuid); - return new ConfigSuccessResponse(configDefinition, data.statusCode, this.processPageInfo(data.payload)); + return new ConfigSuccessResponse(configDefinition, data.statusCode, data.statusText, this.processPageInfo(data.payload)); } else { return new ErrorResponse( Object.assign( new Error('Unexpected response from config endpoint'), - {statusText: data.statusCode} + { statusCode: data.statusCode, statusText: data.statusText } ) ); } diff --git a/src/app/core/config/config.service.spec.ts b/src/app/core/config/config.service.spec.ts index 44cfdee3588..87add6b656a 100644 --- a/src/app/core/config/config.service.spec.ts +++ b/src/app/core/config/config.service.spec.ts @@ -1,4 +1,4 @@ -import { cold, getTestScheduler, hot } from 'jasmine-marbles'; +import { cold, getTestScheduler } from 'jasmine-marbles'; import { TestScheduler } from 'rxjs/testing'; import { getMockRequestService } from '../../shared/mocks/mock-request.service'; import { ConfigService } from './config.service'; diff --git a/src/app/core/config/config.service.ts b/src/app/core/config/config.service.ts index c6c2e2e7d24..340a7a97d6d 100644 --- a/src/app/core/config/config.service.ts +++ b/src/app/core/config/config.service.ts @@ -6,7 +6,6 @@ import { ConfigRequest, FindAllOptions, RestRequest } from '../data/request.mode import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { ConfigData } from './config-data'; -import { RequestEntry } from '../data/request.reducer'; import { getResponseFromEntry } from '../shared/operators'; export abstract class ConfigService { diff --git a/src/app/core/config/models/config-access-condition-option.model.ts b/src/app/core/config/models/config-access-condition-option.model.ts new file mode 100644 index 00000000000..46bf1b60cea --- /dev/null +++ b/src/app/core/config/models/config-access-condition-option.model.ts @@ -0,0 +1,40 @@ +/** + * Model class for an Access Condition + */ +export class AccessConditionOption { + + /** + * The name for this Access Condition + */ + name: string; + + /** + * The uuid of the Group this Access Condition applies to + */ + groupUUID: string; + + /** + * The uuid of the Group that contains set of groups this Resource Policy applies to + */ + selectGroupUUID: string; + + /** + * A boolean representing if this Access Condition has a start date + */ + hasStartDate: boolean; + + /** + * A boolean representing if this Access Condition has an end date + */ + hasEndDate: boolean; + + /** + * Maximum value of the start date + */ + maxStartDate: string; + + /** + * Maximum value of the end date + */ + maxEndDate: string; +} diff --git a/src/app/core/config/models/config-object-factory.ts b/src/app/core/config/models/config-object-factory.ts new file mode 100644 index 00000000000..5dbba7a11f4 --- /dev/null +++ b/src/app/core/config/models/config-object-factory.ts @@ -0,0 +1,33 @@ +import { GenericConstructor } from '../../shared/generic-constructor'; +import { ConfigType } from './config-type'; +import { ConfigObject } from './config.model'; +import { NormalizedSubmissionDefinitionsModel } from './normalized-config-submission-definitions.model'; +import { NormalizedSubmissionFormsModel } from './normalized-config-submission-forms.model'; +import { NormalizedSubmissionSectionModel } from './normalized-config-submission-section.model'; +import { NormalizedSubmissionUploadsModel } from './normalized-config-submission-uploads.model'; + +export class ConfigObjectFactory { + public static getConstructor(type): GenericConstructor { + switch (type) { + case ConfigType.SubmissionDefinition: + case ConfigType.SubmissionDefinitions: { + return NormalizedSubmissionDefinitionsModel + } + case ConfigType.SubmissionForm: + case ConfigType.SubmissionForms: { + return NormalizedSubmissionFormsModel + } + case ConfigType.SubmissionSection: + case ConfigType.SubmissionSections: { + return NormalizedSubmissionSectionModel + } + case ConfigType.SubmissionUpload: + case ConfigType.SubmissionUploads: { + return NormalizedSubmissionUploadsModel + } + default: { + return undefined; + } + } + } +} diff --git a/src/app/core/shared/config/config-submission-definitions.model.ts b/src/app/core/config/models/config-submission-definitions.model.ts similarity index 63% rename from src/app/core/shared/config/config-submission-definitions.model.ts rename to src/app/core/config/models/config-submission-definitions.model.ts index 0247f13944c..8bbbc900566 100644 --- a/src/app/core/shared/config/config-submission-definitions.model.ts +++ b/src/app/core/config/models/config-submission-definitions.model.ts @@ -1,15 +1,17 @@ -import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize'; import { ConfigObject } from './config.model'; import { SubmissionSectionModel } from './config-submission-section.model'; import { PaginatedList } from '../../data/paginated-list'; -@inheritSerialization(ConfigObject) export class SubmissionDefinitionsModel extends ConfigObject { - @autoserialize + /** + * A boolean representing if this submission definition is the default or not + */ isDefault: boolean; - @autoserializeAs(SubmissionSectionModel) + /** + * A list of SubmissionSectionModel that are present in this submission definition + */ sections: PaginatedList; } diff --git a/src/app/core/shared/config/config-submission-forms.model.ts b/src/app/core/config/models/config-submission-forms.model.ts similarity index 59% rename from src/app/core/shared/config/config-submission-forms.model.ts rename to src/app/core/config/models/config-submission-forms.model.ts index 98d3bf9ce7f..ee0962f0e95 100644 --- a/src/app/core/shared/config/config-submission-forms.model.ts +++ b/src/app/core/config/models/config-submission-forms.model.ts @@ -1,14 +1,20 @@ -import { autoserialize, inheritSerialization } from 'cerialize'; import { ConfigObject } from './config.model'; import { FormFieldModel } from '../../../shared/form/builder/models/form-field.model'; +/** + * An interface that define a form row and its properties. + */ export interface FormRowModel { fields: FormFieldModel[]; } -@inheritSerialization(ConfigObject) +/** + * A model class for a NormalizedObject. + */ export class SubmissionFormsModel extends ConfigObject { - @autoserialize + /** + * An array of [FormRowModel] that are present in this form + */ rows: FormRowModel[]; } diff --git a/src/app/core/config/models/config-submission-section.model.ts b/src/app/core/config/models/config-submission-section.model.ts new file mode 100644 index 00000000000..377a8869e1e --- /dev/null +++ b/src/app/core/config/models/config-submission-section.model.ts @@ -0,0 +1,34 @@ +import { ConfigObject } from './config.model'; +import { SectionsType } from '../../../submission/sections/sections-type'; + +/** + * An interface that define section visibility and its properties. + */ +export interface SubmissionSectionVisibility { + main: any, + other: any +} + +export class SubmissionSectionModel extends ConfigObject { + + /** + * The header for this section + */ + header: string; + + /** + * A boolean representing if this submission section is the mandatory or not + */ + mandatory: boolean; + + /** + * A string representing the kind of section object + */ + sectionType: SectionsType; + + /** + * The [SubmissionSectionVisibility] object for this section + */ + visibility: SubmissionSectionVisibility + +} diff --git a/src/app/core/config/models/config-submission-uploads.model.ts b/src/app/core/config/models/config-submission-uploads.model.ts new file mode 100644 index 00000000000..8bb9ba7f1e1 --- /dev/null +++ b/src/app/core/config/models/config-submission-uploads.model.ts @@ -0,0 +1,21 @@ +import { ConfigObject } from './config.model'; +import { AccessConditionOption } from './config-access-condition-option.model'; +import { SubmissionFormsModel } from './config-submission-forms.model'; + +export class SubmissionUploadsModel extends ConfigObject { + + /** + * A list of available bitstream access conditions + */ + accessConditionOptions: AccessConditionOption[]; + + /** + * An object representing the configuration describing the bistream metadata form + */ + metadata: SubmissionFormsModel; + + required: boolean; + + maxSize: number; + +} diff --git a/src/app/core/shared/config/config-type.ts b/src/app/core/config/models/config-type.ts similarity index 57% rename from src/app/core/shared/config/config-type.ts rename to src/app/core/config/models/config-type.ts index 17ed099229f..91371f10f59 100644 --- a/src/app/core/shared/config/config-type.ts +++ b/src/app/core/config/models/config-type.ts @@ -1,9 +1,3 @@ -/** - * TODO replace with actual string enum after upgrade to TypeScript 2.4: - * https://github.com/Microsoft/TypeScript/pull/15486 - */ -import { ResourceType } from '../resource-type'; - export enum ConfigType { SubmissionDefinitions = 'submissiondefinitions', SubmissionDefinition = 'submissiondefinition', @@ -11,5 +5,6 @@ export enum ConfigType { SubmissionForms = 'submissionforms', SubmissionSections = 'submissionsections', SubmissionSection = 'submissionsection', - Authority = 'authority' + SubmissionUploads = 'submissionuploads', + SubmissionUpload = 'submissionupload', } diff --git a/src/app/core/config/models/config.model.ts b/src/app/core/config/models/config.model.ts new file mode 100644 index 00000000000..81f20a0b3c0 --- /dev/null +++ b/src/app/core/config/models/config.model.ts @@ -0,0 +1,27 @@ +import { CacheableObject } from '../../cache/object-cache.reducer'; +import { ResourceType } from '../../shared/resource-type'; + +export abstract class ConfigObject implements CacheableObject { + + /** + * The name for this configuration + */ + public name: string; + + /** + * A string representing the kind of config object + */ + public type: ResourceType; + + /** + * The links to all related resources returned by the rest api. + */ + public _links: { + [name: string]: string + }; + + /** + * The link to the rest endpoint where this config object can be found + */ + self: string; +} diff --git a/src/app/core/config/models/normalized-config-submission-definitions.model.ts b/src/app/core/config/models/normalized-config-submission-definitions.model.ts new file mode 100644 index 00000000000..3887c566c15 --- /dev/null +++ b/src/app/core/config/models/normalized-config-submission-definitions.model.ts @@ -0,0 +1,25 @@ +import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize'; +import { SubmissionSectionModel } from './config-submission-section.model'; +import { PaginatedList } from '../../data/paginated-list'; +import { NormalizedConfigObject } from './normalized-config.model'; +import { SubmissionDefinitionsModel } from './config-submission-definitions.model'; + +/** + * Normalized class for the configuration describing the submission + */ +@inheritSerialization(NormalizedConfigObject) +export class NormalizedSubmissionDefinitionsModel extends NormalizedConfigObject { + + /** + * A boolean representing if this submission definition is the default or not + */ + @autoserialize + isDefault: boolean; + + /** + * A list of SubmissionSectionModel that are present in this submission definition + */ + @autoserializeAs(SubmissionSectionModel) + sections: PaginatedList; + +} diff --git a/src/app/core/config/models/normalized-config-submission-forms.model.ts b/src/app/core/config/models/normalized-config-submission-forms.model.ts new file mode 100644 index 00000000000..a957e8c7fa6 --- /dev/null +++ b/src/app/core/config/models/normalized-config-submission-forms.model.ts @@ -0,0 +1,16 @@ +import { autoserialize, inheritSerialization } from 'cerialize'; +import { NormalizedConfigObject } from './normalized-config.model'; +import { FormRowModel, SubmissionFormsModel } from './config-submission-forms.model'; + +/** + * Normalized class for the configuration describing the submission form + */ +@inheritSerialization(NormalizedConfigObject) +export class NormalizedSubmissionFormsModel extends NormalizedConfigObject { + + /** + * An array of [FormRowModel] that are present in this form + */ + @autoserialize + rows: FormRowModel[]; +} diff --git a/src/app/core/config/models/normalized-config-submission-section.model.ts b/src/app/core/config/models/normalized-config-submission-section.model.ts new file mode 100644 index 00000000000..c876acf6073 --- /dev/null +++ b/src/app/core/config/models/normalized-config-submission-section.model.ts @@ -0,0 +1,37 @@ +import { autoserialize, inheritSerialization } from 'cerialize'; +import { SectionsType } from '../../../submission/sections/sections-type'; +import { NormalizedConfigObject } from './normalized-config.model'; +import { SubmissionFormsModel } from './config-submission-forms.model'; +import { SubmissionSectionVisibility } from './config-submission-section.model'; + +/** + * Normalized class for the configuration describing the submission section + */ +@inheritSerialization(NormalizedConfigObject) +export class NormalizedSubmissionSectionModel extends NormalizedConfigObject { + + /** + * The header for this section + */ + @autoserialize + header: string; + + /** + * A boolean representing if this submission section is the mandatory or not + */ + @autoserialize + mandatory: boolean; + + /** + * A string representing the kind of section object + */ + @autoserialize + sectionType: SectionsType; + + /** + * The [SubmissionSectionVisibility] object for this section + */ + @autoserialize + visibility: SubmissionSectionVisibility + +} diff --git a/src/app/core/config/models/normalized-config-submission-uploads.model.ts b/src/app/core/config/models/normalized-config-submission-uploads.model.ts new file mode 100644 index 00000000000..e49171d6a75 --- /dev/null +++ b/src/app/core/config/models/normalized-config-submission-uploads.model.ts @@ -0,0 +1,31 @@ +import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize'; +import { AccessConditionOption } from './config-access-condition-option.model'; +import { SubmissionFormsModel } from './config-submission-forms.model'; +import { NormalizedConfigObject } from './normalized-config.model'; +import { SubmissionUploadsModel } from './config-submission-uploads.model'; + +/** + * Normalized class for the configuration describing the submission upload section + */ +@inheritSerialization(NormalizedConfigObject) +export class NormalizedSubmissionUploadsModel extends NormalizedConfigObject { + + /** + * A list of available bitstream access conditions + */ + @autoserialize + accessConditionOptions: AccessConditionOption[]; + + /** + * An object representing the configuration describing the bistream metadata form + */ + @autoserializeAs(SubmissionFormsModel) + metadata: SubmissionFormsModel; + + @autoserialize + required: boolean; + + @autoserialize + maxSize: number; + +} diff --git a/src/app/core/config/models/normalized-config.model.ts b/src/app/core/config/models/normalized-config.model.ts new file mode 100644 index 00000000000..0b751585885 --- /dev/null +++ b/src/app/core/config/models/normalized-config.model.ts @@ -0,0 +1,38 @@ +import { autoserialize, inheritSerialization } from 'cerialize'; +import { NormalizedObject } from '../../cache/models/normalized-object.model'; +import { CacheableObject } from '../../cache/object-cache.reducer'; +import { ResourceType } from '../../shared/resource-type'; + +/** + * Normalized abstract class for a configuration object + */ +@inheritSerialization(NormalizedObject) +export abstract class NormalizedConfigObject implements CacheableObject { + + /** + * The name for this configuration + */ + @autoserialize + public name: string; + + /** + * A string representing the kind of config object + */ + @autoserialize + public type: ResourceType; + + /** + * The links to all related resources returned by the rest api. + */ + @autoserialize + public _links: { + [name: string]: string + }; + + /** + * The link to the rest endpoint where this config object can be found + */ + @autoserialize + self: string; + +} diff --git a/src/app/core/config/submission-uploads-config.service.ts b/src/app/core/config/submission-uploads-config.service.ts new file mode 100644 index 00000000000..2e092fa4f34 --- /dev/null +++ b/src/app/core/config/submission-uploads-config.service.ts @@ -0,0 +1,21 @@ +import { Injectable } from '@angular/core'; +import { ConfigService } from './config.service'; +import { RequestService } from '../data/request.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; + +/** + * Provides methods to retrieve, from REST server, bitstream access conditions configurations applicable during the submission process. + */ +@Injectable() +export class SubmissionUploadsConfigService extends ConfigService { + protected linkPath = 'submissionuploads'; + protected browseEndpoint = ''; + + constructor( + protected objectCache: ObjectCacheService, + protected requestService: RequestService, + protected halService: HALEndpointService) { + super(); + } +} diff --git a/src/app/core/core.effects.ts b/src/app/core/core.effects.ts index a23516aa45d..bb25c49a7af 100644 --- a/src/app/core/core.effects.ts +++ b/src/app/core/core.effects.ts @@ -3,6 +3,7 @@ import { ObjectCacheEffects } from './cache/object-cache.effects'; import { UUIDIndexEffects } from './index/index.effects'; import { RequestEffects } from './data/request.effects'; import { AuthEffects } from './auth/auth.effects'; +import { JsonPatchOperationsEffects } from './json-patch/json-patch-operations.effects'; import { ServerSyncBufferEffects } from './cache/server-sync-buffer.effects'; import { ObjectUpdatesEffects } from './data/object-updates/object-updates.effects'; @@ -11,6 +12,7 @@ export const coreEffects = [ ObjectCacheEffects, UUIDIndexEffects, AuthEffects, + JsonPatchOperationsEffects, ServerSyncBufferEffects, ObjectUpdatesEffects ]; diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 357f5520746..6aa58f791e9 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -1,8 +1,8 @@ import { + ModuleWithProviders, NgModule, Optional, - SkipSelf, - ModuleWithProviders + SkipSelf } from '@angular/core'; import { CommonModule } from '@angular/common'; @@ -24,7 +24,9 @@ import { DSOResponseParsingService } from './data/dso-response-parsing.service'; import { SearchResponseParsingService } from './data/search-response-parsing.service'; import { DSpaceRESTv2Service } from './dspace-rest-v2/dspace-rest-v2.service'; import { FormBuilderService } from '../shared/form/builder/form-builder.service'; +import { SectionFormOperationsService } from '../submission/sections/form/section-form-operations.service'; import { FormService } from '../shared/form/form.service'; +import { GroupEpersonService } from './eperson/group-eperson.service'; import { HostWindowService } from '../shared/host-window.service'; import { ItemDataService } from './data/item-data.service'; import { MetadataService } from './metadata/metadata.service'; @@ -37,13 +39,17 @@ import { ServerResponseService } from '../shared/services/server-response.servic import { NativeWindowFactory, NativeWindowService } from '../shared/services/window.service'; import { BrowseService } from './browse/browse.service'; import { BrowseResponseParsingService } from './data/browse-response-parsing.service'; -import { ConfigResponseParsingService } from './data/config-response-parsing.service'; +import { ConfigResponseParsingService } from './config/config-response-parsing.service'; import { RouteService } from '../shared/services/route.service'; import { SubmissionDefinitionsConfigService } from './config/submission-definitions-config.service'; import { SubmissionFormsConfigService } from './config/submission-forms-config.service'; import { SubmissionSectionsConfigService } from './config/submission-sections-config.service'; +import { SubmissionResponseParsingService } from './submission/submission-response-parsing.service'; +import { EpersonResponseParsingService } from './eperson/eperson-response-parsing.service'; +import { JsonPatchOperationsBuilder } from './json-patch/builder/json-patch-operations-builder'; import { AuthorityService } from './integration/authority.service'; import { IntegrationResponseParsingService } from './integration/integration-response-parsing.service'; +import { WorkspaceitemDataService } from './submission/workspaceitem-data.service'; import { UUIDService } from './shared/uuid.service'; import { AuthenticatedGuard } from './auth/authenticated.guard'; import { AuthRequestService } from './auth/auth-request.service'; @@ -58,13 +64,17 @@ import { RegistryService } from './registry/registry.service'; import { RegistryMetadataschemasResponseParsingService } from './data/registry-metadataschemas-response-parsing.service'; import { RegistryMetadatafieldsResponseParsingService } from './data/registry-metadatafields-response-parsing.service'; import { RegistryBitstreamformatsResponseParsingService } from './data/registry-bitstreamformats-response-parsing.service'; +import { WorkflowitemDataService } from './submission/workflowitem-data.service'; import { NotificationsService } from '../shared/notifications/notifications.service'; import { UploaderService } from '../shared/uploader/uploader.service'; +import { FileService } from './shared/file.service'; +import { SubmissionRestService } from '../submission/submission-rest.service'; import { BrowseItemsResponseParsingService } from './data/browse-items-response-parsing-service'; import { DSpaceObjectDataService } from './data/dspace-object-data.service'; import { MetadataschemaParsingService } from './data/metadataschema-parsing.service'; import { CSSVariableService } from '../shared/sass-helper/sass-helper.service'; import { MenuService } from '../shared/menu/menu.service'; +import { SubmissionJsonPatchOperationsService } from './submission/submission-json-patch-operations.service'; import { NormalizedObjectBuildService } from './cache/builders/normalized-object-build.service'; import { DSOChangeAnalyzer } from './data/dso-change-analyzer.service'; import { ObjectUpdatesService } from './data/object-updates/object-updates.service'; @@ -97,7 +107,10 @@ const PROVIDERS = [ DynamicFormService, DynamicFormValidationService, FormBuilderService, + SectionFormOperationsService, FormService, + EpersonResponseParsingService, + GroupEpersonService, HALEndpointService, HostWindowService, ItemDataService, @@ -126,12 +139,21 @@ const PROVIDERS = [ RouteService, SubmissionDefinitionsConfigService, SubmissionFormsConfigService, + SubmissionRestService, SubmissionSectionsConfigService, + SubmissionResponseParsingService, + SubmissionJsonPatchOperationsService, + JsonPatchOperationsBuilder, AuthorityService, IntegrationResponseParsingService, MetadataschemaParsingService, UploaderService, UUIDService, + NotificationsService, + WorkspaceitemDataService, + WorkflowitemDataService, + UploaderService, + FileService, DSpaceObjectDataService, DSOChangeAnalyzer, DefaultChangeAnalyzer, diff --git a/src/app/core/core.reducers.ts b/src/app/core/core.reducers.ts index e0ddb4a9de1..ebfe578a6d1 100644 --- a/src/app/core/core.reducers.ts +++ b/src/app/core/core.reducers.ts @@ -7,6 +7,7 @@ import { objectCacheReducer, ObjectCacheState } from './cache/object-cache.reduc import { indexReducer, IndexState } from './index/index.reducer'; import { requestReducer, RequestState } from './data/request.reducer'; import { authReducer, AuthState } from './auth/auth.reducer'; +import { jsonPatchOperationsReducer, JsonPatchOperationsState } from './json-patch/json-patch-operations.reducer'; import { serverSyncBufferReducer, ServerSyncBufferState } from './cache/server-sync-buffer.reducer'; import { objectUpdatesReducer, @@ -20,6 +21,7 @@ export interface CoreState { 'data/request': RequestState, 'index': IndexState, 'auth': AuthState, + 'json/patch': JsonPatchOperationsState } export const coreReducers: ActionReducerMap = { @@ -29,6 +31,7 @@ export const coreReducers: ActionReducerMap = { 'data/request': requestReducer, 'index': indexReducer, 'auth': authReducer, + 'json/patch': jsonPatchOperationsReducer }; export const coreSelector = createFeatureSelector('core'); diff --git a/src/app/core/data/base-response-parsing.service.ts b/src/app/core/data/base-response-parsing.service.ts index 925caa495c3..6102f930b08 100644 --- a/src/app/core/data/base-response-parsing.service.ts +++ b/src/app/core/data/base-response-parsing.service.ts @@ -37,11 +37,11 @@ export abstract class BaseResponseParsingService { if (isNotEmpty(parsedObj)) { if (isRestPaginatedList(data._embedded[property])) { object[property] = parsedObj; - object[property].page = parsedObj.page.map((obj) => obj.self); + object[property].page = parsedObj.page.map((obj) => this.retrieveObjectOrUrl(obj)); } else if (isRestDataObject(data._embedded[property])) { - object[property] = parsedObj.self; + object[property] = this.retrieveObjectOrUrl(parsedObj); } else if (Array.isArray(parsedObj)) { - object[property] = parsedObj.map((obj) => obj.self) + object[property] = parsedObj.map((obj) => this.retrieveObjectOrUrl(obj)) } } }); @@ -55,8 +55,7 @@ export abstract class BaseResponseParsingService { .filter((property) => data.hasOwnProperty(property)) .filter((property) => hasValue(data[property])) .forEach((property) => { - const obj = this.process(data[property], requestUUID); - result[property] = obj; + result[property] = this.process(data[property], requestUUID); }); return result; @@ -91,8 +90,7 @@ export abstract class BaseResponseParsingService { if (hasValue(normObjConstructor)) { const serializer = new DSpaceRESTv2Serializer(normObjConstructor); - const res = serializer.deserialize(obj); - return res; + return serializer.deserialize(obj); } else { // TODO: move check to Validator? // throw new Error(`The server returned an object with an unknown a known type: ${type}`); @@ -140,6 +138,10 @@ export abstract class BaseResponseParsingService { return obj[keys[0]]; } + protected retrieveObjectOrUrl(obj: any): any { + return this.toCache ? obj.self : obj; + } + // TODO Remove when https://jira.duraspace.org/browse/DS-4006 is fixed // See https://github.com/DSpace/dspace-angular/issues/292 private fixBadEPersonRestResponse(obj: any): any { diff --git a/src/app/core/data/browse-entries-response-parsing.service.spec.ts b/src/app/core/data/browse-entries-response-parsing.service.spec.ts index ee706d202c7..ef9a833765d 100644 --- a/src/app/core/data/browse-entries-response-parsing.service.spec.ts +++ b/src/app/core/data/browse-entries-response-parsing.service.spec.ts @@ -101,15 +101,17 @@ describe('BrowseEntriesResponseParsingService', () => { number: 0 } }, - statusCode: '200' + statusCode: 200, + statusText: 'OK' } as DSpaceRESTV2Response; const invalidResponseNotAList = { - statusCode: '200' + statusCode: 200, + statusText: 'OK' } as DSpaceRESTV2Response; const invalidResponseStatusCode = { - payload: {}, statusCode: '500' + payload: {}, statusCode: 500, statusText: 'Internal Server Error' } as DSpaceRESTV2Response; it('should return a GenericSuccessResponse if data contains a valid browse entries response', () => { diff --git a/src/app/core/data/browse-entries-response-parsing.service.ts b/src/app/core/data/browse-entries-response-parsing.service.ts index d61df1f6119..4690d738ed6 100644 --- a/src/app/core/data/browse-entries-response-parsing.service.ts +++ b/src/app/core/data/browse-entries-response-parsing.service.ts @@ -36,12 +36,12 @@ export class BrowseEntriesResponseParsingService extends BaseResponseParsingServ const serializer = new DSpaceRESTv2Serializer(BrowseEntry); browseEntries = serializer.deserializeArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]]); } - return new GenericSuccessResponse(browseEntries, data.statusCode, this.processPageInfo(data.payload)); + return new GenericSuccessResponse(browseEntries, data.statusCode, data.statusText, this.processPageInfo(data.payload)); } else { return new ErrorResponse( Object.assign( new Error('Unexpected response from browse endpoint'), - { statusText: data.statusCode } + { statusCode: data.statusCode, statusText: data.statusText } ) ); } diff --git a/src/app/core/data/browse-items-response-parsing-service.spec.ts b/src/app/core/data/browse-items-response-parsing-service.spec.ts index f512a9af260..50b3be5de7c 100644 --- a/src/app/core/data/browse-items-response-parsing-service.spec.ts +++ b/src/app/core/data/browse-items-response-parsing-service.spec.ts @@ -108,7 +108,8 @@ describe('BrowseItemsResponseParsingService', () => { number: 0 } }, - statusCode: '200' + statusCode: 200, + statusText: 'OK' } as DSpaceRESTV2Response; const invalidResponseNotAList = { @@ -145,11 +146,12 @@ describe('BrowseItemsResponseParsingService', () => { } } }, - statusCode: '200' + statusCode: 200, + statusText: 'OK' } as DSpaceRESTV2Response; const invalidResponseStatusCode = { - payload: {}, statusCode: '500' + payload: {}, statusCode: 500, statusText: 'Internal Server Error' } as DSpaceRESTV2Response; it('should return a GenericSuccessResponse if data contains a valid browse items response', () => { diff --git a/src/app/core/data/browse-items-response-parsing-service.ts b/src/app/core/data/browse-items-response-parsing-service.ts index b1feb2ab7f6..fb950f6c683 100644 --- a/src/app/core/data/browse-items-response-parsing-service.ts +++ b/src/app/core/data/browse-items-response-parsing-service.ts @@ -1,19 +1,15 @@ import { Inject, Injectable } from '@angular/core'; + import { GLOBAL_CONFIG } from '../../../config'; import { GlobalConfig } from '../../../config/global-config.interface'; import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { - ErrorResponse, - GenericSuccessResponse, - RestResponse -} from '../cache/response.models'; +import { ErrorResponse, GenericSuccessResponse, RestResponse } from '../cache/response.models'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; import { BaseResponseParsingService } from './base-response-parsing.service'; import { ResponseParsingService } from './parsing.service'; import { RestRequest } from './request.models'; -import { Item } from '../shared/item.model'; import { DSpaceObject } from '../shared/dspace-object.model'; import { NormalizedDSpaceObject } from '../cache/models/normalized-dspace-object.model'; @@ -45,14 +41,14 @@ export class BrowseItemsResponseParsingService extends BaseResponseParsingServic && Array.isArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]])) { const serializer = new DSpaceRESTv2Serializer(NormalizedDSpaceObject); const items = serializer.deserializeArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]]); - return new GenericSuccessResponse(items, data.statusCode, this.processPageInfo(data.payload)); + return new GenericSuccessResponse(items, data.statusCode, data.statusText, this.processPageInfo(data.payload)); } else if (hasValue(data.payload) && hasValue(data.payload.page)) { - return new GenericSuccessResponse([], data.statusCode, this.processPageInfo(data.payload)); + return new GenericSuccessResponse([], data.statusCode, data.statusText, this.processPageInfo(data.payload)); } else { return new ErrorResponse( Object.assign( new Error('Unexpected response from browse endpoint'), - { statusText: data.statusCode } + { statusCode: data.statusCode, statusText: data.statusText } ) ); } diff --git a/src/app/core/data/browse-response-parsing.service.spec.ts b/src/app/core/data/browse-response-parsing.service.spec.ts index bedf5f03a75..c1b0566e0b6 100644 --- a/src/app/core/data/browse-response-parsing.service.spec.ts +++ b/src/app/core/data/browse-response-parsing.service.spec.ts @@ -55,7 +55,7 @@ describe('BrowseResponseParsingService', () => { }, _links: { self: { href: 'https://rest.api/discover/browses' } }, page: { size: 20, totalElements: 2, totalPages: 1, number: 0 } - }, statusCode: '200' + }, statusCode: 200, statusText: 'OK' } as DSpaceRESTV2Response; invalidResponse1 = { @@ -78,21 +78,21 @@ describe('BrowseResponseParsingService', () => { }, _links: { self: { href: 'https://rest.api/discover/browses' } }, page: { size: 20, totalElements: 2, totalPages: 1, number: 0 } - }, statusCode: '200' + }, statusCode: 200, statusText: 'OK' } as DSpaceRESTV2Response; invalidResponse2 = { payload: { _links: { self: { href: 'https://rest.api/discover/browses' } }, page: { size: 20, totalElements: 2, totalPages: 1, number: 0 } - }, statusCode: '200' + }, statusCode: 200, statusText: 'OK' } as DSpaceRESTV2Response; invalidResponse3 = { payload: { _links: { self: { href: 'https://rest.api/discover/browses' } }, page: { size: 20, totalElements: 2, totalPages: 1, number: 0 } - }, statusCode: '500' + }, statusCode: 500, statusText: 'Internal Server Error' } as DSpaceRESTV2Response; definitions = [ diff --git a/src/app/core/data/browse-response-parsing.service.ts b/src/app/core/data/browse-response-parsing.service.ts index 523fffd565e..3c67b2b3eb2 100644 --- a/src/app/core/data/browse-response-parsing.service.ts +++ b/src/app/core/data/browse-response-parsing.service.ts @@ -15,12 +15,12 @@ export class BrowseResponseParsingService implements ResponseParsingService { && Array.isArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]])) { const serializer = new DSpaceRESTv2Serializer(BrowseDefinition); const browseDefinitions = serializer.deserializeArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]]); - return new GenericSuccessResponse(browseDefinitions, data.statusCode); + return new GenericSuccessResponse(browseDefinitions, data.statusCode, data.statusText); } else { return new ErrorResponse( Object.assign( new Error('Unexpected response from browse endpoint'), - { statusText: data.statusCode } + { statusCode: data.statusCode, statusText: data.statusText } ) ); } diff --git a/src/app/core/data/collection-data.service.ts b/src/app/core/data/collection-data.service.ts index d9b722fb46a..3d03b9397df 100644 --- a/src/app/core/data/collection-data.service.ts +++ b/src/app/core/data/collection-data.service.ts @@ -17,6 +17,7 @@ import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; @Injectable() export class CollectionDataService extends ComColDataService { protected linkPath = 'collections'; + protected forceBypassCache = false; constructor( protected requestService: RequestService, diff --git a/src/app/core/data/comcol-data.service.spec.ts b/src/app/core/data/comcol-data.service.spec.ts index 784075c855b..cf7b6185ea5 100644 --- a/src/app/core/data/comcol-data.service.spec.ts +++ b/src/app/core/data/comcol-data.service.spec.ts @@ -28,6 +28,7 @@ class NormalizedTestObject extends NormalizedObject { } class TestService extends ComColDataService { + protected forceBypassCache = false; constructor( protected requestService: RequestService, diff --git a/src/app/core/data/community-data.service.ts b/src/app/core/data/community-data.service.ts index 662b82d6ea2..75ef58b06bb 100644 --- a/src/app/core/data/community-data.service.ts +++ b/src/app/core/data/community-data.service.ts @@ -25,6 +25,7 @@ export class CommunityDataService extends ComColDataService { protected linkPath = 'communities'; protected topLinkPath = 'communities/search/top'; protected cds = this; + protected forceBypassCache = false; constructor( protected requestService: RequestService, diff --git a/src/app/core/data/data.service.spec.ts b/src/app/core/data/data.service.spec.ts index 178a16d4c1e..910506bc29a 100644 --- a/src/app/core/data/data.service.spec.ts +++ b/src/app/core/data/data.service.spec.ts @@ -25,6 +25,8 @@ class NormalizedTestObject extends NormalizedObject { } class TestService extends DataService { + protected forceBypassCache = false; + constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, diff --git a/src/app/core/data/data.service.ts b/src/app/core/data/data.service.ts index 42115b6b6ce..984495078b7 100644 --- a/src/app/core/data/data.service.ts +++ b/src/app/core/data/data.service.ts @@ -1,15 +1,9 @@ -import { - distinctUntilChanged, - filter, - find, - first, - map, - mergeMap, - switchMap, - take -} from 'rxjs/operators'; +import { HttpClient } from '@angular/common/http'; + import { Observable } from 'rxjs'; +import { distinctUntilChanged, filter, find, first, map, mergeMap, switchMap, take } from 'rxjs/operators'; import { Store } from '@ngrx/store'; + import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { CoreState } from '../core.reducers'; @@ -26,12 +20,13 @@ import { GetRequest } from './request.models'; import { RequestService } from './request.service'; +import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; import { NormalizedObject } from '../cache/models/normalized-object.model'; +import { SearchParam } from '../cache/models/search-param.model'; import { Operation } from 'fast-json-patch'; import { ObjectCacheService } from '../cache/object-cache.service'; import { DSpaceObject } from '../shared/dspace-object.model'; import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; import { configureRequest, getResponseFromEntry } from '../shared/operators'; import { ErrorResponse, RestResponse } from '../cache/response.models'; import { NotificationOptions } from '../../shared/notifications/models/notification-options.model'; @@ -50,6 +45,7 @@ export abstract class DataService { protected abstract store: Store; protected abstract linkPath: string; protected abstract halService: HALEndpointService; + protected abstract forceBypassCache = false; protected abstract objectCache: ObjectCacheService; protected abstract notificationsService: NotificationsService; protected abstract http: HttpClient; @@ -61,7 +57,28 @@ export abstract class DataService { let result: Observable; const args = []; - result = this.getBrowseEndpoint(options, linkPath); + result = this.getBrowseEndpoint(options, linkPath).pipe(distinctUntilChanged()); + + return this.buildHrefFromFindOptions(result, args, options); + } + + protected getSearchByHref(searchMethod: string, options: FindAllOptions = {}): Observable { + let result: Observable; + const args = []; + + result = this.getSearchEndpoint(searchMethod); + + if (hasValue(options.searchParams)) { + options.searchParams.forEach((param: SearchParam) => { + args.push(`${param.fieldName}=${param.fieldValue}`); + }) + } + + return this.buildHrefFromFindOptions(result, args, options); + } + + protected buildHrefFromFindOptions(href$: Observable, args: string[], options: FindAllOptions): Observable { + if (hasValue(options.currentPage) && typeof options.currentPage === 'number') { /* TODO: this is a temporary fix for the pagination start index (0 or 1) discrepancy between the rest and the frontend respectively */ args.push(`page=${options.currentPage - 1}`); @@ -76,9 +93,9 @@ export abstract class DataService { args.push(`startsWith=${options.startsWith}`); } if (isNotEmpty(args)) { - return result.pipe(map((href: string) => new URLCombiner(href, `?${args.join('&')}`).toString())); + return href$.pipe(map((href: string) => new URLCombiner(href, `?${args.join('&')}`).toString())); } else { - return result; + return href$; } } @@ -86,11 +103,10 @@ export abstract class DataService { const hrefObs = this.getFindAllHref(options); hrefObs.pipe( - filter((href: string) => hasValue(href)), - take(1)) + first((href: string) => hasValue(href))) .subscribe((href: string) => { const request = new FindAllRequest(this.requestService.generateRequestId(), href, options); - this.requestService.configure(request); + this.requestService.configure(request, this.forceBypassCache); }); return this.rdbService.buildList(hrefObs) as Observable>>; @@ -113,17 +129,37 @@ export abstract class DataService { find((href: string) => hasValue(href))) .subscribe((href: string) => { const request = new FindByIDRequest(this.requestService.generateRequestId(), href, id); - this.requestService.configure(request); + this.requestService.configure(request, this.forceBypassCache); }); return this.rdbService.buildSingle(hrefObs); } - findByHref(href: string): Observable> { - this.requestService.configure(new GetRequest(this.requestService.generateRequestId(), href)); + findByHref(href: string, options?: HttpOptions): Observable> { + this.requestService.configure(new GetRequest(this.requestService.generateRequestId(), href, null, options), this.forceBypassCache); return this.rdbService.buildSingle(href); } + protected getSearchEndpoint(searchMethod: string): Observable { + return this.halService.getEndpoint(`${this.linkPath}/search`).pipe( + filter((href: string) => isNotEmpty(href)), + map((href: string) => `${href}/${searchMethod}`)); + } + + protected searchBy(searchMethod: string, options: FindAllOptions = {}): Observable>> { + + const hrefObs = this.getSearchByHref(searchMethod, options); + + hrefObs.pipe( + first((href: string) => hasValue(href))) + .subscribe((href: string) => { + const request = new FindAllRequest(this.requestService.generateRequestId(), href, options); + this.requestService.configure(request, true); + }); + + return this.rdbService.buildList(hrefObs) as Observable>>; + } + /** * Add a new patch to the object cache to a specified object * @param {string} href The selflink of the object that will be patched diff --git a/src/app/core/data/dso-response-parsing.service.ts b/src/app/core/data/dso-response-parsing.service.ts index 3cb0b1e8ff1..eb95cdae8ac 100644 --- a/src/app/core/data/dso-response-parsing.service.ts +++ b/src/app/core/data/dso-response-parsing.service.ts @@ -39,7 +39,7 @@ export class DSOResponseParsingService extends BaseResponseParsingService implem let objectList = processRequestDTO; if (hasNoValue(processRequestDTO)) { - return new DSOSuccessResponse([], data.statusCode, undefined) + return new DSOSuccessResponse([], data.statusCode, data.statusText, undefined) } if (hasValue(processRequestDTO.page)) { objectList = processRequestDTO.page; @@ -47,7 +47,7 @@ export class DSOResponseParsingService extends BaseResponseParsingService implem objectList = [processRequestDTO]; } const selfLinks = objectList.map((no) => no.self); - return new DSOSuccessResponse(selfLinks, data.statusCode, this.processPageInfo(data.payload)) + return new DSOSuccessResponse(selfLinks, data.statusCode, data.statusText, this.processPageInfo(data.payload)) } } diff --git a/src/app/core/data/dspace-object-data.service.spec.ts b/src/app/core/data/dspace-object-data.service.spec.ts index 7047db60654..a0bba214aeb 100644 --- a/src/app/core/data/dspace-object-data.service.spec.ts +++ b/src/app/core/data/dspace-object-data.service.spec.ts @@ -72,7 +72,7 @@ describe('DSpaceObjectDataService', () => { scheduler.schedule(() => service.findById(testObject.uuid)); scheduler.flush(); - expect(requestService.configure).toHaveBeenCalledWith(new FindByIDRequest(requestUUID, requestURL, testObject.uuid)); + expect(requestService.configure).toHaveBeenCalledWith(new FindByIDRequest(requestUUID, requestURL, testObject.uuid), false); }); it('should return a RemoteData for the object with the given ID', () => { diff --git a/src/app/core/data/dspace-object-data.service.ts b/src/app/core/data/dspace-object-data.service.ts index bb02afbcd17..4f0653f4163 100644 --- a/src/app/core/data/dspace-object-data.service.ts +++ b/src/app/core/data/dspace-object-data.service.ts @@ -18,6 +18,7 @@ import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; /* tslint:disable:max-classes-per-file */ class DataServiceImpl extends DataService { protected linkPath = 'dso'; + protected forceBypassCache = false; constructor( protected requestService: RequestService, diff --git a/src/app/core/data/endpoint-map-response-parsing.service.ts b/src/app/core/data/endpoint-map-response-parsing.service.ts index a145477953a..080c665ccf9 100644 --- a/src/app/core/data/endpoint-map-response-parsing.service.ts +++ b/src/app/core/data/endpoint-map-response-parsing.service.ts @@ -20,12 +20,12 @@ export class EndpointMapResponseParsingService implements ResponseParsingService for (const link of Object.keys(links)) { links[link] = links[link].href; } - return new EndpointMapSuccessResponse(links, data.statusCode); + return new EndpointMapSuccessResponse(links, data.statusCode, data.statusText); } else { return new ErrorResponse( Object.assign( new Error('Unexpected response from root endpoint'), - { statusText: data.statusCode } + { statusCode: data.statusCode, statusText: data.statusText } ) ); } diff --git a/src/app/core/data/facet-config-response-parsing.service.ts b/src/app/core/data/facet-config-response-parsing.service.ts index 02b12dfa10a..e65e3176420 100644 --- a/src/app/core/data/facet-config-response-parsing.service.ts +++ b/src/app/core/data/facet-config-response-parsing.service.ts @@ -27,6 +27,6 @@ export class FacetConfigResponseParsingService extends BaseResponseParsingServic const config = data.payload._embedded.facets; const serializer = new DSpaceRESTv2Serializer(SearchFilterConfig); const facetConfig = serializer.deserializeArray(config); - return new FacetConfigSuccessResponse(facetConfig, data.statusCode); + return new FacetConfigSuccessResponse(facetConfig, data.statusCode, data.statusText); } } diff --git a/src/app/core/data/facet-value-map-response-parsing.service.ts b/src/app/core/data/facet-value-map-response-parsing.service.ts index 2f580ee9521..e03c1a78df6 100644 --- a/src/app/core/data/facet-value-map-response-parsing.service.ts +++ b/src/app/core/data/facet-value-map-response-parsing.service.ts @@ -35,10 +35,10 @@ export class FacetValueMapResponseParsingService extends BaseResponseParsingServ payload._embedded.facets.map((facet) => { const values = facet._embedded.values.map((value) => {value.search = value._links.search.href; return value;}); const facetValues = serializer.deserializeArray(values); - const valuesResponse = new FacetValueSuccessResponse(facetValues, data.statusCode, this.processPageInfo(data.payload)); + const valuesResponse = new FacetValueSuccessResponse(facetValues, data.statusCode, data.statusText, this.processPageInfo(data.payload)); facetMap[facet.name] = valuesResponse; }); - return new FacetValueMapSuccessResponse(facetMap, data.statusCode); + return new FacetValueMapSuccessResponse(facetMap, data.statusCode, data.statusText); } } diff --git a/src/app/core/data/facet-value-response-parsing.service.ts b/src/app/core/data/facet-value-response-parsing.service.ts index 54f36a05646..e7665ebed2d 100644 --- a/src/app/core/data/facet-value-response-parsing.service.ts +++ b/src/app/core/data/facet-value-response-parsing.service.ts @@ -26,6 +26,6 @@ export class FacetValueResponseParsingService extends BaseResponseParsingService // const values = payload._embedded.values.map((value) => {value.search = value._links.search.href; return value;}); const facetValues = serializer.deserializeArray(payload._embedded.values); - return new FacetValueSuccessResponse(facetValues, data.statusCode, this.processPageInfo(data.payload)); + return new FacetValueSuccessResponse(facetValues, data.statusCode, data.statusText, this.processPageInfo(data.payload)); } } diff --git a/src/app/core/data/item-data.service.spec.ts b/src/app/core/data/item-data.service.spec.ts index 02c70791b57..3553a63af4e 100644 --- a/src/app/core/data/item-data.service.spec.ts +++ b/src/app/core/data/item-data.service.spec.ts @@ -30,7 +30,7 @@ describe('ItemDataService', () => { }, getByHref(requestHref: string) { const responseCacheEntry = new RequestEntry(); - responseCacheEntry.response = new RestResponse(true, '200'); + responseCacheEntry.response = new RestResponse(true, 200, 'OK'); return observableOf(responseCacheEntry); } } as RequestService; @@ -133,7 +133,7 @@ describe('ItemDataService', () => { }); it('should setWithDrawn', () => { - const expected = new RestResponse(true, '200'); + const expected = new RestResponse(true, 200, 'OK'); const result = service.setWithDrawn(scopeID, true); result.subscribe((v) => expect(v).toEqual(expected)); @@ -155,7 +155,7 @@ describe('ItemDataService', () => { }); it('should setDiscoverable', () => { - const expected = new RestResponse(true, '200'); + const expected = new RestResponse(true, 200, 'OK'); const result = service.setDiscoverable(scopeID, false); 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 a2f6a1cc14b..f6adbb23c2d 100644 --- a/src/app/core/data/item-data.service.ts +++ b/src/app/core/data/item-data.service.ts @@ -24,6 +24,7 @@ import { RequestEntry } from './request.reducer'; @Injectable() export class ItemDataService extends DataService { protected linkPath = 'items'; + protected forceBypassCache = false; constructor( protected requestService: RequestService, diff --git a/src/app/core/data/metadata-schema-data.service.ts b/src/app/core/data/metadata-schema-data.service.ts index 05879d6fbb0..1d2bf3b2213 100644 --- a/src/app/core/data/metadata-schema-data.service.ts +++ b/src/app/core/data/metadata-schema-data.service.ts @@ -1,7 +1,6 @@ import { Injectable } from '@angular/core'; import { Store } from '@ngrx/store'; import { Observable } from 'rxjs'; -import { BrowseService } from '../browse/browse.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { CoreState } from '../core.reducers'; @@ -22,12 +21,12 @@ import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; @Injectable() export class MetadataSchemaDataService extends DataService { protected linkPath = 'metadataschemas'; + protected forceBypassCache = false; constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, protected store: Store, - private bs: BrowseService, protected halService: HALEndpointService, protected objectCache: ObjectCacheService, protected comparator: DefaultChangeAnalyzer, diff --git a/src/app/core/data/metadatafield-parsing.service.ts b/src/app/core/data/metadatafield-parsing.service.ts index 86a3c8a9259..f9582c394d8 100644 --- a/src/app/core/data/metadatafield-parsing.service.ts +++ b/src/app/core/data/metadatafield-parsing.service.ts @@ -16,7 +16,7 @@ export class MetadatafieldParsingService implements ResponseParsingService { const payload = data.payload; const deserialized = new DSpaceRESTv2Serializer(MetadataField).deserialize(payload); - return new MetadatafieldSuccessResponse(deserialized, data.statusCode); + return new MetadatafieldSuccessResponse(deserialized, data.statusCode, data.statusText); } } diff --git a/src/app/core/data/metadataschema-parsing.service.ts b/src/app/core/data/metadataschema-parsing.service.ts index 78a5257456a..f76d6ed2e3b 100644 --- a/src/app/core/data/metadataschema-parsing.service.ts +++ b/src/app/core/data/metadataschema-parsing.service.ts @@ -13,7 +13,7 @@ export class MetadataschemaParsingService implements ResponseParsingService { const payload = data.payload; const deserialized = new DSpaceRESTv2Serializer(MetadataSchema).deserialize(payload); - return new MetadataschemaSuccessResponse(deserialized, data.statusCode); + return new MetadataschemaSuccessResponse(deserialized, data.statusCode, data.statusText); } } diff --git a/src/app/core/data/registry-bitstreamformats-response-parsing.service.ts b/src/app/core/data/registry-bitstreamformats-response-parsing.service.ts index 2ee3bbf75ed..899fee4d1e1 100644 --- a/src/app/core/data/registry-bitstreamformats-response-parsing.service.ts +++ b/src/app/core/data/registry-bitstreamformats-response-parsing.service.ts @@ -19,7 +19,7 @@ export class RegistryBitstreamformatsResponseParsingService implements ResponseP payload.bitstreamformats = bitstreamformats; const deserialized = new DSpaceRESTv2Serializer(RegistryBitstreamformatsResponse).deserialize(payload); - return new RegistryBitstreamformatsSuccessResponse(deserialized, data.statusCode, this.dsoParser.processPageInfo(data.payload.page)); + return new RegistryBitstreamformatsSuccessResponse(deserialized, data.statusCode, data.statusText, this.dsoParser.processPageInfo(data.payload.page)); } } diff --git a/src/app/core/data/registry-metadatafields-response-parsing.service.ts b/src/app/core/data/registry-metadatafields-response-parsing.service.ts index 93fd67b702c..a4bed3240e8 100644 --- a/src/app/core/data/registry-metadatafields-response-parsing.service.ts +++ b/src/app/core/data/registry-metadatafields-response-parsing.service.ts @@ -31,7 +31,7 @@ export class RegistryMetadatafieldsResponseParsingService implements ResponsePar payload.metadatafields = metadatafields; const deserialized = new DSpaceRESTv2Serializer(RegistryMetadatafieldsResponse).deserialize(payload); - return new RegistryMetadatafieldsSuccessResponse(deserialized, data.statusCode, this.dsoParser.processPageInfo(data.payload)); + return new RegistryMetadatafieldsSuccessResponse(deserialized, data.statusCode, data.statusText, this.dsoParser.processPageInfo(data.payload)); } } diff --git a/src/app/core/data/registry-metadataschemas-response-parsing.service.ts b/src/app/core/data/registry-metadataschemas-response-parsing.service.ts index 05a61f6b4f2..d19b334131a 100644 --- a/src/app/core/data/registry-metadataschemas-response-parsing.service.ts +++ b/src/app/core/data/registry-metadataschemas-response-parsing.service.ts @@ -23,7 +23,7 @@ export class RegistryMetadataschemasResponseParsingService implements ResponsePa payload.metadataschemas = metadataschemas; const deserialized = new DSpaceRESTv2Serializer(RegistryMetadataschemasResponse).deserialize(payload); - return new RegistryMetadataschemasSuccessResponse(deserialized, data.statusCode, this.dsoParser.processPageInfo(data.payload)); + return new RegistryMetadataschemasSuccessResponse(deserialized, data.statusCode, data.statusText, this.dsoParser.processPageInfo(data.payload)); } } diff --git a/src/app/core/data/remote-data-error.ts b/src/app/core/data/remote-data-error.ts index a2ff27a0738..9291bc54474 100644 --- a/src/app/core/data/remote-data-error.ts +++ b/src/app/core/data/remote-data-error.ts @@ -1,6 +1,7 @@ export class RemoteDataError { constructor( - public statusCode: string, + public statusCode: number, + public statusText: string, public message: string ) { } diff --git a/src/app/core/data/request.models.ts b/src/app/core/data/request.models.ts index 18820dbd43b..1afd24962c4 100644 --- a/src/app/core/data/request.models.ts +++ b/src/app/core/data/request.models.ts @@ -5,11 +5,14 @@ import { DSOResponseParsingService } from './dso-response-parsing.service'; import { ResponseParsingService } from './parsing.service'; import { EndpointMapResponseParsingService } from './endpoint-map-response-parsing.service'; import { BrowseResponseParsingService } from './browse-response-parsing.service'; -import { ConfigResponseParsingService } from './config-response-parsing.service'; +import { ConfigResponseParsingService } from '../config/config-response-parsing.service'; import { AuthResponseParsingService } from '../auth/auth-response-parsing.service'; import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; +import { SubmissionResponseParsingService } from '../submission/submission-response-parsing.service'; import { IntegrationResponseParsingService } from '../integration/integration-response-parsing.service'; import { RestRequestMethod } from './rest-request-method'; +import { SearchParam } from '../cache/models/search-param.model'; +import { EpersonResponseParsingService } from '../eperson/eperson-response-parsing.service'; import { BrowseItemsResponseParsingService } from './browse-items-response-parsing-service'; import { RegistryMetadataschemasResponseParsingService } from './registry-metadataschemas-response-parsing.service'; import { MetadataschemaParsingService } from './metadataschema-parsing.service'; @@ -131,6 +134,7 @@ export class FindAllOptions { elementsPerPage?: number; currentPage?: number; sort?: SortOptions; + searchParams?: SearchParam[]; startsWith?: string; } @@ -181,8 +185,8 @@ export class BrowseItemsRequest extends GetRequest { } export class ConfigRequest extends GetRequest { - constructor(uuid: string, href: string) { - super(uuid, href); + constructor(uuid: string, href: string, public options?: HttpOptions) { + super(uuid, href, null, options); } getResponseParser(): GenericConstructor { @@ -272,6 +276,77 @@ export class UpdateMetadataFieldRequest extends PutRequest { } } +/** + * Class representing a submission HTTP GET request object + */ +export class SubmissionRequest extends GetRequest { + constructor(uuid: string, href: string) { + super(uuid, href); + } + + getResponseParser(): GenericConstructor { + return SubmissionResponseParsingService; + } +} + +/** + * Class representing a submission HTTP DELETE request object + */ +export class SubmissionDeleteRequest extends DeleteRequest { + constructor(public uuid: string, + public href: string) { + super(uuid, href); + } + + getResponseParser(): GenericConstructor { + return SubmissionResponseParsingService; + } +} + +/** + * Class representing a submission HTTP PATCH request object + */ +export class SubmissionPatchRequest extends PatchRequest { + constructor(public uuid: string, + public href: string, + public body?: any) { + super(uuid, href, body); + } + + getResponseParser(): GenericConstructor { + return SubmissionResponseParsingService; + } +} + +/** + * Class representing a submission HTTP POST request object + */ +export class SubmissionPostRequest extends PostRequest { + constructor(public uuid: string, + public href: string, + public body?: any, + public options?: HttpOptions) { + super(uuid, href, body, options); + } + + getResponseParser(): GenericConstructor { + return SubmissionResponseParsingService; + } +} + +/** + * Class representing an eperson HTTP GET request object + */ +export class EpersonRequest extends GetRequest { + constructor(uuid: string, href: string) { + super(uuid, href); + } + + getResponseParser(): GenericConstructor { + return EpersonResponseParsingService; + } +} + export class CreateRequest extends PostRequest { constructor(uuid: string, href: string, public body?: any, public options?: HttpOptions) { super(uuid, href, body, options); @@ -296,6 +371,7 @@ export class DeleteByIDRequest extends DeleteRequest { } export class RequestError extends Error { + statusCode: number; statusText: string; } /* tslint:enable:max-classes-per-file */ diff --git a/src/app/core/data/request.reducer.spec.ts b/src/app/core/data/request.reducer.spec.ts index 5c35c0a3989..65a4ddba170 100644 --- a/src/app/core/data/request.reducer.spec.ts +++ b/src/app/core/data/request.reducer.spec.ts @@ -9,7 +9,7 @@ import { import { GetRequest } from './request.models'; import { RestResponse } from '../cache/response.models'; -const response = new RestResponse(true, 'OK'); +const response = new RestResponse(true, 200, 'OK'); class NullAction extends RequestCompleteAction { type = null; payload = null; @@ -89,8 +89,8 @@ describe('requestReducer', () => { expect(newState[id1].requestPending).toEqual(state[id1].requestPending); expect(newState[id1].responsePending).toEqual(false); expect(newState[id1].completed).toEqual(true); - expect(newState[id1].response.isSuccessful).toEqual(response.isSuccessful) - expect(newState[id1].response.statusCode).toEqual(response.statusCode) + expect(newState[id1].response.isSuccessful).toEqual(response.isSuccessful); + expect(newState[id1].response.statusCode).toEqual(response.statusCode); expect(newState[id1].response.timeAdded).toBeTruthy() }); diff --git a/src/app/core/data/request.service.spec.ts b/src/app/core/data/request.service.spec.ts index b28436f3a83..28b340056b8 100644 --- a/src/app/core/data/request.service.spec.ts +++ b/src/app/core/data/request.service.spec.ts @@ -1,3 +1,5 @@ +import * as ngrx from '@ngrx/store'; +import { ActionsSubject, Store } from '@ngrx/store'; import { cold, getTestScheduler, hot } from 'jasmine-marbles'; import { of as observableOf } from 'rxjs'; import { getMockObjectCacheService } from '../../shared/mocks/mock-object-cache.service'; @@ -6,7 +8,6 @@ import { ObjectCacheService } from '../cache/object-cache.service'; import { CoreState } from '../core.reducers'; import { UUIDService } from '../shared/uuid.service'; import { RequestConfigureAction, RequestExecuteAction } from './request.actions'; -import * as ngrx from '@ngrx/store'; import { DeleteRequest, GetRequest, @@ -18,7 +19,6 @@ import { RestRequest } from './request.models'; import { RequestService } from './request.service'; -import { ActionsSubject, Store } from '@ngrx/store'; import { TestScheduler } from 'rxjs/testing'; import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject'; import { MockStore } from '../../shared/testing/mock-store'; @@ -173,9 +173,6 @@ describe('RequestService', () => { it('should return an Observable of undefined', () => { const result = service.getByUUID(testUUID); - // const expected = cold('b', { - // b: undefined - // }); scheduler.expectObservable(result).toBe('b', { b: undefined }); }); @@ -293,29 +290,8 @@ describe('RequestService', () => { service.configure(testPatchRequest); expect(serviceAsAny.dispatchRequest).toHaveBeenCalledWith(testPatchRequest); }); - - it('shouldn\'t track it on it\'s way to the store', () => { - spyOn(serviceAsAny, 'trackRequestsOnTheirWayToTheStore'); - - serviceAsAny.dispatchRequest(testPostRequest); - expect(serviceAsAny.trackRequestsOnTheirWayToTheStore).not.toHaveBeenCalled(); - - serviceAsAny.dispatchRequest(testPutRequest); - expect(serviceAsAny.trackRequestsOnTheirWayToTheStore).not.toHaveBeenCalled(); - - serviceAsAny.dispatchRequest(testDeleteRequest); - expect(serviceAsAny.trackRequestsOnTheirWayToTheStore).not.toHaveBeenCalled(); - - serviceAsAny.dispatchRequest(testOptionsRequest); - expect(serviceAsAny.trackRequestsOnTheirWayToTheStore).not.toHaveBeenCalled(); - - serviceAsAny.dispatchRequest(testHeadRequest); - expect(serviceAsAny.trackRequestsOnTheirWayToTheStore).not.toHaveBeenCalled(); - - serviceAsAny.dispatchRequest(testPatchRequest); - expect(serviceAsAny.trackRequestsOnTheirWayToTheStore).not.toHaveBeenCalled(); - }); }); + }); describe('isCachedOrPending', () => { @@ -432,6 +408,30 @@ describe('RequestService', () => { serviceAsAny.dispatchRequest(request); expect(store.dispatch).toHaveBeenCalledWith(new RequestExecuteAction(request.uuid)); }); + + describe('when it\'s not a GET request', () => { + it('shouldn\'t track it', () => { + spyOn(serviceAsAny, 'trackRequestsOnTheirWayToTheStore'); + + serviceAsAny.dispatchRequest(testPostRequest); + expect(serviceAsAny.trackRequestsOnTheirWayToTheStore).not.toHaveBeenCalled(); + + serviceAsAny.dispatchRequest(testPutRequest); + expect(serviceAsAny.trackRequestsOnTheirWayToTheStore).not.toHaveBeenCalled(); + + serviceAsAny.dispatchRequest(testDeleteRequest); + expect(serviceAsAny.trackRequestsOnTheirWayToTheStore).not.toHaveBeenCalled(); + + serviceAsAny.dispatchRequest(testOptionsRequest); + expect(serviceAsAny.trackRequestsOnTheirWayToTheStore).not.toHaveBeenCalled(); + + serviceAsAny.dispatchRequest(testHeadRequest); + expect(serviceAsAny.trackRequestsOnTheirWayToTheStore).not.toHaveBeenCalled(); + + serviceAsAny.dispatchRequest(testPatchRequest); + expect(serviceAsAny.trackRequestsOnTheirWayToTheStore).not.toHaveBeenCalled(); + }); + }); }); describe('trackRequestsOnTheirWayToTheStore', () => { diff --git a/src/app/core/data/request.service.ts b/src/app/core/data/request.service.ts index 93a7a10506e..5c5f0880e0a 100644 --- a/src/app/core/data/request.service.ts +++ b/src/app/core/data/request.service.ts @@ -1,22 +1,10 @@ -import { merge as observableMerge, Observable, of as observableOf } from 'rxjs'; -import { - distinctUntilChanged, - filter, - find, - first, - map, - mergeMap, - reduce, - startWith, - switchMap, - take, - tap -} from 'rxjs/operators'; -import { race as observableRace } from 'rxjs'; import { Injectable } from '@angular/core'; import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store'; -import { hasNoValue, hasValue, isNotEmpty, isNotUndefined } from '../../shared/empty.util'; +import { merge as observableMerge, Observable, of as observableOf, race as observableRace } from 'rxjs'; +import { filter, map, mergeMap, switchMap, take } from 'rxjs/operators'; + +import { hasNoValue, hasValue, isNotEmpty } from '../../shared/empty.util'; import { CacheableObject } from '../cache/object-cache.reducer'; import { ObjectCacheService } from '../cache/object-cache.service'; import { DSOSuccessResponse, RestResponse } from '../cache/response.models'; @@ -26,7 +14,6 @@ import { pathSelector } from '../shared/selectors'; import { UUIDService } from '../shared/uuid.service'; import { RequestConfigureAction, RequestExecuteAction, RequestRemoveAction } from './request.actions'; import { GetRequest, RestRequest } from './request.models'; - import { RequestEntry } from './request.reducer'; import { CommitSSBAction } from '../cache/server-sync-buffer.actions'; import { RestRequestMethod } from './rest-request-method'; @@ -133,7 +120,7 @@ export class RequestService { * @param {RestRequest} request The request to send out * @param {boolean} forceBypassCache When true, a new request is always dispatched */ - // TODO to review "overrideRequest" param when https://github.com/DSpace/dspace-angular/issues/217 will be fixed + // TODO to review "forceBypassCache" param when https://github.com/DSpace/dspace-angular/issues/217 will be fixed configure(request: RestRequest, forceBypassCache: boolean = false): void { const isGetRequest = request.method === RestRequestMethod.GET; if (!isGetRequest || !this.isCachedOrPending(request) || forceBypassCache) { diff --git a/src/app/core/data/search-response-parsing.service.ts b/src/app/core/data/search-response-parsing.service.ts index 8e3171d05e6..0ca793c5aed 100644 --- a/src/app/core/data/search-response-parsing.service.ts +++ b/src/app/core/data/search-response-parsing.service.ts @@ -38,7 +38,8 @@ export class SearchResponseParsingService implements ResponseParsingService { .map((dso) => Object.assign({}, dso, { _embedded: undefined })) .map((dso) => this.dsoParser.parse(request, { payload: dso, - statusCode: data.statusCode + statusCode: data.statusCode, + statusText: data.statusText })) .map((obj) => obj.resourceSelfLinks) .reduce((combined, thisElement) => [...combined, ...thisElement], []); @@ -55,6 +56,6 @@ export class SearchResponseParsingService implements ResponseParsingService { })); payload.objects = objects; const deserialized = new DSpaceRESTv2Serializer(SearchQueryResponse).deserialize(payload); - return new SearchSuccessResponse(deserialized, data.statusCode, this.dsoParser.processPageInfo(payload)); + return new SearchSuccessResponse(deserialized, data.statusCode, data.statusText, this.dsoParser.processPageInfo(payload)); } } diff --git a/src/app/core/dspace-rest-v2/dspace-rest-v2-response.model.ts b/src/app/core/dspace-rest-v2/dspace-rest-v2-response.model.ts index 17fb3897075..d09d398d7cd 100644 --- a/src/app/core/dspace-rest-v2/dspace-rest-v2-response.model.ts +++ b/src/app/core/dspace-rest-v2/dspace-rest-v2-response.model.ts @@ -8,5 +8,6 @@ export interface DSpaceRESTV2Response { page?: any; }, headers?: HttpHeaders, - statusCode: string + statusCode: number, + statusText: string } diff --git a/src/app/core/dspace-rest-v2/dspace-rest-v2.service.spec.ts b/src/app/core/dspace-rest-v2/dspace-rest-v2.service.spec.ts index 26bd1ba5de4..18b9090844e 100644 --- a/src/app/core/dspace-rest-v2/dspace-rest-v2.service.spec.ts +++ b/src/app/core/dspace-rest-v2/dspace-rest-v2.service.spec.ts @@ -8,7 +8,11 @@ describe('DSpaceRESTv2Service', () => { let dSpaceRESTv2Service: DSpaceRESTv2Service; let httpMock: HttpTestingController; const url = 'http://www.dspace.org/'; - const mockError = new ErrorEvent('test error'); + const mockError: any = { + statusCode: 0, + statusText: 'Unknown Error', + message: 'Http failure response for http://www.dspace.org/: 0 ' + }; beforeEach(() => { TestBed.configureTestingModule({ @@ -31,25 +35,26 @@ describe('DSpaceRESTv2Service', () => { const mockPayload = { page: 1 }; - const mockStatusCode = 'GREAT'; + const mockStatusCode = 200; + const mockStatusText = 'GREAT'; dSpaceRESTv2Service.get(url).subscribe((response) => { expect(response).toBeTruthy(); expect(response.statusCode).toEqual(mockStatusCode); + expect(response.statusText).toEqual(mockStatusText); expect(response.payload.page).toEqual(mockPayload.page); }); const req = httpMock.expectOne(url); expect(req.request.method).toBe('GET'); - req.flush(mockPayload, { statusText: mockStatusCode}); + req.flush(mockPayload, { status: mockStatusCode, statusText: mockStatusText}); }); }); it('should throw an error', () => { dSpaceRESTv2Service.get(url).subscribe(() => undefined, (err) => { - expect(err.error).toBe(mockError); + expect(err).toEqual(mockError); }); - const req = httpMock.expectOne(url); expect(req.request.method).toBe('GET'); req.error(mockError); diff --git a/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts b/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts index 6bf5eb08184..a2a9f2530c6 100644 --- a/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts +++ b/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts @@ -39,10 +39,10 @@ export class DSpaceRESTv2Service { */ get(absoluteURL: string): Observable { return this.http.get(absoluteURL, { observe: 'response' }).pipe( - map((res: HttpResponse) => ({ payload: res.body, statusCode: res.statusText })), + map((res: HttpResponse) => ({ payload: res.body, statusCode: res.status, statusText: res.statusText })), catchError((err) => { console.log('Error: ', err); - return observableThrowError(err); + return observableThrowError({statusCode: err.status, statusText: err.statusText, message: err.message}); })); } @@ -72,10 +72,10 @@ export class DSpaceRESTv2Service { requestOptions.responseType = options.responseType; } return this.http.request(method, url, requestOptions).pipe( - map((res) => ({ payload: res.body, headers: res.headers, statusCode: res.statusText })), + map((res) => ({ payload: res.body, headers: res.headers, statusCode: res.status, statusText: res.statusText })), catchError((err) => { console.log('Error: ', err); - return observableThrowError(err); + return observableThrowError({statusCode: err.status, statusText: err.statusText, message: err.message}); })); } diff --git a/src/app/core/eperson/eperson-response-parsing.service.ts b/src/app/core/eperson/eperson-response-parsing.service.ts new file mode 100644 index 00000000000..6c591b0b993 --- /dev/null +++ b/src/app/core/eperson/eperson-response-parsing.service.ts @@ -0,0 +1,46 @@ +import { Inject, Injectable } from '@angular/core'; + +import { RestRequest } from '../data/request.models'; +import { ResponseParsingService } from '../data/parsing.service'; +import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; +import { EpersonSuccessResponse, ErrorResponse, RestResponse } from '../cache/response.models'; +import { isNotEmpty } from '../../shared/empty.util'; +import { BaseResponseParsingService } from '../data/base-response-parsing.service'; +import { GLOBAL_CONFIG } from '../../../config'; +import { GlobalConfig } from '../../../config/global-config.interface'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { NormalizedObjectFactory } from '../cache/models/normalized-object-factory'; +import { ResourceType } from '../shared/resource-type'; +import { DSpaceObject } from '../shared/dspace-object.model'; + +/** + * Provides method to parse response from eperson endpoint. + */ +@Injectable() +export class EpersonResponseParsingService extends BaseResponseParsingService implements ResponseParsingService { + + protected objectFactory = NormalizedObjectFactory; + protected toCache = false; + + constructor( + @Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, + protected objectCache: ObjectCacheService, + ) { + super(); + } + + parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { + if (isNotEmpty(data.payload) && isNotEmpty(data.payload._links)) { + const epersonDefinition = this.process(data.payload, request.href); + return new EpersonSuccessResponse(epersonDefinition[Object.keys(epersonDefinition)[0]], data.statusCode, data.statusText, this.processPageInfo(data.payload)); + } else { + return new ErrorResponse( + Object.assign( + new Error('Unexpected response from EPerson endpoint'), + {statusCode: data.statusCode, statusText: data.statusText} + ) + ); + } + } + +} diff --git a/src/app/core/eperson/eperson.service.ts b/src/app/core/eperson/eperson.service.ts new file mode 100644 index 00000000000..70ecf3f59e0 --- /dev/null +++ b/src/app/core/eperson/eperson.service.ts @@ -0,0 +1,14 @@ +import { Observable } from 'rxjs'; +import { FindAllOptions } from '../data/request.models'; +import { DataService } from '../data/data.service'; +import { CacheableObject } from '../cache/object-cache.reducer'; + +/** + * An abstract class that provides methods to make HTTP request to eperson endpoint. + */ +export abstract class EpersonService extends DataService { + + public getBrowseEndpoint(options: FindAllOptions): Observable { + return this.halService.getEndpoint(this.linkPath); + } +} diff --git a/src/app/core/eperson/group-eperson.service.ts b/src/app/core/eperson/group-eperson.service.ts new file mode 100644 index 00000000000..07a1bb6aba8 --- /dev/null +++ b/src/app/core/eperson/group-eperson.service.ts @@ -0,0 +1,66 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; + +import { Store } from '@ngrx/store'; +import { Observable } from 'rxjs'; +import { filter, map, take } from 'rxjs/operators'; + +import { EpersonService } from './eperson.service'; +import { RequestService } from '../data/request.service'; +import { FindAllOptions } from '../data/request.models'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { Group } from './models/group.model'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { CoreState } from '../core.reducers'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { SearchParam } from '../cache/models/search-param.model'; +import { RemoteData } from '../data/remote-data'; +import { PaginatedList } from '../data/paginated-list'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; +import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; + +/** + * Provides methods to retrieve eperson group resources. + */ +@Injectable() +export class GroupEpersonService extends EpersonService { + protected linkPath = 'groups'; + protected browseEndpoint = ''; + protected forceBypassCache = false; + + constructor( + protected comparator: DSOChangeAnalyzer, + protected dataBuildService: NormalizedObjectBuildService, + protected http: HttpClient, + protected notificationsService: NotificationsService, + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService + ) { + super(); + } + + /** + * Check if the current user is member of to the indicated group + * + * @param groupName + * the group name + * @return boolean + * true if user is member of the indicated group, false otherwise + */ + isMemberOf(groupName: string): Observable { + const searchHref = 'isMemberOf'; + const options = new FindAllOptions(); + options.searchParams = [new SearchParam('groupName', groupName)]; + + return this.searchBy(searchHref, options).pipe( + filter((groups: RemoteData>) => !groups.isResponsePending), + take(1), + map((groups: RemoteData>) => groups.payload.totalElements > 0) + ); + } + +} diff --git a/src/app/core/eperson/models/eperson.model.ts b/src/app/core/eperson/models/eperson.model.ts index 7d2138b633e..32286929ee3 100644 --- a/src/app/core/eperson/models/eperson.model.ts +++ b/src/app/core/eperson/models/eperson.model.ts @@ -1,22 +1,50 @@ +import { Observable } from 'rxjs'; + import { DSpaceObject } from '../../shared/dspace-object.model'; import { Group } from './group.model'; +import { RemoteData } from '../../data/remote-data'; +import { PaginatedList } from '../../data/paginated-list'; export class EPerson extends DSpaceObject { + /** + * A string representing the unique handle of this Collection + */ public handle: string; - public groups: Group[]; + /** + * List of Groups that this EPerson belong to + */ + public groups: Observable>>; + /** + * A string representing the netid of this EPerson + */ public netid: string; + /** + * A string representing the last active date for this EPerson + */ public lastActive: string; + /** + * A boolean representing if this EPerson can log in + */ public canLogIn: boolean; + /** + * The EPerson email address + */ public email: string; + /** + * A boolean representing if this EPerson require certificate + */ public requireCertificate: boolean; + /** + * A boolean representing if this EPerson registered itself + */ public selfRegistered: boolean; /** Getter to retrieve the EPerson's full name as a string */ diff --git a/src/app/core/eperson/models/group.model.ts b/src/app/core/eperson/models/group.model.ts index cd41ce9e257..91ce5d90f32 100644 --- a/src/app/core/eperson/models/group.model.ts +++ b/src/app/core/eperson/models/group.model.ts @@ -1,8 +1,28 @@ +import { Observable } from 'rxjs'; + import { DSpaceObject } from '../../shared/dspace-object.model'; +import { PaginatedList } from '../../data/paginated-list'; +import { RemoteData } from '../../data/remote-data'; export class Group extends DSpaceObject { + /** + * List of Groups that this Group belong to + */ + public groups: Observable>>; + + /** + * A string representing the unique handle of this Group + */ public handle: string; + /** + * A string representing the name of this Group + */ + public name: string; + + /** + * A string representing the name of this Group is permanent + */ public permanent: boolean; } diff --git a/src/app/core/eperson/models/normalized-eperson.model.ts b/src/app/core/eperson/models/normalized-eperson.model.ts index bcd7e498718..ad4b20ee806 100644 --- a/src/app/core/eperson/models/normalized-eperson.model.ts +++ b/src/app/core/eperson/models/normalized-eperson.model.ts @@ -1,4 +1,5 @@ -import { autoserialize, inheritSerialization } from 'cerialize'; +import { autoserialize, deserialize, inheritSerialization } from 'cerialize'; + import { CacheableObject } from '../../cache/object-cache.reducer'; import { ListableObject } from '../../../shared/object-collection/shared/listable-object.model'; import { NormalizedDSpaceObject } from '../../cache/models/normalized-dspace-object.model'; @@ -10,28 +11,52 @@ import { ResourceType } from '../../shared/resource-type'; @inheritSerialization(NormalizedDSpaceObject) export class NormalizedEPerson extends NormalizedDSpaceObject implements CacheableObject, ListableObject { + /** + * A string representing the unique handle of this EPerson + */ @autoserialize public handle: string; - @autoserialize + /** + * List of Groups that this EPerson belong to + */ + @deserialize @relationship(ResourceType.Group, true) groups: string[]; + /** + * A string representing the netid of this EPerson + */ @autoserialize public netid: string; + /** + * A string representing the last active date for this EPerson + */ @autoserialize public lastActive: string; + /** + * A boolean representing if this EPerson can log in + */ @autoserialize public canLogIn: boolean; + /** + * The EPerson email address + */ @autoserialize public email: string; + /** + * A boolean representing if this EPerson require certificate + */ @autoserialize public requireCertificate: boolean; + /** + * A boolean representing if this EPerson registered itself + */ @autoserialize public selfRegistered: boolean; } diff --git a/src/app/core/eperson/models/normalized-group.model.ts b/src/app/core/eperson/models/normalized-group.model.ts index d576f399ff6..f86bec86281 100644 --- a/src/app/core/eperson/models/normalized-group.model.ts +++ b/src/app/core/eperson/models/normalized-group.model.ts @@ -1,17 +1,38 @@ -import { autoserialize, inheritSerialization } from 'cerialize'; +import { autoserialize, deserialize, inheritSerialization } from 'cerialize'; + import { CacheableObject } from '../../cache/object-cache.reducer'; import { ListableObject } from '../../../shared/object-collection/shared/listable-object.model'; import { NormalizedDSpaceObject } from '../../cache/models/normalized-dspace-object.model'; -import { mapsTo } from '../../cache/builders/build-decorators'; +import { mapsTo, relationship } from '../../cache/builders/build-decorators'; import { Group } from './group.model'; +import { ResourceType } from '../../shared/resource-type'; @mapsTo(Group) @inheritSerialization(NormalizedDSpaceObject) export class NormalizedGroup extends NormalizedDSpaceObject implements CacheableObject, ListableObject { + /** + * List of Groups that this Group belong to + */ + @deserialize + @relationship(ResourceType.Group, true) + groups: string[]; + + /** + * A string representing the unique handle of this Group + */ @autoserialize public handle: string; + /** + * A string representing the name of this Group + */ + @autoserialize + public name: string; + + /** + * A string representing the name of this Group is permanent + */ @autoserialize public permanent: boolean; } diff --git a/src/app/core/integration/authority.service.ts b/src/app/core/integration/authority.service.ts index a5fa3a8d096..f0a1759be6a 100644 --- a/src/app/core/integration/authority.service.ts +++ b/src/app/core/integration/authority.service.ts @@ -3,15 +3,19 @@ import { Injectable } from '@angular/core'; import { RequestService } from '../data/request.service'; import { IntegrationService } from './integration.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; @Injectable() export class AuthorityService extends IntegrationService { protected linkPath = 'authorities'; - protected browseEndpoint = 'entries'; + protected entriesEndpoint = 'entries'; + protected entryValueEndpoint = 'entryValues'; constructor( protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, protected halService: HALEndpointService) { super(); } + } diff --git a/src/app/core/integration/integration-object-factory.ts b/src/app/core/integration/integration-object-factory.ts index 4f69dbd6fe2..f66a070fdfa 100644 --- a/src/app/core/integration/integration-object-factory.ts +++ b/src/app/core/integration/integration-object-factory.ts @@ -1,13 +1,13 @@ import { GenericConstructor } from '../shared/generic-constructor'; import { IntegrationType } from './intergration-type'; -import { AuthorityValueModel } from './models/authority-value.model'; import { IntegrationModel } from './models/integration.model'; +import { NormalizedAuthorityValue } from './models/normalized-authority-value.model'; export class IntegrationObjectFactory { public static getConstructor(type): GenericConstructor { switch (type) { case IntegrationType.Authority: { - return AuthorityValueModel; + return NormalizedAuthorityValue; } default: { return undefined; diff --git a/src/app/core/integration/integration-response-parsing.service.spec.ts b/src/app/core/integration/integration-response-parsing.service.spec.ts index baa4343724a..4187606265e 100644 --- a/src/app/core/integration/integration-response-parsing.service.spec.ts +++ b/src/app/core/integration/integration-response-parsing.service.spec.ts @@ -7,7 +7,7 @@ import { Store } from '@ngrx/store'; import { CoreState } from '../core.reducers'; import { IntegrationResponseParsingService } from './integration-response-parsing.service'; import { IntegrationRequest } from '../data/request.models'; -import { AuthorityValueModel } from './models/authority-value.model'; +import { AuthorityValue } from './models/authority.value'; import { PageInfo } from '../shared/page-info.model'; import { PaginatedList } from '../data/paginated-list'; @@ -35,35 +35,35 @@ describe('IntegrationResponseParsingService', () => { function initVars() { pageInfo = Object.assign(new PageInfo(), { elementsPerPage: 5, totalElements: 5, totalPages: 1, currentPage: 1, self: 'https://rest.api/integration/authorities/type/entries'}); definitions = new PaginatedList(pageInfo,[ - Object.assign({}, new AuthorityValueModel(), { + Object.assign(new AuthorityValue(), { type: 'authority', display: 'One', id: 'One', otherInformation: undefined, value: 'One' }), - Object.assign({}, new AuthorityValueModel(), { + Object.assign(new AuthorityValue(), { type: 'authority', display: 'Two', id: 'Two', otherInformation: undefined, value: 'Two' }), - Object.assign({}, new AuthorityValueModel(), { + Object.assign(new AuthorityValue(), { type: 'authority', display: 'Three', id: 'Three', otherInformation: undefined, value: 'Three' }), - Object.assign({}, new AuthorityValueModel(), { + Object.assign(new AuthorityValue(), { type: 'authority', display: 'Four', id: 'Four', otherInformation: undefined, value: 'Four' }), - Object.assign({}, new AuthorityValueModel(), { + Object.assign(new AuthorityValue(), { type: 'authority', display: 'Five', id: 'Five', @@ -125,12 +125,14 @@ describe('IntegrationResponseParsingService', () => { self: { href: 'https://rest.api/integration/authorities/type/entries' } } }, - statusCode: '200' + statusCode: 200, + statusText: 'OK' }; invalidResponse1 = { payload: {}, - statusCode: '200' + statusCode: 400, + statusText: 'Bad Request' }; invalidResponse2 = { @@ -183,7 +185,8 @@ describe('IntegrationResponseParsingService', () => { }, _links: {} }, - statusCode: '200' + statusCode: 500, + statusText: 'Internal Server Error' }; } beforeEach(() => { diff --git a/src/app/core/integration/integration-response-parsing.service.ts b/src/app/core/integration/integration-response-parsing.service.ts index ef278c93de8..2d3693cf3d0 100644 --- a/src/app/core/integration/integration-response-parsing.service.ts +++ b/src/app/core/integration/integration-response-parsing.service.ts @@ -16,12 +16,14 @@ import { GlobalConfig } from '../../../config/global-config.interface'; import { ObjectCacheService } from '../cache/object-cache.service'; import { IntegrationModel } from './models/integration.model'; import { IntegrationType } from './intergration-type'; +import { AuthorityValue } from './models/authority.value'; +import { PaginatedList } from '../data/paginated-list'; @Injectable() export class IntegrationResponseParsingService extends BaseResponseParsingService implements ResponseParsingService { protected objectFactory = IntegrationObjectFactory; - protected toCache = false; + protected toCache = true; constructor( @Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, @@ -33,15 +35,26 @@ export class IntegrationResponseParsingService extends BaseResponseParsingServic parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { if (isNotEmpty(data.payload) && isNotEmpty(data.payload._links)) { const dataDefinition = this.process(data.payload, request.uuid); - return new IntegrationSuccessResponse(dataDefinition, data.statusCode, this.processPageInfo(data.payload.page)); + return new IntegrationSuccessResponse(this.processResponse(dataDefinition), data.statusCode, data.statusText, this.processPageInfo(data.payload)); } else { return new ErrorResponse( Object.assign( new Error('Unexpected response from Integration endpoint'), - {statusText: data.statusCode} + {statusCode: data.statusCode, statusText: data.statusText} ) ); } } + protected processResponse(data: PaginatedList): any { + const returnList = Array.of(); + data.page.forEach((item, index) => { + if (item.type === IntegrationType.Authority) { + data.page[index] = Object.assign(new AuthorityValue(), item); + } + }); + + return data; + } + } diff --git a/src/app/core/integration/integration.service.spec.ts b/src/app/core/integration/integration.service.spec.ts index 152d7ab1656..02fff950edd 100644 --- a/src/app/core/integration/integration.service.spec.ts +++ b/src/app/core/integration/integration.service.spec.ts @@ -8,16 +8,21 @@ import { HALEndpointService } from '../shared/hal-endpoint.service'; import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub'; import { IntegrationService } from './integration.service'; import { IntegrationSearchOptions } from './models/integration-options.model'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { getMockRemoteDataBuildService } from '../../shared/mocks/mock-remote-data-build.service'; const LINK_NAME = 'authorities'; -const BROWSE = 'entries'; +const ENTRIES = 'entries'; +const ENTRY_VALUE = 'entryValue'; class TestService extends IntegrationService { protected linkPath = LINK_NAME; - protected browseEndpoint = BROWSE; + protected entriesEndpoint = ENTRIES; + protected entryValueEndpoint = ENTRY_VALUE; constructor( protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, protected halService: HALEndpointService) { super(); } @@ -27,28 +32,33 @@ describe('IntegrationService', () => { let scheduler: TestScheduler; let service: TestService; let requestService: RequestService; + let rdbService: RemoteDataBuildService; let halService: any; let findOptions: IntegrationSearchOptions; const name = 'type'; const metadata = 'dc.type'; const query = ''; + const value = 'test'; const uuid = 'd9d30c0c-69b7-4369-8397-ca67c888974d'; const integrationEndpoint = 'https://rest.api/integration'; const serviceEndpoint = `${integrationEndpoint}/${LINK_NAME}`; const entriesEndpoint = `${serviceEndpoint}/${name}/entries?query=${query}&metadata=${metadata}&uuid=${uuid}`; + const entryValueEndpoint = `${serviceEndpoint}/${name}/entryValue/${value}?metadata=${metadata}`; findOptions = new IntegrationSearchOptions(uuid, name, metadata); function initTestService(): TestService { return new TestService( requestService, + rdbService, halService ); } beforeEach(() => { requestService = getMockRequestService(); + rdbService = getMockRemoteDataBuildService(); scheduler = getTestScheduler(); halService = new HALEndpointServiceStub(integrationEndpoint); findOptions = new IntegrationSearchOptions(uuid, name, metadata, query); @@ -67,4 +77,20 @@ describe('IntegrationService', () => { }); }); + describe('getEntryByValue', () => { + + it('should configure a new IntegrationRequest', () => { + findOptions = new IntegrationSearchOptions( + null, + name, + metadata, + value); + + const expected = new IntegrationRequest(requestService.generateRequestId(), entryValueEndpoint); + scheduler.schedule(() => service.getEntryByValue(findOptions).subscribe()); + scheduler.flush(); + + expect(requestService.configure).toHaveBeenCalledWith(expected); + }); + }); }); diff --git a/src/app/core/integration/integration.service.ts b/src/app/core/integration/integration.service.ts index 2ace710dc7e..5826f4646dd 100644 --- a/src/app/core/integration/integration.service.ts +++ b/src/app/core/integration/integration.service.ts @@ -7,23 +7,25 @@ import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { IntegrationData } from './integration-data'; import { IntegrationSearchOptions } from './models/integration-options.model'; -import { RequestEntry } from '../data/request.reducer'; import { getResponseFromEntry } from '../shared/operators'; export abstract class IntegrationService { protected request: IntegrationRequest; protected abstract requestService: RequestService; protected abstract linkPath: string; - protected abstract browseEndpoint: string; + protected abstract entriesEndpoint: string; + protected abstract entryValueEndpoint: string; protected abstract halService: HALEndpointService; protected getData(request: GetRequest): Observable { return this.requestService.getByHref(request.href).pipe( getResponseFromEntry(), - mergeMap((response) => { + mergeMap((response: IntegrationSuccessResponse) => { if (response.isSuccessful && isNotEmpty(response)) { - const dataResponse = response as IntegrationSuccessResponse; - return observableOf(new IntegrationData(dataResponse.pageInfo, dataResponse.dataDefinition)); + return observableOf(new IntegrationData( + response.pageInfo, + (response.dataDefinition) ? response.dataDefinition.page : [] + )); } else if (!response.isSuccessful) { return observableThrowError(new Error(`Couldn't retrieve the integration data`)); } @@ -32,12 +34,12 @@ export abstract class IntegrationService { ); } - protected getIntegrationHref(endpoint, options: IntegrationSearchOptions = new IntegrationSearchOptions()): string { + protected getEntriesHref(endpoint, options: IntegrationSearchOptions = new IntegrationSearchOptions()): string { let result; const args = []; if (hasValue(options.name)) { - result = `${endpoint}/${options.name}/${this.browseEndpoint}`; + result = `${endpoint}/${options.name}/${this.entriesEndpoint}`; } else { result = endpoint; } @@ -73,9 +75,41 @@ export abstract class IntegrationService { return result; } + protected getEntryValueHref(endpoint, options: IntegrationSearchOptions = new IntegrationSearchOptions()): string { + let result; + const args = []; + + if (hasValue(options.name) && hasValue(options.query)) { + result = `${endpoint}/${options.name}/${this.entryValueEndpoint}/${options.query}`; + } else { + result = endpoint; + } + + if (hasValue(options.metadata)) { + args.push(`metadata=${options.metadata}`); + } + + if (isNotEmpty(args)) { + result = `${result}?${args.join('&')}`; + } + + return result; + } + public getEntriesByName(options: IntegrationSearchOptions): Observable { return this.halService.getEndpoint(this.linkPath).pipe( - map((endpoint: string) => this.getIntegrationHref(endpoint, options)), + map((endpoint: string) => this.getEntriesHref(endpoint, options)), + filter((href: string) => isNotEmpty(href)), + distinctUntilChanged(), + map((endpointURL: string) => new IntegrationRequest(this.requestService.generateRequestId(), endpointURL)), + tap((request: GetRequest) => this.requestService.configure(request)), + mergeMap((request: GetRequest) => this.getData(request)), + distinctUntilChanged()); + } + + public getEntryByValue(options: IntegrationSearchOptions): Observable { + return this.halService.getEndpoint(this.linkPath).pipe( + map((endpoint: string) => this.getEntryValueHref(endpoint, options)), filter((href: string) => isNotEmpty(href)), distinctUntilChanged(), map((endpointURL: string) => new IntegrationRequest(this.requestService.generateRequestId(), endpointURL)), diff --git a/src/app/core/integration/models/authority-value.model.ts b/src/app/core/integration/models/authority-value.model.ts deleted file mode 100644 index e2ef9ce9db8..00000000000 --- a/src/app/core/integration/models/authority-value.model.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { IntegrationModel } from './integration.model'; -import { autoserialize } from 'cerialize'; - -export class AuthorityValueModel extends IntegrationModel { - - @autoserialize - id: string; - - @autoserialize - display: string; - - @autoserialize - value: string; - - @autoserialize - otherInformation: any; - - @autoserialize - language: string; -} diff --git a/src/app/core/integration/models/authority.value.ts b/src/app/core/integration/models/authority.value.ts new file mode 100644 index 00000000000..31cb0a5787f --- /dev/null +++ b/src/app/core/integration/models/authority.value.ts @@ -0,0 +1,72 @@ +import { IntegrationModel } from './integration.model'; +import { isNotEmpty } from '../../../shared/empty.util'; +import { PLACEHOLDER_PARENT_METADATA } from '../../../shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.model'; +import { OtherInformation } from '../../../shared/form/builder/models/form-field-metadata-value.model'; +import { MetadataValueInterface } from '../../shared/metadata.models'; + +/** + * Class representing an authority object + */ +export class AuthorityValue extends IntegrationModel implements MetadataValueInterface { + + /** + * The identifier of this authority + */ + id: string; + + /** + * The display value of this authority + */ + display: string; + + /** + * The value of this authority + */ + value: string; + + /** + * An object containing additional information related to this authority + */ + otherInformation: OtherInformation; + + /** + * The language code of this authority value + */ + language: string; + + /** + * This method checks if authority has an identifier value + * + * @return boolean + */ + hasAuthority(): boolean { + return isNotEmpty(this.id); + } + + /** + * This method checks if authority has a value + * + * @return boolean + */ + hasValue(): boolean { + return isNotEmpty(this.value); + } + + /** + * This method checks if authority has related information object + * + * @return boolean + */ + hasOtherInformation(): boolean { + return isNotEmpty(this.otherInformation); + } + + /** + * This method checks if authority has a placeholder as value + * + * @return boolean + */ + hasPlaceholder(): boolean { + return this.hasValue() && this.value === PLACEHOLDER_PARENT_METADATA; + } +} diff --git a/src/app/core/integration/models/confidence-type.ts b/src/app/core/integration/models/confidence-type.ts new file mode 100644 index 00000000000..3630d029706 --- /dev/null +++ b/src/app/core/integration/models/confidence-type.ts @@ -0,0 +1,44 @@ +export enum ConfidenceType { + /** + * This authority value has been confirmed as accurate by an + * interactive user or authoritative policy + */ + CF_ACCEPTED = 600, + + /** + * Value is singular and valid but has not been seen and accepted + * by a human, so its provenance is uncertain. + */ + CF_UNCERTAIN = 500, + + /** + * There are multiple matching authority values of equal validity. + */ + CF_AMBIGUOUS = 400, + + /** + * There are no matching answers from the authority. + */ + CF_NOTFOUND = 300, + + /** + * The authority encountered an internal failure - this preserves a + * record in the metadata of why there is no value. + */ + CF_FAILED = 200, + + /** + * The authority recommends this submission be rejected. + */ + CF_REJECTED = 100, + + /** + * No reasonable confidence value is available + */ + CF_NOVALUE = 0, + + /** + * Value has not been set (DB default). + */ + CF_UNSET = -1 +} diff --git a/src/app/core/integration/models/integration.model.ts b/src/app/core/integration/models/integration.model.ts index d3383ab94ab..3158abc7eb3 100644 --- a/src/app/core/integration/models/integration.model.ts +++ b/src/app/core/integration/models/integration.model.ts @@ -1,12 +1,20 @@ import { autoserialize } from 'cerialize'; +import { CacheableObject } from '../../cache/object-cache.reducer'; -export abstract class IntegrationModel { +export abstract class IntegrationModel implements CacheableObject { @autoserialize - public type: string; + self: string; + + @autoserialize + uuid: string; + + @autoserialize + public type: any; @autoserialize public _links: { [name: string]: string } + } diff --git a/src/app/core/integration/models/normalized-authority-value.model.ts b/src/app/core/integration/models/normalized-authority-value.model.ts new file mode 100644 index 00000000000..5ebb61281d1 --- /dev/null +++ b/src/app/core/integration/models/normalized-authority-value.model.ts @@ -0,0 +1,28 @@ +import { autoserialize, inheritSerialization } from 'cerialize'; +import { IntegrationModel } from './integration.model'; +import { mapsTo } from '../../cache/builders/build-decorators'; +import { AuthorityValue } from './authority.value'; + +/** + * Normalized model class for an Authority Value + */ +@mapsTo(AuthorityValue) +@inheritSerialization(IntegrationModel) +export class NormalizedAuthorityValue extends IntegrationModel { + + @autoserialize + id: string; + + @autoserialize + display: string; + + @autoserialize + value: string; + + @autoserialize + otherInformation: any; + + @autoserialize + language: string; + +} diff --git a/src/app/core/json-patch/builder/json-patch-operation-path-combiner.ts b/src/app/core/json-patch/builder/json-patch-operation-path-combiner.ts new file mode 100644 index 00000000000..d29bf993cc5 --- /dev/null +++ b/src/app/core/json-patch/builder/json-patch-operation-path-combiner.ts @@ -0,0 +1,57 @@ +import { isNotUndefined } from '../../../shared/empty.util'; +import { URLCombiner } from '../../url-combiner/url-combiner'; + +/** + * Interface used to represent a JSON-PATCH path member + * in JsonPatchOperationsState + */ +export interface JsonPatchOperationPathObject { + rootElement: string; + subRootElement: string; + path: string; +} + +/** + * Combines a variable number of strings representing parts + * of a JSON-PATCH path + */ +export class JsonPatchOperationPathCombiner extends URLCombiner { + private _rootElement: string; + private _subRootElement: string; + + constructor(rootElement, ...subRootElements: string[]) { + super(rootElement, ...subRootElements); + this._rootElement = rootElement; + this._subRootElement = subRootElements.join('/'); + } + + get rootElement(): string { + return this._rootElement; + } + + get subRootElement(): string { + return this._subRootElement; + } + + /** + * Combines the parts of this JsonPatchOperationPathCombiner in to a JSON-PATCH path member + * + * e.g. new JsonPatchOperationPathCombiner('sections', 'basic').getPath(['dc.title', '0']) + * returns: {rootElement: 'sections', subRootElement: 'basic', path: '/sections/basic/dc.title/0'} + * + * @return {JsonPatchOperationPathObject} + * The combined path object + */ + public getPath(fragment?: string|string[]): JsonPatchOperationPathObject { + if (isNotUndefined(fragment) && Array.isArray(fragment)) { + fragment = fragment.join('/'); + } + + let path = '/' + this.toString(); + if (isNotUndefined(fragment)) { + path += '/' + fragment; + } + + return {rootElement: this._rootElement, subRootElement: this._subRootElement, path: path}; + } +} diff --git a/src/app/core/json-patch/builder/json-patch-operations-builder.ts b/src/app/core/json-patch/builder/json-patch-operations-builder.ts new file mode 100644 index 00000000000..c45183b4efe --- /dev/null +++ b/src/app/core/json-patch/builder/json-patch-operations-builder.ts @@ -0,0 +1,138 @@ +import { Store } from '@ngrx/store'; +import { CoreState } from '../../core.reducers'; +import { + NewPatchAddOperationAction, + NewPatchRemoveOperationAction, + NewPatchReplaceOperationAction +} from '../json-patch-operations.actions'; +import { JsonPatchOperationPathObject } from './json-patch-operation-path-combiner'; +import { Injectable } from '@angular/core'; +import { isEmpty, isNotEmpty } from '../../../shared/empty.util'; +import { dateToISOFormat } from '../../../shared/date.util'; +import { AuthorityValue } from '../../integration/models/authority.value'; +import { FormFieldMetadataValueObject } from '../../../shared/form/builder/models/form-field-metadata-value.model'; +import { FormFieldLanguageValueObject } from '../../../shared/form/builder/models/form-field-language-value.model'; + +/** + * Provides methods to dispatch JsonPatch Operations Actions + */ +@Injectable() +export class JsonPatchOperationsBuilder { + + constructor(private store: Store) { + } + + /** + * Dispatches a new NewPatchAddOperationAction + * + * @param path + * a JsonPatchOperationPathObject representing path + * @param value + * The value to update the referenced path + * @param first + * A boolean representing if the value to be added is the first of an array + * @param plain + * A boolean representing if the value to be added is a plain text value + */ + add(path: JsonPatchOperationPathObject, value, first = false, plain = false) { + this.store.dispatch( + new NewPatchAddOperationAction( + path.rootElement, + path.subRootElement, + path.path, this.prepareValue(value, plain, first))); + } + + /** + * Dispatches a new NewPatchReplaceOperationAction + * + * @param path + * a JsonPatchOperationPathObject representing path + * @param value + * the value to update the referenced path + * @param plain + * a boolean representing if the value to be added is a plain text value + */ + replace(path: JsonPatchOperationPathObject, value, plain = false) { + this.store.dispatch( + new NewPatchReplaceOperationAction( + path.rootElement, + path.subRootElement, + path.path, + this.prepareValue(value, plain, false))); + } + + /** + * Dispatches a new NewPatchRemoveOperationAction + * + * @param path + * a JsonPatchOperationPathObject representing path + */ + remove(path: JsonPatchOperationPathObject) { + this.store.dispatch( + new NewPatchRemoveOperationAction( + path.rootElement, + path.subRootElement, + path.path)); + } + + protected prepareValue(value: any, plain: boolean, first: boolean) { + let operationValue: any = null; + if (isNotEmpty(value)) { + if (plain) { + operationValue = value; + } else { + if (Array.isArray(value)) { + operationValue = []; + value.forEach((entry) => { + if ((typeof entry === 'object')) { + operationValue.push(this.prepareObjectValue(entry)); + } else { + operationValue.push(new FormFieldMetadataValueObject(entry)); + } + }); + } else if (typeof value === 'object') { + operationValue = this.prepareObjectValue(value); + } else { + operationValue = new FormFieldMetadataValueObject(value); + } + } + } + return (first && !Array.isArray(operationValue)) ? [operationValue] : operationValue; + } + + protected prepareObjectValue(value: any) { + let operationValue = Object.create({}); + if (isEmpty(value) || value instanceof FormFieldMetadataValueObject) { + operationValue = value; + } else if (value instanceof Date) { + operationValue = new FormFieldMetadataValueObject(dateToISOFormat(value)); + } else if (value instanceof AuthorityValue) { + operationValue = this.prepareAuthorityValue(value); + } else if (value instanceof FormFieldLanguageValueObject) { + operationValue = new FormFieldMetadataValueObject(value.value, value.language); + } else if (value.hasOwnProperty('value')) { + operationValue = new FormFieldMetadataValueObject(value.value); + } else { + Object.keys(value) + .forEach((key) => { + if (typeof value[key] === 'object') { + operationValue[key] = this.prepareObjectValue(value[key]); + } else { + operationValue[key] = value[key]; + } + }); + } + return operationValue; + } + + protected prepareAuthorityValue(value: any) { + let operationValue: any = null; + if (isNotEmpty(value.id)) { + operationValue = new FormFieldMetadataValueObject(value.value, value.language, value.id); + } else { + operationValue = new FormFieldMetadataValueObject(value.value, value.language); + } + return operationValue; + } + +} diff --git a/src/app/core/json-patch/json-patch-operations.actions.ts b/src/app/core/json-patch/json-patch-operations.actions.ts new file mode 100644 index 00000000000..cb3e3b0d387 --- /dev/null +++ b/src/app/core/json-patch/json-patch-operations.actions.ts @@ -0,0 +1,279 @@ +import { Action } from '@ngrx/store'; + +import { type } from '../../shared/ngrx/type'; + +/** + * For each action type in an action group, make a simple + * enum object for all of this group's action types. + * + * The 'type' utility function coerces strings into string + * literal types and runs a simple check to guarantee all + * action types in the application are unique. + */ +export const JsonPatchOperationsActionTypes = { + NEW_JSON_PATCH_ADD_OPERATION: type('dspace/core/patch/NEW_JSON_PATCH_ADD_OPERATION'), + NEW_JSON_PATCH_COPY_OPERATION: type('dspace/core/patch/NEW_JSON_PATCH_COPY_OPERATION'), + NEW_JSON_PATCH_MOVE_OPERATION: type('dspace/core/patch/NEW_JSON_PATCH_MOVE_OPERATION'), + NEW_JSON_PATCH_REMOVE_OPERATION: type('dspace/core/patch/NEW_JSON_PATCH_REMOVE_OPERATION'), + NEW_JSON_PATCH_REPLACE_OPERATION: type('dspace/core/patch/NEW_JSON_PATCH_REPLACE_OPERATION'), + COMMIT_JSON_PATCH_OPERATIONS: type('dspace/core/patch/COMMIT_JSON_PATCH_OPERATIONS'), + ROLLBACK_JSON_PATCH_OPERATIONS: type('dspace/core/patch/ROLLBACK_JSON_PATCH_OPERATIONS'), + FLUSH_JSON_PATCH_OPERATIONS: type('dspace/core/patch/FLUSH_JSON_PATCH_OPERATIONS'), + START_TRANSACTION_JSON_PATCH_OPERATIONS: type('dspace/core/patch/START_TRANSACTION_JSON_PATCH_OPERATIONS'), +}; + +/* tslint:disable:max-classes-per-file */ + +/** + * An ngrx action to commit the current transaction + */ +export class CommitPatchOperationsAction implements Action { + type = JsonPatchOperationsActionTypes.COMMIT_JSON_PATCH_OPERATIONS; + payload: { + resourceType: string; + resourceId: string; + }; + + /** + * Create a new CommitPatchOperationsAction + * + * @param resourceType + * the resource's type + * @param resourceId + * the resource's ID + */ + constructor(resourceType: string, resourceId: string) { + this.payload = { resourceType, resourceId }; + } +} + +/** + * An ngrx action to rollback the current transaction + */ +export class RollbacktPatchOperationsAction implements Action { + type = JsonPatchOperationsActionTypes.ROLLBACK_JSON_PATCH_OPERATIONS; + payload: { + resourceType: string; + resourceId: string; + }; + + /** + * Create a new CommitPatchOperationsAction + * + * @param resourceType + * the resource's type + * @param resourceId + * the resource's ID + */ + constructor(resourceType: string, resourceId: string) { + this.payload = { resourceType, resourceId }; + } +} + +/** + * An ngrx action to initiate a transaction block + */ +export class StartTransactionPatchOperationsAction implements Action { + type = JsonPatchOperationsActionTypes.START_TRANSACTION_JSON_PATCH_OPERATIONS; + payload: { + resourceType: string; + resourceId: string; + startTime: number; + }; + + /** + * Create a new CommitPatchOperationsAction + * + * @param resourceType + * the resource's type + * @param resourceId + * the resource's ID + * @param startTime + * the start timestamp + */ + constructor(resourceType: string, resourceId: string, startTime: number) { + this.payload = { resourceType, resourceId, startTime }; + } +} + +/** + * An ngrx action to flush list of the JSON Patch operations + */ +export class FlushPatchOperationsAction implements Action { + type = JsonPatchOperationsActionTypes.FLUSH_JSON_PATCH_OPERATIONS; + payload: { + resourceType: string; + resourceId: string; + }; + + /** + * Create a new FlushPatchOperationsAction + * + * @param resourceType + * the resource's type + * @param resourceId + * the resource's ID + */ + constructor(resourceType: string, resourceId: string) { + this.payload = { resourceType, resourceId }; + } +} + +/** + * An ngrx action to Add new HTTP/PATCH ADD operations to state + */ +export class NewPatchAddOperationAction implements Action { + type = JsonPatchOperationsActionTypes.NEW_JSON_PATCH_ADD_OPERATION; + payload: { + resourceType: string; + resourceId: string; + path: string; + value: any + }; + + /** + * Create a new NewPatchAddOperationAction + * + * @param resourceType + * the resource's type where to add operation + * @param resourceId + * the resource's ID + * @param path + * the path of the operation + * @param value + * the operation's payload + */ + constructor(resourceType: string, resourceId: string, path: string, value: any) { + this.payload = { resourceType, resourceId, path, value }; + } +} + +/** + * An ngrx action to add new JSON Patch COPY operation to state + */ +export class NewPatchCopyOperationAction implements Action { + type = JsonPatchOperationsActionTypes.NEW_JSON_PATCH_COPY_OPERATION; + payload: { + resourceType: string; + resourceId: string; + from: string; + path: string; + }; + + /** + * Create a new NewPatchCopyOperationAction + * + * @param resourceType + * the resource's type + * @param resourceId + * the resource's ID + * @param from + * the path to copy the value from + * @param path + * the path where to copy the value + */ + constructor(resourceType: string, resourceId: string, from: string, path: string) { + this.payload = { resourceType, resourceId, from, path }; + } +} + +/** + * An ngrx action to Add new JSON Patch MOVE operation to state + */ +export class NewPatchMoveOperationAction implements Action { + type = JsonPatchOperationsActionTypes.NEW_JSON_PATCH_MOVE_OPERATION; + payload: { + resourceType: string; + resourceId: string; + from: string; + path: string; + }; + + /** + * Create a new NewPatchMoveOperationAction + * + * @param resourceType + * the resource's type + * @param resourceId + * the resource's ID + * @param from + * the path to move the value from + * @param path + * the path where to move the value + */ + constructor(resourceType: string, resourceId: string, from: string, path: string) { + this.payload = { resourceType, resourceId, from, path }; + } +} + +/** + * An ngrx action to Add new JSON Patch REMOVE operation to state + */ +export class NewPatchRemoveOperationAction implements Action { + type = JsonPatchOperationsActionTypes.NEW_JSON_PATCH_REMOVE_OPERATION; + payload: { + resourceType: string; + resourceId: string; + path: string; + }; + + /** + * Create a new NewPatchRemoveOperationAction + * + * @param resourceType + * the resource's type + * @param resourceId + * the resource's ID + * @param path + * the path of the operation + */ + constructor(resourceType: string, resourceId: string, path: string) { + this.payload = { resourceType, resourceId, path }; + } +} + +/** + * An ngrx action to add new JSON Patch REPLACE operation to state + */ +export class NewPatchReplaceOperationAction implements Action { + type = JsonPatchOperationsActionTypes.NEW_JSON_PATCH_REPLACE_OPERATION; + payload: { + resourceType: string; + resourceId: string; + path: string; + value: any + }; + + /** + * Create a new NewPatchReplaceOperationAction + * + * @param resourceType + * the resource's type + * @param resourceId + * the resource's ID + * @param path + * the path of the operation + * @param value + * the operation's payload + */ + constructor(resourceType: string, resourceId: string, path: string, value: any) { + this.payload = { resourceType, resourceId, path, value }; + } +} + +/* tslint:enable:max-classes-per-file */ + +/** + * Export a type alias of all actions in this action group + * so that reducers can easily compose action types + */ +export type PatchOperationsActions + = CommitPatchOperationsAction + | FlushPatchOperationsAction + | NewPatchAddOperationAction + | NewPatchCopyOperationAction + | NewPatchMoveOperationAction + | NewPatchRemoveOperationAction + | NewPatchReplaceOperationAction + | RollbacktPatchOperationsAction + | StartTransactionPatchOperationsAction diff --git a/src/app/core/json-patch/json-patch-operations.effects.spec.ts b/src/app/core/json-patch/json-patch-operations.effects.spec.ts new file mode 100644 index 00000000000..c0fa12cbf39 --- /dev/null +++ b/src/app/core/json-patch/json-patch-operations.effects.spec.ts @@ -0,0 +1,54 @@ +import { TestBed } from '@angular/core/testing'; + +import { cold, hot } from 'jasmine-marbles'; +import { provideMockActions } from '@ngrx/effects/testing'; +import { Store } from '@ngrx/store'; +import { Observable, of as observableOf } from 'rxjs'; + +import { JsonPatchOperationsEffects } from './json-patch-operations.effects'; +import { JsonPatchOperationsState } from './json-patch-operations.reducer'; + +import { FlushPatchOperationsAction, JsonPatchOperationsActionTypes } from './json-patch-operations.actions'; + +describe('JsonPatchOperationsEffects test suite', () => { + let jsonPatchOperationsEffects: JsonPatchOperationsEffects; + let actions: Observable; + const store: Store = jasmine.createSpyObj('store', { + /* tslint:disable:no-empty */ + dispatch: {}, + /* tslint:enable:no-empty */ + select: observableOf(true) + }); + const testJsonPatchResourceType = 'testResourceType'; + const testJsonPatchResourceId = 'testResourceId'; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + JsonPatchOperationsEffects, + {provide: Store, useValue: store}, + provideMockActions(() => actions), + // other providers + ], + }); + + jsonPatchOperationsEffects = TestBed.get(JsonPatchOperationsEffects); + }); + + describe('commit$', () => { + it('should return a FLUSH_JSON_PATCH_OPERATIONS action in response to a COMMIT_JSON_PATCH_OPERATIONS action', () => { + actions = hot('--a-', { + a: { + type: JsonPatchOperationsActionTypes.COMMIT_JSON_PATCH_OPERATIONS, + payload: {resourceType: testJsonPatchResourceType, resourceId: testJsonPatchResourceId} + } + }); + + const expected = cold('--b-', { + b: new FlushPatchOperationsAction(testJsonPatchResourceType, testJsonPatchResourceId) + }); + + expect(jsonPatchOperationsEffects.commit$).toBeObservable(expected); + }); + }); +}); diff --git a/src/app/core/json-patch/json-patch-operations.effects.ts b/src/app/core/json-patch/json-patch-operations.effects.ts new file mode 100644 index 00000000000..3304db5b9e3 --- /dev/null +++ b/src/app/core/json-patch/json-patch-operations.effects.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@angular/core'; + +import { map } from 'rxjs/operators'; +import { Effect, Actions, ofType } from '@ngrx/effects'; + +import { + CommitPatchOperationsAction, FlushPatchOperationsAction, + JsonPatchOperationsActionTypes +} from './json-patch-operations.actions'; + +/** + * Provides effect methods for jsonPatch Operations actions + */ +@Injectable() +export class JsonPatchOperationsEffects { + + /** + * Dispatches a FlushPatchOperationsAction for every dispatched CommitPatchOperationsAction + */ + @Effect() commit$ = this.actions$.pipe( + ofType(JsonPatchOperationsActionTypes.COMMIT_JSON_PATCH_OPERATIONS), + map((action: CommitPatchOperationsAction) => { + return new FlushPatchOperationsAction(action.payload.resourceType, action.payload.resourceId); + })); + + constructor(private actions$: Actions) {} + +} diff --git a/src/app/core/json-patch/json-patch-operations.reducer.spec.ts b/src/app/core/json-patch/json-patch-operations.reducer.spec.ts new file mode 100644 index 00000000000..c6b21ce0375 --- /dev/null +++ b/src/app/core/json-patch/json-patch-operations.reducer.spec.ts @@ -0,0 +1,326 @@ +import * as deepFreeze from 'deep-freeze'; + +import { + CommitPatchOperationsAction, + FlushPatchOperationsAction, + NewPatchAddOperationAction, + NewPatchRemoveOperationAction, + RollbacktPatchOperationsAction, + StartTransactionPatchOperationsAction +} from './json-patch-operations.actions'; +import { + JsonPatchOperationsEntry, + jsonPatchOperationsReducer, + JsonPatchOperationsResourceEntry, + JsonPatchOperationsState +} from './json-patch-operations.reducer'; + +class NullAction extends NewPatchAddOperationAction { + resourceType: string; + resourceId: string; + path: string; + value: any; + + constructor() { + super(null, null, null, null); + this.type = null; + } +} + +describe('jsonPatchOperationsReducer test suite', () => { + const testJsonPatchResourceType = 'testResourceType'; + const testJsonPatchResourceId = 'testResourceId'; + const testJsonPatchResourceAnotherId = 'testResourceAnotherId'; + const testJsonPatchResourcePath = '/testResourceType/testResourceId/testField'; + const testJsonPatchResourceValue = ['test']; + const patchOpBody = [{ + op: 'add', + path: '/testResourceType/testResourceId/testField', + value: ['test'] + }]; + const timestampBeforeStart = 1545994811991; + const timestampAfterStart = 1545994837492; + const startTimestamp = 1545994827492; + const testState: JsonPatchOperationsState = { + testResourceType: { + children: { + testResourceId: { + body: [ + { + operation: { + op: 'add', + path: '/testResourceType/testResourceId/testField', + value: ['test'] + }, + timeAdded: timestampBeforeStart + }, + ] + } as JsonPatchOperationsEntry + }, + transactionStartTime: null, + commitPending: false + } as JsonPatchOperationsResourceEntry + }; + + let initState: JsonPatchOperationsState; + + const anotherTestState: JsonPatchOperationsState = { + testResourceType: { + children: { + testResourceId: { + body: [ + { + operation: { + op: 'add', + path: '/testResourceType/testResourceId/testField', + value: ['test'] + }, + timeAdded: timestampBeforeStart + }, + { + operation: { + op: 'remove', + path: '/testResourceType/testResourceId/testField' + }, + timeAdded: timestampBeforeStart + }, + ] + } as JsonPatchOperationsEntry + }, + transactionStartTime: null, + commitPending: false + } as JsonPatchOperationsResourceEntry + }; + deepFreeze(testState); + + beforeEach(() => { + spyOn(Date.prototype, 'getTime').and.callFake(() => { + return timestampBeforeStart; + }); + }); + + it('should start with an empty state', () => { + const action = new NullAction(); + const initialState = jsonPatchOperationsReducer(undefined, action); + + expect(initialState).toEqual(Object.create(null)); + }); + + it('should return the current state when no valid actions have been made', () => { + const action = new NullAction(); + const newState = jsonPatchOperationsReducer(testState, action); + + expect(newState).toEqual(testState); + }); + + describe('When a new patch operation actions have been dispatched', () => { + + it('should return the properly state when it is empty', () => { + const action = new NewPatchAddOperationAction( + testJsonPatchResourceType, + testJsonPatchResourceId, + testJsonPatchResourcePath, + testJsonPatchResourceValue); + const newState = jsonPatchOperationsReducer(undefined, action); + + expect(newState).toEqual(testState); + }); + + it('should return the properly state when it is not empty', () => { + const action = new NewPatchRemoveOperationAction( + testJsonPatchResourceType, + testJsonPatchResourceId, + testJsonPatchResourcePath); + const newState = jsonPatchOperationsReducer(testState, action); + + expect(newState).toEqual(anotherTestState); + }); + }); + + describe('When StartTransactionPatchOperationsAction has been dispatched', () => { + it('should set \'transactionStartTime\' and \'commitPending\' to true', () => { + const action = new StartTransactionPatchOperationsAction( + testJsonPatchResourceType, + testJsonPatchResourceId, + startTimestamp); + const newState = jsonPatchOperationsReducer(testState, action); + + expect(newState[testJsonPatchResourceType].transactionStartTime).toEqual(startTimestamp); + expect(newState[testJsonPatchResourceType].commitPending).toBeTruthy(); + }); + }); + + describe('When CommitPatchOperationsAction has been dispatched', () => { + it('should set \'commitPending\' to false ', () => { + const action = new CommitPatchOperationsAction( + testJsonPatchResourceType, + testJsonPatchResourceId); + initState = Object.assign({}, testState, { + [testJsonPatchResourceType]: Object.assign({}, testState[testJsonPatchResourceType], { + transactionStartTime: startTimestamp, + commitPending: true + }) + }); + const newState = jsonPatchOperationsReducer(initState, action); + + expect(newState[testJsonPatchResourceType].transactionStartTime).toEqual(startTimestamp); + expect(newState[testJsonPatchResourceType].commitPending).toBeFalsy(); + }); + }); + + describe('When RollbacktPatchOperationsAction has been dispatched', () => { + it('should set \'transactionStartTime\' to null and \'commitPending\' to false ', () => { + const action = new RollbacktPatchOperationsAction( + testJsonPatchResourceType, + testJsonPatchResourceId); + initState = Object.assign({}, testState, { + [testJsonPatchResourceType]: Object.assign({}, testState[testJsonPatchResourceType], { + transactionStartTime: startTimestamp, + commitPending: true + }) + }); + const newState = jsonPatchOperationsReducer(initState, action); + + expect(newState[testJsonPatchResourceType].transactionStartTime).toBeNull(); + expect(newState[testJsonPatchResourceType].commitPending).toBeFalsy(); + }); + }); + + describe('When FlushPatchOperationsAction has been dispatched', () => { + + it('should flush only committed operations', () => { + const action = new FlushPatchOperationsAction( + testJsonPatchResourceType, + testJsonPatchResourceId); + initState = Object.assign({}, testState, { + [testJsonPatchResourceType]: Object.assign({}, testState[testJsonPatchResourceType], { + children: { + testResourceId: { + body: [ + { + operation: { + op: 'add', + path: '/testResourceType/testResourceId/testField', + value: ['test'] + }, + timeAdded: timestampBeforeStart + }, + { + operation: { + op: 'remove', + path: '/testResourceType/testResourceId/testField' + }, + timeAdded: timestampAfterStart + }, + ] + } as JsonPatchOperationsEntry + }, + transactionStartTime: startTimestamp, + commitPending: false + }) + }); + const newState = jsonPatchOperationsReducer(initState, action); + const expectedBody: any = [ + { + operation: { + op: 'remove', + path: '/testResourceType/testResourceId/testField' + }, + timeAdded: timestampAfterStart + }, + ]; + expect(newState[testJsonPatchResourceType].transactionStartTime).toBeNull(); + expect(newState[testJsonPatchResourceType].commitPending).toBeFalsy(); + expect(newState[testJsonPatchResourceType].children[testJsonPatchResourceId].body).toEqual(expectedBody); + }); + + beforeEach(() => { + initState = Object.assign({}, testState, { + [testJsonPatchResourceType]: Object.assign({}, testState[testJsonPatchResourceType], { + children: { + testResourceId: { + body: [ + { + operation: { + op: 'add', + path: '/testResourceType/testResourceId/testField', + value: ['test'] + }, + timeAdded: timestampBeforeStart + }, + { + operation: { + op: 'remove', + path: '/testResourceType/testResourceId/testField' + }, + timeAdded: timestampBeforeStart + }, + ] + } as JsonPatchOperationsEntry, + testResourceAnotherId: { + body: [ + { + operation: { + op: 'add', + path: '/testResourceType/testResourceAnotherId/testField', + value: ['test'] + }, + timeAdded: timestampBeforeStart + }, + { + operation: { + op: 'remove', + path: '/testResourceType/testResourceAnotherId/testField' + }, + timeAdded: timestampBeforeStart + }, + ] + } as JsonPatchOperationsEntry + }, + transactionStartTime: startTimestamp, + commitPending: false + }) + }); + }); + + it('should flush committed operations for specified resource id', () => { + const action = new FlushPatchOperationsAction( + testJsonPatchResourceType, + testJsonPatchResourceId); + const newState = jsonPatchOperationsReducer(initState, action); + const expectedBody: any = [ + { + operation: { + op: 'add', + path: '/testResourceType/testResourceAnotherId/testField', + value: ['test'] + }, + timeAdded: timestampBeforeStart + }, + { + operation: { + op: 'remove', + path: '/testResourceType/testResourceAnotherId/testField' + }, + timeAdded: timestampBeforeStart + }, + ]; + expect(newState[testJsonPatchResourceType].transactionStartTime).toBeNull(); + expect(newState[testJsonPatchResourceType].commitPending).toBeFalsy(); + expect(newState[testJsonPatchResourceType].children[testJsonPatchResourceId].body).toEqual([]); + expect(newState[testJsonPatchResourceType].children[testJsonPatchResourceAnotherId].body).toEqual(expectedBody); + }); + + it('should flush operation list', () => { + const action = new FlushPatchOperationsAction(testJsonPatchResourceType, undefined); + const newState = jsonPatchOperationsReducer(initState, action); + + expect(newState[testJsonPatchResourceType].transactionStartTime).toBeNull(); + expect(newState[testJsonPatchResourceType].commitPending).toBeFalsy(); + expect(newState[testJsonPatchResourceType].children[testJsonPatchResourceId].body).toEqual([]); + expect(newState[testJsonPatchResourceType].children[testJsonPatchResourceAnotherId].body).toEqual([]); + }); + + }); + +}); diff --git a/src/app/core/json-patch/json-patch-operations.reducer.ts b/src/app/core/json-patch/json-patch-operations.reducer.ts new file mode 100644 index 00000000000..906d5e03314 --- /dev/null +++ b/src/app/core/json-patch/json-patch-operations.reducer.ts @@ -0,0 +1,322 @@ +import { hasValue, isNotEmpty, isNotUndefined, isNull } from '../../shared/empty.util'; + +import { + FlushPatchOperationsAction, + PatchOperationsActions, + JsonPatchOperationsActionTypes, + NewPatchAddOperationAction, + NewPatchCopyOperationAction, + NewPatchMoveOperationAction, + NewPatchRemoveOperationAction, + NewPatchReplaceOperationAction, + CommitPatchOperationsAction, + StartTransactionPatchOperationsAction, + RollbacktPatchOperationsAction +} from './json-patch-operations.actions'; +import { JsonPatchOperationModel, JsonPatchOperationType } from './json-patch.model'; + +/** + * An interface to represent JSON-PATCH Operation objects to execute + */ +export interface JsonPatchOperationObject { + operation: JsonPatchOperationModel; + timeAdded: number; +} + +/** + * An interface to represent the body containing a list of JsonPatchOperationObject + */ +export interface JsonPatchOperationsEntry { + body: JsonPatchOperationObject[]; +} + +/** + * Interface used to represent a JSON-PATCH path member + * in JsonPatchOperationsState + */ +export interface JsonPatchOperationsResourceEntry { + children: { [resourceId: string]: JsonPatchOperationsEntry }; + transactionStartTime: number; + commitPending: boolean; +} + +/** + * The JSON patch operations State + * + * Consists of a map with a namespace as key, + * and an array of JsonPatchOperationModel as values + */ +export interface JsonPatchOperationsState { + [resourceType: string]: JsonPatchOperationsResourceEntry; +} + +const initialState: JsonPatchOperationsState = Object.create(null); + +/** + * The JSON-PATCH operations Reducer + * + * @param state + * the current state + * @param action + * the action to perform on the state + * @return JsonPatchOperationsState + * the new state + */ +export function jsonPatchOperationsReducer(state = initialState, action: PatchOperationsActions): JsonPatchOperationsState { + switch (action.type) { + + case JsonPatchOperationsActionTypes.COMMIT_JSON_PATCH_OPERATIONS: { + return commitOperations(state, action as CommitPatchOperationsAction); + } + + case JsonPatchOperationsActionTypes.FLUSH_JSON_PATCH_OPERATIONS: { + return flushOperation(state, action as FlushPatchOperationsAction); + } + + case JsonPatchOperationsActionTypes.NEW_JSON_PATCH_ADD_OPERATION: { + return newOperation(state, action as NewPatchAddOperationAction); + } + + case JsonPatchOperationsActionTypes.NEW_JSON_PATCH_COPY_OPERATION: { + return newOperation(state, action as NewPatchCopyOperationAction); + } + + case JsonPatchOperationsActionTypes.NEW_JSON_PATCH_MOVE_OPERATION: { + return newOperation(state, action as NewPatchMoveOperationAction); + } + + case JsonPatchOperationsActionTypes.NEW_JSON_PATCH_REMOVE_OPERATION: { + return newOperation(state, action as NewPatchRemoveOperationAction); + } + + case JsonPatchOperationsActionTypes.NEW_JSON_PATCH_REPLACE_OPERATION: { + return newOperation(state, action as NewPatchReplaceOperationAction); + } + + case JsonPatchOperationsActionTypes.ROLLBACK_JSON_PATCH_OPERATIONS: { + return rollbackOperations(state, action as RollbacktPatchOperationsAction); + } + + case JsonPatchOperationsActionTypes.START_TRANSACTION_JSON_PATCH_OPERATIONS: { + return startTransactionPatchOperations(state, action as StartTransactionPatchOperationsAction); + } + + default: { + return state; + } + } +} + +/** + * Set the transaction start time. + * + * @param state + * the current state + * @param action + * an StartTransactionPatchOperationsAction + * @return JsonPatchOperationsState + * the new state. + */ +function startTransactionPatchOperations(state: JsonPatchOperationsState, action: StartTransactionPatchOperationsAction): JsonPatchOperationsState { + if (hasValue(state[ action.payload.resourceType ]) + && isNull(state[ action.payload.resourceType ].transactionStartTime)) { + return Object.assign({}, state, { + [action.payload.resourceType]: Object.assign({}, state[ action.payload.resourceType ], { + transactionStartTime: action.payload.startTime, + commitPending: true + }) + }); + } else { + return state; + } +} + +/** + * Set commit pending state. + * + * @param state + * the current state + * @param action + * an CommitPatchOperationsAction + * @return JsonPatchOperationsState + * the new state, with the section new validity status. + */ +function commitOperations(state: JsonPatchOperationsState, action: CommitPatchOperationsAction): JsonPatchOperationsState { + if (hasValue(state[ action.payload.resourceType ]) + && state[ action.payload.resourceType ].commitPending) { + return Object.assign({}, state, { + [action.payload.resourceType]: Object.assign({}, state[ action.payload.resourceType ], { + commitPending: false + }) + }); + } else { + return state; + } +} + +/** + * Set commit pending state. + * + * @param state + * the current state + * @param action + * an RollbacktPatchOperationsAction + * @return JsonPatchOperationsState + * the new state. + */ +function rollbackOperations(state: JsonPatchOperationsState, action: RollbacktPatchOperationsAction): JsonPatchOperationsState { + if (hasValue(state[ action.payload.resourceType ]) + && state[ action.payload.resourceType ].commitPending) { + return Object.assign({}, state, { + [action.payload.resourceType]: Object.assign({}, state[ action.payload.resourceType ], { + transactionStartTime: null, + commitPending: false + }) + }); + } else { + return state; + } +} + +/** + * Add new JSON patch operation list. + * + * @param state + * the current state + * @param action + * an NewPatchAddOperationAction + * @return JsonPatchOperationsState + * the new state, with the section new validity status. + */ +function newOperation(state: JsonPatchOperationsState, action): JsonPatchOperationsState { + const newState = Object.assign({}, state); + const body: any[] = hasValidBody(newState, action.payload.resourceType, action.payload.resourceId) + ? newState[ action.payload.resourceType ].children[ action.payload.resourceId ].body : Array.of(); + const newBody = addOperationToList( + body, + action.type, + action.payload.path, + hasValue(action.payload.value) ? action.payload.value : null); + + if (hasValue(newState[ action.payload.resourceType ]) + && hasValue(newState[ action.payload.resourceType ].children)) { + return Object.assign({}, state, { + [action.payload.resourceType]: Object.assign({}, state[ action.payload.resourceType ], { + children: Object.assign({}, state[ action.payload.resourceType ].children, { + [action.payload.resourceId]: { + body: newBody, + } + }), + commitPending: isNotUndefined(state[ action.payload.resourceType ].commitPending) ? state[ action.payload.resourceType ].commitPending : false + }) + }); + } else { + return Object.assign({}, state, { + [action.payload.resourceType]: Object.assign({}, { + children: { + [action.payload.resourceId]: { + body: newBody, + } + }, + transactionStartTime: null, + commitPending: false + }) + }); + } +} + +/** + * Check if state has a valid body. + * + * @param state + * the current state + * @param resourceType + * an resource type + * @param resourceId + * an resource ID + * @return boolean + */ +function hasValidBody(state: JsonPatchOperationsState, resourceType: any, resourceId: any): boolean { + return (hasValue(state[ resourceType ]) + && hasValue(state[ resourceType ].children) + && hasValue(state[ resourceType ].children[ resourceId ]) + && isNotEmpty(state[ resourceType ].children[ resourceId ].body)) +} + +/** + * Set the section validity. + * + * @param state + * the current state + * @param action + * an FlushPatchOperationsAction + * @return SubmissionObjectState + * the new state, with the section new validity status. + */ +function flushOperation(state: JsonPatchOperationsState, action: FlushPatchOperationsAction): JsonPatchOperationsState { + if (hasValue(state[ action.payload.resourceType ])) { + let newChildren; + if (isNotUndefined(action.payload.resourceId)) { + // flush only specified child's operations + if (hasValue(state[ action.payload.resourceType ].children) + && hasValue(state[ action.payload.resourceType ].children[ action.payload.resourceId ])) { + newChildren = Object.assign({}, state[ action.payload.resourceType ].children, { + [action.payload.resourceId]: { + body: state[ action.payload.resourceType ].children[ action.payload.resourceId ].body + .filter((entry) => entry.timeAdded > state[ action.payload.resourceType ].transactionStartTime) + } + }); + } else { + newChildren = state[ action.payload.resourceType ].children; + } + } else { + // flush all children's operations + newChildren = state[ action.payload.resourceType ].children; + Object.keys(newChildren) + .forEach((resourceId) => { + newChildren = Object.assign({}, newChildren, { + [resourceId]: { + body: newChildren[ resourceId ].body + .filter((entry) => entry.timeAdded > state[ action.payload.resourceType ].transactionStartTime) + } + }); + }) + } + return Object.assign({}, state, { + [action.payload.resourceType]: Object.assign({}, state[ action.payload.resourceType ], { + children: newChildren, + transactionStartTime: null, + }) + }); + } else { + return state; + } +} + +function addOperationToList(body: JsonPatchOperationObject[], actionType, targetPath, value?) { + const newBody = Array.from(body); + switch (actionType) { + case JsonPatchOperationsActionTypes.NEW_JSON_PATCH_ADD_OPERATION: + newBody.push(makeOperationEntry({ + op: JsonPatchOperationType.add, + path: targetPath, + value: value + })); + break; + case JsonPatchOperationsActionTypes.NEW_JSON_PATCH_REPLACE_OPERATION: + newBody.push(makeOperationEntry({ + op: JsonPatchOperationType.replace, + path: targetPath, + value: value + })); + break; + case JsonPatchOperationsActionTypes.NEW_JSON_PATCH_REMOVE_OPERATION: + newBody.push(makeOperationEntry({ op: JsonPatchOperationType.remove, path: targetPath })); + break; + } + return newBody; +} + +function makeOperationEntry(operation) { + return { operation: operation, timeAdded: new Date().getTime() }; +} diff --git a/src/app/core/json-patch/json-patch-operations.service.spec.ts b/src/app/core/json-patch/json-patch-operations.service.spec.ts new file mode 100644 index 00000000000..4ecc215dc7b --- /dev/null +++ b/src/app/core/json-patch/json-patch-operations.service.spec.ts @@ -0,0 +1,253 @@ +import { async, TestBed } from '@angular/core/testing'; + +import { getTestScheduler } from 'jasmine-marbles'; +import { TestScheduler } from 'rxjs/testing'; +import { of as observableOf } from 'rxjs'; +import { Store, StoreModule } from '@ngrx/store'; + +import { getMockRequestService } from '../../shared/mocks/mock-request.service'; +import { RequestService } from '../data/request.service'; +import { SubmissionPatchRequest } from '../data/request.models'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { getMockRemoteDataBuildService } from '../../shared/mocks/mock-remote-data-build.service'; +import { JsonPatchOperationsService } from './json-patch-operations.service'; +import { SubmitDataResponseDefinitionObject } from '../shared/submit-data-response-definition.model'; +import { CoreState } from '../core.reducers'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { JsonPatchOperationsEntry, JsonPatchOperationsResourceEntry } from './json-patch-operations.reducer'; +import { + CommitPatchOperationsAction, + RollbacktPatchOperationsAction, + StartTransactionPatchOperationsAction +} from './json-patch-operations.actions'; +import { MockStore } from '../../shared/testing/mock-store'; +import { RequestEntry } from '../data/request.reducer'; +import { catchError } from 'rxjs/operators'; + +class TestService extends JsonPatchOperationsService { + protected linkPath = ''; + protected patchRequestConstructor = SubmissionPatchRequest; + + constructor( + protected requestService: RequestService, + protected store: Store, + protected halService: HALEndpointService) { + + super(); + } +} + +describe('JsonPatchOperationsService test suite', () => { + let scheduler: TestScheduler; + let service: TestService; + let requestService: RequestService; + let rdbService: RemoteDataBuildService; + let halService: any; + let store: any; + + const timestamp = 1545994811991; + const timestampResponse = 1545994811992; + const mockState = { + 'json/patch': { + testResourceType: { + children: { + testResourceId: { + body: [ + { + operation: { + op: 'add', + path: '/testResourceType/testResourceId/testField', + value: ['test'] + }, + timeAdded: timestamp + }, + ] + } as JsonPatchOperationsEntry + }, + transactionStartTime: null, + commitPending: false + } as JsonPatchOperationsResourceEntry + } + }; + const resourceEndpointURL = 'https://rest.api/endpoint'; + const resourceEndpoint = 'resource'; + const resourceScope = '260'; + const resourceHref = resourceEndpointURL + '/' + resourceEndpoint + '/' + resourceScope; + + const testJsonPatchResourceType = 'testResourceType'; + const testJsonPatchResourceId = 'testResourceId'; + const patchOpBody = [{ + op: 'add', + path: '/testResourceType/testResourceId/testField', + value: ['test'] + }]; + + const getRequestEntry$ = (successful: boolean) => { + return observableOf({ + response: { isSuccessful: successful, timeAdded: timestampResponse } as any + } as RequestEntry) + }; + + function initTestService(): TestService { + return new TestService( + requestService, + store, + halService + ); + + } + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({}), + ], + providers: [ + { provide: Store, useClass: MockStore } + ] + }).compileComponents(); + })); + + beforeEach(() => { + store = TestBed.get(Store); + requestService = getMockRequestService(getRequestEntry$(true)); + rdbService = getMockRemoteDataBuildService(); + scheduler = getTestScheduler(); + halService = new HALEndpointServiceStub(resourceEndpointURL); + service = initTestService(); + + spyOn(store, 'select').and.returnValue(observableOf(mockState['json/patch'][testJsonPatchResourceType])); + spyOn(store, 'dispatch').and.callThrough(); + spyOn(Date.prototype, 'getTime').and.callFake(() => { + return timestamp; + }); + }); + + describe('jsonPatchByResourceType', () => { + + it('should call submitJsonPatchOperations method', () => { + spyOn((service as any), 'submitJsonPatchOperations').and.callThrough(); + + scheduler.schedule(() => service.jsonPatchByResourceType(resourceEndpointURL, resourceScope, testJsonPatchResourceType).subscribe()); + scheduler.flush(); + + expect((service as any).submitJsonPatchOperations).toHaveBeenCalled(); + }); + + it('should configure a new SubmissionPatchRequest', () => { + const expected = new SubmissionPatchRequest(requestService.generateRequestId(), resourceHref, patchOpBody); + scheduler.schedule(() => service.jsonPatchByResourceType(resourceEndpoint, resourceScope, testJsonPatchResourceType).subscribe()); + scheduler.flush(); + + expect(requestService.configure).toHaveBeenCalledWith(expected); + }); + + it('should dispatch a new StartTransactionPatchOperationsAction', () => { + const expectedAction = new StartTransactionPatchOperationsAction(testJsonPatchResourceType, undefined, timestamp); + scheduler.schedule(() => service.jsonPatchByResourceType(resourceEndpoint, resourceScope, testJsonPatchResourceType).subscribe()); + scheduler.flush(); + + expect(store.dispatch).toHaveBeenCalledWith(expectedAction); + }); + + describe('when request is successful', () => { + it('should dispatch a new CommitPatchOperationsAction', () => { + const expectedAction = new CommitPatchOperationsAction(testJsonPatchResourceType, undefined); + scheduler.schedule(() => service.jsonPatchByResourceType(resourceEndpoint, resourceScope, testJsonPatchResourceType).subscribe()); + scheduler.flush(); + + expect(store.dispatch).toHaveBeenCalledWith(expectedAction); + }); + }); + + describe('when request is not successful', () => { + beforeEach(() => { + store = TestBed.get(Store); + requestService = getMockRequestService(getRequestEntry$(false)); + rdbService = getMockRemoteDataBuildService(); + scheduler = getTestScheduler(); + halService = new HALEndpointServiceStub(resourceEndpointURL); + service = initTestService(); + + store.select.and.returnValue(observableOf(mockState['json/patch'][testJsonPatchResourceType])); + store.dispatch.and.callThrough(); + }); + + it('should dispatch a new RollbacktPatchOperationsAction', () => { + + const expectedAction = new RollbacktPatchOperationsAction(testJsonPatchResourceType, undefined); + scheduler.schedule(() => service.jsonPatchByResourceType(resourceEndpoint, resourceScope, testJsonPatchResourceType) + .pipe(catchError(() => observableOf({}))) + .subscribe()); + scheduler.flush(); + + expect(store.dispatch).toHaveBeenCalledWith(expectedAction); + }); + }); + }); + + describe('jsonPatchByResourceID', () => { + + it('should call submitJsonPatchOperations method', () => { + spyOn((service as any), 'submitJsonPatchOperations').and.callThrough(); + + scheduler.schedule(() => service.jsonPatchByResourceID(resourceEndpointURL, resourceScope, testJsonPatchResourceType, testJsonPatchResourceId).subscribe()); + scheduler.flush(); + + expect((service as any).submitJsonPatchOperations).toHaveBeenCalled(); + }); + + it('should configure a new SubmissionPatchRequest', () => { + const expected = new SubmissionPatchRequest(requestService.generateRequestId(), resourceHref, patchOpBody); + scheduler.schedule(() => service.jsonPatchByResourceID(resourceEndpoint, resourceScope, testJsonPatchResourceType, testJsonPatchResourceId).subscribe()); + scheduler.flush(); + + expect(requestService.configure).toHaveBeenCalledWith(expected); + }); + + it('should dispatch a new StartTransactionPatchOperationsAction', () => { + const expectedAction = new StartTransactionPatchOperationsAction(testJsonPatchResourceType, testJsonPatchResourceId, timestamp); + scheduler.schedule(() => service.jsonPatchByResourceID(resourceEndpoint, resourceScope, testJsonPatchResourceType, testJsonPatchResourceId).subscribe()); + scheduler.flush(); + + expect(store.dispatch).toHaveBeenCalledWith(expectedAction); + }); + + describe('when request is successful', () => { + it('should dispatch a new CommitPatchOperationsAction', () => { + const expectedAction = new CommitPatchOperationsAction(testJsonPatchResourceType, testJsonPatchResourceId); + scheduler.schedule(() => service.jsonPatchByResourceID(resourceEndpoint, resourceScope, testJsonPatchResourceType, testJsonPatchResourceId).subscribe()); + scheduler.flush(); + + expect(store.dispatch).toHaveBeenCalledWith(expectedAction); + }); + }); + + describe('when request is not successful', () => { + beforeEach(() => { + store = TestBed.get(Store); + requestService = getMockRequestService(getRequestEntry$(false)); + rdbService = getMockRemoteDataBuildService(); + scheduler = getTestScheduler(); + halService = new HALEndpointServiceStub(resourceEndpointURL); + service = initTestService(); + + store.select.and.returnValue(observableOf(mockState['json/patch'][testJsonPatchResourceType])); + store.dispatch.and.callThrough(); + }); + + it('should dispatch a new RollbacktPatchOperationsAction', () => { + + const expectedAction = new RollbacktPatchOperationsAction(testJsonPatchResourceType, testJsonPatchResourceId); + scheduler.schedule(() => service.jsonPatchByResourceID(resourceEndpoint, resourceScope, testJsonPatchResourceType, testJsonPatchResourceId) + .pipe(catchError(() => observableOf({}))) + .subscribe()); + scheduler.flush(); + + expect(store.dispatch).toHaveBeenCalledWith(expectedAction); + }); + }); + }); + +}); diff --git a/src/app/core/json-patch/json-patch-operations.service.ts b/src/app/core/json-patch/json-patch-operations.service.ts new file mode 100644 index 00000000000..90eaf87a0eb --- /dev/null +++ b/src/app/core/json-patch/json-patch-operations.service.ts @@ -0,0 +1,170 @@ +import { merge as observableMerge, Observable, throwError as observableThrowError } from 'rxjs'; +import { distinctUntilChanged, filter, find, flatMap, map, partition, take, tap } from 'rxjs/operators'; +import { Store } from '@ngrx/store'; + +import { hasValue, isEmpty, isNotEmpty, isNotUndefined, isUndefined } from '../../shared/empty.util'; +import { ErrorResponse, PostPatchSuccessResponse, RestResponse } from '../cache/response.models'; +import { PatchRequest } from '../data/request.models'; +import { RequestService } from '../data/request.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { CoreState } from '../core.reducers'; +import { jsonPatchOperationsByResourceType } from './selectors'; +import { JsonPatchOperationsResourceEntry } from './json-patch-operations.reducer'; +import { + CommitPatchOperationsAction, + RollbacktPatchOperationsAction, + StartTransactionPatchOperationsAction +} from './json-patch-operations.actions'; +import { JsonPatchOperationModel } from './json-patch.model'; +import { getResponseFromEntry } from '../shared/operators'; +import { ObjectCacheEntry } from '../cache/object-cache.reducer'; + +/** + * An abstract class that provides methods to make JSON Patch requests. + */ +export abstract class JsonPatchOperationsService { + + protected abstract requestService: RequestService; + protected abstract store: Store; + protected abstract linkPath: string; + protected abstract halService: HALEndpointService; + protected abstract patchRequestConstructor: any; + + /** + * Submit a new JSON Patch request with all operations stored in the state that are ready to be dispatched + * + * @param hrefObs + * Observable of request href + * @param resourceType + * The resource type value + * @param resourceId + * The resource id value + * @return Observable + * observable of response + */ + protected submitJsonPatchOperations(hrefObs: Observable, resourceType: string, resourceId?: string): Observable { + const requestId = this.requestService.generateRequestId(); + let startTransactionTime = null; + const [patchRequest$, emptyRequest$] = partition((request: PatchRequestDefinition) => isNotEmpty(request.body))(hrefObs.pipe( + flatMap((endpointURL: string) => { + return this.store.select(jsonPatchOperationsByResourceType(resourceType)).pipe( + take(1), + filter((operationsList: JsonPatchOperationsResourceEntry) => isUndefined(operationsList) || !(operationsList.commitPending)), + tap(() => startTransactionTime = new Date().getTime()), + map((operationsList: JsonPatchOperationsResourceEntry) => { + const body: JsonPatchOperationModel[] = []; + if (isNotEmpty(operationsList)) { + if (isNotEmpty(resourceId)) { + if (isNotUndefined(operationsList.children[resourceId]) && isNotEmpty(operationsList.children[resourceId].body)) { + operationsList.children[resourceId].body.forEach((entry) => { + body.push(entry.operation); + }); + } + } else { + Object.keys(operationsList.children) + .filter((key) => operationsList.children.hasOwnProperty(key)) + .filter((key) => hasValue(operationsList.children[key])) + .filter((key) => hasValue(operationsList.children[key].body)) + .forEach((key) => { + operationsList.children[key].body.forEach((entry) => { + body.push(entry.operation); + }); + }) + } + } + return this.getRequestInstance(requestId, endpointURL, body); + })); + }))); + + return observableMerge( + emptyRequest$.pipe( + filter((request: PatchRequestDefinition) => isEmpty(request.body)), + tap(() => startTransactionTime = null), + map(() => null)), + patchRequest$.pipe( + filter((request: PatchRequestDefinition) => isNotEmpty(request.body)), + tap(() => this.store.dispatch(new StartTransactionPatchOperationsAction(resourceType, resourceId, startTransactionTime))), + tap((request: PatchRequestDefinition) => this.requestService.configure(request)), + flatMap(() => { + const [successResponse$, errorResponse$] = partition((response: RestResponse) => response.isSuccessful)(this.requestService.getByUUID(requestId).pipe( + getResponseFromEntry(), + find((entry: ObjectCacheEntry) => startTransactionTime < entry.timeAdded), + map((entry: ObjectCacheEntry) => entry), + )); + return observableMerge( + errorResponse$.pipe( + tap(() => this.store.dispatch(new RollbacktPatchOperationsAction(resourceType, resourceId))), + flatMap((error: ErrorResponse) => observableThrowError(error))), + successResponse$.pipe( + filter((response: PostPatchSuccessResponse) => isNotEmpty(response)), + tap(() => this.store.dispatch(new CommitPatchOperationsAction(resourceType, resourceId))), + map((response: PostPatchSuccessResponse) => response.dataDefinition), + distinctUntilChanged())); + })) + ); + } + + /** + * Return an instance for RestRequest class + * + * @param uuid + * The request uuid + * @param href + * The request href + * @param body + * The request body + * @return Object + * instance of PatchRequestDefinition + */ + protected getRequestInstance(uuid: string, href: string, body?: any): PatchRequestDefinition { + return new this.patchRequestConstructor(uuid, href, body); + } + + protected getEndpointByIDHref(endpoint, resourceID): string { + return isNotEmpty(resourceID) ? `${endpoint}/${resourceID}` : `${endpoint}`; + } + + /** + * Make a new JSON Patch request with all operations related to the specified resource type + * + * @param linkPath + * The link path of the request + * @param scopeId + * The scope id + * @param resourceType + * The resource type value + * @return Observable + * observable of response + */ + public jsonPatchByResourceType(linkPath: string, scopeId: string, resourceType: string): Observable { + const href$ = this.halService.getEndpoint(linkPath).pipe( + filter((href: string) => isNotEmpty(href)), + distinctUntilChanged(), + map((endpointURL: string) => this.getEndpointByIDHref(endpointURL, scopeId))); + + return this.submitJsonPatchOperations(href$, resourceType); + } + + /** + * Make a new JSON Patch request with all operations related to the specified resource id + * + * @param linkPath + * The link path of the request + * @param scopeId + * The scope id + * @param resourceType + * The resource type value + * @param resourceId + * The resource id value + * @return Observable + * observable of response + */ + public jsonPatchByResourceID(linkPath: string, scopeId: string, resourceType: string, resourceId: string): Observable { + const hrefObs = this.halService.getEndpoint(linkPath).pipe( + filter((href: string) => isNotEmpty(href)), + distinctUntilChanged(), + map((endpointURL: string) => this.getEndpointByIDHref(endpointURL, scopeId))); + + return this.submitJsonPatchOperations(hrefObs, resourceType, resourceId); + } +} diff --git a/src/app/core/json-patch/json-patch.model.ts b/src/app/core/json-patch/json-patch.model.ts new file mode 100644 index 00000000000..f855333fab3 --- /dev/null +++ b/src/app/core/json-patch/json-patch.model.ts @@ -0,0 +1,20 @@ +/** + * Represents all JSON Patch operations type. + */ +export enum JsonPatchOperationType { + test = 'test', + remove = 'remove', + add = 'add', + replace = 'replace', + move = 'move', + copy = 'copy', +} + +/** + * Represents a JSON Patch operations. + */ +export class JsonPatchOperationModel { + op: JsonPatchOperationType; + path: string; + value: any; +} diff --git a/src/app/core/json-patch/selectors.ts b/src/app/core/json-patch/selectors.ts new file mode 100644 index 00000000000..a77afb7b7d4 --- /dev/null +++ b/src/app/core/json-patch/selectors.ts @@ -0,0 +1,52 @@ +// @TODO: Merge with keySelector function present in 'src/app/core/shared/selectors.ts' +import { createSelector, MemoizedSelector, Selector } from '@ngrx/store'; +import { hasValue } from '../../shared/empty.util'; +import { coreSelector, CoreState } from '../core.reducers'; +import { JsonPatchOperationsEntry, JsonPatchOperationsResourceEntry } from './json-patch-operations.reducer'; + +export function keySelector(parentSelector: Selector, subState: string, key: string): MemoizedSelector { + return createSelector(parentSelector, (state: T) => { + if (hasValue(state[subState])) { + return state[subState][key]; + } else { + return undefined; + } + }); +} + +export function subStateSelector(parentSelector: Selector, subState: string): MemoizedSelector { + return createSelector(parentSelector, (state: T) => { + if (hasValue(state[subState])) { + return state[subState]; + } else { + return undefined; + } + }); +} + +/** + * Return MemoizedSelector to select all jsonPatchOperations for a specified resource type, stored in the state + * + * @param resourceType + * the resource type + * @return MemoizedSelector + * MemoizedSelector + */ +export function jsonPatchOperationsByResourceType(resourceType: string): MemoizedSelector { + return keySelector(coreSelector,'json/patch', resourceType); +} + +/** + * Return MemoizedSelector to select all jsonPatchOperations for a specified resource id, stored in the state + * + * @param resourceType + * the resource type + * @param resourceId + * the resourceId type + * @return MemoizedSelector + * MemoizedSelector + */ +export function jsonPatchOperationsByResourceId(resourceType: string, resourceId: string): MemoizedSelector { + const resourceTypeSelector = jsonPatchOperationsByResourceType(resourceType); + return subStateSelector(resourceTypeSelector, resourceId); +} diff --git a/src/app/core/metadata/metadata.service.spec.ts b/src/app/core/metadata/metadata.service.spec.ts index bd3532b8402..cfb5a0751dd 100644 --- a/src/app/core/metadata/metadata.service.spec.ts +++ b/src/app/core/metadata/metadata.service.spec.ts @@ -116,7 +116,7 @@ describe('MetadataService', () => { { provide: RequestService, useValue: requestService }, { provide: RemoteDataBuildService, useValue: remoteDataBuildService }, { provide: GLOBAL_CONFIG, useValue: ENV_CONFIG }, - { provide: HALEndpointService, useValue: {}}, + { provide: HALEndpointService, useValue: {} }, { provide: AuthService, useValue: {} }, { provide: NotificationsService, useValue: {} }, { provide: HttpClient, useValue: {} }, @@ -180,7 +180,7 @@ describe('MetadataService', () => { spyOn(itemDataService, 'findById').and.returnValue(mockRemoteData(MockItem)); router.navigate(['/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357']); tick(); - expect(tagStore.size).toBeGreaterThan(0) + expect(tagStore.size).toBeGreaterThan(0); router.navigate(['/other']); tick(); expect(tagStore.size).toEqual(2); @@ -213,13 +213,13 @@ describe('MetadataService', () => { undefined, MockItem )); - } + }; const mockType = (mockItem: Item, type: string): Item => { const typedMockItem = Object.assign(new Item(), mockItem) as Item; typedMockItem.metadata['dc.type'] = [ { value: type } ] as MetadataValue[]; return typedMockItem; - } + }; const mockPublisher = (mockItem: Item): Item => { const publishedMockItem = Object.assign(new Item(), mockItem) as Item; diff --git a/src/app/core/registry/registry.service.spec.ts b/src/app/core/registry/registry.service.spec.ts index 64de242eaae..8274ceef609 100644 --- a/src/app/core/registry/registry.service.spec.ts +++ b/src/app/core/registry/registry.service.spec.ts @@ -5,7 +5,7 @@ import { RequestService } from '../data/request.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; -import { Observable, of as observableOf, combineLatest as observableCombineLatest } from 'rxjs'; +import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs'; import { RequestEntry } from '../data/request.reducer'; import { RemoteData } from '../data/remote-data'; import { PageInfo } from '../shared/page-info.model'; @@ -14,26 +14,29 @@ import { getMockRequestService } from '../../shared/mocks/mock-request.service'; import { RegistryBitstreamformatsSuccessResponse, RegistryMetadatafieldsSuccessResponse, - RegistryMetadataschemasSuccessResponse, RestResponse + RegistryMetadataschemasSuccessResponse, + RestResponse } from '../cache/response.models'; import { Component } from '@angular/core'; import { RegistryMetadataschemasResponse } from './registry-metadataschemas-response.model'; import { RegistryMetadatafieldsResponse } from './registry-metadatafields-response.model'; import { RegistryBitstreamformatsResponse } from './registry-bitstreamformats-response.model'; import { map } from 'rxjs/operators'; -import { Store } from '@ngrx/store'; -import { AppState } from '../../app.reducer'; +import { Store, StoreModule } from '@ngrx/store'; import { MockStore } from '../../shared/testing/mock-store'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { NotificationsServiceStub } from '../../shared/testing/notifications-service-stub'; import { TranslateModule } from '@ngx-translate/core'; import { MetadataRegistryCancelFieldAction, - MetadataRegistryCancelSchemaAction, MetadataRegistryDeselectAllFieldAction, - MetadataRegistryDeselectAllSchemaAction, MetadataRegistryDeselectFieldAction, + MetadataRegistryCancelSchemaAction, + MetadataRegistryDeselectAllFieldAction, + MetadataRegistryDeselectAllSchemaAction, + MetadataRegistryDeselectFieldAction, MetadataRegistryDeselectSchemaAction, MetadataRegistryEditFieldAction, - MetadataRegistryEditSchemaAction, MetadataRegistrySelectFieldAction, + MetadataRegistryEditSchemaAction, + MetadataRegistrySelectFieldAction, MetadataRegistrySelectSchemaAction } from '../../+admin/admin-registries/metadata-registry/metadata-registry.actions'; import { MetadataSchema } from '../metadata/metadataschema.model'; @@ -45,6 +48,7 @@ class DummyComponent { describe('RegistryService', () => { let registryService: RegistryService; + let mockStore; const pagination: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { id: 'registry-service-spec-pagination', pageSize: 20 @@ -98,40 +102,6 @@ describe('RegistryService', () => { schema: mockSchemasList[1] } ]; - const mockFormatsList = [ - { - shortDescription: 'Unknown', - description: 'Unknown data format', - mimetype: 'application/octet-stream', - supportLevel: 0, - internal: false, - extensions: null - }, - { - shortDescription: 'License', - description: 'Item-specific license agreed upon to submission', - mimetype: 'text/plain; charset=utf-8', - supportLevel: 1, - internal: true, - extensions: null - }, - { - shortDescription: 'CC License', - description: 'Item-specific Creative Commons license agreed upon to submission', - mimetype: 'text/html; charset=utf-8', - supportLevel: 2, - internal: true, - extensions: null - }, - { - shortDescription: 'Adobe PDF', - description: 'Adobe Portable Document Format', - mimetype: 'application/pdf', - supportLevel: 0, - internal: false, - extensions: null - } - ]; const pageInfo = new PageInfo(); pageInfo.elementsPerPage = 20; @@ -158,11 +128,9 @@ describe('RegistryService', () => { } }; - const mockStore = new MockStore(Object.create(null)); - beforeEach(() => { TestBed.configureTestingModule({ - imports: [CommonModule, TranslateModule.forRoot()], + imports: [CommonModule, StoreModule.forRoot({}), TranslateModule.forRoot()], declarations: [ DummyComponent ], @@ -170,13 +138,13 @@ describe('RegistryService', () => { { provide: RequestService, useValue: getMockRequestService() }, { provide: RemoteDataBuildService, useValue: rdbStub }, { provide: HALEndpointService, useValue: halServiceStub }, - { provide: Store, useValue: mockStore }, + { provide: Store, useClass: MockStore }, { provide: NotificationsService, useValue: new NotificationsServiceStub() }, RegistryService ] }); registryService = TestBed.get(RegistryService); - + mockStore = TestBed.get(Store); spyOn((registryService as any).halService, 'getEndpoint').and.returnValue(observableOf(endpoint)); }); @@ -185,7 +153,7 @@ describe('RegistryService', () => { metadataschemas: mockSchemasList, page: pageInfo }); - const response = new RegistryMetadataschemasSuccessResponse(queryResponse, '200', pageInfo); + const response = new RegistryMetadataschemasSuccessResponse(queryResponse, 200, 'OK', pageInfo); const responseEntry = Object.assign(new RequestEntry(), { response: response }); beforeEach(() => { @@ -214,7 +182,7 @@ describe('RegistryService', () => { metadataschemas: mockSchemasList, page: pageInfo }); - const response = new RegistryMetadataschemasSuccessResponse(queryResponse, '200', pageInfo); + const response = new RegistryMetadataschemasSuccessResponse(queryResponse, 200, 'OK', pageInfo); const responseEntry = Object.assign(new RequestEntry(), { response: response }); beforeEach(() => { @@ -243,7 +211,7 @@ describe('RegistryService', () => { metadatafields: mockFieldsList, page: pageInfo }); - const response = new RegistryMetadatafieldsSuccessResponse(queryResponse, '200', pageInfo); + const response = new RegistryMetadatafieldsSuccessResponse(queryResponse, 200, 'OK', pageInfo); const responseEntry = Object.assign(new RequestEntry(), { response: response }); beforeEach(() => { @@ -272,7 +240,7 @@ describe('RegistryService', () => { bitstreamformats: mockFieldsList, page: pageInfo }); - const response = new RegistryBitstreamformatsSuccessResponse(queryResponse, '200', pageInfo); + const response = new RegistryBitstreamformatsSuccessResponse(queryResponse, 200, 'OK', pageInfo); const responseEntry = Object.assign(new RequestEntry(), { response: response }); beforeEach(() => { diff --git a/src/app/core/shared/collection.model.ts b/src/app/core/shared/collection.model.ts index c630c9dd572..0471d1fbbba 100644 --- a/src/app/core/shared/collection.model.ts +++ b/src/app/core/shared/collection.model.ts @@ -3,6 +3,9 @@ import { Bitstream } from './bitstream.model'; import { Item } from './item.model'; import { RemoteData } from '../data/remote-data'; import { Observable } from 'rxjs'; +import { License } from './license.model'; +import { ResourcePolicy } from './resource-policy.model'; +import { PaginatedList } from '../data/paginated-list'; export class Collection extends DSpaceObject { @@ -39,7 +42,7 @@ export class Collection extends DSpaceObject { * The license of this Collection * Corresponds to the metadata field dc.rights.license */ - get license(): string { + get dcLicense(): string { return this.firstMetadataValue('dc.rights.license'); } @@ -51,11 +54,21 @@ export class Collection extends DSpaceObject { return this.firstMetadataValue('dc.description.tableofcontents'); } + /** + * The deposit license of this Collection + */ + license: Observable>; + /** * The Bitstream that represents the logo of this Collection */ logo: Observable>; + /** + * The default access conditions of this Collection + */ + defaultAccessConditions: Observable>>; + /** * An array of Collections that are direct parents of this Collection */ diff --git a/src/app/core/shared/config/config-authority.model.ts b/src/app/core/shared/config/config-authority.model.ts deleted file mode 100644 index bbb8605bcc1..00000000000 --- a/src/app/core/shared/config/config-authority.model.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize'; -import { ConfigObject } from './config.model'; -import { SubmissionSectionModel } from './config-submission-section.model'; - -@inheritSerialization(ConfigObject) -export class ConfigAuthorityModel extends ConfigObject { - - @autoserialize - id: string; - - @autoserialize - display: string; - - @autoserialize - value: string; - - @autoserialize - otherInformation: any; - - @autoserialize - language: string; - -} diff --git a/src/app/core/shared/config/config-object-factory.ts b/src/app/core/shared/config/config-object-factory.ts deleted file mode 100644 index 4cb5016983a..00000000000 --- a/src/app/core/shared/config/config-object-factory.ts +++ /dev/null @@ -1,34 +0,0 @@ - -import { GenericConstructor } from '../../shared/generic-constructor'; - -import { SubmissionSectionModel } from './config-submission-section.model'; -import { SubmissionFormsModel } from './config-submission-forms.model'; -import { SubmissionDefinitionsModel } from './config-submission-definitions.model'; -import { ConfigType } from './config-type'; -import { ConfigObject } from './config.model'; -import { ConfigAuthorityModel } from './config-authority.model'; - -export class ConfigObjectFactory { - public static getConstructor(type): GenericConstructor { - switch (type) { - case ConfigType.SubmissionDefinition: - case ConfigType.SubmissionDefinitions: { - return SubmissionDefinitionsModel - } - case ConfigType.SubmissionForm: - case ConfigType.SubmissionForms: { - return SubmissionFormsModel - } - case ConfigType.SubmissionSection: - case ConfigType.SubmissionSections: { - return SubmissionSectionModel - } - case ConfigType.Authority: { - return ConfigAuthorityModel - } - default: { - return undefined; - } - } - } -} diff --git a/src/app/core/shared/config/config-submission-section.model.ts b/src/app/core/shared/config/config-submission-section.model.ts deleted file mode 100644 index 0eb9daaeabb..00000000000 --- a/src/app/core/shared/config/config-submission-section.model.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize'; -import { ConfigObject } from './config.model'; - -@inheritSerialization(ConfigObject) -export class SubmissionSectionModel extends ConfigObject { - - @autoserialize - header: string; - - @autoserialize - mandatory: boolean; - - @autoserialize - sectionType: string; - - @autoserialize - visibility: { - main: any, - other: any - } - -} diff --git a/src/app/core/shared/config/config.model.ts b/src/app/core/shared/config/config.model.ts deleted file mode 100644 index 8d86f317e1e..00000000000 --- a/src/app/core/shared/config/config.model.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { autoserialize, autoserializeAs } from 'cerialize'; - -export abstract class ConfigObject { - - @autoserialize - public name: string; - - @autoserialize - public type: string; - - @autoserialize - public _links: { - [name: string]: string - } - - /** - * The link to the rest endpoint where this config object can be found - */ - @autoserialize - self: string; -} diff --git a/src/app/core/shared/dspace-object.model.ts b/src/app/core/shared/dspace-object.model.ts index a2f5f721a48..71c6ee7837b 100644 --- a/src/app/core/shared/dspace-object.model.ts +++ b/src/app/core/shared/dspace-object.model.ts @@ -1,21 +1,20 @@ -import { - MetadataMap, - MetadataValue, - MetadataValueFilter, - MetadatumViewModel -} from './metadata.models'; +import { Observable } from 'rxjs'; + +import { MetadataMap, MetadataValue, MetadataValueFilter, MetadatumViewModel } from './metadata.models'; import { Metadata } from './metadata.utils'; +import { isUndefined } from '../../shared/empty.util'; import { CacheableObject } from '../cache/object-cache.reducer'; import { RemoteData } from '../data/remote-data'; import { ResourceType } from './resource-type'; import { ListableObject } from '../../shared/object-collection/shared/listable-object.model'; -import { Observable } from 'rxjs'; /** * An abstract model class for a DSpaceObject. */ export class DSpaceObject implements CacheableObject, ListableObject { + private _name: string; + self: string; /** @@ -37,7 +36,14 @@ export class DSpaceObject implements CacheableObject, ListableObject { * The name for this DSpaceObject */ get name(): string { - return this.firstMetadataValue('dc.title'); + return (isUndefined(this._name)) ? this.firstMetadataValue('dc.title') : this._name; + } + + /** + * The name for this DSpaceObject + */ + set name(name) { + this._name = name; } /** diff --git a/src/app/core/shared/file.service.ts b/src/app/core/shared/file.service.ts new file mode 100644 index 00000000000..7e89a4e5dd2 --- /dev/null +++ b/src/app/core/shared/file.service.ts @@ -0,0 +1,46 @@ +import { Injectable } from '@angular/core'; +import { HttpHeaders } from '@angular/common/http'; + +import { DSpaceRESTv2Service, HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; +import { RestRequestMethod } from '../data/rest-request-method'; +import { saveAs } from 'file-saver'; +import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; + +/** + * Provides utility methods to save files on the client-side. + */ +@Injectable() +export class FileService { + constructor( + private restService: DSpaceRESTv2Service + ) { } + + /** + * Makes a HTTP Get request to download a file + * + * @param url + * file url + */ + downloadFile(url: string) { + const headers = new HttpHeaders(); + const options: HttpOptions = Object.create({headers, responseType: 'blob'}); + return this.restService.request(RestRequestMethod.GET, url, null, options) + .subscribe((data) => { + saveAs(data.payload as Blob, this.getFileNameFromResponseContentDisposition(data)); + }); + } + + /** + * Derives file name from the http response + * by looking inside content-disposition + * @param res + * http DSpaceRESTV2Response + */ + getFileNameFromResponseContentDisposition(res: DSpaceRESTV2Response) { + // NOTE: to be able to retrieve 'Content-Disposition' header, + // you need to set 'Access-Control-Expose-Headers': 'Content-Disposition' ON SERVER SIDE + const contentDisposition = res.headers.get('content-disposition') || ''; + const matches = /filename="([^;]+)"/ig.exec(contentDisposition) || []; + return (matches[1] || 'untitled').trim().replace(/\.[^/.]+$/, ''); + }; +} diff --git a/src/app/core/shared/item.model.ts b/src/app/core/shared/item.model.ts index 69def7b9698..6cd5634fd05 100644 --- a/src/app/core/shared/item.model.ts +++ b/src/app/core/shared/item.model.ts @@ -1,4 +1,4 @@ -import {map, startWith, filter} from 'rxjs/operators'; +import { map, startWith, filter, take } from 'rxjs/operators'; import { Observable } from 'rxjs'; import { DSpaceObject } from './dspace-object.model'; @@ -90,14 +90,16 @@ export class Item extends DSpaceObject { */ getBitstreamsByBundleName(bundleName: string): Observable { return this.bitstreams.pipe( + filter((rd: RemoteData>) => !rd.isResponsePending), map((rd: RemoteData>) => rd.payload.page), filter((bitstreams: Bitstream[]) => hasValue(bitstreams)), + take(1), startWith([]), map((bitstreams) => { return bitstreams .filter((bitstream) => hasValue(bitstream)) .filter((bitstream) => bitstream.bundleName === bundleName) - }),); + })); } } diff --git a/src/app/core/shared/license.model.ts b/src/app/core/shared/license.model.ts new file mode 100644 index 00000000000..a04422242ac --- /dev/null +++ b/src/app/core/shared/license.model.ts @@ -0,0 +1,14 @@ +import { DSpaceObject } from './dspace-object.model'; + +export class License extends DSpaceObject { + + /** + * Is the license custom? + */ + custom: boolean; + + /** + * The text of the license + */ + text: string; +} diff --git a/src/app/core/shared/metadata.models.ts b/src/app/core/shared/metadata.models.ts index b72eb340d39..ab007c15f6e 100644 --- a/src/app/core/shared/metadata.models.ts +++ b/src/app/core/shared/metadata.models.ts @@ -2,14 +2,28 @@ import * as uuidv4 from 'uuid/v4'; import { autoserialize, Serialize, Deserialize } from 'cerialize'; /* tslint:disable:max-classes-per-file */ +/** A single metadata value and its properties. */ +export interface MetadataValueInterface { + + /** The language. */ + language: string; + + /** The string value. */ + value: string; +} + +/** A map of metadata keys to an ordered list of MetadataValue objects. */ +export interface MetadataMapInterface { + [key: string]: MetadataValueInterface[]; +} + /** A map of metadata keys to an ordered list of MetadataValue objects. */ -export class MetadataMap { +export class MetadataMap implements MetadataMapInterface { [key: string]: MetadataValue[]; } /** A single metadata value and its properties. */ - -export class MetadataValue { +export class MetadataValue implements MetadataValueInterface { /** The uuid. */ uuid: string = uuidv4(); diff --git a/src/app/core/shared/metadata.utils.ts b/src/app/core/shared/metadata.utils.ts index 1e9446912dd..938d646a822 100644 --- a/src/app/core/shared/metadata.utils.ts +++ b/src/app/core/shared/metadata.utils.ts @@ -1,6 +1,6 @@ import { isEmpty, isNotUndefined, isUndefined } from '../../shared/empty.util'; import { - MetadataMap, + MetadataMapInterface, MetadataValue, MetadataValueFilter, MetadatumViewModel @@ -25,23 +25,23 @@ export class Metadata { /** * Gets all matching metadata in the map(s). * - * @param {MetadataMap|MetadataMap[]} mapOrMaps The source map(s). When multiple maps are given, they will be + * @param {MetadataMapInterface|MetadataMapInterface[]} mapOrMaps The source map(s). When multiple maps are given, they will be * checked in order, and only values from the first with at least one match will be returned. * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see above. * @param {MetadataValueFilter} filter The value filter to use. If unspecified, no filtering will be done. * @returns {MetadataValue[]} the matching values or an empty array. */ - public static all(mapOrMaps: MetadataMap | MetadataMap[], keyOrKeys: string | string[], + public static all(mapOrMaps: MetadataMapInterface | MetadataMapInterface[], keyOrKeys: string | string[], filter?: MetadataValueFilter): MetadataValue[] { - const mdMaps: MetadataMap[] = mapOrMaps instanceof Array ? mapOrMaps : [mapOrMaps]; + const mdMaps: MetadataMapInterface[] = mapOrMaps instanceof Array ? mapOrMaps : [mapOrMaps]; const matches: MetadataValue[] = []; for (const mdMap of mdMaps) { for (const mdKey of Metadata.resolveKeys(mdMap, keyOrKeys)) { const candidates = mdMap[mdKey]; if (candidates) { for (const candidate of candidates) { - if (Metadata.valueMatches(candidate, filter)) { - matches.push(candidate); + if (Metadata.valueMatches(candidate as MetadataValue, filter)) { + matches.push(candidate as MetadataValue); } } } @@ -56,13 +56,13 @@ export class Metadata { /** * Like [[Metadata.all]], but only returns string values. * - * @param {MetadataMap|MetadataMap[]} mapOrMaps The source map(s). When multiple maps are given, they will be + * @param {MetadataMapInterface|MetadataMapInterface[]} mapOrMaps The source map(s). When multiple maps are given, they will be * checked in order, and only values from the first with at least one match will be returned. * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see above. * @param {MetadataValueFilter} filter The value filter to use. If unspecified, no filtering will be done. * @returns {string[]} the matching string values or an empty array. */ - public static allValues(mapOrMaps: MetadataMap | MetadataMap[], keyOrKeys: string | string[], + public static allValues(mapOrMaps: MetadataMapInterface | MetadataMapInterface[], keyOrKeys: string | string[], filter?: MetadataValueFilter): string[] { return Metadata.all(mapOrMaps, keyOrKeys, filter).map((mdValue) => mdValue.value); } @@ -70,17 +70,17 @@ export class Metadata { /** * Gets the first matching MetadataValue object in the map(s), or `undefined`. * - * @param {MetadataMap|MetadataMap[]} mapOrMaps The source map(s). + * @param {MetadataMapInterface|MetadataMapInterface[]} mapOrMaps The source map(s). * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see above. * @param {MetadataValueFilter} filter The value filter to use. If unspecified, no filtering will be done. * @returns {MetadataValue} the first matching value, or `undefined`. */ - public static first(mdMapOrMaps: MetadataMap | MetadataMap[], keyOrKeys: string | string[], + public static first(mdMapOrMaps: MetadataMapInterface | MetadataMapInterface[], keyOrKeys: string | string[], filter?: MetadataValueFilter): MetadataValue { - const mdMaps: MetadataMap[] = mdMapOrMaps instanceof Array ? mdMapOrMaps : [mdMapOrMaps]; + const mdMaps: MetadataMapInterface[] = mdMapOrMaps instanceof Array ? mdMapOrMaps : [mdMapOrMaps]; for (const mdMap of mdMaps) { for (const key of Metadata.resolveKeys(mdMap, keyOrKeys)) { - const values: MetadataValue[] = mdMap[key]; + const values: MetadataValue[] = mdMap[key] as MetadataValue[]; if (values) { return values.find((value: MetadataValue) => Metadata.valueMatches(value, filter)); } @@ -91,12 +91,12 @@ export class Metadata { /** * Like [[Metadata.first]], but only returns a string value, or `undefined`. * - * @param {MetadataMap|MetadataMap[]} mapOrMaps The source map(s). + * @param {MetadataMapInterface|MetadataMapInterface[]} mapOrMaps The source map(s). * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see above. * @param {MetadataValueFilter} filter The value filter to use. If unspecified, no filtering will be done. * @returns {string} the first matching string value, or `undefined`. */ - public static firstValue(mdMapOrMaps: MetadataMap | MetadataMap[], keyOrKeys: string | string[], + public static firstValue(mdMapOrMaps: MetadataMapInterface | MetadataMapInterface[], keyOrKeys: string | string[], filter?: MetadataValueFilter): string { const value = Metadata.first(mdMapOrMaps, keyOrKeys, filter); return isUndefined(value) ? undefined : value.value; @@ -105,12 +105,12 @@ export class Metadata { /** * Checks for a matching metadata value in the given map(s). * - * @param {MetadataMap|MetadataMap[]} mapOrMaps The source map(s). + * @param {MetadataMapInterface|MetadataMapInterface[]} mapOrMaps The source map(s). * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see above. * @param {MetadataValueFilter} filter The value filter to use. If unspecified, no filtering will be done. * @returns {boolean} whether a match is found. */ - public static has(mdMapOrMaps: MetadataMap | MetadataMap[], keyOrKeys: string | string[], + public static has(mdMapOrMaps: MetadataMapInterface | MetadataMapInterface[], keyOrKeys: string | string[], filter?: MetadataValueFilter): boolean { return isNotUndefined(Metadata.first(mdMapOrMaps, keyOrKeys, filter)); } @@ -146,10 +146,10 @@ export class Metadata { /** * Gets the list of keys in the map limited by, and in the order given by `keyOrKeys`. * - * @param {MetadataMap} mdMap The source map. + * @param {MetadataMapInterface} mdMap The source map. * @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see above. */ - private static resolveKeys(mdMap: MetadataMap = {}, keyOrKeys: string | string[]): string[] { + private static resolveKeys(mdMap: MetadataMapInterface = {}, keyOrKeys: string | string[]): string[] { const inputKeys: string[] = keyOrKeys instanceof Array ? keyOrKeys : [keyOrKeys]; const outputKeys: string[] = []; for (const inputKey of inputKeys) { @@ -168,12 +168,12 @@ export class Metadata { } /** - * Creates an array of MetadatumViewModels from an existing MetadataMap. + * Creates an array of MetadatumViewModels from an existing MetadataMapInterface. * - * @param {MetadataMap} mdMap The source map. + * @param {MetadataMapInterface} mdMap The source map. * @returns {MetadatumViewModel[]} List of metadata view models based on the source map. */ - public static toViewModelList(mdMap: MetadataMap): MetadatumViewModel[] { + public static toViewModelList(mdMap: MetadataMapInterface): MetadatumViewModel[] { let metadatumList: MetadatumViewModel[] = []; Object.keys(mdMap) .sort() @@ -193,13 +193,13 @@ export class Metadata { } /** - * Creates an MetadataMap from an existing array of MetadatumViewModels. + * Creates an MetadataMapInterface from an existing array of MetadatumViewModels. * * @param {MetadatumViewModel[]} viewModelList The source list. - * @returns {MetadataMap} Map with metadata values based on the source list. + * @returns {MetadataMapInterface} Map with metadata values based on the source list. */ - public static toMetadataMap(viewModelList: MetadatumViewModel[]): MetadataMap { - const metadataMap: MetadataMap = {}; + public static toMetadataMap(viewModelList: MetadatumViewModel[]): MetadataMapInterface { + const metadataMap: MetadataMapInterface = {}; const groupedList = groupBy(viewModelList, (viewModel) => viewModel.key); Object.keys(groupedList) .sort() diff --git a/src/app/core/shared/operators.spec.ts b/src/app/core/shared/operators.spec.ts index 5086976f8be..2eb47507b27 100644 --- a/src/app/core/shared/operators.spec.ts +++ b/src/app/core/shared/operators.spec.ts @@ -142,7 +142,7 @@ describe('Core Module - RxJS Operators', () => { scheduler.schedule(() => source.pipe(configureRequest(requestService)).subscribe()); scheduler.flush(); - expect(requestService.configure).toHaveBeenCalledWith(testRequest); + expect(requestService.configure).toHaveBeenCalledWith(testRequest, undefined); }); }); diff --git a/src/app/core/shared/operators.ts b/src/app/core/shared/operators.ts index a2c421255ea..ce9740a0fcc 100644 --- a/src/app/core/shared/operators.ts +++ b/src/app/core/shared/operators.ts @@ -50,9 +50,9 @@ export const getResourceLinksFromResponse = () => map((response: DSOSuccessResponse) => response.resourceSelfLinks), ); -export const configureRequest = (requestService: RequestService) => +export const configureRequest = (requestService: RequestService, forceBypassCache?: boolean) => (source: Observable): Observable => - source.pipe(tap((request: RestRequest) => requestService.configure(request))); + source.pipe(tap((request: RestRequest) => requestService.configure(request, forceBypassCache))); export const getRemoteDataPayload = () => (source: Observable>): Observable => diff --git a/src/app/core/shared/resource-policy.model.ts b/src/app/core/shared/resource-policy.model.ts index cccbea1e892..ee3d5293f52 100644 --- a/src/app/core/shared/resource-policy.model.ts +++ b/src/app/core/shared/resource-policy.model.ts @@ -18,9 +18,9 @@ export class ResourcePolicy implements CacheableObject { name: string; /** - * The Group this Resource Policy applies to + * The uuid of the Group this Resource Policy applies to */ - group: Group; + groupUUID: string; /** * The link to the rest endpoint where this Resource Policy can be found diff --git a/src/app/core/shared/resource-type.ts b/src/app/core/shared/resource-type.ts index 88ffa3386eb..484f1ea6e25 100644 --- a/src/app/core/shared/resource-type.ts +++ b/src/app/core/shared/resource-type.ts @@ -10,5 +10,14 @@ export enum ResourceType { Group = 'group', ResourcePolicy = 'resourcePolicy', MetadataSchema = 'metadataschema', - MetadataField = 'metadatafield' + MetadataField = 'metadatafield', + License = 'license', + Workflowitem = 'workflowitem', + Workspaceitem = 'workspaceitem', + SubmissionDefinitions = 'submissiondefinitions', + SubmissionDefinition = 'submissiondefinition', + SubmissionForm = 'submissionform', + SubmissionForms = 'submissionforms', + SubmissionSections = 'submissionsections', + SubmissionSection = 'submissionsection', } diff --git a/src/app/core/shared/submit-data-response-definition.model.ts b/src/app/core/shared/submit-data-response-definition.model.ts new file mode 100644 index 00000000000..beb2b320cf9 --- /dev/null +++ b/src/app/core/shared/submit-data-response-definition.model.ts @@ -0,0 +1,8 @@ +import { ConfigObject } from '../config/models/config.model'; +import { SubmissionObject } from '../submission/models/submission-object.model'; + +/** + * Defines a type for submission request responses. + */ +export type SubmitDataResponseDefinitionObject + = Array; diff --git a/src/app/core/submission/models/edititem.model.ts b/src/app/core/submission/models/edititem.model.ts new file mode 100644 index 00000000000..9c8da2ab5a8 --- /dev/null +++ b/src/app/core/submission/models/edititem.model.ts @@ -0,0 +1,4 @@ +import { Workspaceitem } from './workspaceitem.model'; + +export class EditItem extends Workspaceitem { +} diff --git a/src/app/core/submission/models/normalized-edititem.model.ts b/src/app/core/submission/models/normalized-edititem.model.ts new file mode 100644 index 00000000000..5615512399f --- /dev/null +++ b/src/app/core/submission/models/normalized-edititem.model.ts @@ -0,0 +1,10 @@ +import { inheritSerialization } from 'cerialize'; +import { mapsTo } from '../../cache/builders/build-decorators'; +import { NormalizedSubmissionObject } from './normalized-submission-object.model'; +import { EditItem } from './edititem.model'; + +@mapsTo(EditItem) +@inheritSerialization(NormalizedSubmissionObject) +export class NormalizedEditItem extends NormalizedSubmissionObject { + +} diff --git a/src/app/core/submission/models/normalized-submission-object.model.ts b/src/app/core/submission/models/normalized-submission-object.model.ts new file mode 100644 index 00000000000..80917817608 --- /dev/null +++ b/src/app/core/submission/models/normalized-submission-object.model.ts @@ -0,0 +1,37 @@ +import { autoserialize, inheritSerialization } from 'cerialize'; + +import { NormalizedDSpaceObject } from '../../cache/models/normalized-dspace-object.model'; +import { WorkspaceitemSectionsObject } from './workspaceitem-sections.model'; +import { SubmissionObjectError } from './submission-object.model'; +import { DSpaceObject } from '../../shared/dspace-object.model'; + +/** + * An abstract model class for a NormalizedSubmissionObject. + */ +@inheritSerialization(NormalizedDSpaceObject) +export class NormalizedSubmissionObject extends NormalizedDSpaceObject { + + /** + * The workspaceitem/workflowitem identifier + */ + @autoserialize + id: string; + + /** + * The workspaceitem/workflowitem last modified date + */ + @autoserialize + lastModified: Date; + + /** + * The workspaceitem/workflowitem last sections data + */ + @autoserialize + sections: WorkspaceitemSectionsObject; + + /** + * The workspaceitem/workflowitem last sections errors + */ + @autoserialize + errors: SubmissionObjectError[]; +} diff --git a/src/app/core/submission/models/normalized-workflowitem.model.ts b/src/app/core/submission/models/normalized-workflowitem.model.ts new file mode 100644 index 00000000000..0ea4ff61501 --- /dev/null +++ b/src/app/core/submission/models/normalized-workflowitem.model.ts @@ -0,0 +1,28 @@ +import { autoserialize, inheritSerialization } from 'cerialize'; + +import { mapsTo, relationship } from '../../cache/builders/build-decorators'; +import { Workflowitem } from './workflowitem.model'; +import { NormalizedSubmissionObject } from './normalized-submission-object.model'; +import { ResourceType } from '../../shared/resource-type'; + +@mapsTo(Workflowitem) +@inheritSerialization(NormalizedSubmissionObject) +export class NormalizedWorkflowItem extends NormalizedSubmissionObject { + + @autoserialize + @relationship(ResourceType.Collection, false) + collection: string; + + @autoserialize + @relationship(ResourceType.Item, false) + item: string; + + @autoserialize + @relationship(ResourceType.SubmissionDefinition, false) + submissionDefinition: string; + + @autoserialize + @relationship(ResourceType.EPerson, false) + submitter: string; + +} diff --git a/src/app/core/submission/models/normalized-workspaceitem.model.ts b/src/app/core/submission/models/normalized-workspaceitem.model.ts new file mode 100644 index 00000000000..7ec40d6524c --- /dev/null +++ b/src/app/core/submission/models/normalized-workspaceitem.model.ts @@ -0,0 +1,30 @@ +import { autoserialize, inheritSerialization } from 'cerialize'; + +import { Workspaceitem } from './workspaceitem.model'; +import { NormalizedSubmissionObject } from './normalized-submission-object.model'; +import { mapsTo, relationship } from '../../cache/builders/build-decorators'; +import { NormalizedDSpaceObject } from '../../cache/models/normalized-dspace-object.model'; +import { ResourceType } from '../../shared/resource-type'; +import { Workflowitem } from './workflowitem.model'; + +@mapsTo(Workspaceitem) +@inheritSerialization(NormalizedDSpaceObject) +@inheritSerialization(NormalizedSubmissionObject) +export class NormalizedWorkspaceItem extends NormalizedSubmissionObject { + + @autoserialize + @relationship(ResourceType.Collection, false) + collection: string; + + @autoserialize + @relationship(ResourceType.Item, false) + item: string; + + @autoserialize + @relationship(ResourceType.SubmissionDefinition, false) + submissionDefinition: string; + + @autoserialize + @relationship(ResourceType.EPerson, false) + submitter: string; +} diff --git a/src/app/core/submission/models/submission-object.model.ts b/src/app/core/submission/models/submission-object.model.ts new file mode 100644 index 00000000000..7e3b74a6a90 --- /dev/null +++ b/src/app/core/submission/models/submission-object.model.ts @@ -0,0 +1,62 @@ +import { Observable } from 'rxjs'; + +import { CacheableObject } from '../../cache/object-cache.reducer'; +import { ListableObject } from '../../../shared/object-collection/shared/listable-object.model'; +import { DSpaceObject } from '../../shared/dspace-object.model'; +import { EPerson } from '../../eperson/models/eperson.model'; +import { RemoteData } from '../../data/remote-data'; +import { Collection } from '../../shared/collection.model'; +import { Item } from '../../shared/item.model'; +import { SubmissionDefinitionsModel } from '../../config/models/config-submission-definitions.model'; +import { WorkspaceitemSectionsObject } from './workspaceitem-sections.model'; + +export interface SubmissionObjectError { + message: string, + paths: string[], +} + +/** + * An abstract model class for a SubmissionObject. + */ +export abstract class SubmissionObject extends DSpaceObject implements CacheableObject, ListableObject { + + /** + * The workspaceitem/workflowitem identifier + */ + id: string; + + /** + * The workspaceitem/workflowitem last modified date + */ + lastModified: Date; + + /** + * The collection this submission applies to + */ + collection: Observable> | Collection; + + /** + * The submission item + */ + item: Observable> | Item; + + /** + * The workspaceitem/workflowitem last sections data + */ + sections: WorkspaceitemSectionsObject; + + /** + * The submission config definition + */ + submissionDefinition: Observable> | SubmissionDefinitionsModel; + + /** + * The workspaceitem submitter + */ + submitter: Observable> | EPerson; + + /** + * The workspaceitem/workflowitem last sections errors + */ + errors: SubmissionObjectError[]; +} diff --git a/src/app/core/submission/models/submission-upload-file-access-condition.model.ts b/src/app/core/submission/models/submission-upload-file-access-condition.model.ts new file mode 100644 index 00000000000..ca2f21de47b --- /dev/null +++ b/src/app/core/submission/models/submission-upload-file-access-condition.model.ts @@ -0,0 +1,7 @@ +export class SubmissionUploadFileAccessConditionObject { + id: string; + name: string; + groupUUID: string; + startDate: string; + endDate: string; +} diff --git a/src/app/core/submission/models/workflowitem.model.ts b/src/app/core/submission/models/workflowitem.model.ts new file mode 100644 index 00000000000..3df49c91f75 --- /dev/null +++ b/src/app/core/submission/models/workflowitem.model.ts @@ -0,0 +1,4 @@ +import { Workspaceitem } from './workspaceitem.model'; + +export class Workflowitem extends Workspaceitem { +} diff --git a/src/app/core/submission/models/workspaceitem-section-deduplication.model.ts b/src/app/core/submission/models/workspaceitem-section-deduplication.model.ts new file mode 100644 index 00000000000..9233780be86 --- /dev/null +++ b/src/app/core/submission/models/workspaceitem-section-deduplication.model.ts @@ -0,0 +1,21 @@ +import { Item } from '../../shared/item.model'; + +export interface WorkspaceitemSectionDetectDuplicateObject { + matches: { + [itemId: string]: DetectDuplicateMatch; + }; +} + +export interface DetectDuplicateMatch { + submitterDecision?: string; // [reject|verify] + submitterNote?: string; + submitterTime?: string; // (readonly) + + workflowDecision?: string; // [reject|verify] + workflowNote?: string; + workflowTime?: string; // (readonly) + + adminDecision?: string; + + matchObject?: Item; +} diff --git a/src/app/core/submission/models/workspaceitem-section-form.model.ts b/src/app/core/submission/models/workspaceitem-section-form.model.ts new file mode 100644 index 00000000000..cfae3f5b0f7 --- /dev/null +++ b/src/app/core/submission/models/workspaceitem-section-form.model.ts @@ -0,0 +1,6 @@ +import { FormFieldMetadataValueObject } from '../../../shared/form/builder/models/form-field-metadata-value.model'; +import { MetadataMapInterface } from '../../shared/metadata.models'; + +export interface WorkspaceitemSectionFormObject extends MetadataMapInterface { + [metadata: string]: FormFieldMetadataValueObject[]; +} diff --git a/src/app/core/submission/models/workspaceitem-section-license.model.ts b/src/app/core/submission/models/workspaceitem-section-license.model.ts new file mode 100644 index 00000000000..4a86503a041 --- /dev/null +++ b/src/app/core/submission/models/workspaceitem-section-license.model.ts @@ -0,0 +1,5 @@ +export interface WorkspaceitemSectionLicenseObject { + url: string; + acceptanceDate: string; + granted: boolean; +} diff --git a/src/app/core/submission/models/workspaceitem-section-recycle.model.ts b/src/app/core/submission/models/workspaceitem-section-recycle.model.ts new file mode 100644 index 00000000000..760114e73af --- /dev/null +++ b/src/app/core/submission/models/workspaceitem-section-recycle.model.ts @@ -0,0 +1,8 @@ +import { FormFieldMetadataValueObject } from '../../../shared/form/builder/models/form-field-metadata-value.model'; +import { WorkspaceitemSectionUploadFileObject } from './workspaceitem-section-upload-file.model'; + +export interface WorkspaceitemSectionRecycleObject { + unexpected: any; + metadata: FormFieldMetadataValueObject[]; + files: WorkspaceitemSectionUploadFileObject[]; +} diff --git a/src/app/core/submission/models/workspaceitem-section-upload-file.model.ts b/src/app/core/submission/models/workspaceitem-section-upload-file.model.ts new file mode 100644 index 00000000000..a42a334b86b --- /dev/null +++ b/src/app/core/submission/models/workspaceitem-section-upload-file.model.ts @@ -0,0 +1,15 @@ +import { SubmissionUploadFileAccessConditionObject } from './submission-upload-file-access-condition.model'; +import { WorkspaceitemSectionFormObject } from './workspaceitem-section-form.model'; + +export class WorkspaceitemSectionUploadFileObject { + uuid: string; + metadata: WorkspaceitemSectionFormObject; + sizeBytes: number; + checkSum: { + checkSumAlgorithm: string; + value: string; + }; + url: string; + thumbnail: string; + accessConditions: SubmissionUploadFileAccessConditionObject[]; +} diff --git a/src/app/core/submission/models/workspaceitem-section-upload.model.ts b/src/app/core/submission/models/workspaceitem-section-upload.model.ts new file mode 100644 index 00000000000..b936b5d4d88 --- /dev/null +++ b/src/app/core/submission/models/workspaceitem-section-upload.model.ts @@ -0,0 +1,5 @@ +import { WorkspaceitemSectionUploadFileObject } from './workspaceitem-section-upload-file.model'; + +export interface WorkspaceitemSectionUploadObject { + files: WorkspaceitemSectionUploadFileObject[]; +} diff --git a/src/app/core/submission/models/workspaceitem-sections.model.ts b/src/app/core/submission/models/workspaceitem-sections.model.ts new file mode 100644 index 00000000000..e954c880c43 --- /dev/null +++ b/src/app/core/submission/models/workspaceitem-sections.model.ts @@ -0,0 +1,17 @@ +import { WorkspaceitemSectionFormObject } from './workspaceitem-section-form.model'; +import { WorkspaceitemSectionLicenseObject } from './workspaceitem-section-license.model'; +import { WorkspaceitemSectionUploadObject } from './workspaceitem-section-upload.model'; +import { WorkspaceitemSectionRecycleObject } from './workspaceitem-section-recycle.model'; +import { WorkspaceitemSectionDetectDuplicateObject } from './workspaceitem-section-deduplication.model'; + +export class WorkspaceitemSectionsObject { + [name: string]: WorkspaceitemSectionDataType; +} + +export type WorkspaceitemSectionDataType + = WorkspaceitemSectionUploadObject + | WorkspaceitemSectionFormObject + | WorkspaceitemSectionLicenseObject + | WorkspaceitemSectionRecycleObject + | WorkspaceitemSectionDetectDuplicateObject + | string; diff --git a/src/app/core/submission/models/workspaceitem.model.ts b/src/app/core/submission/models/workspaceitem.model.ts new file mode 100644 index 00000000000..e927431d716 --- /dev/null +++ b/src/app/core/submission/models/workspaceitem.model.ts @@ -0,0 +1,5 @@ +import { SubmissionObject } from './submission-object.model'; + +export class Workspaceitem extends SubmissionObject { + +} diff --git a/src/app/core/submission/submission-json-patch-operations.service.spec.ts b/src/app/core/submission/submission-json-patch-operations.service.spec.ts new file mode 100644 index 00000000000..39e6cd42fbf --- /dev/null +++ b/src/app/core/submission/submission-json-patch-operations.service.spec.ts @@ -0,0 +1,37 @@ +import { Store } from '@ngrx/store'; + +import { getTestScheduler } from 'jasmine-marbles'; +import { TestScheduler } from 'rxjs/testing'; + +import { CoreState } from '../core.reducers'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { SubmissionJsonPatchOperationsService } from './submission-json-patch-operations.service'; +import { RequestService } from '../data/request.service'; +import { SubmissionPatchRequest } from '../data/request.models'; + +describe('SubmissionJsonPatchOperationsService', () => { + let scheduler: TestScheduler; + let service: SubmissionJsonPatchOperationsService; + const requestService = {} as RequestService; + const store = {} as Store; + const halEndpointService = {} as HALEndpointService; + + function initTestService() { + return new SubmissionJsonPatchOperationsService( + requestService, + store, + halEndpointService + ); + } + + beforeEach(() => { + scheduler = getTestScheduler(); + service = initTestService(); + }); + + it('should instantiate SubmissionJsonPatchOperationsService properly', () => { + expect(service).toBeDefined(); + expect((service as any).patchRequestConstructor).toEqual(SubmissionPatchRequest); + }); + +}); diff --git a/src/app/core/submission/submission-json-patch-operations.service.ts b/src/app/core/submission/submission-json-patch-operations.service.ts new file mode 100644 index 00000000000..f9371100d66 --- /dev/null +++ b/src/app/core/submission/submission-json-patch-operations.service.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@angular/core'; + +import { Store } from '@ngrx/store'; + +import { RequestService } from '../data/request.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { JsonPatchOperationsService } from '../json-patch/json-patch-operations.service'; +import { SubmitDataResponseDefinitionObject } from '../shared/submit-data-response-definition.model'; +import { SubmissionPatchRequest } from '../data/request.models'; +import { CoreState } from '../core.reducers'; + +@Injectable() +export class SubmissionJsonPatchOperationsService extends JsonPatchOperationsService { + protected linkPath = ''; + protected patchRequestConstructor = SubmissionPatchRequest; + + constructor( + protected requestService: RequestService, + protected store: Store, + protected halService: HALEndpointService) { + + super(); + } + +} diff --git a/src/app/core/submission/submission-resource-type.ts b/src/app/core/submission/submission-resource-type.ts new file mode 100644 index 00000000000..8718fc55d31 --- /dev/null +++ b/src/app/core/submission/submission-resource-type.ts @@ -0,0 +1,22 @@ +export enum SubmissionResourceType { + Bundle = 'bundle', + Bitstream = 'bitstream', + BitstreamFormat = 'bitstreamformat', + Item = 'item', + Collection = 'collection', + Community = 'community', + ResourcePolicy = 'resourcePolicy', + License = 'license', + EPerson = 'eperson', + Group = 'group', + WorkspaceItem = 'workspaceitem', + WorkflowItem = 'workflowitem', + EditItem = 'edititem', + SubmissionDefinitions = 'submissiondefinitions', + SubmissionDefinition = 'submissiondefinition', + SubmissionForm = 'submissionform', + SubmissionForms = 'submissionforms', + SubmissionSections = 'submissionsections', + SubmissionSection = 'submissionsection', + Authority = 'authority' +} diff --git a/src/app/core/submission/submission-response-parsing.service.ts b/src/app/core/submission/submission-response-parsing.service.ts new file mode 100644 index 00000000000..abce34808e4 --- /dev/null +++ b/src/app/core/submission/submission-response-parsing.service.ts @@ -0,0 +1,145 @@ +import { Inject, Injectable } from '@angular/core'; + +import { ResponseParsingService } from '../data/parsing.service'; +import { RestRequest } from '../data/request.models'; +import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; +import { ErrorResponse, RestResponse, SubmissionSuccessResponse } from '../cache/response.models'; +import { isEmpty, isNotEmpty, isNotNull } from '../../shared/empty.util'; +import { ConfigObject } from '../config/models/config.model'; +import { BaseResponseParsingService } from '../data/base-response-parsing.service'; +import { GLOBAL_CONFIG } from '../../../config'; +import { GlobalConfig } from '../../../config/global-config.interface'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { SubmissionResourceType } from './submission-resource-type'; +import { NormalizedWorkspaceItem } from './models/normalized-workspaceitem.model'; +import { NormalizedWorkflowItem } from './models/normalized-workflowitem.model'; +import { NormalizedEditItem } from './models/normalized-edititem.model'; +import { FormFieldMetadataValueObject } from '../../shared/form/builder/models/form-field-metadata-value.model'; +import { SubmissionObject } from './models/submission-object.model'; +import { NormalizedObjectFactory } from '../cache/models/normalized-object-factory'; + +export function isServerFormValue(obj: any): boolean { + return (typeof obj === 'object' + && obj.hasOwnProperty('value') + && obj.hasOwnProperty('language') + && obj.hasOwnProperty('authority') + && obj.hasOwnProperty('confidence') + && obj.hasOwnProperty('place')) +} + +export function normalizeSectionData(obj: any) { + let result: any = obj; + if (isNotNull(obj)) { + // If is an Instance of FormFieldMetadataValueObject normalize it + if (typeof obj === 'object' && isServerFormValue(obj)) { + // If authority property is set normalize as a FormFieldMetadataValueObject object + /* NOTE: Data received from server could have authority property equal to null, but into form + field's model is required a FormFieldMetadataValueObject object as field value, so instantiate it */ + result = new FormFieldMetadataValueObject( + obj.value, + obj.language, + obj.authority, + (obj.display || obj.value), + obj.place, + obj.confidence, + obj.otherInformation + ); + } else if (Array.isArray(obj)) { + result = []; + obj.forEach((item, index) => { + result[index] = normalizeSectionData(item); + }); + } else if (typeof obj === 'object') { + result = Object.create({}); + Object.keys(obj) + .forEach((key) => { + result[key] = normalizeSectionData(obj[key]); + }); + } + } + return result; +} + +/** + * Provides methods to parse response for a submission request. + */ +@Injectable() +export class SubmissionResponseParsingService extends BaseResponseParsingService implements ResponseParsingService { + + protected objectFactory = NormalizedObjectFactory; + protected toCache = false; + + constructor(@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, + protected objectCache: ObjectCacheService) { + super(); + } + + parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { + if (isNotEmpty(data.payload) + && isNotEmpty(data.payload._links) + && (data.statusCode === 201 || data.statusCode === 200)) { + const dataDefinition = this.processResponse(data.payload, request.href); + return new SubmissionSuccessResponse(dataDefinition, data.statusCode, data.statusText, this.processPageInfo(data.payload)); + } else if (isEmpty(data.payload) && data.statusCode === 204) { + // Response from a DELETE request + return new SubmissionSuccessResponse(null, data.statusCode, data.statusText); + } else { + return new ErrorResponse( + Object.assign( + new Error('Unexpected response from server'), + {statusCode: data.statusCode, statusText: data.statusText} + ) + ); + } + } + + protected processResponse(data: any, requestHref: string): any[] { + const dataDefinition = this.process(data, requestHref); + const normalizedDefinition = Array.of(); + const processedList = Array.isArray(dataDefinition) ? dataDefinition : Array.of(dataDefinition); + + processedList.forEach((item) => { + + let normalizedItem = Object.assign({}, item); + // In case data is an Instance of NormalizedWorkspaceItem normalize field value of all the section of type form + if (item instanceof NormalizedWorkspaceItem + || item instanceof NormalizedWorkflowItem + || item instanceof NormalizedEditItem) { + if (item.sections) { + const precessedSection = Object.create({}); + // Iterate over all workspaceitem's sections + Object.keys(item.sections) + .forEach((sectionId) => { + if (typeof item.sections[sectionId] === 'object' && isNotEmpty(item.sections[sectionId])) { + const normalizedSectionData = Object.create({}); + // Iterate over all sections property + Object.keys(item.sections[sectionId]) + .forEach((metdadataId) => { + const entry = item.sections[sectionId][metdadataId]; + // If entry is not an array, for sure is not a section of type form + if (Array.isArray(entry)) { + normalizedSectionData[metdadataId] = []; + entry.forEach((valueItem) => { + // Parse value and normalize it + const normValue = normalizeSectionData(valueItem); + if (isNotEmpty(normValue)) { + normalizedSectionData[metdadataId].push(normValue); + } + }); + } else { + normalizedSectionData[metdadataId] = entry; + } + }); + precessedSection[sectionId] = normalizedSectionData; + } + }); + normalizedItem = Object.assign({}, item, { sections: precessedSection }); + } + } + normalizedDefinition.push(normalizedItem); + }); + + return normalizedDefinition; + } + +} diff --git a/src/app/core/submission/submission-scope-type.ts b/src/app/core/submission/submission-scope-type.ts new file mode 100644 index 00000000000..80d57c853f6 --- /dev/null +++ b/src/app/core/submission/submission-scope-type.ts @@ -0,0 +1,5 @@ +export enum SubmissionScopeType { + WorkspaceItem = 'WORKSPACE', + WorkflowItem = 'WORKFLOW', + EditItem = 'ITEM', +} diff --git a/src/app/core/submission/workflowitem-data.service.ts b/src/app/core/submission/workflowitem-data.service.ts new file mode 100644 index 00000000000..266d2b5411a --- /dev/null +++ b/src/app/core/submission/workflowitem-data.service.ts @@ -0,0 +1,39 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; + +import { Store } from '@ngrx/store'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { CoreState } from '../core.reducers'; +import { DataService } from '../data/data.service'; +import { RequestService } from '../data/request.service'; +import { Workflowitem } from './models/workflowitem.model'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { FindAllOptions } from '../data/request.models'; +import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; + +@Injectable() +export class WorkflowitemDataService extends DataService { + protected linkPath = 'workflowitems'; + protected forceBypassCache = true; + + constructor( + protected comparator: DSOChangeAnalyzer, + protected dataBuildService: NormalizedObjectBuildService, + protected halService: HALEndpointService, + protected http: HttpClient, + protected notificationsService: NotificationsService, + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected store: Store) { + super(); + } + + public getBrowseEndpoint(options: FindAllOptions) { + return this.halService.getEndpoint(this.linkPath); + } + +} diff --git a/src/app/core/submission/workspaceitem-data.service.ts b/src/app/core/submission/workspaceitem-data.service.ts new file mode 100644 index 00000000000..119bfb66cc7 --- /dev/null +++ b/src/app/core/submission/workspaceitem-data.service.ts @@ -0,0 +1,39 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; + +import { Store } from '@ngrx/store'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { CoreState } from '../core.reducers'; +import { DataService } from '../data/data.service'; +import { RequestService } from '../data/request.service'; +import { Workspaceitem } from './models/workspaceitem.model'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { FindAllOptions } from '../data/request.models'; +import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; + +@Injectable() +export class WorkspaceitemDataService extends DataService { + protected linkPath = 'workspaceitems'; + protected forceBypassCache = true; + + constructor( + protected comparator: DSOChangeAnalyzer, + protected dataBuildService: NormalizedObjectBuildService, + protected halService: HALEndpointService, + protected http: HttpClient, + protected notificationsService: NotificationsService, + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected store: Store) { + super(); + } + + public getBrowseEndpoint(options: FindAllOptions) { + return this.halService.getEndpoint(this.linkPath); + } + +} diff --git a/src/app/pagenotfound/pagenotfound.component.ts b/src/app/pagenotfound/pagenotfound.component.ts index e7923b34667..a3a55c26bf7 100644 --- a/src/app/pagenotfound/pagenotfound.component.ts +++ b/src/app/pagenotfound/pagenotfound.component.ts @@ -1,5 +1,6 @@ import { ServerResponseService } from '../shared/services/server-response.service'; -import { Component, ChangeDetectionStrategy } from '@angular/core'; +import { Component, ChangeDetectionStrategy, OnInit } from '@angular/core'; +import { AuthService } from '../core/auth/auth.service'; @Component({ selector: 'ds-pagenotfound', @@ -7,8 +8,13 @@ import { Component, ChangeDetectionStrategy } from '@angular/core'; templateUrl: './pagenotfound.component.html', changeDetection: ChangeDetectionStrategy.Default }) -export class PageNotFoundComponent { - constructor(responseService: ServerResponseService) { - responseService.setNotFound(); +export class PageNotFoundComponent implements OnInit { + constructor(private authservice: AuthService, private responseService: ServerResponseService) { + this.responseService.setNotFound(); } + + ngOnInit(): void { + this.authservice.clearRedirectUrl(); + } + } diff --git a/src/app/shared/alerts/alerts.component.html b/src/app/shared/alerts/alerts.component.html new file mode 100644 index 00000000000..0b30edb5cc9 --- /dev/null +++ b/src/app/shared/alerts/alerts.component.html @@ -0,0 +1,9 @@ + diff --git a/src/app/shared/alerts/alerts.component.scss b/src/app/shared/alerts/alerts.component.scss new file mode 100644 index 00000000000..1a70081367a --- /dev/null +++ b/src/app/shared/alerts/alerts.component.scss @@ -0,0 +1,3 @@ +.close:focus { + outline: none !important; +} diff --git a/src/app/shared/alerts/alerts.component.ts b/src/app/shared/alerts/alerts.component.ts new file mode 100644 index 00000000000..c9fc0ec9cca --- /dev/null +++ b/src/app/shared/alerts/alerts.component.ts @@ -0,0 +1,44 @@ +import { ChangeDetectorRef, Component, EventEmitter, Input, Output, ViewEncapsulation } from '@angular/core'; +import { trigger } from '@angular/animations'; + +import { AlertType } from './aletrs-type'; +import { fadeOutLeave, fadeOutState } from '../animations/fade'; + +@Component({ + selector: 'ds-alert', + encapsulation: ViewEncapsulation.None, + animations: [ + trigger('enterLeave', [ + fadeOutLeave, fadeOutState, + ]) + ], + templateUrl: './alerts.component.html', + styleUrls: ['./alerts.component.scss'] +}) + +export class AlertsComponent { + + @Input() content: string; + @Input() dismissible = false; + @Input() type: AlertType; + @Output() close: EventEmitter = new EventEmitter(); + + public animate = 'fadeIn'; + public dismissed = false; + + constructor(private cdr: ChangeDetectorRef) { + } + + dismiss() { + if (this.dismissible) { + this.animate = 'fadeOut'; + this.cdr.detectChanges(); + setTimeout(() => { + this.dismissed = true; + this.close.emit(); + this.cdr.detectChanges(); + }, 300); + + } + } +} diff --git a/src/app/shared/alerts/aletrs-type.ts b/src/app/shared/alerts/aletrs-type.ts new file mode 100644 index 00000000000..aacfb451f96 --- /dev/null +++ b/src/app/shared/alerts/aletrs-type.ts @@ -0,0 +1,6 @@ +export enum AlertType { + Success = 'alert-success', + Error = 'alert-danger', + Info = 'alert-info', + Warning = 'alert-warning' +} diff --git a/src/app/shared/auth-nav-menu/auth-nav-menu.component.scss b/src/app/shared/auth-nav-menu/auth-nav-menu.component.scss index a8c7b84f567..7b7e7af12f7 100644 --- a/src/app/shared/auth-nav-menu/auth-nav-menu.component.scss +++ b/src/app/shared/auth-nav-menu/auth-nav-menu.component.scss @@ -6,3 +6,8 @@ #loginDropdownMenu { min-height: 260px; } + +.dropdown-item.active, .dropdown-item:active, +.dropdown-item:hover, .dropdown-item:focus { + background-color: transparent !important; +} diff --git a/src/app/shared/auth-nav-menu/auth-nav-menu.component.ts b/src/app/shared/auth-nav-menu/auth-nav-menu.component.ts index fc85616de9d..1b39ad15d96 100644 --- a/src/app/shared/auth-nav-menu/auth-nav-menu.component.ts +++ b/src/app/shared/auth-nav-menu/auth-nav-menu.component.ts @@ -9,13 +9,9 @@ import { fadeInOut, fadeOut } from '../animations/fade'; import { HostWindowService } from '../host-window.service'; import { AppState, routerStateSelector } from '../../app.reducer'; import { isNotUndefined } from '../empty.util'; -import { - getAuthenticatedUser, - isAuthenticated, - isAuthenticationLoading -} from '../../core/auth/selectors'; +import { getAuthenticatedUser, isAuthenticated, isAuthenticationLoading } from '../../core/auth/selectors'; import { EPerson } from '../../core/eperson/models/eperson.model'; -import { AuthService, LOGIN_ROUTE, LOGOUT_ROUTE } from '../../core/auth/auth.service'; +import { LOGIN_ROUTE, LOGOUT_ROUTE } from '../../core/auth/auth.service'; @Component({ selector: 'ds-auth-nav-menu', @@ -45,8 +41,7 @@ export class AuthNavMenuComponent implements OnInit { public sub: Subscription; constructor(private store: Store, - private windowService: HostWindowService, - private authService: AuthService + private windowService: HostWindowService ) { this.isXsOrSm$ = this.windowService.isXsOrSm(); } @@ -63,14 +58,9 @@ export class AuthNavMenuComponent implements OnInit { this.showAuth = this.store.pipe( select(routerStateSelector), filter((router: RouterReducerState) => isNotUndefined(router) && isNotUndefined(router.state)), - map((router: RouterReducerState) => { - const url = router.state.url; - const show = !router.state.url.startsWith(LOGIN_ROUTE) && !router.state.url.startsWith(LOGOUT_ROUTE); - if (show) { - this.authService.setRedirectUrl(url); - } - return show; - }) + map((router: RouterReducerState) => (!router.state.url.startsWith(LOGIN_ROUTE) + && !router.state.url.startsWith(LOGOUT_ROUTE)) + ) ); } } diff --git a/src/app/shared/authority-confidence/authority-confidence-state.directive.ts b/src/app/shared/authority-confidence/authority-confidence-state.directive.ts new file mode 100644 index 00000000000..da62204d108 --- /dev/null +++ b/src/app/shared/authority-confidence/authority-confidence-state.directive.ts @@ -0,0 +1,100 @@ +import { + Directive, + ElementRef, EventEmitter, + HostListener, + Inject, + Input, + OnChanges, + Output, + Renderer2, + SimpleChanges +} from '@angular/core'; + +import { findIndex } from 'lodash'; + +import { AuthorityValue } from '../../core/integration/models/authority.value'; +import { FormFieldMetadataValueObject } from '../form/builder/models/form-field-metadata-value.model'; +import { ConfidenceType } from '../../core/integration/models/confidence-type'; +import { isNotEmpty, isNull } from '../empty.util'; +import { GLOBAL_CONFIG, GlobalConfig } from '../../../config'; +import { ConfidenceIconConfig } from '../../../config/submission-config.interface'; + +@Directive({ + selector: '[dsAuthorityConfidenceState]' +}) +export class AuthorityConfidenceStateDirective implements OnChanges { + + @Input() authorityValue: AuthorityValue | FormFieldMetadataValueObject | string; + @Input() visibleWhenAuthorityEmpty = true; + + private previousClass: string = null; + private newClass: string; + + @Output() whenClickOnConfidenceNotAccepted: EventEmitter = new EventEmitter(); + + @HostListener('click') onClick() { + if (isNotEmpty(this.authorityValue) && this.getConfidenceByValue(this.authorityValue) !== ConfidenceType.CF_ACCEPTED) { + this.whenClickOnConfidenceNotAccepted.emit(this.getConfidenceByValue(this.authorityValue)); + } + } + + constructor( + @Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, + private elem: ElementRef, + private renderer: Renderer2 + ) { + } + + ngOnChanges(changes: SimpleChanges): void { + if (!changes.authorityValue.firstChange) { + this.previousClass = this.getClassByConfidence(this.getConfidenceByValue(changes.authorityValue.previousValue)) + } + this.newClass = this.getClassByConfidence(this.getConfidenceByValue(changes.authorityValue.currentValue)); + + if (isNull(this.previousClass)) { + this.renderer.addClass(this.elem.nativeElement, this.newClass); + } else if (this.previousClass !== this.newClass) { + this.renderer.removeClass(this.elem.nativeElement, this.previousClass); + this.renderer.addClass(this.elem.nativeElement, this.newClass); + } + } + + ngAfterViewInit() { + if (isNull(this.previousClass)) { + this.renderer.addClass(this.elem.nativeElement, this.newClass); + } else if (this.previousClass !== this.newClass) { + this.renderer.removeClass(this.elem.nativeElement, this.previousClass); + this.renderer.addClass(this.elem.nativeElement, this.newClass); + } + } + + private getConfidenceByValue(value: any): ConfidenceType { + let confidence: ConfidenceType = ConfidenceType.CF_UNSET; + + if (isNotEmpty(value) && value instanceof AuthorityValue && value.hasAuthority()) { + confidence = ConfidenceType.CF_ACCEPTED; + } + + if (isNotEmpty(value) && value instanceof FormFieldMetadataValueObject) { + confidence = value.confidence; + } + + return confidence; + } + + private getClassByConfidence(confidence: any): string { + if (!this.visibleWhenAuthorityEmpty && confidence === ConfidenceType.CF_UNSET) { + return 'd-none'; + } + + const confidenceIcons: ConfidenceIconConfig[] = this.EnvConfig.submission.icons.authority.confidence; + + const confidenceIndex: number = findIndex(confidenceIcons, {value: confidence}); + + const defaultconfidenceIndex: number = findIndex(confidenceIcons, {value: 'default' as any}); + const defaultClass: string = (defaultconfidenceIndex !== -1) ? confidenceIcons[defaultconfidenceIndex].style : ''; + + return (confidenceIndex !== -1) ? confidenceIcons[confidenceIndex].style : defaultClass; + } + +} diff --git a/src/app/shared/chips/chips.component.html b/src/app/shared/chips/chips.component.html index 9116aa55833..db8f08dad03 100644 --- a/src/app/shared/chips/chips.component.html +++ b/src/app/shared/chips/chips.component.html @@ -1,23 +1,37 @@