diff --git a/config/environment.default.js b/config/environment.default.js index 17dfdd454a..387b2bb48a 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 0081f6231c..46eeb7be2f 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", @@ -119,6 +120,7 @@ "pem": "1.12.3", "reflect-metadata": "0.1.12", "rxjs": "6.2.2", + "rxjs-spy": "^7.5.1", "sortablejs": "1.7.0", "text-mask-core": "5.0.1", "ts-loader": "^5.2.1", @@ -142,6 +144,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 8e8109ee20..1952e345d8 100644 --- a/resources/i18n/en.json +++ b/resources/i18n/en.json @@ -369,7 +369,7 @@ "results-per-page": "Results Per Page", "sort-direction": "Sort Options", "showing": { - "label": "Now showing items ", + "label": "Now showing ", "detail": "{{ range }} of {{ total }}" } }, @@ -726,13 +726,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...", @@ -741,7 +753,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 elements", + "other-information": { + } }, "login": { "title": "Login", @@ -799,5 +813,97 @@ }, "placeholder": "Search for a {{ type }}", "no-results": "No {{ type }} found" + }, + "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 explicitly 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/resources/i18n/nl.json b/resources/i18n/nl.json index 6c3b1fe401..f4dda3b3c8 100644 --- a/resources/i18n/nl.json +++ b/resources/i18n/nl.json @@ -40,8 +40,8 @@ "description": "Beschrijving:" }, "link": { - "simple": "Eenvoudige item weergave", - "full": "Volledige item weergave" + "simple": "Eenvoudige itemweergave", + "full": "Volledige itemweergave" } } }, @@ -52,10 +52,10 @@ }, "pagination": { "results-per-page": "Resultaten per pagina", - "sort-direction": "Sorteer mogelijkheden", + "sort-direction": "Sorteermogelijkheden", "showing": { - "label": "Getoonde items ", - "detail": "{{ range }} tot {{ total }}" + "label": "Resultaten ", + "detail": "{{ range }} van {{ total }}" } }, "sorting": { @@ -116,8 +116,8 @@ "reset": "Filters verwijderen", "applied": { "f.author": "Auteur", - "f.dateIssued.min": "Start datum", - "f.dateIssued.max": "Eind datum", + "f.dateIssued.min": "Startdatum", + "f.dateIssued.max": "Einddatum", "f.subject": "Sleutelwoord", "f.has_content_in_original_bundle": "Heeft bestanden" }, @@ -129,7 +129,7 @@ "head": "Auteur" }, "scope": { - "placeholder": "Bereik filter", + "placeholder": "Bereikfilter", "head": "Bereik" }, "subject": { @@ -159,27 +159,27 @@ "metadata": { "title": "DSpace Angular :: Metadata Register", "head": "Metadata Register", - "description": "Het metadata register omvat de lijst van alle metadata velden die beschikbaar zijn in het systeem. Deze velden kunnen verspreid zijn over verschillende metadata schema's. Het qualified Dublin Core schema (dc) is een verplicht schema en kan niet worden verwijderd.", + "description": "Het metadataregister omvat de lijst van alle metadatavelden die beschikbaar zijn in het systeem. Deze velden kunnen verspreid zijn over verschillende metadataschema's. Het qualified Dublin Core schema (dc) is een verplicht schema en kan niet worden verwijderd.", "schemas": { "table": { "id": "ID", "namespace": "Naamruimte", "name": "Naam" }, - "no-items": "Er kunnen geen metadata schema's getoond worden." + "no-items": "Er kunnen geen metadataschema's getoond worden." } }, "schema": { "title": "DSpace Angular :: Metadata Schema Register", "head": "Metadata Schema", - "description": "Dit is het metadata schema voor \"{{namespace}}\".", + "description": "Dit is het metadataschema voor \"{{namespace}}\".", "fields": { - "head": "Schema metadata velden", + "head": "Schema metadatavelden", "table": { "field": "Veld", "scopenote": "Opmerking over bereik" }, - "no-items": "Er kunnen geen metadata velden getoond worden." + "no-items": "Er kunnen geen metadatavelden getoond worden." } }, "bitstream-formats": { @@ -198,7 +198,7 @@ }, "internal": "intern" }, - "no-items": "Er kunnen geen bitstream formaten getoond worden." + "no-items": "Er kunnen geen bitstreamformaten getoond worden." } } } @@ -229,7 +229,7 @@ "validation": { "pattern": "Deze invoer is niet toegelaten volgens dit patroon: {{ pattern }}.", "license": { - "notgranted": "U moet de invoerlicentie goedkeuren om de invoer af te werken. Indien u deze licentie momenteel niet kan of mag goedkeuren, kan u uw werk opslaan en de invoer later afwerken. U kan dit nieuwe item ook verwijderen indien u niet voldoet aan de vereisten van de invoer licentie." + "notgranted": "U moet de invoerlicentie goedkeuren om de invoer af te werken. Indien u deze licentie momenteel niet kan of mag goedkeuren, kan u uw werk opslaan en de invoer later afwerken. U kunt dit nieuwe item ook verwijderen indien u niet voldoet aan de vereisten van de invoerlicentie." } } }, @@ -271,7 +271,7 @@ "expired": "Uw sessie is vervallen. Gelieve opnieuw aan te melden." }, "errors": { - "invalid-user": "Ongeldig email adres of wachtwoord." + "invalid-user": "Ongeldig e-mailadres of wachtwoord." } } } 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 66088236a4..d641c97352 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 37fb51e5c7..674798848b 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 6e411cb29d..6265b223d8 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/+home-page/top-level-community-list/top-level-community-list.component.html b/src/app/+home-page/top-level-community-list/top-level-community-list.component.html index 934bb3933c..f318a04f38 100644 --- a/src/app/+home-page/top-level-community-list/top-level-community-list.component.html +++ b/src/app/+home-page/top-level-community-list/top-level-community-list.component.html @@ -1,12 +1,13 @@ - -
+ +

{{'home.top-level-communities.head' | translate}}

{{'home.top-level-communities.help' | translate}}

+ [objects]="communitiesRD$ | async" + [hideGear]="true" + (paginationChange)="onPaginationChange($event)">
diff --git a/src/app/+home-page/top-level-community-list/top-level-community-list.component.ts b/src/app/+home-page/top-level-community-list/top-level-community-list.component.ts index 3fdb7e48a2..1115d785a3 100644 --- a/src/app/+home-page/top-level-community-list/top-level-community-list.component.ts +++ b/src/app/+home-page/top-level-community-list/top-level-community-list.component.ts @@ -1,5 +1,5 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; -import { Observable } from 'rxjs'; +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { BehaviorSubject, Observable } from 'rxjs'; import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; import { CommunityDataService } from '../../core/data/community-data.service'; import { PaginatedList } from '../../core/data/paginated-list'; @@ -9,7 +9,11 @@ import { Community } from '../../core/shared/community.model'; import { fadeInOut } from '../../shared/animations/fade'; import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; +import { take } from 'rxjs/operators'; +/** + * this component renders the Top-Level Community list + */ @Component({ selector: 'ds-top-level-community-list', styleUrls: ['./top-level-community-list.component.scss'], @@ -18,9 +22,20 @@ import { PaginationComponentOptions } from '../../shared/pagination/pagination-c animations: [fadeInOut] }) -export class TopLevelCommunityListComponent { - communitiesRDObs: Observable>>; +export class TopLevelCommunityListComponent implements OnInit { + /** + * A list of remote data objects of all top communities + */ + communitiesRD$: BehaviorSubject>> = new BehaviorSubject>>({} as any); + + /** + * The pagination configuration + */ config: PaginationComponentOptions; + + /** + * The sorting configuration + */ sortConfig: SortOptions; constructor(private cds: CommunityDataService) { @@ -29,20 +44,34 @@ export class TopLevelCommunityListComponent { this.config.pageSize = 5; this.config.currentPage = 1; this.sortConfig = new SortOptions('dc.title', SortDirection.ASC); - - this.updatePage({ - page: this.config.currentPage, - pageSize: this.config.pageSize, - sortField: this.sortConfig.field, - direction: this.sortConfig.direction - }); } - updatePage(data) { - this.communitiesRDObs = this.cds.findTop({ - currentPage: data.page, - elementsPerPage: data.pageSize, - sort: { field: data.sortField, direction: data.sortDirection } + ngOnInit() { + this.updatePage(); + } + + /** + * Called when one of the pagination settings is changed + * @param event The new pagination data + */ + onPaginationChange(event) { + this.config.currentPage = event.page; + this.config.pageSize = event.pageSize; + this.sortConfig.field = event.sortField; + this.sortConfig.direction = event.sortDirection; + this.updatePage(); + } + + /** + * Update the list of top communities + */ + updatePage() { + this.cds.findTop({ + currentPage: this.config.currentPage, + elementsPerPage: this.config.pageSize, + sort: { field: this.sortConfig.field, direction: this.sortConfig.direction } + }).pipe(take(1)).subscribe((results) => { + this.communitiesRD$.next(results); }); } } 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 9f9447704b..651bebde58 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 97c81681d0..7516a84265 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 e89eda736f..f606fb4a83 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 9305459c12..ac49eee7e7 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 1c4cae552e..32acdef467 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/+login-page/login-page-routing.module.ts b/src/app/+login-page/login-page-routing.module.ts index 4e932c50ce..d3c6425dd3 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 234435a410..74ce5d4f9a 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 2752973130..6a8508eb45 100644 --- a/src/app/+login-page/login-page.component.ts +++ b/src/app/+login-page/login-page.component.ts @@ -1,20 +1,81 @@ -import { Component, OnDestroy } from '@angular/core'; +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { combineLatest as observableCombineLatest, 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'; +/** + * This component represents the login page + */ @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 { - constructor(private store: Store) {} + /** + * Subscription to unsubscribe onDestroy + * @type {Subscription} + */ + sub: Subscription; + /** + * Initialize instance variables + * + * @param {ActivatedRoute} route + * @param {Store} store + */ + constructor(private route: ActivatedRoute, + private store: Store) {} + + /** + * Initialize instance variables + */ + 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)); + } + } + }) + } + + /** + * Unsubscribe from subscription + */ 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/filtered-search-page.component.ts b/src/app/+search-page/filtered-search-page.component.ts index 21e8b8c1c1..da5920cd6d 100644 --- a/src/app/+search-page/filtered-search-page.component.ts +++ b/src/app/+search-page/filtered-search-page.component.ts @@ -1,5 +1,4 @@ import { HostWindowService } from '../shared/host-window.service'; -import { SearchFilterService } from './search-filters/search-filter/search-filter.service'; import { SearchService } from './search-service/search.service'; import { SearchSidebarService } from './search-sidebar/search-sidebar.service'; import { SearchPageComponent } from './search-page.component'; @@ -33,10 +32,9 @@ export class FilteredSearchPageComponent extends SearchPageComponent { constructor(protected service: SearchService, protected sidebarService: SearchSidebarService, protected windowService: HostWindowService, - protected filterService: SearchFilterService, protected searchConfigService: SearchConfigurationService, protected routeService: RouteService) { - super(service, sidebarService, windowService, filterService, searchConfigService, routeService); + super(service, sidebarService, windowService, searchConfigService, routeService); } /** diff --git a/src/app/+search-page/search-filters/search-filter/search-boolean-filter/search-boolean-filter.component.html b/src/app/+search-page/search-filters/search-filter/search-boolean-filter/search-boolean-filter.component.html index 32d9ea6e77..968bf9e420 100644 --- a/src/app/+search-page/search-filters/search-filter/search-boolean-filter/search-boolean-filter.component.html +++ b/src/app/+search-page/search-filters/search-filter/search-boolean-filter/search-boolean-filter.component.html @@ -1,24 +1,9 @@
- - - {{value}} - +
diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.html b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.html new file mode 100644 index 0000000000..7ab7ffd0ca --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.html @@ -0,0 +1,9 @@ + + + {{filterValue.value}} + + {{filterValue.count}} + + \ No newline at end of file diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.spec.ts b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.spec.ts new file mode 100644 index 0000000000..f1dbedfb40 --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.spec.ts @@ -0,0 +1,121 @@ +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { SearchFacetOptionComponent } from './search-facet-option.component'; +import { SearchFilterConfig } from '../../../../search-service/search-filter-config.model'; +import { FilterType } from '../../../../search-service/filter-type.model'; +import { FacetValue } from '../../../../search-service/facet-value.model'; +import { FormsModule } from '@angular/forms'; +import { of as observableOf } from 'rxjs'; +import { SearchService } from '../../../../search-service/search.service'; +import { SearchServiceStub } from '../../../../../shared/testing/search-service-stub'; +import { Router } from '@angular/router'; +import { RouterStub } from '../../../../../shared/testing/router-stub'; +import { SearchConfigurationService } from '../../../../search-service/search-configuration.service'; +import { SearchFilterService } from '../../search-filter.service'; +import { By } from '@angular/platform-browser'; + +describe('SearchFacetOptionComponent', () => { + let comp: SearchFacetOptionComponent; + let fixture: ComponentFixture; + const filterName1 = 'test name'; + const value1 = 'testvalue1'; + const value2 = 'test2'; + const value3 = 'another value3'; + const mockFilterConfig = Object.assign(new SearchFilterConfig(), { + name: filterName1, + type: FilterType.range, + hasFacets: false, + isOpenByDefault: false, + pageSize: 2, + minValue: 200, + maxValue: 3000, + }); + const value: FacetValue = { + value: value2, + count: 20, + search: '' + }; + + const searchLink = '/search'; + const selectedValues = [value1]; + const selectedValues$ = observableOf(selectedValues); + let filterService; + let searchService; + let router; + const page = observableOf(0); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), NoopAnimationsModule, FormsModule], + declarations: [SearchFacetOptionComponent], + providers: [ + { provide: SearchService, useValue: new SearchServiceStub(searchLink) }, + { provide: Router, useValue: new RouterStub() }, + { + provide: SearchConfigurationService, useValue: { + searchOptions: observableOf({}) + } + }, + { + provide: SearchFilterService, useValue: { + getSelectedValuesForFilter: () => selectedValues, + isFilterActiveWithValue: (paramName: string, filterValue: string) => observableOf(true), + getPage: (paramName: string) => page, + /* tslint:disable:no-empty */ + incrementPage: (filterName: string) => { + }, + resetPage: (filterName: string) => { + } + /* tslint:enable:no-empty */ + } + } + ], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(SearchFacetOptionComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(SearchFacetOptionComponent); + comp = fixture.componentInstance; // SearchPageComponent test instance + filterService = (comp as any).filterService; + searchService = (comp as any).searchService; + router = (comp as any).router; + comp.filterValue = value; + comp.selectedValues$ = selectedValues$; + comp.filterConfig = mockFilterConfig; + fixture.detectChanges(); + }); + + describe('when the updateAddParams method is called wih a value', () => { + it('should update the addQueryParams with the new parameter values', () => { + comp.addQueryParams = {}; + (comp as any).updateAddParams(selectedValues); + expect(comp.addQueryParams).toEqual({ + [mockFilterConfig.paramName]: [value1, value.value], + page: 1 + }); + }); + }); + + describe('when isVisible emits true', () => { + it('the facet option should be visible', () => { + comp.isVisible = observableOf(true); + fixture.detectChanges(); + const linkEl = fixture.debugElement.query(By.css('a')); + expect(linkEl).not.toBeNull(); + }); + }); + + describe('when isVisible emits false', () => { + it('the facet option should not be visible', () => { + comp.isVisible = observableOf(false); + fixture.detectChanges(); + const linkEl = fixture.debugElement.query(By.css('a')); + expect(linkEl).toBeNull(); + }); + }); +}); diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.ts b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.ts new file mode 100644 index 0000000000..7a6a51e99d --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.ts @@ -0,0 +1,102 @@ +import { combineLatest as observableCombineLatest, Observable, Subscription } from 'rxjs'; +import { map, take } from 'rxjs/operators'; +import { Component, Input, OnDestroy, OnInit } from '@angular/core'; +import { Router } from '@angular/router'; +import { FacetValue } from '../../../../search-service/facet-value.model'; +import { SearchFilterConfig } from '../../../../search-service/search-filter-config.model'; +import { SearchService } from '../../../../search-service/search.service'; +import { SearchFilterService } from '../../search-filter.service'; +import { SearchConfigurationService } from '../../../../search-service/search-configuration.service'; +import { hasValue } from '../../../../../shared/empty.util'; + +@Component({ + selector: 'ds-search-facet-option', + templateUrl: './search-facet-option.component.html', +}) + +/** + * Represents a single option in a filter facet + */ +export class SearchFacetOptionComponent implements OnInit, OnDestroy { + /** + * A single value for this component + */ + @Input() filterValue: FacetValue; + + /** + * The filter configuration for this facet option + */ + @Input() filterConfig: SearchFilterConfig; + + /** + * Emits the active values for this filter + */ + @Input() selectedValues$: Observable; + + /** + * Emits true when this option should be visible and false when it should be invisible + */ + isVisible: Observable; + + /** + * UI parameters when this filter is added + */ + addQueryParams; + + /** + * Subscription to unsubscribe from on destroy + */ + sub: Subscription; + + constructor(protected searchService: SearchService, + protected filterService: SearchFilterService, + protected searchConfigService: SearchConfigurationService, + protected router: Router + ) { + } + + /** + * Initializes all observable instance variables and starts listening to them + */ + ngOnInit(): void { + this.isVisible = this.isChecked().pipe(map((checked: boolean) => !checked)); + this.sub = observableCombineLatest(this.selectedValues$, this.searchConfigService.searchOptions) + .subscribe(([selectedValues, searchOptions]) => { + this.updateAddParams(selectedValues) + }); + } + + /** + * Checks if a value for this filter is currently active + */ + private isChecked(): Observable { + return this.filterService.isFilterActiveWithValue(this.filterConfig.paramName, this.filterValue.value); + } + + /** + * @returns {string} The base path to the search page + */ + getSearchLink() { + return this.searchService.getSearchLink(); + } + + /** + * Calculates the parameters that should change if a given value for this filter would be added to the active filters + * @param {string[]} selectedValues The values that are currently selected for this filter + */ + private updateAddParams(selectedValues: string[]): void { + this.addQueryParams = { + [this.filterConfig.paramName]: [...selectedValues, this.filterValue.value], + page: 1 + }; + } + + /** + * Make sure the subscription is unsubscribed from when this component is destroyed + */ + ngOnDestroy(): void { + if (hasValue(this.sub)) { + this.sub.unsubscribe(); + } + } +} diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.html b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.html new file mode 100644 index 0000000000..b485fe0fd0 --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.html @@ -0,0 +1,8 @@ + + {{filterValue.value}} + + {{filterValue.count}} + + \ No newline at end of file diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.spec.ts b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.spec.ts new file mode 100644 index 0000000000..218730263b --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.spec.ts @@ -0,0 +1,125 @@ +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { SearchFilterConfig } from '../../../../search-service/search-filter-config.model'; +import { FilterType } from '../../../../search-service/filter-type.model'; +import { FacetValue } from '../../../../search-service/facet-value.model'; +import { FormsModule } from '@angular/forms'; +import { of as observableOf } from 'rxjs'; +import { SearchService } from '../../../../search-service/search.service'; +import { SearchServiceStub } from '../../../../../shared/testing/search-service-stub'; +import { Router } from '@angular/router'; +import { RouterStub } from '../../../../../shared/testing/router-stub'; +import { SearchConfigurationService } from '../../../../search-service/search-configuration.service'; +import { SearchFilterService } from '../../search-filter.service'; +import { By } from '@angular/platform-browser'; +import { SearchFacetRangeOptionComponent } from './search-facet-range-option.component'; +import { + RANGE_FILTER_MAX_SUFFIX, + RANGE_FILTER_MIN_SUFFIX +} from '../../search-range-filter/search-range-filter.component'; + +describe('SearchFacetRangeOptionComponent', () => { + let comp: SearchFacetRangeOptionComponent; + let fixture: ComponentFixture; + const filterName1 = 'test name'; + const value2 = '20 - 30'; + const mockFilterConfig = Object.assign(new SearchFilterConfig(), { + name: filterName1, + type: FilterType.range, + hasFacets: false, + isOpenByDefault: false, + pageSize: 2, + minValue: 200, + maxValue: 3000, + }); + const value: FacetValue = { + value: value2, + count: 20, + search: '' + }; + + const searchLink = '/search'; + let filterService; + let searchService; + let router; + const page = observableOf(0); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), NoopAnimationsModule, FormsModule], + declarations: [SearchFacetRangeOptionComponent], + providers: [ + { provide: SearchService, useValue: new SearchServiceStub(searchLink) }, + { provide: Router, useValue: new RouterStub() }, + { + provide: SearchConfigurationService, useValue: { + searchOptions: observableOf({}) + } + }, + { + provide: SearchFilterService, useValue: { + isFilterActiveWithValue: (paramName: string, filterValue: string) => observableOf(true), + getPage: (paramName: string) => page, + /* tslint:disable:no-empty */ + incrementPage: (filterName: string) => { + }, + resetPage: (filterName: string) => { + } + /* tslint:enable:no-empty */ + } + } + ], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(SearchFacetRangeOptionComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(SearchFacetRangeOptionComponent); + comp = fixture.componentInstance; // SearchFacetRangeOptionComponent test instance + filterService = (comp as any).filterService; + searchService = (comp as any).searchService; + router = (comp as any).router; + comp.filterValue = value; + comp.filterConfig = mockFilterConfig; + fixture.detectChanges(); + }); + + describe('when the updateChangeParams method is called wih a value', () => { + it('should update the changeQueryParams with the new parameter values', () => { + comp.changeQueryParams = {}; + comp.filterValue = { + value: '50-60', + count: 20, + search: '' + }; + (comp as any).updateChangeParams(); + expect(comp.changeQueryParams).toEqual({ + [mockFilterConfig.paramName + RANGE_FILTER_MIN_SUFFIX]: ['50'], + [mockFilterConfig.paramName + RANGE_FILTER_MAX_SUFFIX]: ['60'], + page: 1 + }); + }); + }); + + describe('when isVisible emits true', () => { + it('the facet option should be visible', () => { + comp.isVisible = observableOf(true); + fixture.detectChanges(); + const linkEl = fixture.debugElement.query(By.css('a')); + expect(linkEl).not.toBeNull(); + }); + }); + + describe('when isVisible emits false', () => { + it('the facet option should not be visible', () => { + comp.isVisible = observableOf(false); + fixture.detectChanges(); + const linkEl = fixture.debugElement.query(By.css('a')); + expect(linkEl).toBeNull(); + }); + }); +}); diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.ts b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.ts new file mode 100644 index 0000000000..b7f02ad18b --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.ts @@ -0,0 +1,105 @@ +import { Observable, Subscription } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { Component, Input, OnDestroy, OnInit } from '@angular/core'; +import { Router } from '@angular/router'; +import { FacetValue } from '../../../../search-service/facet-value.model'; +import { SearchFilterConfig } from '../../../../search-service/search-filter-config.model'; +import { SearchService } from '../../../../search-service/search.service'; +import { SearchFilterService } from '../../search-filter.service'; +import { + RANGE_FILTER_MAX_SUFFIX, + RANGE_FILTER_MIN_SUFFIX +} from '../../search-range-filter/search-range-filter.component'; +import { SearchConfigurationService } from '../../../../search-service/search-configuration.service'; +import { hasValue } from '../../../../../shared/empty.util'; + +const rangeDelimiter = '-'; + +@Component({ + selector: 'ds-search-facet-range-option', + templateUrl: './search-facet-range-option.component.html', +}) + +/** + * Represents a single option in a range filter facet + */ +export class SearchFacetRangeOptionComponent implements OnInit, OnDestroy { + /** + * A single value for this component + */ + @Input() filterValue: FacetValue; + + /** + * The filter configuration for this facet option + */ + @Input() filterConfig: SearchFilterConfig; + + /** + * Emits true when this option should be visible and false when it should be invisible + */ + isVisible: Observable; + + /** + * UI parameters when this filter is changed + */ + changeQueryParams; + + /** + * Subscription to unsubscribe from on destroy + */ + sub: Subscription; + + constructor(protected searchService: SearchService, + protected filterService: SearchFilterService, + protected searchConfigService: SearchConfigurationService, + protected router: Router + ) { + } + + /** + * Initializes all observable instance variables and starts listening to them + */ + ngOnInit(): void { + this.isVisible = this.isChecked().pipe(map((checked: boolean) => !checked)); + this.sub = this.searchConfigService.searchOptions.subscribe(() => { + this.updateChangeParams() + }); + } + + /** + * Checks if a value for this filter is currently active + */ + private isChecked(): Observable { + return this.filterService.isFilterActiveWithValue(this.filterConfig.paramName, this.filterValue.value); + } + + /** + * @returns {string} The base path to the search page + */ + getSearchLink() { + return this.searchService.getSearchLink(); + } + + /** + * Calculates the parameters that should change if a given values for this range filter would be changed + */ + private updateChangeParams(): void { + const parts = this.filterValue.value.split(rangeDelimiter); + const min = parts.length > 1 ? parts[0].trim() : this.filterValue.value; + const max = parts.length > 1 ? parts[1].trim() : this.filterValue.value; + this.changeQueryParams = { + [this.filterConfig.paramName + RANGE_FILTER_MIN_SUFFIX]: [min], + [this.filterConfig.paramName + RANGE_FILTER_MAX_SUFFIX]: [max], + page: 1 + }; + } + + /** + * Make sure the subscription is unsubscribed from when this component is destroyed + */ + ngOnDestroy(): void { + if (hasValue(this.sub)) { + this.sub.unsubscribe(); + } + } +} diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.html b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.html new file mode 100644 index 0000000000..ba43bae100 --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.html @@ -0,0 +1,6 @@ + + + {{selectedValue}} + \ No newline at end of file diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.spec.ts b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.spec.ts new file mode 100644 index 0000000000..545ba1d66b --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.spec.ts @@ -0,0 +1,95 @@ +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { SearchFilterConfig } from '../../../../search-service/search-filter-config.model'; +import { FilterType } from '../../../../search-service/filter-type.model'; +import { FormsModule } from '@angular/forms'; +import { of as observableOf } from 'rxjs'; +import { SearchService } from '../../../../search-service/search.service'; +import { SearchServiceStub } from '../../../../../shared/testing/search-service-stub'; +import { Router } from '@angular/router'; +import { RouterStub } from '../../../../../shared/testing/router-stub'; +import { SearchConfigurationService } from '../../../../search-service/search-configuration.service'; +import { SearchFilterService } from '../../search-filter.service'; +import { SearchFacetSelectedOptionComponent } from './search-facet-selected-option.component'; + +describe('SearchFacetSelectedOptionComponent', () => { + let comp: SearchFacetSelectedOptionComponent; + let fixture: ComponentFixture; + const filterName1 = 'test name'; + const value1 = 'testvalue1'; + const value2 = 'test2'; + const mockFilterConfig = Object.assign(new SearchFilterConfig(), { + name: filterName1, + type: FilterType.range, + hasFacets: false, + isOpenByDefault: false, + pageSize: 2, + minValue: 200, + maxValue: 3000, + }); + + const searchLink = '/search'; + const selectedValues = [value1, value2]; + const selectedValues$ = observableOf(selectedValues); + let filterService; + let searchService; + let router; + const page = observableOf(0); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), NoopAnimationsModule, FormsModule], + declarations: [SearchFacetSelectedOptionComponent], + providers: [ + { provide: SearchService, useValue: new SearchServiceStub(searchLink) }, + { provide: Router, useValue: new RouterStub() }, + { + provide: SearchConfigurationService, useValue: { + searchOptions: observableOf({}) + } + }, + { + provide: SearchFilterService, useValue: { + getSelectedValuesForFilter: () => selectedValues, + isFilterActiveWithValue: (paramName: string, filterValue: string) => observableOf(true), + getPage: (paramName: string) => page, + /* tslint:disable:no-empty */ + incrementPage: (filterName: string) => { + }, + resetPage: (filterName: string) => { + } + /* tslint:enable:no-empty */ + } + } + ], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(SearchFacetSelectedOptionComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(SearchFacetSelectedOptionComponent); + comp = fixture.componentInstance; // SearchFacetSelectedOptionComponent test instance + filterService = (comp as any).filterService; + searchService = (comp as any).searchService; + router = (comp as any).router; + comp.selectedValue = value2; + comp.selectedValues$ = selectedValues$; + comp.filterConfig = mockFilterConfig; + fixture.detectChanges(); + }); + + describe('when the updateRemoveParams method is called wih a value', () => { + it('should update the removeQueryParams with the new parameter values', () => { + comp.removeQueryParams = {}; + (comp as any).updateRemoveParams(selectedValues); + expect(comp.removeQueryParams).toEqual({ + [mockFilterConfig.paramName]: [value1], + page: 1 + }); + }); + }); +}); diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.ts b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.ts new file mode 100644 index 0000000000..5137bf8ffc --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.ts @@ -0,0 +1,87 @@ +import { combineLatest as observableCombineLatest, Observable, Subscription } from 'rxjs'; +import { Component, Input, OnDestroy, OnInit } from '@angular/core'; +import { Router } from '@angular/router'; +import { SearchFilterConfig } from '../../../../search-service/search-filter-config.model'; +import { SearchService } from '../../../../search-service/search.service'; +import { SearchFilterService } from '../../search-filter.service'; +import { hasValue } from '../../../../../shared/empty.util'; +import { SearchConfigurationService } from '../../../../search-service/search-configuration.service'; + +@Component({ + selector: 'ds-search-facet-selected-option', + templateUrl: './search-facet-selected-option.component.html', +}) + +/** + * Represents a single selected option in a filter facet + */ +export class SearchFacetSelectedOptionComponent implements OnInit, OnDestroy { + /** + * The value for this component + */ + @Input() selectedValue: string; + + /** + * The filter configuration for this facet option + */ + @Input() filterConfig: SearchFilterConfig; + + /** + * Emits the active values for this filter + */ + @Input() selectedValues$: Observable; + + /** + * UI parameters when this filter is removed + */ + removeQueryParams; + + /** + * Subscription to unsubscribe from on destroy + */ + sub: Subscription; + + constructor(protected searchService: SearchService, + protected filterService: SearchFilterService, + protected searchConfigService: SearchConfigurationService, + protected router: Router + ) { + } + + /** + * Initializes all observable instance variables and starts listening to them + */ + ngOnInit(): void { + this.sub = observableCombineLatest(this.selectedValues$, this.searchConfigService.searchOptions) + .subscribe(([selectedValues, searchOptions]) => { + this.updateRemoveParams(selectedValues) + }); + } + + /** + * @returns {string} The base path to the search page + */ + getSearchLink() { + return this.searchService.getSearchLink(); + } + + /** + * Calculates the parameters that should change if a given value for this filter would be removed from the active filters + * @param {string[]} selectedValues The values that are currently selected for this filter + */ + private updateRemoveParams(selectedValues: string[]): void { + this.removeQueryParams = { + [this.filterConfig.paramName]: selectedValues.filter((v) => v !== this.selectedValue), + page: 1 + }; + } + + /** + * Make sure the subscription is unsubscribed from when this component is destroyed + */ + ngOnDestroy(): void { + if (hasValue(this.sub)) { + this.sub.unsubscribe(); + } + } +} diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter-wrapper/search-facet-filter-wrapper.component.html b/src/app/+search-page/search-filters/search-filter/search-facet-filter-wrapper/search-facet-filter-wrapper.component.html index b7e03af473..4a325d9b3c 100644 --- a/src/app/+search-page/search-filters/search-filter/search-facet-filter-wrapper/search-facet-filter-wrapper.component.html +++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter-wrapper/search-facet-filter-wrapper.component.html @@ -1 +1 @@ - + \ No newline at end of file diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter-wrapper/search-facet-filter-wrapper.component.ts b/src/app/+search-page/search-filters/search-filter/search-facet-filter-wrapper/search-facet-filter-wrapper.component.ts index bc088777fa..6369a7691e 100644 --- a/src/app/+search-page/search-filters/search-filter/search-facet-filter-wrapper/search-facet-filter-wrapper.component.ts +++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter-wrapper/search-facet-filter-wrapper.component.ts @@ -3,6 +3,8 @@ import { renderFilterType } from '../search-filter-type-decorator'; import { FilterType } from '../../../search-service/filter-type.model'; import { SearchFilterConfig } from '../../../search-service/search-filter-config.model'; import { FILTER_CONFIG } from '../search-filter.service'; +import { GenericConstructor } from '../../../../core/shared/generic-constructor'; +import { SearchFacetFilterComponent } from '../search-facet-filter/search-facet-filter.component'; @Component({ selector: 'ds-search-facet-filter-wrapper', @@ -18,6 +20,10 @@ export class SearchFacetFilterWrapperComponent implements OnInit { */ @Input() filterConfig: SearchFilterConfig; + /** + * The constructor of the search facet filter that should be rendered, based on the filter config's type + */ + searchFilter: GenericConstructor; /** * Injector to inject a child component with the @Input parameters */ @@ -30,6 +36,7 @@ export class SearchFacetFilterWrapperComponent implements OnInit { * Initialize and add the filter config to the injector */ ngOnInit(): void { + this.searchFilter = this.getSearchFilter(); this.objectInjector = Injector.create({ providers: [ { provide: FILTER_CONFIG, useFactory: () => (this.filterConfig), deps: [] } @@ -41,7 +48,7 @@ export class SearchFacetFilterWrapperComponent implements OnInit { /** * Find the correct component based on the filter config's type */ - getSearchFilter() { + private getSearchFilter() { const type: FilterType = this.filterConfig.type; return renderFilterType(type); } diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.spec.ts b/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.spec.ts index 498c41dd6c..cb3d4730b4 100644 --- a/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.spec.ts +++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.spec.ts @@ -120,20 +120,6 @@ describe('SearchFacetFilterComponent', () => { }); }); - describe('when the getAddParams method is called wih a value', () => { - it('should return the selectedValue list with the new parameter value', () => { - const result = comp.getAddParams(value3); - result.subscribe((r) => expect(r[mockFilterConfig.paramName]).toEqual([value1, value2, value3])); - }); - }); - - describe('when the getRemoveParams method is called wih a value', () => { - it('should return the selectedValue list with the parameter value left out', () => { - const result = comp.getRemoveParams(value1); - result.subscribe((r) => expect(r[mockFilterConfig.paramName]).toEqual([value2])); - }); - }); - describe('when the showMore method is called', () => { beforeEach(() => { spyOn(filterService, 'incrementPage'); diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.ts b/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.ts index 1675dd051a..367947a377 100644 --- a/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.ts +++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.ts @@ -22,6 +22,7 @@ import { FILTER_CONFIG, SearchFilterService } from '../search-filter.service'; import { SearchConfigurationService } from '../../../search-service/search-configuration.service'; import { getSucceededRemoteData } from '../../../../core/shared/operators'; import { InputSuggestion } from '../../../../shared/input-suggestions/input-suggestions.model'; +import { SearchOptions } from '../../../search-options.model'; @Component({ selector: 'ds-search-facet-filter', @@ -65,7 +66,7 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { /** * Emits the active values for this filter */ - selectedValues: Observable; + selectedValues$: Observable; private collapseNextUpdate = true; /** @@ -73,6 +74,11 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { */ animationState = 'loading'; + /** + * Emits all current search options available in the search URL + */ + searchOptions$: Observable; + constructor(protected searchService: SearchService, protected filterService: SearchFilterService, protected searchConfigService: SearchConfigurationService, @@ -87,10 +93,11 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { ngOnInit(): void { this.filterValues$ = new BehaviorSubject(new RemoteData(true, false, undefined, undefined, undefined)); this.currentPage = this.getCurrentPage().pipe(distinctUntilChanged()); - this.selectedValues = this.filterService.getSelectedValuesForFilter(this.filterConfig); - const searchOptions = this.searchConfigService.searchOptions; - this.subs.push(this.searchConfigService.searchOptions.subscribe(() => this.updateFilterValueList())); - const facetValues = observableCombineLatest(searchOptions, this.currentPage).pipe( + + this.selectedValues$ = this.filterService.getSelectedValuesForFilter(this.filterConfig); + this.searchOptions$ = this.searchConfigService.searchOptions; + this.subs.push(this.searchOptions$.subscribe(() => this.updateFilterValueList())); + const facetValues = observableCombineLatest(this.searchOptions$, this.currentPage).pipe( map(([options, page]) => { return { options, page } }), @@ -190,7 +197,7 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { * @param data The string from the input field */ onSubmit(data: any) { - this.selectedValues.pipe(take(1)).subscribe((selectedValues) => { + this.selectedValues$.pipe(take(1)).subscribe((selectedValues) => { if (isNotEmpty(data)) { this.router.navigate([this.getSearchLink()], { queryParams: @@ -204,6 +211,10 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { ) } + /** + * On click, set the input's value to the clicked data + * @param data The value of the option that was clicked + */ onClick(data: any) { this.filter = data; } @@ -215,34 +226,6 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { return hasValue(o); } - /** - * Calculates the parameters that should change if a given value for this filter would be removed from the active filters - * @param {string} value The value that is removed for this filter - * @returns {Observable} The changed filter parameters - */ - getRemoveParams(value: string): Observable { - return this.selectedValues.pipe(map((selectedValues) => { - return { - [this.filterConfig.paramName]: selectedValues.filter((v) => v !== value), - page: 1 - }; - })); - } - - /** - * Calculates the parameters that should change if a given value for this filter would be added to the active filters - * @param {string} value The value that is added for this filter - * @returns {Observable} The changed filter parameters - */ - getAddParams(value: string): Observable { - return this.selectedValues.pipe(map((selectedValues) => { - return { - [this.filterConfig.paramName]: [...selectedValues, value], - page: 1 - }; - })); - } - /** * Unsubscribe from all subscriptions */ @@ -259,7 +242,7 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { */ findSuggestions(data): void { if (isNotEmpty(data)) { - this.searchConfigService.searchOptions.pipe(take(1)).subscribe( + this.searchOptions$.pipe(take(1)).subscribe( (options) => { this.filterSearchResults = this.searchService.getFacetValuesFor(this.filterConfig, 1, options, data.toLowerCase()) .pipe( @@ -290,6 +273,13 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { getDisplayValue(facet: FacetValue, query: string): string { return new EmphasizePipe().transform(facet.value, query) + ' (' + facet.count + ')'; } + + /** + * Prevent unnecessary rerendering + */ + trackUpdate(index, value: FacetValue) { + return value ? value.search : undefined; + } } export const facetLoad = trigger('facetLoad', [ diff --git a/src/app/+search-page/search-filters/search-filter/search-filter.actions.ts b/src/app/+search-page/search-filters/search-filter/search-filter.actions.ts index 2e556b32d6..f7f80eefff 100644 --- a/src/app/+search-page/search-filters/search-filter/search-filter.actions.ts +++ b/src/app/+search-page/search-filters/search-filter/search-filter.actions.ts @@ -1,6 +1,7 @@ import { Action } from '@ngrx/store'; import { type } from '../../../shared/ngrx/type'; +import { SearchFilterConfig } from '../../search-service/search-filter-config.model'; /** * For each action type in an action group, make a simple @@ -12,9 +13,8 @@ import { type } from '../../../shared/ngrx/type'; */ export const SearchFilterActionTypes = { COLLAPSE: type('dspace/search-filter/COLLAPSE'), - INITIAL_COLLAPSE: type('dspace/search-filter/INITIAL_COLLAPSE'), + INITIALIZE: type('dspace/search-filter/INITIALIZE'), EXPAND: type('dspace/search-filter/EXPAND'), - INITIAL_EXPAND: type('dspace/search-filter/INITIAL_EXPAND'), TOGGLE: type('dspace/search-filter/TOGGLE'), DECREMENT_PAGE: type('dspace/search-filter/DECREMENT_PAGE'), INCREMENT_PAGE: type('dspace/search-filter/INCREMENT_PAGE'), @@ -64,17 +64,15 @@ export class SearchFilterToggleAction extends SearchFilterAction { } /** - * Used to set the initial state of a filter to collapsed + * Used to set the initial state of a filter */ -export class SearchFilterInitialCollapseAction extends SearchFilterAction { - type = SearchFilterActionTypes.INITIAL_COLLAPSE; -} - -/** - * Used to set the initial state of a filter to expanded - */ -export class SearchFilterInitialExpandAction extends SearchFilterAction { - type = SearchFilterActionTypes.INITIAL_EXPAND; +export class SearchFilterInitializeAction extends SearchFilterAction { + type = SearchFilterActionTypes.INITIALIZE; + initiallyExpanded; + constructor(filter: SearchFilterConfig) { + super(filter.name); + this.initiallyExpanded = filter.isOpenByDefault; + } } /** diff --git a/src/app/+search-page/search-filters/search-filter/search-filter.component.html b/src/app/+search-page/search-filters/search-filter/search-filter.component.html index 1013bf7e28..5c4db44d24 100644 --- a/src/app/+search-page/search-filters/search-filter/search-filter.component.html +++ b/src/app/+search-page/search-filters/search-filter/search-filter.component.html @@ -1,7 +1,7 @@ -
+
{{'search.filters.filter.' + filter.name + '.head'| translate}}
-
+ [ngClass]="(collapsed$ | async) ? 'fa-plus' : 'fa-minus'">
+
\ No newline at end of file diff --git a/src/app/+search-page/search-filters/search-filter/search-filter.component.scss b/src/app/+search-page/search-filters/search-filter/search-filter.component.scss index 4e45f49468..1db5e9a1b2 100644 --- a/src/app/+search-page/search-filters/search-filter/search-filter.component.scss +++ b/src/app/+search-page/search-filters/search-filter/search-filter.component.scss @@ -1,7 +1,7 @@ @import '../../../../styles/variables.scss'; @import '../../../../styles/mixins.scss'; -:host { +:host .facet-filter { border: 1px solid map-get($theme-colors, light); cursor: pointer; .search-filter-wrapper.closed { diff --git a/src/app/+search-page/search-filters/search-filter/search-filter.component.spec.ts b/src/app/+search-page/search-filters/search-filter/search-filter.component.spec.ts index caa5a6febc..30ef349675 100644 --- a/src/app/+search-page/search-filters/search-filter/search-filter.component.spec.ts +++ b/src/app/+search-page/search-filters/search-filter/search-filter.component.spec.ts @@ -10,6 +10,7 @@ import { SearchService } from '../../search-service/search.service'; import { SearchFilterComponent } from './search-filter.component'; import { SearchFilterConfig } from '../../search-service/search-filter-config.model'; import { FilterType } from '../../search-service/filter-type.model'; +import { SearchConfigurationService } from '../../search-service/search-configuration.service'; describe('SearchFilterComponent', () => { let comp: SearchFilterComponent; @@ -33,9 +34,7 @@ describe('SearchFilterComponent', () => { }, expand: (filter) => { }, - initialCollapse: (filter) => { - }, - initialExpand: (filter) => { + initializeFilter: (filter) => { }, getSelectedValuesForFilter: (filter) => { return observableOf([filterName1, filterName2, filterName3]) @@ -55,6 +54,8 @@ describe('SearchFilterComponent', () => { getFacetValuesFor: (filter) => mockResults }; + const searchConfigServiceStub = {}; + beforeEach(async(() => { TestBed.configureTestingModule({ imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NoopAnimationsModule], @@ -65,6 +66,7 @@ describe('SearchFilterComponent', () => { provide: SearchFilterService, useValue: mockFilterService }, + { provide: SearchConfigurationService, useValue: searchConfigServiceStub }, ], schemas: [NO_ERRORS_SCHEMA] }).overrideComponent(SearchFilterComponent, { @@ -91,32 +93,21 @@ describe('SearchFilterComponent', () => { }); }); - describe('when the initialCollapse method is triggered', () => { + describe('when the initializeFilter method is triggered', () => { beforeEach(() => { - spyOn(filterService, 'initialCollapse'); - comp.initialCollapse(); + spyOn(filterService, 'initializeFilter'); + comp.initializeFilter(); }); it('should call initialCollapse with the correct filter configuration name', () => { - expect(filterService.initialCollapse).toHaveBeenCalledWith(mockFilterConfig.name) - }); - }); - - describe('when the initialExpand method is triggered', () => { - beforeEach(() => { - spyOn(filterService, 'initialExpand'); - comp.initialExpand(); - }); - - it('should call initialCollapse with the correct filter configuration name', () => { - expect(filterService.initialExpand).toHaveBeenCalledWith(mockFilterConfig.name) + expect(filterService.initializeFilter).toHaveBeenCalledWith(mockFilterConfig) }); }); describe('when getSelectedValues is called', () => { let valuesObservable: Observable; beforeEach(() => { - valuesObservable = comp.getSelectedValues(); + valuesObservable = (comp as any).getSelectedValues(); }); it('should return an observable containing the existing filters', () => { @@ -141,7 +132,7 @@ describe('SearchFilterComponent', () => { let isActive: Observable; beforeEach(() => { filterService.isCollapsed = () => observableOf(true); - isActive = comp.isCollapsed(); + isActive = (comp as any).isCollapsed(); }); it('should return an observable containing true', () => { @@ -156,7 +147,7 @@ describe('SearchFilterComponent', () => { let isActive: Observable; beforeEach(() => { filterService.isCollapsed = () => observableOf(false); - isActive = comp.isCollapsed(); + isActive = (comp as any).isCollapsed(); }); it('should return an observable containing false', () => { diff --git a/src/app/+search-page/search-filters/search-filter/search-filter.component.ts b/src/app/+search-page/search-filters/search-filter/search-filter.component.ts index dcc01f2b46..14ba8f0b76 100644 --- a/src/app/+search-page/search-filters/search-filter/search-filter.component.ts +++ b/src/app/+search-page/search-filters/search-filter/search-filter.component.ts @@ -1,11 +1,12 @@ - -import { take } from 'rxjs/operators'; +import { filter, first, map, startWith, switchMap, take } from 'rxjs/operators'; import { Component, Input, OnInit } from '@angular/core'; import { SearchFilterConfig } from '../../search-service/search-filter-config.model'; import { SearchFilterService } from './search-filter.service'; -import { Observable } from 'rxjs'; +import { BehaviorSubject, Observable, of as observableOf } from 'rxjs'; import { slide } from '../../../shared/animations/slide'; import { isNotEmpty } from '../../../shared/empty.util'; +import { SearchService } from '../../search-service/search.service'; +import { SearchConfigurationService } from '../../search-service/search-configuration.service'; @Component({ selector: 'ds-search-filter', @@ -26,9 +27,24 @@ export class SearchFilterComponent implements OnInit { /** * True when the filter is 100% collapsed in the UI */ - collapsed; + closed = true; - constructor(private filterService: SearchFilterService) { + /** + * Emits true when the filter is currently collapsed in the store + */ + collapsed$: Observable; + + /** + * Emits all currently selected values for this filter + */ + selectedValues$: Observable; + + /** + * Emits true when the current filter is supposed to be shown + */ + active$: Observable; + + constructor(private filterService: SearchFilterService, private searchService: SearchService, private searchConfigService: SearchConfigurationService) { } /** @@ -37,11 +53,13 @@ export class SearchFilterComponent implements OnInit { * Else, the filter should initially be collapsed */ ngOnInit() { - this.getSelectedValues().pipe(take(1)).subscribe((isActive) => { - if (this.filter.isOpenByDefault || isNotEmpty(isActive)) { - this.initialExpand(); - } else { - this.initialCollapse(); + this.selectedValues$ = this.getSelectedValues(); + this.active$ = this.isActive(); + this.collapsed$ = this.isCollapsed(); + this.initializeFilter(); + this.selectedValues$.pipe(take(1)).subscribe((selectedValues) => { + if (isNotEmpty(selectedValues)) { + this.filterService.expand(this.filter.name); } }); } @@ -57,30 +75,21 @@ export class SearchFilterComponent implements OnInit { * Checks if the filter is currently collapsed * @returns {Observable} Emits true when the current state of the filter is collapsed, false when it's expanded */ - isCollapsed(): Observable { + private isCollapsed(): Observable { return this.filterService.isCollapsed(this.filter.name); } /** - * Changes the initial state to collapsed + * Sets the initial state of the filter */ - initialCollapse() { - this.filterService.initialCollapse(this.filter.name); - this.collapsed = true; - } - - /** - * Changes the initial state to expanded - */ - initialExpand() { - this.filterService.initialExpand(this.filter.name); - this.collapsed = false; + initializeFilter() { + this.filterService.initializeFilter(this.filter); } /** * @returns {Observable} Emits a list of all values that are currently active for this filter */ - getSelectedValues(): Observable { + private getSelectedValues(): Observable { return this.filterService.getSelectedValuesForFilter(this.filter); } @@ -90,7 +99,7 @@ export class SearchFilterComponent implements OnInit { */ finishSlide(event: any): void { if (event.fromState === 'collapsed') { - this.collapsed = false; + this.closed = false; } } @@ -100,7 +109,31 @@ export class SearchFilterComponent implements OnInit { */ startSlide(event: any): void { if (event.toState === 'collapsed') { - this.collapsed = true; + this.closed = true; } } + + /** + * Check if a given filter is supposed to be shown or not + * @returns {Observable} Emits true whenever a given filter config should be shown + */ + private isActive(): Observable { + return this.selectedValues$.pipe( + switchMap((isActive) => { + if (isNotEmpty(isActive)) { + return observableOf(true); + } else { + return this.searchConfigService.searchOptions.pipe( + switchMap((options) => { + return this.searchService.getFacetValuesFor(this.filter, 1, options).pipe( + filter((RD) => !RD.isLoading), + map((valuesRD) => { + return valuesRD.payload.totalElements > 0 + }),) + } + )) + } + }), + startWith(true)); + } } diff --git a/src/app/+search-page/search-filters/search-filter/search-filter.reducer.spec.ts b/src/app/+search-page/search-filters/search-filter/search-filter.reducer.spec.ts index 8fbfbf2e65..2f3268fba5 100644 --- a/src/app/+search-page/search-filters/search-filter/search-filter.reducer.spec.ts +++ b/src/app/+search-page/search-filters/search-filter/search-filter.reducer.spec.ts @@ -1,10 +1,8 @@ import * as deepFreeze from 'deep-freeze'; import { SearchFilterCollapseAction, SearchFilterExpandAction, SearchFilterIncrementPageAction, - SearchFilterInitialCollapseAction, - SearchFilterInitialExpandAction, SearchFilterToggleAction, - SearchFilterDecrementPageAction, SearchFilterResetPageAction + SearchFilterDecrementPageAction, SearchFilterResetPageAction, SearchFilterInitializeAction } from './search-filter.actions'; import { filterReducer } from './search-filter.reducer'; @@ -98,35 +96,39 @@ describe('filterReducer', () => { filterReducer(state, action); }); - it('should set filterCollapsed to true in response to the INITIAL_COLLAPSE action when no state has been set for this filter', () => { + it('should set filterCollapsed to true in response to the INITIALIZE action with isOpenByDefault to false when no state has been set for this filter', () => { const state = {}; state[filterName2] = { filterCollapsed: false, page: 1 }; - const action = new SearchFilterInitialCollapseAction(filterName1); + const filterConfig = {isOpenByDefault: false, name: filterName1} as any; + const action = new SearchFilterInitializeAction(filterConfig); const newState = filterReducer(state, action); expect(newState[filterName1].filterCollapsed).toEqual(true); }); - it('should set filterCollapsed to true in response to the INITIAL_EXPAND action when no state has been set for this filter', () => { + it('should set filterCollapsed to false in response to the INITIALIZE action with isOpenByDefault to true when no state has been set for this filter', () => { const state = {}; state[filterName2] = { filterCollapsed: true, page: 1 }; - const action = new SearchFilterInitialExpandAction(filterName1); + const filterConfig = {isOpenByDefault: true, name: filterName1} as any; + const action = new SearchFilterInitializeAction(filterConfig); const newState = filterReducer(state, action); expect(newState[filterName1].filterCollapsed).toEqual(false); }); - it('should not change the state in response to the INITIAL_COLLAPSE action when the state has already been set for this filter', () => { + it('should not change the state in response to the INITIALIZE action with isOpenByDefault to false when the state has already been set for this filter', () => { const state = {}; state[filterName1] = { filterCollapsed: false, page: 1 }; - const action = new SearchFilterInitialCollapseAction(filterName1); + const filterConfig = { isOpenByDefault: true, name: filterName1 } as any; + const action = new SearchFilterInitializeAction(filterConfig); const newState = filterReducer(state, action); expect(newState).toEqual(state); }); - it('should not change the state in response to the INITIAL_EXPAND action when the state has already been set for this filter', () => { + it('should not change the state in response to the INITIALIZE action with isOpenByDefault to true when the state has already been set for this filter', () => { const state = {}; state[filterName1] = { filterCollapsed: true, page: 1 }; - const action = new SearchFilterInitialExpandAction(filterName1); + const filterConfig = { isOpenByDefault: false, name: filterName1 } as any; + const action = new SearchFilterInitializeAction(filterConfig); const newState = filterReducer(state, action); expect(newState).toEqual(state); }); diff --git a/src/app/+search-page/search-filters/search-filter/search-filter.reducer.ts b/src/app/+search-page/search-filters/search-filter/search-filter.reducer.ts index f7e064fcc7..187bcd50d0 100644 --- a/src/app/+search-page/search-filters/search-filter/search-filter.reducer.ts +++ b/src/app/+search-page/search-filters/search-filter/search-filter.reducer.ts @@ -1,5 +1,9 @@ -import { SearchFilterAction, SearchFilterActionTypes } from './search-filter.actions'; -import { isEmpty } from '../../../shared/empty.util'; +import { + SearchFilterAction, + SearchFilterActionTypes, + SearchFilterInitializeAction +} from './search-filter.actions'; +import { isEmpty, isNotUndefined } from '../../../shared/empty.util'; /** * Interface that represents the state for a single filters @@ -28,27 +32,14 @@ export function filterReducer(state = initialState, action: SearchFilterAction): switch (action.type) { - case SearchFilterActionTypes.INITIAL_COLLAPSE: { - if (isEmpty(state) || isEmpty(state[action.filterName])) { - return Object.assign({}, state, { - [action.filterName]: { - filterCollapsed: true, - page: 1 - } - }); - } - return state; - } - - case SearchFilterActionTypes.INITIAL_EXPAND: { - if (isEmpty(state) || isEmpty(state[action.filterName])) { - return Object.assign({}, state, { - [action.filterName]: { - filterCollapsed: false, - page: 1 - } - }); - } + case SearchFilterActionTypes.INITIALIZE: { + const initAction = (action as SearchFilterInitializeAction); + return Object.assign({}, state, { + [action.filterName]: { + filterCollapsed: !initAction.initiallyExpanded, + page: 1 + } + }); return state; } diff --git a/src/app/+search-page/search-filters/search-filter/search-filter.service.spec.ts b/src/app/+search-page/search-filters/search-filter/search-filter.service.spec.ts index 92739dbf2c..5d45e8155e 100644 --- a/src/app/+search-page/search-filters/search-filter/search-filter.service.spec.ts +++ b/src/app/+search-page/search-filters/search-filter/search-filter.service.spec.ts @@ -5,8 +5,7 @@ import { SearchFilterDecrementPageAction, SearchFilterExpandAction, SearchFilterIncrementPageAction, - SearchFilterInitialCollapseAction, - SearchFilterInitialExpandAction, + SearchFilterInitializeAction, SearchFilterResetPageAction, SearchFilterToggleAction } from './search-filter.actions'; @@ -74,23 +73,13 @@ describe('SearchFilterService', () => { service = new SearchFilterService(store, routeServiceStub, mockFixedFilterService); }); - describe('when the initialCollapse method is triggered', () => { + describe('when the initializeFilter method is triggered', () => { beforeEach(() => { - service.initialCollapse(mockFilterConfig.name); + service.initializeFilter(mockFilterConfig); }); - it('SearchFilterInitialCollapseAction should be dispatched to the store', () => { - expect(store.dispatch).toHaveBeenCalledWith(new SearchFilterInitialCollapseAction(mockFilterConfig.name)); - }); - }); - - describe('when the initialExpand method is triggered', () => { - beforeEach(() => { - service.initialExpand(mockFilterConfig.name); - }); - - it('SearchFilterInitialExpandAction should be dispatched to the store', () => { - expect(store.dispatch).toHaveBeenCalledWith(new SearchFilterInitialExpandAction(mockFilterConfig.name)); + it('SearchFilterInitializeAction should be dispatched to the store', () => { + expect(store.dispatch).toHaveBeenCalledWith(new SearchFilterInitializeAction(mockFilterConfig)); }); }); diff --git a/src/app/+search-page/search-filters/search-filter/search-filter.service.ts b/src/app/+search-page/search-filters/search-filter/search-filter.service.ts index e0c189e26f..19617bb5aa 100644 --- a/src/app/+search-page/search-filters/search-filter/search-filter.service.ts +++ b/src/app/+search-page/search-filters/search-filter/search-filter.service.ts @@ -8,8 +8,7 @@ import { SearchFilterDecrementPageAction, SearchFilterExpandAction, SearchFilterIncrementPageAction, - SearchFilterInitialCollapseAction, - SearchFilterInitialExpandAction, + SearchFilterInitializeAction, SearchFilterResetPageAction, SearchFilterToggleAction } from './search-filter.actions'; @@ -22,7 +21,7 @@ import { SearchOptions } from '../../search-options.model'; import { PaginatedSearchOptions } from '../../paginated-search-options.model'; import { SearchFixedFilterService } from './search-fixed-filter.service'; import { Params } from '@angular/router'; - +// const spy = create(); const filterStateSelector = (state: SearchFiltersState) => state.searchFilter; export const FILTER_CONFIG: InjectionToken = new InjectionToken('filterConfig'); @@ -197,7 +196,7 @@ export class SearchFilterService { getSelectedValuesForFilter(filterConfig: SearchFilterConfig): Observable { const values$ = this.routeService.getQueryParameterValues(filterConfig.paramName); const prefixValues$ = this.routeService.getQueryParamsWithPrefix(filterConfig.paramName + '.').pipe( - map((params: Params) => [].concat(...Object.values(params))) + map((params: Params) => [].concat(...Object.values(params))), ); return observableCombineLatest(values$, prefixValues$).pipe( @@ -225,13 +224,14 @@ export class SearchFilterService { } else { return false; } - }) + }), + distinctUntilChanged() ); } /** * Request the current page of a given filter - * @param {string} filterName The filtername for which the page state is checked + * @param {string} filterName The filter name for which the page state is checked * @returns {Observable} Emits the current page state of the given filter, if it's unavailable, return 1 */ getPage(filterName: string): Observable { @@ -243,7 +243,8 @@ export class SearchFilterService { } else { return 1; } - })); + }), + distinctUntilChanged()); } /** @@ -271,19 +272,11 @@ export class SearchFilterService { } /** - * Dispatches an initial collapse action to the store for a given filter - * @param {string} filterName The filter for which the action is dispatched + * Dispatches an initialize action to the store for a given filter + * @param {SearchFilterConfig} filter The filter for which the action is dispatched */ - public initialCollapse(filterName: string): void { - this.store.dispatch(new SearchFilterInitialCollapseAction(filterName)); - } - - /** - * Dispatches an initial expand action to the store for a given filter - * @param {string} filterName The filter for which the action is dispatched - */ - public initialExpand(filterName: string): void { - this.store.dispatch(new SearchFilterInitialExpandAction(filterName)); + public initializeFilter(filter: SearchFilterConfig): void { + this.store.dispatch(new SearchFilterInitializeAction(filter)); } /** diff --git a/src/app/+search-page/search-filters/search-filter/search-fixed-filter.service.spec.ts b/src/app/+search-page/search-filters/search-filter/search-fixed-filter.service.spec.ts index 2957b32c7f..3207345564 100644 --- a/src/app/+search-page/search-filters/search-filter/search-fixed-filter.service.spec.ts +++ b/src/app/+search-page/search-filters/search-filter/search-fixed-filter.service.spec.ts @@ -18,7 +18,7 @@ describe('SearchFixedFilterService', () => { /* tslint:enable:no-empty */ generateRequestId: () => 'fake-id', getByUUID: () => observableOf(Object.assign(new RequestEntry(), { - response: new FilteredDiscoveryQueryResponse(filterQuery, '200') + response: new FilteredDiscoveryQueryResponse(filterQuery, 200, 'OK') })) }) as RequestService; const halServiceStub = Object.assign(new HALEndpointService(requestServiceStub, undefined), { diff --git a/src/app/+search-page/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.html b/src/app/+search-page/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.html index 812f543716..b6ae0ada63 100644 --- a/src/app/+search-page/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.html +++ b/src/app/+search-page/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.html @@ -1,24 +1,9 @@
- - - {{value}} - +
diff --git a/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.html b/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.html index 352c1710c0..9d35cc518a 100644 --- a/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.html +++ b/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.html @@ -24,16 +24,7 @@
diff --git a/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.spec.ts b/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.spec.ts index 6f3450e18e..930ea8c9fb 100644 --- a/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.spec.ts +++ b/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.spec.ts @@ -106,16 +106,6 @@ describe('SearchRangeFilterComponent', () => { fixture.detectChanges(); }); - describe('when the getChangeParams method is called wih a value', () => { - it('should return the selectedValue list with the new parameter value', () => { - const result$ = comp.getChangeParams(value3); - result$.subscribe((result) => { - expect(result[mockFilterConfig.paramName + minSuffix]).toEqual(['1990']); - expect(result[mockFilterConfig.paramName + maxSuffix]).toEqual(['1992']); - }); - }); - }); - describe('when the onSubmit method is called with data', () => { const searchUrl = '/search/path'; // const data = { [mockFilterConfig.paramName + minSuffix]: '1900', [mockFilterConfig.paramName + maxSuffix]: '1950' }; diff --git a/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.ts b/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.ts index 6cb04c6c1f..ebdb797500 100644 --- a/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.ts +++ b/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.ts @@ -1,9 +1,4 @@ -import { - of as observableOf, - combineLatest as observableCombineLatest, - Observable, - Subscription -} from 'rxjs'; +import { combineLatest as observableCombineLatest, Subscription } from 'rxjs'; import { map, startWith } from 'rxjs/operators'; import { isPlatformBrowser } from '@angular/common'; import { Component, Inject, OnDestroy, OnInit, PLATFORM_ID } from '@angular/core'; @@ -23,16 +18,26 @@ import { RouteService } from '../../../../shared/services/route.service'; import { hasValue } from '../../../../shared/empty.util'; import { SearchConfigurationService } from '../../../search-service/search-configuration.service'; +/** + * The suffix for a range filters' minimum in the frontend URL + */ +export const RANGE_FILTER_MIN_SUFFIX = '.min'; + +/** + * The suffix for a range filters' maximum in the frontend URL + */ +export const RANGE_FILTER_MAX_SUFFIX = '.max'; + +/** + * The date formats that are possible to appear in a date filter + */ +const dateFormats = ['YYYY', 'YYYY-MM', 'YYYY-MM-DD']; + /** * This component renders a simple item page. * The route parameter 'id' is used to request the item it represents. * All fields of the item that should be displayed, are defined in its template. */ -const minSuffix = '.min'; -const maxSuffix = '.max'; -const dateFormats = ['YYYY', 'YYYY-MM', 'YYYY-MM-DD']; -const rangeDelimiter = '-'; - @Component({ selector: 'ds-search-range-filter', styleUrls: ['./search-range-filter.component.scss'], @@ -85,8 +90,8 @@ export class SearchRangeFilterComponent extends SearchFacetFilterComponent imple super.ngOnInit(); this.min = moment(this.filterConfig.minValue, dateFormats).year() || this.min; this.max = moment(this.filterConfig.maxValue, dateFormats).year() || this.max; - const iniMin = this.route.getQueryParameterValue(this.filterConfig.paramName + minSuffix).pipe(startWith(undefined)); - const iniMax = this.route.getQueryParameterValue(this.filterConfig.paramName + maxSuffix).pipe(startWith(undefined)); + const iniMin = this.route.getQueryParameterValue(this.filterConfig.paramName + RANGE_FILTER_MIN_SUFFIX).pipe(startWith(undefined)); + const iniMax = this.route.getQueryParameterValue(this.filterConfig.paramName + RANGE_FILTER_MAX_SUFFIX).pipe(startWith(undefined)); this.sub = observableCombineLatest(iniMin, iniMax).pipe( map(([min, max]) => { const minimum = hasValue(min) ? min : this.min; @@ -96,23 +101,6 @@ export class SearchRangeFilterComponent extends SearchFacetFilterComponent imple ).subscribe((minmax) => this.range = minmax); } - /** - * Calculates the parameters that should change if a given values for this range filter would be changed - * @param {string} value The values that are changed for this filter - * @returns {Observable} The changed filter parameters - */ - getChangeParams(value: string) { - const parts = value.split(rangeDelimiter); - const min = parts.length > 1 ? parts[0].trim() : value; - const max = parts.length > 1 ? parts[1].trim() : value; - return observableOf( - { - [this.filterConfig.paramName + minSuffix]: [min], - [this.filterConfig.paramName + maxSuffix]: [max], - page: 1 - }); - } - /** * Submits new custom range values to the range filter from the widget */ @@ -122,8 +110,8 @@ export class SearchRangeFilterComponent extends SearchFacetFilterComponent imple this.router.navigate([this.getSearchLink()], { queryParams: { - [this.filterConfig.paramName + minSuffix]: newMin, - [this.filterConfig.paramName + maxSuffix]: newMax + [this.filterConfig.paramName + RANGE_FILTER_MIN_SUFFIX]: newMin, + [this.filterConfig.paramName + RANGE_FILTER_MAX_SUFFIX]: newMax }, queryParamsHandling: 'merge' }); @@ -148,8 +136,4 @@ export class SearchRangeFilterComponent extends SearchFacetFilterComponent imple this.sub.unsubscribe(); } } - - out(call) { - console.log(call); - } } diff --git a/src/app/+search-page/search-filters/search-filter/search-text-filter/search-text-filter.component.html b/src/app/+search-page/search-filters/search-filter/search-text-filter/search-text-filter.component.html index fcc2393b93..25ff8e46d3 100644 --- a/src/app/+search-page/search-filters/search-filter/search-text-filter/search-text-filter.component.html +++ b/src/app/+search-page/search-filters/search-filter/search-text-filter/search-text-filter.component.html @@ -1,26 +1,9 @@
- - - {{value}} - - + +
@@ -40,6 +23,5 @@ (submitSuggestion)="onSubmit($event)" (clickSuggestion)="onClick($event)" (findSuggestions)="findSuggestions($event)" - ngDefaultControl - > + ngDefaultControl>
diff --git a/src/app/+search-page/search-filters/search-filters.component.html b/src/app/+search-page/search-filters/search-filters.component.html index 0522c1fba0..895765f6ac 100644 --- a/src/app/+search-page/search-filters/search-filters.component.html +++ b/src/app/+search-page/search-filters/search-filters.component.html @@ -1,7 +1,7 @@

{{"search.filters.head" | translate}}

-
- +
+
{{"search.filters.reset" | translate}} \ No newline at end of file diff --git a/src/app/+search-page/search-filters/search-filters.component.ts b/src/app/+search-page/search-filters/search-filters.component.ts index f16faff1f3..1dd747e908 100644 --- a/src/app/+search-page/search-filters/search-filters.component.ts +++ b/src/app/+search-page/search-filters/search-filters.component.ts @@ -1,12 +1,11 @@ -import { Observable, of as observableOf } from 'rxjs'; +import { Observable } from 'rxjs'; -import { filter, map, mergeMap, startWith, switchMap } from 'rxjs/operators'; +import { map } from 'rxjs/operators'; import { Component } from '@angular/core'; import { SearchService } from '../search-service/search.service'; import { RemoteData } from '../../core/data/remote-data'; import { SearchFilterConfig } from '../search-service/search-filter-config.model'; import { SearchConfigurationService } from '../search-service/search-configuration.service'; -import { isNotEmpty } from '../../shared/empty.util'; import { SearchFilterService } from './search-filter/search-filter.service'; import { getSucceededRemoteData } from '../../core/shared/operators'; @@ -53,26 +52,9 @@ export class SearchFiltersComponent { } /** - * Check if a given filter is supposed to be shown or not - * @param {SearchFilterConfig} filter The filter to check for - * @returns {Observable} Emits true whenever a given filter config should be shown + * Prevent unnecessary rerendering */ - isActive(filterConfig: SearchFilterConfig): Observable { - return this.filterService.getSelectedValuesForFilter(filterConfig).pipe( - mergeMap((isActive) => { - if (isNotEmpty(isActive)) { - return observableOf(true); - } else { - return this.searchConfigService.searchOptions.pipe( - switchMap((options) => { - return this.searchService.getFacetValuesFor(filterConfig, 1, options).pipe( - filter((RD) => !RD.isLoading), - map((valuesRD) => { - return valuesRD.payload.totalElements > 0 - }),) - } - )) - } - }),startWith(true),); + trackUpdate(index, config: SearchFilterConfig) { + return config ? config.name : undefined; } } diff --git a/src/app/+search-page/search-page.component.ts b/src/app/+search-page/search-page.component.ts index 8db94bc8d2..b94eb39f9b 100644 --- a/src/app/+search-page/search-page.component.ts +++ b/src/app/+search-page/search-page.component.ts @@ -76,7 +76,6 @@ export class SearchPageComponent implements OnInit { constructor(protected service: SearchService, protected sidebarService: SearchSidebarService, protected windowService: HostWindowService, - protected filterService: SearchFilterService, protected searchConfigService: SearchConfigurationService, protected routeService: RouteService) { this.isXsOrSm$ = this.windowService.isXsOrSm(); diff --git a/src/app/+search-page/search-page.module.ts b/src/app/+search-page/search-page.module.ts index d199bad724..2b2ee861da 100644 --- a/src/app/+search-page/search-page.module.ts +++ b/src/app/+search-page/search-page.module.ts @@ -30,6 +30,9 @@ import { SearchFacetFilterWrapperComponent } from './search-filters/search-filte import { SearchBooleanFilterComponent } from './search-filters/search-filter/search-boolean-filter/search-boolean-filter.component'; import { SearchHierarchyFilterComponent } from './search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component'; import { SearchConfigurationService } from './search-service/search-configuration.service'; +import { SearchFacetOptionComponent } from './search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component'; +import { SearchFacetSelectedOptionComponent } from './search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component'; +import { SearchFacetRangeOptionComponent } from './search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component'; const effects = [ SearchSidebarEffects @@ -62,6 +65,9 @@ const effects = [ SearchTextFilterComponent, SearchHierarchyFilterComponent, SearchBooleanFilterComponent, + SearchFacetOptionComponent, + SearchFacetSelectedOptionComponent, + SearchFacetRangeOptionComponent ], providers: [ SearchSidebarService, @@ -83,6 +89,9 @@ const effects = [ SearchTextFilterComponent, SearchHierarchyFilterComponent, SearchBooleanFilterComponent, + SearchFacetOptionComponent, + SearchFacetSelectedOptionComponent, + SearchFacetRangeOptionComponent ], exports: [ FilteredSearchPageComponent, diff --git a/src/app/+search-page/search-service/search-configuration.service.spec.ts b/src/app/+search-page/search-service/search-configuration.service.spec.ts index 68405dcf6b..88fd784f0b 100644 --- a/src/app/+search-page/search-service/search-configuration.service.spec.ts +++ b/src/app/+search-page/search-service/search-configuration.service.spec.ts @@ -121,7 +121,7 @@ describe('SearchConfigurationService', () => { describe('when subscribeToSearchOptions is called', () => { beforeEach(() => { - service.subscribeToSearchOptions(defaults) + (service as any).subscribeToSearchOptions(defaults) }); it('should call all getters it needs, but not call any others', () => { expect(service.getCurrentPagination).not.toHaveBeenCalled(); @@ -135,7 +135,7 @@ describe('SearchConfigurationService', () => { describe('when subscribeToPaginatedSearchOptions is called', () => { beforeEach(() => { - service.subscribeToPaginatedSearchOptions(defaults); + (service as any).subscribeToPaginatedSearchOptions(defaults); }); it('should call all getters it needs', () => { expect(service.getCurrentPagination).toHaveBeenCalled(); diff --git a/src/app/+search-page/search-service/search-configuration.service.ts b/src/app/+search-page/search-service/search-configuration.service.ts index d642944d18..4fbd5e81bc 100644 --- a/src/app/+search-page/search-service/search-configuration.service.ts +++ b/src/app/+search-page/search-service/search-configuration.service.ts @@ -197,7 +197,7 @@ export class SearchConfigurationService implements OnDestroy { * @param {SearchOptions} defaults Default values for when no parameters are available * @returns {Subscription} The subscription to unsubscribe from */ - subscribeToSearchOptions(defaults: SearchOptions): Subscription { + private subscribeToSearchOptions(defaults: SearchOptions): Subscription { return observableMerge( this.getScopePart(defaults.scope), this.getQueryPart(defaults.query), @@ -216,7 +216,7 @@ export class SearchConfigurationService implements OnDestroy { * @param {PaginatedSearchOptions} defaults Default values for when no parameters are available * @returns {Subscription} The subscription to unsubscribe from */ - subscribeToPaginatedSearchOptions(defaults: PaginatedSearchOptions): Subscription { + private subscribeToPaginatedSearchOptions(defaults: PaginatedSearchOptions): Subscription { return observableMerge( this.getPaginationPart(defaults.pagination), this.getSortPart(defaults.sort), 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 75708f5fd1..ee41d3d9aa 100644 --- a/src/app/+search-page/search-service/search.service.spec.ts +++ b/src/app/+search-page/search-service/search.service.spec.ts @@ -158,7 +158,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 */ @@ -183,7 +183,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 */ @@ -210,7 +210,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 0000000000..7a123bfc31 --- /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 0000000000..e43d9d36aa --- /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 0000000000..aa182eb291 --- /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 0000000000..fbb53d8dcc --- /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 0000000000..d10c53e138 --- /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 0000000000..65a40f3f7c --- /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 1448601067..be956ee895 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -29,6 +29,9 @@ export function getCommunityModulePath() { { 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 c90d35678d..fa7e7a873a 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 e079400e85..bd2d832c67 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 10c6643fbb..da01b1297a 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 8c4126f8ed..f9d6e50dcc 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 756bcb6f23..ea2512a974 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 ee9f2e571b..0b2c32fc04 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 61559991ec..3cb00789f6 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 0dc8abf860..8c2b4026e0 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 dd9e3fb5e7..da760b8faa 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 d39c0a4590..c461148eea 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 14b023e362..725b371c14 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 8d40741dc0..c0b359e7ea 100644 --- a/src/app/core/cache/builders/remote-data-build.service.ts +++ b/src/app/core/cache/builders/remote-data-build.service.ts @@ -1,25 +1,15 @@ -import { - combineLatest as observableCombineLatest, - Observable, - of as observableOf, - race as observableRace -} from 'rxjs'; import { Injectable } from '@angular/core'; -import { distinctUntilChanged, flatMap, map, startWith, switchMap, tap } from 'rxjs/operators'; -import { - hasValue, - hasValueOperator, - isEmpty, - isNotEmpty, - isNotUndefined -} from '../../../shared/empty.util'; + +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, 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'; @@ -99,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( @@ -232,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); @@ -260,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 4eb6e1027b..ddfcc29a2c 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 2248b62509..e12faa4a77 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 0000000000..02bd1808c8 --- /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 c7f6bcc36a..895595ba45 100644 --- a/src/app/core/cache/models/normalized-object-factory.ts +++ b/src/app/core/cache/models/normalized-object-factory.ts @@ -9,12 +9,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> { @@ -37,6 +43,9 @@ export class NormalizedObjectFactory { case ResourceType.BitstreamFormat: { return NormalizedBitstreamFormat } + case ResourceType.License: { + return NormalizedLicense + } case ResourceType.ResourcePolicy: { return NormalizedResourcePolicy } @@ -61,6 +70,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 de04572dcd..6ac8985d64 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 b6c8c1369a..9438c1da0a 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 0000000000..a33bbee5e6 --- /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/object-cache.service.ts b/src/app/core/cache/object-cache.service.ts index b4c37df736..483de65b98 100644 --- a/src/app/core/cache/object-cache.service.ts +++ b/src/app/core/cache/object-cache.service.ts @@ -1,34 +1,44 @@ +import { Injectable } from '@angular/core'; +import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store'; +import { applyPatch, Operation } from 'fast-json-patch'; import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; import { distinctUntilChanged, filter, map, mergeMap, take, } from 'rxjs/operators'; -import { Injectable } from '@angular/core'; -import { MemoizedSelector, select, Store } from '@ngrx/store'; -import { IndexName } from '../index/index.reducer'; - -import { CacheableObject, ObjectCacheEntry } from './object-cache.reducer'; +import { hasNoValue, hasValue, isNotEmpty } from '../../shared/empty.util'; +import { CoreState } from '../core.reducers'; +import { coreSelector } from '../core.selectors'; +import { RestRequestMethod } from '../data/rest-request-method'; +import { selfLinkFromUuidSelector } from '../index/index.selectors'; +import { GenericConstructor } from '../shared/generic-constructor'; +import { NormalizedObjectFactory } from './models/normalized-object-factory'; +import { NormalizedObject } from './models/normalized-object.model'; import { AddPatchObjectCacheAction, AddToObjectCacheAction, ApplyPatchObjectCacheAction, RemoveFromObjectCacheAction } from './object-cache.actions'; -import { hasNoValue, isNotEmpty } from '../../shared/empty.util'; -import { GenericConstructor } from '../shared/generic-constructor'; -import { coreSelector, CoreState } from '../core.reducers'; -import { pathSelector } from '../shared/selectors'; -import { NormalizedObjectFactory } from './models/normalized-object-factory'; -import { NormalizedObject } from './models/normalized-object.model'; -import { applyPatch, Operation } from 'fast-json-patch'; + +import { CacheableObject, ObjectCacheEntry, ObjectCacheState } from './object-cache.reducer'; import { AddToSSBAction } from './server-sync-buffer.actions'; -import { RestRequestMethod } from '../data/rest-request-method'; -function selfLinkFromUuidSelector(uuid: string): MemoizedSelector { - return pathSelector(coreSelector, 'index', IndexName.OBJECT, uuid); -} +/** + * The base selector function to select the object cache in the store + */ +const objectCacheSelector = createSelector( + coreSelector, + (state: CoreState) => state['cache/object'] +); -function entryFromSelfLinkSelector(selfLink: string): MemoizedSelector { - return pathSelector(coreSelector, 'cache/object', selfLink); -} +/** + * Selector function to select an object entry by self link from the cache + * @param selfLink The self link of the object + */ +const entryFromSelfLinkSelector = + (selfLink: string): MemoizedSelector => createSelector( + objectCacheSelector, + (state: ObjectCacheState) => state[selfLink], + ); /** * A service to interact with the object cache diff --git a/src/app/core/cache/response.models.ts b/src/app/core/cache/response.models.ts index f9bdfb8a39..edebfa253e 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,29 +203,65 @@ 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); } } export class FilteredDiscoveryQueryResponse extends RestResponse { constructor( public filterQuery: string, - public statusCode: string, + public statusCode: number, + public statusText: string, public pageInfo?: PageInfo ) { - super(true, statusCode); + super(true, statusCode, statusText); } } /* tslint:enable:max-classes-per-file */ 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 87d8804e9c..773e0ab60c 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/cache/server-sync-buffer.effects.ts b/src/app/core/cache/server-sync-buffer.effects.ts index 2e11f15540..3aa6ad312f 100644 --- a/src/app/core/cache/server-sync-buffer.effects.ts +++ b/src/app/core/cache/server-sync-buffer.effects.ts @@ -1,6 +1,7 @@ import { delay, exhaustMap, map, switchMap, take } from 'rxjs/operators'; import { Inject, Injectable } from '@angular/core'; import { Actions, Effect, ofType } from '@ngrx/effects'; +import { coreSelector } from '../core.selectors'; import { AddToSSBAction, CommitSSBAction, @@ -9,7 +10,7 @@ import { } from './server-sync-buffer.actions'; import { GLOBAL_CONFIG } from '../../../config'; import { GlobalConfig } from '../../../config/global-config.interface'; -import { coreSelector, CoreState } from '../core.reducers'; +import { CoreState } from '../core.reducers'; import { Action, createSelector, MemoizedSelector, select, Store } from '@ngrx/store'; import { ServerSyncBufferEntry, ServerSyncBufferState } from './server-sync-buffer.reducer'; import { combineLatest as observableCombineLatest, of as observableOf } from 'rxjs'; diff --git a/src/app/core/config/config-data.ts b/src/app/core/config/config-data.ts index efcdb7eed4..cb40514e45 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 a2c5cbbadc..7c69f1bdb3 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 50303d0a09..b81dc07624 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 44cfdee358..87add6b656 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 c6c2e2e7d2..340a7a97d6 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 0000000000..46bf1b60ce --- /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 0000000000..44b2e377c4 --- /dev/null +++ b/src/app/core/config/models/config-object-factory.ts @@ -0,0 +1,36 @@ +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'; + +/** + * Class to return normalized models for config objects + */ +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 0247f13944..8bbbc90056 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 98d3bf9ce7..ee0962f0e9 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 0000000000..377a8869e1 --- /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 0000000000..8bb9ba7f1e --- /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 17ed099229..91371f10f5 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 0000000000..81f20a0b3c --- /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 0000000000..3887c566c1 --- /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 0000000000..a957e8c7fa --- /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 0000000000..c876acf607 --- /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 0000000000..e49171d6a7 --- /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 0000000000..0b75158588 --- /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 0000000000..2e092fa4f3 --- /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 a23516aa45..bb25c49a7a 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 b8b13e5616..20ae0401cc 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,14 +64,18 @@ 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 { FilteredDiscoveryPageResponseParsingService } from './data/filtered-discovery-page-response-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'; @@ -99,7 +109,10 @@ const PROVIDERS = [ DynamicFormService, DynamicFormValidationService, FormBuilderService, + SectionFormOperationsService, FormService, + EpersonResponseParsingService, + GroupEpersonService, HALEndpointService, HostWindowService, ItemDataService, @@ -128,12 +141,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 e0ddb4a9de..c93b4bf44b 100644 --- a/src/app/core/core.reducers.ts +++ b/src/app/core/core.reducers.ts @@ -4,9 +4,10 @@ import { } from '@ngrx/store'; import { objectCacheReducer, ObjectCacheState } from './cache/object-cache.reducer'; -import { indexReducer, IndexState } from './index/index.reducer'; +import { indexReducer, MetaIndexState } 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, @@ -18,8 +19,9 @@ export interface CoreState { 'cache/syncbuffer': ServerSyncBufferState, 'cache/object-updates': ObjectUpdatesState 'data/request': RequestState, - 'index': IndexState, + 'index': MetaIndexState, 'auth': AuthState, + 'json/patch': JsonPatchOperationsState } export const coreReducers: ActionReducerMap = { @@ -29,6 +31,5 @@ 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/core.selectors.ts b/src/app/core/core.selectors.ts new file mode 100644 index 0000000000..60365be7c2 --- /dev/null +++ b/src/app/core/core.selectors.ts @@ -0,0 +1,7 @@ +import { createFeatureSelector } from '@ngrx/store'; +import { CoreState } from './core.reducers'; + +/** + * Base selector to select the core state from the store + */ +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 f7f904f790..71564883f2 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; @@ -93,8 +92,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}`); @@ -142,6 +140,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 ee706d202c..ef9a833765 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 d61df1f611..4690d738ed 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 f512a9af26..50b3be5de7 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 b1feb2ab7f..fb950f6c68 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 bedf5f03a7..c1b0566e0b 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 523fffd565..3c67b2b3eb 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 d9b722fb46..3d03b9397d 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 d93600a06a..7f628fc5b9 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 662b82d6ea..75ef58b06b 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 51474f6740..4a244db24f 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 72af52c4c8..fc4da69a5c 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; @@ -57,11 +53,57 @@ export abstract class DataService { public abstract getBrowseEndpoint(options: FindAllOptions, linkPath?: string): Observable + /** + * Create the HREF with given options object + * + * @param options The [[FindAllOptions]] object + * @param linkPath The link path for the object + * @return {Observable} + * Return an observable that emits created HREF + */ protected getFindAllHref(options: FindAllOptions = {}, linkPath?: string): Observable { let result: Observable; const args = []; - result = this.getBrowseEndpoint(options, linkPath); + result = this.getBrowseEndpoint(options, linkPath).pipe(distinctUntilChanged()); + + return this.buildHrefFromFindOptions(result, args, options); + } + + /** + * Create the HREF for a specific object's search method with given options object + * + * @param searchMethod The search method for the object + * @param options The [[FindAllOptions]] object + * @return {Observable} + * Return an observable that emits created HREF + */ + 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); + } + + /** + * Turn an options object into a query string and combine it with the given HREF + * + * @param href$ The HREF to which the query string should be appended + * @param args Array with additional params to combine with query string + * @param options The [[FindAllOptions]] object + * @return {Observable} + * Return an observable that emits created HREF + */ + 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 +118,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 +128,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 +154,50 @@ 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); } + /** + * Return object search endpoint by given search method + * + * @param searchMethod The search method for the object + */ + protected getSearchEndpoint(searchMethod: string): Observable { + return this.halService.getEndpoint(`${this.linkPath}/search`).pipe( + filter((href: string) => isNotEmpty(href)), + map((href: string) => `${href}/${searchMethod}`)); + } + + /** + * Make a new FindAllRequest with given search method + * + * @param searchMethod The search method for the object + * @param options The [[FindAllOptions]] object + * @return {Observable>} + * Return an observable that emits response from the server + */ + 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 3cb0b1e8ff..eb95cdae8a 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 7047db6065..a0bba214ae 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 bb02afbcd1..4f0653f416 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 a145477953..080c665ccf 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 02b12dfa10..e65e317642 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 2f580ee952..e03c1a78df 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 54f36a0564..e7665ebed2 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/filtered-discovery-page-response-parsing.service.ts b/src/app/core/data/filtered-discovery-page-response-parsing.service.ts index 45f7ae3069..166a915b16 100644 --- a/src/app/core/data/filtered-discovery-page-response-parsing.service.ts +++ b/src/app/core/data/filtered-discovery-page-response-parsing.service.ts @@ -30,6 +30,6 @@ export class FilteredDiscoveryPageResponseParsingService extends BaseResponsePar */ parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { const query = data.payload['discovery-query']; - return new FilteredDiscoveryQueryResponse(query, data.statusCode); + return new FilteredDiscoveryQueryResponse(query, data.statusCode, data.statusText); } } diff --git a/src/app/core/data/item-data.service.spec.ts b/src/app/core/data/item-data.service.spec.ts index 02c70791b5..3553a63af4 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 a2f6a1cc14..f6adbb23c2 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 05879d6fbb..1d2bf3b221 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 86a3c8a925..f9582c394d 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 78a5257456..f76d6ed2e3 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/object-updates/object-updates.service.ts b/src/app/core/data/object-updates/object-updates.service.ts index 85e17b5b2f..a13fb9487b 100644 --- a/src/app/core/data/object-updates/object-updates.service.ts +++ b/src/app/core/data/object-updates/object-updates.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@angular/core'; import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store'; -import { coreSelector, CoreState } from '../../core.reducers'; +import { CoreState } from '../../core.reducers'; +import { coreSelector } from '../../core.selectors'; import { FieldState, FieldUpdates, 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 2ee3bbf75e..899fee4d1e 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 93fd67b702..a4bed3240e 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 05a61f6b4f..d19b334131 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 a2ff27a073..9291bc5447 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 951dbacff6..d2cdd45a0a 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'; @@ -132,6 +135,7 @@ export class FindAllOptions { elementsPerPage?: number; currentPage?: number; sort?: SortOptions; + searchParams?: SearchParam[]; startsWith?: string; } @@ -182,8 +186,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 { @@ -273,6 +277,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); @@ -297,6 +372,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 5c35c0a398..65a4ddba17 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 b3d9436d30..e2bc04040f 100644 --- a/src/app/core/data/request.service.spec.ts +++ b/src/app/core/data/request.service.spec.ts @@ -1,13 +1,14 @@ +import * as ngrx from '@ngrx/store'; +import { ActionsSubject, Store } from '@ngrx/store'; import { cold, getTestScheduler, hot } from 'jasmine-marbles'; -import { of as observableOf, EMPTY } from 'rxjs'; +import { BehaviorSubject, EMPTY, of as observableOf } from 'rxjs'; + import { getMockObjectCacheService } from '../../shared/mocks/mock-object-cache.service'; import { defaultUUID, getMockUUIDService } from '../../shared/mocks/mock-uuid.service'; 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 { ActionsSubject, Store } from '@ngrx/store'; import { DeleteRequest, GetRequest, @@ -20,7 +21,6 @@ import { } from './request.models'; import { RequestService } from './request.service'; import { TestScheduler } from 'rxjs/testing'; -import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject'; describe('RequestService', () => { let scheduler: TestScheduler; @@ -172,9 +172,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 }); }); @@ -292,29 +289,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', () => { @@ -385,6 +361,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 f129f8becd..fd463047f1 100644 --- a/src/app/core/data/request.service.ts +++ b/src/app/core/data/request.service.ts @@ -1,27 +1,78 @@ -import { Observable, race as observableRace } from 'rxjs'; -import { filter, mergeMap, take } from 'rxjs/operators'; import { Injectable } from '@angular/core'; import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store'; +import { Observable, race as observableRace } from 'rxjs'; +import { filter, mergeMap, take } from 'rxjs/operators'; + +import { AppState } from '../../app.reducer'; import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { CacheableObject } from '../cache/object-cache.reducer'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { coreSelector, CoreState } from '../core.reducers'; -import { IndexName, IndexState } from '../index/index.reducer'; -import { pathSelector } from '../shared/selectors'; -import { UUIDService } from '../shared/uuid.service'; +import { CoreState } from '../core.reducers'; +import { IndexName, IndexState, MetaIndexState } from '../index/index.reducer'; import { - RequestConfigureAction, - RequestExecuteAction, - RequestRemoveAction -} from './request.actions'; -import { EndpointMapRequest, GetRequest, RestRequest } from './request.models'; - -import { RequestEntry } from './request.reducer'; + originalRequestUUIDFromRequestUUIDSelector, + requestIndexSelector, + uuidFromHrefSelector +} from '../index/index.selectors'; +import { UUIDService } from '../shared/uuid.service'; +import { RequestConfigureAction, RequestExecuteAction, RequestRemoveAction } from './request.actions'; +import { GetRequest, RestRequest } from './request.models'; +import { RequestEntry, RequestState } from './request.reducer'; import { CommitSSBAction } from '../cache/server-sync-buffer.actions'; import { RestRequestMethod } from './rest-request-method'; import { AddToIndexAction, RemoveFromIndexBySubstringAction } from '../index/index.actions'; +import { coreSelector } from '../core.selectors'; +/** + * The base selector function to select the request state in the store + */ +const requestCacheSelector = createSelector( + coreSelector, + (state: CoreState) => state['data/request'] +); + +/** + * Selector function to select a request entry by uuid from the cache + * @param uuid The uuid of the request + */ +const entryFromUUIDSelector = (uuid: string): MemoizedSelector => createSelector( + requestCacheSelector, + (state: RequestState) => { + return hasValue(state) ? state[uuid] : undefined; + } +); + +/** + * Create a selector that fetches a list of request UUIDs from a given index substate of which the request href + * contains a given substring + * @param selector MemoizedSelector to start from + * @param name The name of the index substate we're fetching request UUIDs from + * @param href Substring that the request's href should contain + */ +const uuidsFromHrefSubstringSelector = + (selector: MemoizedSelector, href: string): MemoizedSelector => createSelector( + selector, + (state: IndexState) => getUuidsFromHrefSubstring(state, href) + ); + +/** + * Fetch a list of request UUIDs from a given index substate of which the request href contains a given substring + * @param state The IndexState + * @param href Substring that the request's href should contain + */ +const getUuidsFromHrefSubstring = (state: IndexState, href: string): string[] => { + let result = []; + if (isNotEmpty(state)) { + result = Object.values(state) + .filter((value: string) => value.startsWith(href)); + } + return result; +}; + +/** + * A service to interact with the request state in the store + */ @Injectable() export class RequestService { private requestsOnTheirWayToTheStore: string[] = []; @@ -29,51 +80,7 @@ export class RequestService { constructor(private objectCache: ObjectCacheService, private uuidService: UUIDService, private store: Store, - private indexStore: Store) { - } - - private entryFromUUIDSelector(uuid: string): MemoizedSelector { - return pathSelector(coreSelector, 'data/request', uuid); - } - - private uuidFromHrefSelector(href: string): MemoizedSelector { - return pathSelector(coreSelector, 'index', IndexName.REQUEST, href); - } - - private originalUUIDFromUUIDSelector(uuid: string): MemoizedSelector { - return pathSelector(coreSelector, 'index', IndexName.UUID_MAPPING, uuid); - } - - /** - * Create a selector that fetches a list of request UUIDs from a given index substate of which the request href - * contains a given substring - * @param selector MemoizedSelector to start from - * @param name The name of the index substate we're fetching request UUIDs from - * @param href Substring that the request's href should contain - */ - private uuidsFromHrefSubstringSelector(selector: MemoizedSelector, name: string, href: string): MemoizedSelector { - return createSelector(selector, (state: IndexState) => this.getUuidsFromHrefSubstring(state, name, href)); - } - - /** - * Fetch a list of request UUIDs from a given index substate of which the request href contains a given substring - * @param state The IndexState - * @param name The name of the index substate we're fetching request UUIDs from - * @param href Substring that the request's href should contain - */ - private getUuidsFromHrefSubstring(state: IndexState, name: string, href: string): string[] { - let result = []; - if (isNotEmpty(state)) { - const subState = state[name]; - if (isNotEmpty(subState)) { - for (const value in subState) { - if (value.indexOf(href) > -1) { - result = [...result, subState[value]]; - } - } - } - } - return result; + private indexStore: Store) { } generateRequestId(): string { @@ -104,11 +111,11 @@ export class RequestService { */ getByUUID(uuid: string): Observable { return observableRace( - this.store.pipe(select(this.entryFromUUIDSelector(uuid))), + this.store.pipe(select(entryFromUUIDSelector(uuid))), this.store.pipe( - select(this.originalUUIDFromUUIDSelector(uuid)), + select(originalRequestUUIDFromRequestUUIDSelector(uuid)), mergeMap((originalUUID) => { - return this.store.pipe(select(this.entryFromUUIDSelector(originalUUID))) + return this.store.pipe(select(entryFromUUIDSelector(originalUUID))) }, )) ); @@ -119,7 +126,7 @@ export class RequestService { */ getByHref(href: string): Observable { return this.store.pipe( - select(this.uuidFromHrefSelector(href)), + select(uuidFromHrefSelector(href)), mergeMap((uuid: string) => this.getByUUID(uuid)) ); } @@ -130,7 +137,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) { @@ -156,7 +163,7 @@ export class RequestService { */ removeByHrefSubstring(href: string) { this.store.pipe( - select(this.uuidsFromHrefSubstringSelector(pathSelector(coreSelector, 'index'), IndexName.REQUEST, href)), + select(uuidsFromHrefSubstringSelector(requestIndexSelector, href)), take(1) ).subscribe((uuids: string[]) => { for (const uuid of uuids) { diff --git a/src/app/core/data/search-response-parsing.service.ts b/src/app/core/data/search-response-parsing.service.ts index 8e3171d05e..0ca793c5ae 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 17fb389707..d09d398d7c 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 26bd1ba5de..18b9090844 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 6bf5eb0818..a2a9f2530c 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 0000000000..6c591b0b99 --- /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 0000000000..70ecf3f59e --- /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 0000000000..07a1bb6aba --- /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 7d2138b633..32286929ee 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 cd41ce9e25..91ce5d90f3 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 bcd7e49871..ad4b20ee80 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 d576f399ff..f86bec8628 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/index/index.reducer.spec.ts b/src/app/core/index/index.reducer.spec.ts index d1403ac5bf..ef46c760c6 100644 --- a/src/app/core/index/index.reducer.spec.ts +++ b/src/app/core/index/index.reducer.spec.ts @@ -1,6 +1,6 @@ import * as deepFreeze from 'deep-freeze'; -import { IndexName, indexReducer, IndexState } from './index.reducer'; +import { IndexName, indexReducer, MetaIndexState } from './index.reducer'; import { AddToIndexAction, RemoveFromIndexBySubstringAction, RemoveFromIndexByValueAction } from './index.actions'; class NullAction extends AddToIndexAction { @@ -17,7 +17,7 @@ describe('requestReducer', () => { const key2 = '1911e8a4-6939-490c-b58b-a5d70f8d91fb'; const val1 = 'https://dspace7.4science.it/dspace-spring-rest/api/core/items/567a639f-f5ff-4126-807c-b7d0910808c8'; const val2 = 'https://dspace7.4science.it/dspace-spring-rest/api/core/items/1911e8a4-6939-490c-b58b-a5d70f8d91fb'; - const testState: IndexState = { + const testState: MetaIndexState = { [IndexName.OBJECT]: { [key1]: val1 },[IndexName.REQUEST]: { diff --git a/src/app/core/index/index.reducer.ts b/src/app/core/index/index.reducer.ts index 3597c786d8..b4cd8aa84b 100644 --- a/src/app/core/index/index.reducer.ts +++ b/src/app/core/index/index.reducer.ts @@ -1,26 +1,57 @@ import { + AddToIndexAction, IndexAction, IndexActionTypes, - AddToIndexAction, - RemoveFromIndexByValueAction, RemoveFromIndexBySubstringAction + RemoveFromIndexBySubstringAction, + RemoveFromIndexByValueAction } from './index.actions'; +/** + * An enum containing all index names + */ export enum IndexName { + // Contains all objects in the object cache indexed by UUID OBJECT = 'object/uuid-to-self-link', + + // contains all requests in the request cache indexed by UUID REQUEST = 'get-request/href-to-uuid', + + /** + * Contains the UUIDs of requests that were sent to the server and + * have their responses cached, indexed by the UUIDs of requests that + * weren't sent because the response they requested was already cached + */ UUID_MAPPING = 'get-request/configured-to-cache-uuid' } -export type IndexState = { - [name in IndexName]: { - [key: string]: string - } +/** + * The state of a single index + */ +export interface IndexState { + [key: string]: string +} + +/** + * The state that contains all indices + */ +export type MetaIndexState = { + [name in IndexName]: IndexState } // Object.create(null) ensures the object has no default js properties (e.g. `__proto__`) -const initialState: IndexState = Object.create(null); +const initialState: MetaIndexState = Object.create(null); -export function indexReducer(state = initialState, action: IndexAction): IndexState { +/** + * The Index Reducer + * + * @param state + * the current state + * @param action + * the action to perform on the state + * @return MetaIndexState + * the new state + */ +export function indexReducer(state = initialState, action: IndexAction): MetaIndexState { switch (action.type) { case IndexActionTypes.ADD: { @@ -41,7 +72,17 @@ export function indexReducer(state = initialState, action: IndexAction): IndexSt } } -function addToIndex(state: IndexState, action: AddToIndexAction): IndexState { +/** + * Add an entry to a given index + * + * @param state + * The MetaIndexState that contains all indices + * @param action + * The AddToIndexAction containing the value to add, and the index to add it to + * @return MetaIndexState + * the new state + */ +function addToIndex(state: MetaIndexState, action: AddToIndexAction): MetaIndexState { const subState = state[action.payload.name]; const newSubState = Object.assign({}, subState, { [action.payload.key]: action.payload.value @@ -52,7 +93,17 @@ function addToIndex(state: IndexState, action: AddToIndexAction): IndexState { return obs; } -function removeFromIndexByValue(state: IndexState, action: RemoveFromIndexByValueAction): IndexState { +/** + * Remove a entries that contain a given value from a given index + * + * @param state + * The MetaIndexState that contains all indices + * @param action + * The RemoveFromIndexByValueAction containing the value to remove, and the index to remove it from + * @return MetaIndexState + * the new state + */ +function removeFromIndexByValue(state: MetaIndexState, action: RemoveFromIndexByValueAction): MetaIndexState { const subState = state[action.payload.name]; const newSubState = Object.create(null); for (const value in subState) { @@ -66,11 +117,16 @@ function removeFromIndexByValue(state: IndexState, action: RemoveFromIndexByValu } /** - * Remove values from the IndexState's substate that contain a given substring - * @param state The IndexState to remove values from - * @param action The RemoveFromIndexByValueAction containing the necessary information to remove the values + * Remove entries that contain a given substring from a given index + * + * @param state + * The MetaIndexState that contains all indices + * @param action + * The RemoveFromIndexByValueAction the substring to remove, and the index to remove it from + * @return MetaIndexState + * the new state */ -function removeFromIndexBySubstring(state: IndexState, action: RemoveFromIndexByValueAction): IndexState { +function removeFromIndexBySubstring(state: MetaIndexState, action: RemoveFromIndexByValueAction): MetaIndexState { const subState = state[action.payload.name]; const newSubState = Object.create(null); for (const value in subState) { diff --git a/src/app/core/index/index.selectors.ts b/src/app/core/index/index.selectors.ts new file mode 100644 index 0000000000..3c7b331a92 --- /dev/null +++ b/src/app/core/index/index.selectors.ts @@ -0,0 +1,94 @@ +import { createSelector, MemoizedSelector } from '@ngrx/store'; +import { AppState } from '../../app.reducer'; +import { hasValue } from '../../shared/empty.util'; +import { CoreState } from '../core.reducers'; +import { coreSelector } from '../core.selectors'; +import { IndexName, IndexState, MetaIndexState } from './index.reducer'; + +/** + * Return the MetaIndexState based on the CoreSate + * + * @returns + * a MemoizedSelector to select the MetaIndexState + */ +export const metaIndexSelector: MemoizedSelector = createSelector( + coreSelector, + (state: CoreState) => state.index +); + +/** + * Return the object index based on the MetaIndexState + * It contains all objects in the object cache indexed by UUID + * + * @returns + * a MemoizedSelector to select the object index + */ +export const objectIndexSelector: MemoizedSelector = createSelector( + metaIndexSelector, + (state: MetaIndexState) => state[IndexName.OBJECT] +); + +/** + * Return the request index based on the MetaIndexState + * + * @returns + * a MemoizedSelector to select the request index + */ +export const requestIndexSelector: MemoizedSelector = createSelector( + metaIndexSelector, + (state: MetaIndexState) => state[IndexName.REQUEST] +); + +/** + * Return the request UUID mapping index based on the MetaIndexState + * + * @returns + * a MemoizedSelector to select the request UUID mapping + */ +export const requestUUIDIndexSelector: MemoizedSelector = createSelector( + metaIndexSelector, + (state: MetaIndexState) => state[IndexName.UUID_MAPPING] +); + +/** + * Return the self link of an object in the object-cache based on its UUID + * + * @param uuid + * the UUID for which you want to find the matching self link + * @returns + * a MemoizedSelector to select the self link + */ +export const selfLinkFromUuidSelector = + (uuid: string): MemoizedSelector => createSelector( + objectIndexSelector, + (state: IndexState) => hasValue(state) ? state[uuid] : undefined + ); + +/** + * Return the UUID of a GET request based on its href + * + * @param href + * the href of the GET request + * @returns + * a MemoizedSelector to select the UUID + */ +export const uuidFromHrefSelector = + (href: string): MemoizedSelector => createSelector( + requestIndexSelector, + (state: IndexState) => hasValue(state) ? state[href] : undefined + ); + +/** + * Return the UUID of a cached request based on the UUID of a request + * that wasn't sent because the response was already cached + * + * @param uuid + * The UUID of the new request + * @returns + * a MemoizedSelector to select the UUID of the cached request + */ +export const originalRequestUUIDFromRequestUUIDSelector = + (uuid: string): MemoizedSelector => createSelector( + requestUUIDIndexSelector, + (state: IndexState) => hasValue(state) ? state[uuid] : undefined + ); diff --git a/src/app/core/integration/authority.service.ts b/src/app/core/integration/authority.service.ts index a5fa3a8d09..f0a1759be6 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 4f69dbd6fe..f66a070fdf 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 baa4343724..4187606265 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 ef278c93de..2d3693cf3d 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 152d7ab165..02fff950ed 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 2ace710dc7..5826f4646d 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 e2ef9ce9db..0000000000 --- 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 0000000000..31cb0a5787 --- /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 0000000000..3630d02970 --- /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 d3383ab94a..3158abc7eb 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 0000000000..5ebb61281d --- /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 0000000000..d29bf993cc --- /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 0000000000..c45183b4ef --- /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 0000000000..cb3e3b0d38 --- /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 0000000000..c0fa12cbf3 --- /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 0000000000..3304db5b9e --- /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 0000000000..c6b21ce037 --- /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 0000000000..906d5e0331 --- /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 0000000000..4ecc215dc7 --- /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 0000000000..90eaf87a0e --- /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 0000000000..f855333fab --- /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 0000000000..1ccde294de --- /dev/null +++ b/src/app/core/json-patch/selectors.ts @@ -0,0 +1,32 @@ +import { MemoizedSelector } from '@ngrx/store'; +import { CoreState } from '../core.reducers'; +import { coreSelector } from '../core.selectors'; +import { JsonPatchOperationsEntry, JsonPatchOperationsResourceEntry } from './json-patch-operations.reducer'; +import { keySelector, subStateSelector } from '../../submission/selectors'; + +/** + * 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 bd3532b840..cfb5a0751d 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 64de242eaa..8274ceef60 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 c630c9dd57..0471d1fbbb 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 bbb8605bcc..0000000000 --- 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 4cb5016983..0000000000 --- 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 0eb9daaeab..0000000000 --- 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 8d86f317e1..0000000000 --- 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 085988d745..063398b339 100644 --- a/src/app/core/shared/dspace-object.model.ts +++ b/src/app/core/shared/dspace-object.model.ts @@ -1,15 +1,12 @@ -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'; import { hasNoValue } from '../../shared/empty.util'; /** @@ -17,6 +14,8 @@ import { hasNoValue } from '../../shared/empty.util'; */ export class DSpaceObject implements CacheableObject, ListableObject { + private _name: string; + self: string; /** @@ -38,7 +37,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 0000000000..7e89a4e5dd --- /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 7dadfafdd9..645b50d5db 100644 --- a/src/app/core/shared/item.model.ts +++ b/src/app/core/shared/item.model.ts @@ -1,5 +1,5 @@ +import { map, startWith, filter, take } from 'rxjs/operators'; import { Observable } from 'rxjs'; -import { filter, map, startWith, tap } from 'rxjs/operators'; import { DSpaceObject } from './dspace-object.model'; import { Collection } from './collection.model'; @@ -95,14 +95,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 0000000000..a04422242a --- /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 c843b0ec74..9c7e30dcb4 100644 --- a/src/app/core/shared/metadata.models.ts +++ b/src/app/core/shared/metadata.models.ts @@ -5,14 +5,28 @@ import { hasValue } from '../../shared/empty.util'; const VIRTUAL_METADATA_PREFIX = 'virtual::'; +/** 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 class MetadataMap { +export interface MetadataMapInterface { + [key: string]: MetadataValueInterface[]; +} + +/** A map of metadata keys to an ordered list of MetadataValue objects. */ +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 330cbdb32b..62a1957e22 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 5086976f8b..2eb47507b2 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 a2c421255e..ce9740a0fc 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 cccbea1e89..ee3d5293f5 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 c876d02a56..d5afa4105f 100644 --- a/src/app/core/shared/resource-type.ts +++ b/src/app/core/shared/resource-type.ts @@ -14,4 +14,13 @@ export enum ResourceType { Relationship = 'relationship', RelationshipType = 'relationshiptype', ItemType = 'entitytype', + 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/selectors.ts b/src/app/core/shared/selectors.ts deleted file mode 100644 index 7bd35d39c1..0000000000 --- a/src/app/core/shared/selectors.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { createSelector, MemoizedSelector } from '@ngrx/store'; -import { hasNoValue, isEmpty } from '../../shared/empty.util'; - -export function pathSelector(selector: MemoizedSelector, ...path: string[]): MemoizedSelector { - return createSelector(selector, (state: any) => getSubState(state, path)); -} - -function getSubState(state: any, path: string[]) { - const current = path[0]; - const remainingPath = path.slice(1); - const subState = state[current]; - if (hasNoValue(subState) || isEmpty(remainingPath)) { - return subState; - } else { - return getSubState(subState, remainingPath); - } -} 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 0000000000..beb2b320cf --- /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/normalized-submission-object.model.ts b/src/app/core/submission/models/normalized-submission-object.model.ts new file mode 100644 index 0000000000..8091781760 --- /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 0000000000..a3fa8992a2 --- /dev/null +++ b/src/app/core/submission/models/normalized-workflowitem.model.ts @@ -0,0 +1,43 @@ +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'; + +/** + * An model class for a NormalizedWorkflowItem. + */ +@mapsTo(Workflowitem) +@inheritSerialization(NormalizedSubmissionObject) +export class NormalizedWorkflowItem extends NormalizedSubmissionObject { + + /** + * The collection this workflowitem belonging to + */ + @autoserialize + @relationship(ResourceType.Collection, false) + collection: string; + + /** + * The item created with this workflowitem + */ + @autoserialize + @relationship(ResourceType.Item, false) + item: string; + + /** + * The configuration object that define this workflowitem + */ + @autoserialize + @relationship(ResourceType.SubmissionDefinition, false) + submissionDefinition: string; + + /** + * The EPerson who submit this workflowitem + */ + @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 0000000000..7c15925c98 --- /dev/null +++ b/src/app/core/submission/models/normalized-workspaceitem.model.ts @@ -0,0 +1,45 @@ +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'; + +/** + * An model class for a NormalizedWorkspaceItem. + */ +@mapsTo(Workspaceitem) +@inheritSerialization(NormalizedDSpaceObject) +@inheritSerialization(NormalizedSubmissionObject) +export class NormalizedWorkspaceItem extends NormalizedSubmissionObject { + + /** + * The collection this workspaceitem belonging to + */ + @autoserialize + @relationship(ResourceType.Collection, false) + collection: string; + + /** + * The item created with this workspaceitem + */ + @autoserialize + @relationship(ResourceType.Item, false) + item: string; + + /** + * The configuration object that define this workspaceitem + */ + @autoserialize + @relationship(ResourceType.SubmissionDefinition, false) + submissionDefinition: string; + + /** + * The EPerson who submit this workspaceitem + */ + @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 0000000000..6b2d9a03b9 --- /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 configuration object that define this submission + */ + 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 0000000000..8b89397f24 --- /dev/null +++ b/src/app/core/submission/models/submission-upload-file-access-condition.model.ts @@ -0,0 +1,30 @@ +/** + * An interface to represent bitstream's access condition. + */ +export class SubmissionUploadFileAccessConditionObject { + + /** + * The access condition id + */ + id: string; + + /** + * The access condition name + */ + name: string; + + /** + * The access group UUID defined in this access condition + */ + groupUUID: string; + + /** + * Possible start date of the access condition + */ + startDate: string; + + /** + * Possible end date of the access condition + */ + 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 0000000000..f1a0467f43 --- /dev/null +++ b/src/app/core/submission/models/workflowitem.model.ts @@ -0,0 +1,7 @@ +import { Workspaceitem } from './workspaceitem.model'; + +/** + * A model class for a Workflowitem. + */ +export class Workflowitem extends Workspaceitem { +} 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 0000000000..1462a96d81 --- /dev/null +++ b/src/app/core/submission/models/workspaceitem-section-form.model.ts @@ -0,0 +1,10 @@ +import { FormFieldMetadataValueObject } from '../../../shared/form/builder/models/form-field-metadata-value.model'; +import { MetadataMapInterface } from '../../shared/metadata.models'; + +/** + * An interface to represent submission's form section data. + * A map of metadata keys to an ordered list of FormFieldMetadataValueObject objects. + */ +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 0000000000..26f625871e --- /dev/null +++ b/src/app/core/submission/models/workspaceitem-section-license.model.ts @@ -0,0 +1,20 @@ + +/** + * An interface to represent submission's license section data. + */ +export interface WorkspaceitemSectionLicenseObject { + /** + * The license url + */ + url: string; + + /** + * The acceptance date of the license + */ + acceptanceDate: string; + + /** + * A boolean representing if license has been granted + */ + granted: boolean; +} 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 0000000000..177473b7d5 --- /dev/null +++ b/src/app/core/submission/models/workspaceitem-section-upload-file.model.ts @@ -0,0 +1,46 @@ +import { SubmissionUploadFileAccessConditionObject } from './submission-upload-file-access-condition.model'; +import { WorkspaceitemSectionFormObject } from './workspaceitem-section-form.model'; + +/** + * An interface to represent submission's upload section file entry. + */ +export class WorkspaceitemSectionUploadFileObject { + + /** + * The file UUID + */ + uuid: string; + + /** + * The file metadata + */ + metadata: WorkspaceitemSectionFormObject; + + /** + * The file size + */ + sizeBytes: number; + + /** + * The file check sum + */ + checkSum: { + checkSumAlgorithm: string; + value: string; + }; + + /** + * The file url + */ + url: string; + + /** + * The file thumbnail url + */ + thumbnail: string; + + /** + * The list of file access conditions + */ + 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 0000000000..f98e0584eb --- /dev/null +++ b/src/app/core/submission/models/workspaceitem-section-upload.model.ts @@ -0,0 +1,12 @@ +import { WorkspaceitemSectionUploadFileObject } from './workspaceitem-section-upload-file.model'; + +/** + * An interface to represent submission's upload section data. + */ +export interface WorkspaceitemSectionUploadObject { + + /** + * A list of [[WorkspaceitemSectionUploadFileObject]] + */ + 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 0000000000..165e69869c --- /dev/null +++ b/src/app/core/submission/models/workspaceitem-sections.model.ts @@ -0,0 +1,20 @@ +import { WorkspaceitemSectionFormObject } from './workspaceitem-section-form.model'; +import { WorkspaceitemSectionLicenseObject } from './workspaceitem-section-license.model'; +import { WorkspaceitemSectionUploadObject } from './workspaceitem-section-upload.model'; + +/** + * An interface to represent submission's section object. + * A map of section keys to an ordered list of WorkspaceitemSectionDataType objects. + */ +export class WorkspaceitemSectionsObject { + [name: string]: WorkspaceitemSectionDataType; +} + +/** + * Export a type alias of all sections + */ +export type WorkspaceitemSectionDataType + = WorkspaceitemSectionUploadObject + | WorkspaceitemSectionFormObject + | WorkspaceitemSectionLicenseObject + | 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 0000000000..6548191ba2 --- /dev/null +++ b/src/app/core/submission/models/workspaceitem.model.ts @@ -0,0 +1,8 @@ +import { SubmissionObject } from './submission-object.model'; + +/** + * A model class for a Workspaceitem. + */ +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 0000000000..39e6cd42fb --- /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 0000000000..d469f2098f --- /dev/null +++ b/src/app/core/submission/submission-json-patch-operations.service.ts @@ -0,0 +1,28 @@ +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'; + +/** + * A service that provides methods to make JSON Patch requests. + */ +@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 0000000000..f5b8e2c423 --- /dev/null +++ b/src/app/core/submission/submission-resource-type.ts @@ -0,0 +1,21 @@ +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', + 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 0000000000..20dfb43cbd --- /dev/null +++ b/src/app/core/submission/submission-response-parsing.service.ts @@ -0,0 +1,167 @@ +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 { 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 a function to check if object has same properties of FormFieldMetadataValueObject + * + * @param obj + */ +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 a function to normalize sections object of the server response + * + * @param obj + */ +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(); + } + + /** + * Parses data from the workspaceitems/workflowitems endpoints + * + * @param {RestRequest} request + * @param {DSpaceRESTV2Response} data + * @returns {RestResponse} + */ + 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} + ) + ); + } + } + + /** + * Parses response and normalize it + * + * @param {DSpaceRESTV2Response} data + * @param {string} requestHref + * @returns {any[]} + */ + 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) { + 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-rest.service.spec.ts b/src/app/core/submission/submission-rest.service.spec.ts new file mode 100644 index 0000000000..6e748c5575 --- /dev/null +++ b/src/app/core/submission/submission-rest.service.spec.ts @@ -0,0 +1,88 @@ +import { TestScheduler } from 'rxjs/testing'; +import { getTestScheduler } from 'jasmine-marbles'; + +import { SubmissionRestService } from './submission-rest.service'; +import { RequestService } from '../data/request.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { getMockRequestService } from '../../shared/mocks/mock-request.service'; +import { getMockRemoteDataBuildService } from '../../shared/mocks/mock-remote-data-build.service'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub'; +import { + SubmissionDeleteRequest, + SubmissionPatchRequest, + SubmissionPostRequest, + SubmissionRequest +} from '../data/request.models'; +import { FormFieldMetadataValueObject } from '../../shared/form/builder/models/form-field-metadata-value.model'; + +describe('SubmissionRestService test suite', () => { + let scheduler: TestScheduler; + let service: SubmissionRestService; + let requestService: RequestService; + let rdbService: RemoteDataBuildService; + let halService: any; + + const resourceEndpointURL = 'https://rest.api/endpoint'; + const resourceEndpoint = 'workspaceitems'; + const resourceScope = '260'; + const body = { test: new FormFieldMetadataValueObject('test')}; + const resourceHref = resourceEndpointURL + '/' + resourceEndpoint + '/' + resourceScope; + const timestampResponse = 1545994811992; + + function initTestService() { + return new SubmissionRestService( + rdbService, + requestService, + halService + ); + } + + beforeEach(() => { + requestService = getMockRequestService(); + rdbService = getMockRemoteDataBuildService(); + scheduler = getTestScheduler(); + halService = new HALEndpointServiceStub(resourceEndpointURL); + service = initTestService(); + + }); + + describe('deleteById', () => { + it('should configure a new SubmissionDeleteRequest', () => { + const expected = new SubmissionDeleteRequest(requestService.generateRequestId(), resourceHref); + scheduler.schedule(() => service.deleteById(resourceScope, resourceEndpoint).subscribe()); + scheduler.flush(); + + expect(requestService.configure).toHaveBeenCalledWith(expected); + }); + }); + + describe('getDataById', () => { + it('should configure a new SubmissionRequest', () => { + const expected = new SubmissionRequest(requestService.generateRequestId(), resourceHref); + scheduler.schedule(() => service.getDataById(resourceEndpoint, resourceScope).subscribe()); + scheduler.flush(); + + expect(requestService.configure).toHaveBeenCalledWith(expected, true); + }); + }); + + describe('postToEndpoint', () => { + it('should configure a new SubmissionPostRequest', () => { + const expected = new SubmissionPostRequest(requestService.generateRequestId(), resourceHref, body); + scheduler.schedule(() => service.postToEndpoint(resourceEndpoint, body, resourceScope).subscribe()); + scheduler.flush(); + + expect(requestService.configure).toHaveBeenCalledWith(expected); + }); + }); + + describe('patchToEndpoint', () => { + it('should configure a new SubmissionPatchRequest', () => { + const expected = new SubmissionPatchRequest(requestService.generateRequestId(), resourceHref, body); + scheduler.schedule(() => service.patchToEndpoint(resourceEndpoint, body, resourceScope).subscribe()); + scheduler.flush(); + + expect(requestService.configure).toHaveBeenCalledWith(expected); + }); + }); +}); diff --git a/src/app/core/submission/submission-rest.service.ts b/src/app/core/submission/submission-rest.service.ts new file mode 100644 index 0000000000..e2b8bb01c8 --- /dev/null +++ b/src/app/core/submission/submission-rest.service.ts @@ -0,0 +1,167 @@ +import { Injectable } from '@angular/core'; + +import { merge as observableMerge, Observable, throwError as observableThrowError } from 'rxjs'; +import { distinctUntilChanged, filter, flatMap, map, mergeMap, tap } from 'rxjs/operators'; + +import { RequestService } from '../data/request.service'; +import { isNotEmpty } from '../../shared/empty.util'; +import { + DeleteRequest, + PostRequest, + RestRequest, + SubmissionDeleteRequest, + SubmissionPatchRequest, + SubmissionPostRequest, + SubmissionRequest +} from '../data/request.models'; +import { SubmitDataResponseDefinitionObject } from '../shared/submit-data-response-definition.model'; +import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ErrorResponse, RestResponse, SubmissionSuccessResponse } from '../cache/response.models'; +import { getResponseFromEntry } from '../shared/operators'; + +/** + * The service handling all submission REST requests + */ +@Injectable() +export class SubmissionRestService { + protected linkPath = 'workspaceitems'; + + constructor( + protected rdbService: RemoteDataBuildService, + protected requestService: RequestService, + protected halService: HALEndpointService) { + } + + /** + * Fetch a RestRequest + * + * @param requestId + * The base endpoint for the type of object + * @return Observable + * server response + */ + protected fetchRequest(requestId: string): Observable { + const responses = this.requestService.getByUUID(requestId).pipe( + getResponseFromEntry() + ); + const errorResponses = responses.pipe( + filter((response: RestResponse) => !response.isSuccessful), + mergeMap((error: ErrorResponse) => observableThrowError(error)) + ); + const successResponses = responses.pipe( + filter((response: RestResponse) => response.isSuccessful), + map((response: SubmissionSuccessResponse) => response.dataDefinition as any), + distinctUntilChanged() + ); + return observableMerge(errorResponses, successResponses); + } + + /** + * Create the HREF for a specific submission object based on its identifier + * + * @param endpoint + * The base endpoint for the type of object + * @param resourceID + * The identifier for the object + */ + protected getEndpointByIDHref(endpoint, resourceID): string { + return isNotEmpty(resourceID) ? `${endpoint}/${resourceID}` : `${endpoint}`; + } + + /** + * Delete an existing submission Object on the server + * + * @param scopeId + * The submission Object to be removed + * @param linkName + * The endpoint link name + * @return Observable + * server response + */ + public deleteById(scopeId: string, linkName?: string): Observable { + const requestId = this.requestService.generateRequestId(); + return this.halService.getEndpoint(linkName || this.linkPath).pipe( + filter((href: string) => isNotEmpty(href)), + distinctUntilChanged(), + map((endpointURL: string) => this.getEndpointByIDHref(endpointURL, scopeId)), + map((endpointURL: string) => new SubmissionDeleteRequest(requestId, endpointURL)), + tap((request: DeleteRequest) => this.requestService.configure(request)), + flatMap(() => this.fetchRequest(requestId)), + distinctUntilChanged()); + } + + /** + * Return an existing submission Object from the server + * + * @param linkName + * The endpoint link name + * @param id + * The submission Object to retrieve + * @return Observable + * server response + */ + public getDataById(linkName: string, id: string): Observable { + const requestId = this.requestService.generateRequestId(); + return this.halService.getEndpoint(linkName).pipe( + map((endpointURL: string) => this.getEndpointByIDHref(endpointURL, id)), + filter((href: string) => isNotEmpty(href)), + distinctUntilChanged(), + map((endpointURL: string) => new SubmissionRequest(requestId, endpointURL)), + tap((request: RestRequest) => this.requestService.configure(request, true)), + flatMap(() => this.fetchRequest(requestId)), + distinctUntilChanged()); + } + + /** + * Make a new post request + * + * @param linkName + * The endpoint link name + * @param body + * The post request body + * @param scopeId + * The submission Object id + * @param options + * The [HttpOptions] object + * @return Observable + * server response + */ + public postToEndpoint(linkName: string, body: any, scopeId?: string, options?: HttpOptions): Observable { + const requestId = this.requestService.generateRequestId(); + return this.halService.getEndpoint(linkName).pipe( + filter((href: string) => isNotEmpty(href)), + map((endpointURL: string) => this.getEndpointByIDHref(endpointURL, scopeId)), + distinctUntilChanged(), + map((endpointURL: string) => new SubmissionPostRequest(requestId, endpointURL, body, options)), + tap((request: PostRequest) => this.requestService.configure(request)), + flatMap(() => this.fetchRequest(requestId)), + distinctUntilChanged()); + } + + /** + * Make a new patch to a specified object + * + * @param linkName + * The endpoint link name + * @param body + * The post request body + * @param scopeId + * The submission Object id + * @return Observable + * server response + */ + public patchToEndpoint(linkName: string, body: any, scopeId?: string): Observable { + const requestId = this.requestService.generateRequestId(); + return this.halService.getEndpoint(linkName).pipe( + filter((href: string) => isNotEmpty(href)), + map((endpointURL: string) => this.getEndpointByIDHref(endpointURL, scopeId)), + distinctUntilChanged(), + map((endpointURL: string) => new SubmissionPatchRequest(requestId, endpointURL, body)), + tap((request: PostRequest) => this.requestService.configure(request)), + flatMap(() => this.fetchRequest(requestId)), + distinctUntilChanged()); + } + +} 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 0000000000..6ed32d3b4e --- /dev/null +++ b/src/app/core/submission/submission-scope-type.ts @@ -0,0 +1,4 @@ +export enum SubmissionScopeType { + WorkspaceItem = 'WORKSPACE', + WorkflowItem = 'WORKFLOW' +} 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 0000000000..e739a62e81 --- /dev/null +++ b/src/app/core/submission/workflowitem-data.service.ts @@ -0,0 +1,42 @@ +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'; + +/** + * A service that provides methods to make REST requests with workflowitems endpoint. + */ +@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 0000000000..3bb3eb1ee8 --- /dev/null +++ b/src/app/core/submission/workspaceitem-data.service.ts @@ -0,0 +1,42 @@ +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'; + +/** + * A service that provides methods to make REST requests with workspaceitems endpoint. + */ +@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 e7923b3466..6e173b4139 100644 --- a/src/app/pagenotfound/pagenotfound.component.ts +++ b/src/app/pagenotfound/pagenotfound.component.ts @@ -1,14 +1,33 @@ 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'; +/** + * This component representing the `PageNotFound` DSpace page. + */ @Component({ selector: 'ds-pagenotfound', styleUrls: ['./pagenotfound.component.scss'], templateUrl: './pagenotfound.component.html', changeDetection: ChangeDetectionStrategy.Default }) -export class PageNotFoundComponent { - constructor(responseService: ServerResponseService) { - responseService.setNotFound(); +export class PageNotFoundComponent implements OnInit { + + /** + * Initialize instance variables + * + * @param {AuthService} authservice + * @param {ServerResponseService} responseService + */ + constructor(private authservice: AuthService, private responseService: ServerResponseService) { + this.responseService.setNotFound(); } + + /** + * Remove redirect url from the state + */ + ngOnInit(): void { + this.authservice.clearRedirectUrl(); + } + } diff --git a/src/app/shared/alert/alert.component.html b/src/app/shared/alert/alert.component.html new file mode 100644 index 0000000000..0b30edb5cc --- /dev/null +++ b/src/app/shared/alert/alert.component.html @@ -0,0 +1,9 @@ + diff --git a/src/app/shared/alert/alert.component.scss b/src/app/shared/alert/alert.component.scss new file mode 100644 index 0000000000..1a70081367 --- /dev/null +++ b/src/app/shared/alert/alert.component.scss @@ -0,0 +1,3 @@ +.close:focus { + outline: none !important; +} diff --git a/src/app/shared/alert/alert.component.spec.ts b/src/app/shared/alert/alert.component.spec.ts new file mode 100644 index 0000000000..e235e27b28 --- /dev/null +++ b/src/app/shared/alert/alert.component.spec.ts @@ -0,0 +1,114 @@ +import { ChangeDetectorRef, Component, NO_ERRORS_SCHEMA } from '@angular/core'; +import { BrowserModule, By } from '@angular/platform-browser'; +import { CommonModule } from '@angular/common'; +import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +import { TranslateModule } from '@ngx-translate/core'; + +import { AlertComponent } from './alert.component'; +import { createTestComponent } from '../testing/utils'; +import { AlertType } from './aletr-type'; + +describe('AlertComponent test suite', () => { + + let comp: AlertComponent; + let compAsAny: any; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + BrowserModule, + CommonModule, + NoopAnimationsModule, + TranslateModule.forRoot() + ], + declarations: [ + AlertComponent, + TestComponent + ], + providers: [ + ChangeDetectorRef, + AlertComponent + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents().then(); + })); + + describe('', () => { + let testComp: TestComponent; + let testFixture: ComponentFixture; + + // synchronous beforeEach + beforeEach(() => { + const html = ` + `; + + testFixture = createTestComponent(html, TestComponent) as ComponentFixture; + testComp = testFixture.componentInstance; + }); + + afterEach(() => { + testFixture.destroy(); + }); + + it('should create AlertComponent', inject([AlertComponent], (app: AlertComponent) => { + + expect(app).toBeDefined(); + })); + }); + + describe('', () => { + beforeEach(() => { + fixture = TestBed.createComponent(AlertComponent); + comp = fixture.componentInstance; + compAsAny = comp; + comp.content = 'test alert'; + comp.dismissible = true; + comp.type = AlertType.Info; + fixture.detectChanges(); + }); + + it('should display close icon when dismissible is true', () => { + + const btn = fixture.debugElement.query(By.css('.close')); + expect(btn).toBeDefined(); + }); + + it('should not display close icon when dismissible is false', () => { + comp.dismissible = false; + fixture.detectChanges(); + + const btn = fixture.debugElement.query(By.css('.close')); + expect(btn).toBeDefined(); + }); + + it('should dismiss alert when click on close icon', () => { + spyOn(comp, 'dismiss'); + const btn = fixture.debugElement.query(By.css('.close')); + + btn.nativeElement.click(); + + expect(comp.dismiss).toHaveBeenCalled(); + }); + + afterEach(() => { + fixture.destroy(); + comp = null; + compAsAny = null; + }); + }); +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { + + content = 'test alert'; + dismissible = true; + type = AlertType.Info; +} diff --git a/src/app/shared/alert/alert.component.ts b/src/app/shared/alert/alert.component.ts new file mode 100644 index 0000000000..93535d2057 --- /dev/null +++ b/src/app/shared/alert/alert.component.ts @@ -0,0 +1,76 @@ +import { ChangeDetectorRef, Component, EventEmitter, Input, Output, ViewEncapsulation } from '@angular/core'; +import { trigger } from '@angular/animations'; + +import { AlertType } from './aletr-type'; +import { fadeOutLeave, fadeOutState } from '../animations/fade'; + +/** + * This component allow to create div that uses the Bootstrap's Alerts component. + */ +@Component({ + selector: 'ds-alert', + encapsulation: ViewEncapsulation.None, + animations: [ + trigger('enterLeave', [ + fadeOutLeave, fadeOutState, + ]) + ], + templateUrl: './alert.component.html', + styleUrls: ['./alert.component.scss'] +}) +export class AlertComponent { + + /** + * The alert content + */ + @Input() content: string; + + /** + * A boolean representing if alert is dismissible + */ + @Input() dismissible = false; + + /** + * The alert type + */ + @Input() type: AlertType; + + /** + * An event fired when alert is dismissed. + */ + @Output() close: EventEmitter = new EventEmitter(); + + /** + * The initial animation name + */ + public animate = 'fadeIn'; + + /** + * A boolean representing if alert is dismissed or not + */ + public dismissed = false; + + /** + * Initialize instance variables + * + * @param {ChangeDetectorRef} cdr + */ + constructor(private cdr: ChangeDetectorRef) { + } + + /** + * Dismiss div with animation + */ + 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/alert/aletr-type.ts b/src/app/shared/alert/aletr-type.ts new file mode 100644 index 0000000000..aacfb451f9 --- /dev/null +++ b/src/app/shared/alert/aletr-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 a8c7b84f56..7b7e7af12f 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 fc85616de9..1b39ad15d9 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 0000000000..6362daf3c7 --- /dev/null +++ b/src/app/shared/authority-confidence/authority-confidence-state.directive.ts @@ -0,0 +1,148 @@ +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 to add to the element a bootstrap utility class based on metadata confidence value + */ +@Directive({ + selector: '[dsAuthorityConfidenceState]' +}) +export class AuthorityConfidenceStateDirective implements OnChanges { + + /** + * The metadata value + */ + @Input() authorityValue: AuthorityValue | FormFieldMetadataValueObject | string; + + /** + * A boolean representing if to show html icon if authority value is empty + */ + @Input() visibleWhenAuthorityEmpty = true; + + /** + * The css class applied before directive changes + */ + private previousClass: string = null; + + /** + * The css class applied after directive changes + */ + private newClass: string; + + /** + * An event fired when click on element that has a confidence value empty or different from CF_ACCEPTED + */ + @Output() whenClickOnConfidenceNotAccepted: EventEmitter = new EventEmitter(); + + /** + * Listener to click event + */ + @HostListener('click') onClick() { + if (isNotEmpty(this.authorityValue) && this.getConfidenceByValue(this.authorityValue) !== ConfidenceType.CF_ACCEPTED) { + this.whenClickOnConfidenceNotAccepted.emit(this.getConfidenceByValue(this.authorityValue)); + } + } + + /** + * Initialize instance variables + * + * @param {GlobalConfig} EnvConfig + * @param {ElementRef} elem + * @param {Renderer2} renderer + */ + constructor( + @Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, + private elem: ElementRef, + private renderer: Renderer2 + ) { + } + + /** + * Apply css class to element whenever authority value change + * + * @param {SimpleChanges} changes + */ + 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); + } + } + + /** + * Apply css class to element after view init + */ + 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); + } + } + + /** + * Return confidence value as ConfidenceType + * + * @param value + */ + 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; + } + + /** + * Return the properly css class based on confidence value + * + * @param 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 9116aa5583..db8f08dad0 100644 --- a/src/app/shared/chips/chips.component.html +++ b/src/app/shared/chips/chips.component.html @@ -1,23 +1,37 @@