diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000000..682f67294b --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,28 @@ +## References +_Add references/links to any related tickets or PRs. These may include:_ +* Link to [Angular issue or PR](https://github.com/DSpace/dspace-angular/issues) related to this PR, if any +* Link to [JIRA](https://jira.lyrasis.org/projects/DS/summary) ticket(s), if any + +## Description +Short summary of changes (1-2 sentences). + +## Instructions for Reviewers +Please add a more detailed description of the changes made by your PR. At a minimum, providing a bulleted list of changes in your PR is helpful to reviewers. + +List of changes in this PR: +* First, ... +* Second, ... + +**Include guidance for how to test or review your PR.** This may include: steps to reproduce a bug, screenshots or description of a new feature, or reasons behind specific changes. + +## Checklist +_This checklist provides a reminder of what we are going to look for when reviewing your PR. You need not complete this checklist prior to creating your PR (draft PRs are always welcome). If you are unsure about an item in the checklist, don't hesitate to ask. We're here to help!_ + +- [ ] My PR is small in size (e.g. less than 1,000 lines of code, not including comments & specs/tests), or I have provided reasons as to why that's not possible. +- [ ] My PR passes [TSLint](https://palantir.github.io/tslint/) validation using `yarn run lint` +- [ ] My PR includes [TypeDoc](https://typedoc.org/) comments for _all new (or modified) public methods and classes_. It also includes TypeDoc for large or complex private methods. +- [ ] My PR passes all specs/tests and includes new/updated specs for any bug fixes, improvements or new features. A few reminders about what constitutes good tests: + * Include tests for different user types (if behavior differs), including: (1) Anonymous user, (2) Logged in user (non-admin), and (3) Administrator. + * Include tests for error scenarios, e.g. when errors/warnings should appear (or buttons should be disabled). + * For bug fixes, include a test that reproduces the bug and proves it is fixed. For clarity, it may be useful to provide the test in a separate commit from the bug fix. +- [ ] If my PR includes new, third-party dependencies (in `package.json`), I've made sure their licenses align with the [DSpace BSD License](https://github.com/DSpace/DSpace/blob/master/LICENSE) based on the [Licensing of Contributions](https://wiki.lyrasis.org/display/DSPACE/Code+Contribution+Guidelines#CodeContributionGuidelines-LicensingofContributions) documentation. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000..b7cb98fe83 --- /dev/null +++ b/LICENSE @@ -0,0 +1,39 @@ +DSpace source code BSD License: + +Copyright (c) 2002-2020, LYRASIS. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +- Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + +- Redistributions in binary form must reproduce the above copyright +notice, this list of conditions and the following disclaimer in the +documentation and/or other materials provided with the distribution. + +- Neither the name DuraSpace nor the name of the DSpace Foundation +nor the names of its contributors may be used to endorse or promote +products derived from this software without specific prior written +permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS +OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR +TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE +USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + + +DSpace uses third-party libraries which may be distributed under +different licenses to the above. Information about these licenses +is detailed in the LICENSES_THIRD_PARTY file at the root of the source +tree. You must agree to the terms of these licenses, in addition to +the above DSpace source code license, in order to use this software. diff --git a/LICENSES_THIRD_PARTY b/LICENSES_THIRD_PARTY new file mode 100644 index 0000000000..c42cc0b255 --- /dev/null +++ b/LICENSES_THIRD_PARTY @@ -0,0 +1,15 @@ + +DSpace uses third-party libraries which may be distributed under different licenses. +A summary of all third-party, production dependencies used by this user interface may be found by running: + + npx license-checker --production --summary + +(Additional license-checker options may be found in its documentation: https://github.com/davglass/license-checker) + +You must agree to the terms of these licenses, in addition to the DSpace source code license, in order to use this +software. + +PLEASE NOTE: Some third-party dependencies may be listed under multiple licenses if they are dual-licensed. +This is especially true of anything listed as GPL (or similar), as DSpace does NOT allow for the inclusion of +any dependencies that are solely released under GPL (or similar) terms. For more info see: +https://wiki.lyrasis.org/display/DSPACE/Code+Contribution+Guidelines#CodeContributionGuidelines-LicensingofContributions diff --git a/resources/i18n/en.json5 b/resources/i18n/en.json5 index 270a3276b3..01a65d9105 100644 --- a/resources/i18n/en.json5 +++ b/resources/i18n/en.json5 @@ -38,7 +38,7 @@ "admin.registries.bitstream-formats.edit.extensions.label": "File extensions", - "admin.registries.bitstream-formats.edit.extensions.placeholder": "Enter a file extenstion without the dot", + "admin.registries.bitstream-formats.edit.extensions.placeholder": "Enter a file extension without the dot", "admin.registries.bitstream-formats.edit.failure.content": "An error occurred while editing the bitstream format.", @@ -268,6 +268,42 @@ + "bitstream.edit.bitstream": "Bitstream: ", + + "bitstream.edit.form.description.hint": "Optionally, provide a brief description of the file, for example \"Main article\" or \"Experiment data readings\".", + + "bitstream.edit.form.description.label": "Description", + + "bitstream.edit.form.embargo.hint": "The first day from which access is allowed. This date cannot be modified on this form. To set an embargo date for a bitstream, go to the Item Status tab, click Authorizations..., create or edit the bitstream's READ policy, and set the Start Date as desired.", + + "bitstream.edit.form.embargo.label": "Embargo until specific date", + + "bitstream.edit.form.fileName.hint": "Change the filename for the bitstream. Note that this will change the display bitstream URL, but old links will still resolve as long as the sequence ID does not change.", + + "bitstream.edit.form.fileName.label": "Filename", + + "bitstream.edit.form.newFormat.label": "Describe new format", + + "bitstream.edit.form.newFormat.hint": "The application you used to create the file, and the version number (for example, \"ACMESoft SuperApp version 1.5\").", + + "bitstream.edit.form.primaryBitstream.label": "Primary bitstream", + + "bitstream.edit.form.selectedFormat.hint": "If the format is not in the above list, select \"format not in list\" above and describe it under \"Describe new format\".", + + "bitstream.edit.form.selectedFormat.label": "Selected Format", + + "bitstream.edit.form.selectedFormat.unknown": "Format not in list", + + "bitstream.edit.notifications.error.format.title": "An error occurred saving the bitstream's format", + + "bitstream.edit.notifications.saved.content": "Your changes to this bitstream were saved.", + + "bitstream.edit.notifications.saved.title": "Bitstream saved", + + "bitstream.edit.title": "Edit bitstream", + + + "browse.comcol.by.author": "By Author", "browse.comcol.by.dateissued": "By Issue Date", @@ -638,6 +674,8 @@ + "error.bitstream": "Error fetching bitstream", + "error.browse-by": "Error fetching items", "error.collection": "Error fetching collection", @@ -745,6 +783,93 @@ + + "item.bitstreams.upload.bundle": "Bundle", + + "item.bitstreams.upload.bundle.placeholder": "Select a bundle", + + "item.bitstreams.upload.bundle.new": "Create bundle", + + "item.bitstreams.upload.bundles.empty": "This item doesn\'t contain any bundles to upload a bitstream to.", + + "item.bitstreams.upload.cancel": "Cancel", + + "item.bitstreams.upload.drop-message": "Drop a file to upload", + + "item.bitstreams.upload.item": "Item: ", + + "item.bitstreams.upload.notifications.bundle.created.content": "Successfully created new bundle.", + + "item.bitstreams.upload.notifications.bundle.created.title": "Created bundle", + + "item.bitstreams.upload.notifications.upload.failed": "Upload failed. Please verify the content before retrying.", + + "item.bitstreams.upload.title": "Upload bitstream", + + + + "item.edit.bitstreams.bundle.edit.buttons.upload": "Upload", + + "item.edit.bitstreams.bundle.displaying": "Currently displaying {{ amount }} bitstreams of {{ total }}.", + + "item.edit.bitstreams.bundle.load.all": "Load all ({{ total }})", + + "item.edit.bitstreams.bundle.load.more": "Load more", + + "item.edit.bitstreams.bundle.name": "BUNDLE: {{ name }}", + + "item.edit.bitstreams.discard-button": "Discard", + + "item.edit.bitstreams.edit.buttons.download": "Download", + + "item.edit.bitstreams.edit.buttons.drag": "Drag", + + "item.edit.bitstreams.edit.buttons.edit": "Edit", + + "item.edit.bitstreams.edit.buttons.remove": "Remove", + + "item.edit.bitstreams.edit.buttons.undo": "Undo changes", + + "item.edit.bitstreams.empty": "This item doesn't contain any bitstreams. Click the upload button to create one.", + + "item.edit.bitstreams.headers.actions": "Actions", + + "item.edit.bitstreams.headers.bundle": "Bundle", + + "item.edit.bitstreams.headers.description": "Description", + + "item.edit.bitstreams.headers.format": "Format", + + "item.edit.bitstreams.headers.name": "Name", + + "item.edit.bitstreams.notifications.discarded.content": "Your changes were discarded. To reinstate your changes click the 'Undo' button", + + "item.edit.bitstreams.notifications.discarded.title": "Changes discarded", + + "item.edit.bitstreams.notifications.move.failed.title": "Error moving bitstreams", + + "item.edit.bitstreams.notifications.move.saved.content": "Your move changes to this item's bitstreams and bundles have been saved.", + + "item.edit.bitstreams.notifications.move.saved.title": "Move changes saved", + + "item.edit.bitstreams.notifications.outdated.content": "The item you're currently working on has been changed by another user. Your current changes are discarded to prevent conflicts", + + "item.edit.bitstreams.notifications.outdated.title": "Changes outdated", + + "item.edit.bitstreams.notifications.remove.failed.title": "Error deleting bitstream", + + "item.edit.bitstreams.notifications.remove.saved.content": "Your removal changes to this item's bitstreams have been saved.", + + "item.edit.bitstreams.notifications.remove.saved.title": "Removal changes saved", + + "item.edit.bitstreams.reinstate-button": "Undo", + + "item.edit.bitstreams.save-button": "Save", + + "item.edit.bitstreams.upload-button": "Upload", + + + "item.edit.delete.cancel": "Cancel", "item.edit.delete.confirm": "Delete", @@ -943,7 +1068,7 @@ - "item.edit.tabs.bitstreams.head": "Item Bitstreams", + "item.edit.tabs.bitstreams.head": "Bitstreams", "item.edit.tabs.bitstreams.title": "Item Edit - Bitstreams", @@ -951,11 +1076,11 @@ "item.edit.tabs.curate.title": "Item Edit - Curate", - "item.edit.tabs.metadata.head": "Item Metadata", + "item.edit.tabs.metadata.head": "Metadata", "item.edit.tabs.metadata.title": "Item Edit - Metadata", - "item.edit.tabs.relationships.head": "Item Relationships", + "item.edit.tabs.relationships.head": "Relationships", "item.edit.tabs.relationships.title": "Item Edit - Relationships", @@ -993,7 +1118,7 @@ "item.edit.tabs.status.description": "Welcome to the item management page. From here you can withdraw, reinstate, move or delete the item. You may also update or add new metadata / bitstreams on the other tabs.", - "item.edit.tabs.status.head": "Item Status", + "item.edit.tabs.status.head": "Status", "item.edit.tabs.status.labels.handle": "Handle", @@ -1162,6 +1287,10 @@ + "loading.bitstream": "Loading bitstream...", + + "loading.bitstreams": "Loading bitstreams...", + "loading.browse-by": "Loading items...", "loading.browse-by-page": "Loading page...", @@ -1204,8 +1333,12 @@ "login.form.new-user": "New user? Click here to register.", + "login.form.or-divider": "or", + "login.form.password": "Password", + "login.form.shibboleth": "Log in with Shibboleth", + "login.form.submit": "Log in", "login.title": "Login", diff --git a/src/app/+bitstream-page/bitstream-page-routing.module.ts b/src/app/+bitstream-page/bitstream-page-routing.module.ts new file mode 100644 index 0000000000..14d688064c --- /dev/null +++ b/src/app/+bitstream-page/bitstream-page-routing.module.ts @@ -0,0 +1,30 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { EditBitstreamPageComponent } from './edit-bitstream-page/edit-bitstream-page.component'; +import { AuthenticatedGuard } from '../core/auth/authenticated.guard'; +import { BitstreamPageResolver } from './bitstream-page.resolver'; + +const EDIT_BITSTREAM_PATH = ':id/edit'; + +/** + * Routing module to help navigate Bitstream pages + */ +@NgModule({ + imports: [ + RouterModule.forChild([ + { + path: EDIT_BITSTREAM_PATH, + component: EditBitstreamPageComponent, + resolve: { + bitstream: BitstreamPageResolver + }, + canActivate: [AuthenticatedGuard] + } + ]) + ], + providers: [ + BitstreamPageResolver, + ] +}) +export class BitstreamPageRoutingModule { +} diff --git a/src/app/+bitstream-page/bitstream-page.module.ts b/src/app/+bitstream-page/bitstream-page.module.ts new file mode 100644 index 0000000000..24b4cd512f --- /dev/null +++ b/src/app/+bitstream-page/bitstream-page.module.ts @@ -0,0 +1,21 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '../shared/shared.module'; +import { EditBitstreamPageComponent } from './edit-bitstream-page/edit-bitstream-page.component'; +import { BitstreamPageRoutingModule } from './bitstream-page-routing.module'; + +/** + * This module handles all components that are necessary for Bitstream related pages + */ +@NgModule({ + imports: [ + CommonModule, + SharedModule, + BitstreamPageRoutingModule + ], + declarations: [ + EditBitstreamPageComponent + ] +}) +export class BitstreamPageModule { +} diff --git a/src/app/+bitstream-page/bitstream-page.resolver.ts b/src/app/+bitstream-page/bitstream-page.resolver.ts new file mode 100644 index 0000000000..8e9f64fcc1 --- /dev/null +++ b/src/app/+bitstream-page/bitstream-page.resolver.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; +import { RemoteData } from '../core/data/remote-data'; +import { Observable } from 'rxjs/internal/Observable'; +import { find } from 'rxjs/operators'; +import { hasValue } from '../shared/empty.util'; +import { Bitstream } from '../core/shared/bitstream.model'; +import { BitstreamDataService } from '../core/data/bitstream-data.service'; + +/** + * This class represents a resolver that requests a specific bitstream before the route is activated + */ +@Injectable() +export class BitstreamPageResolver implements Resolve> { + constructor(private bitstreamService: BitstreamDataService) { + } + + /** + * Method for resolving a bitstream based on the parameters in the current route + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + * @param {RouterStateSnapshot} state The current RouterStateSnapshot + * @returns Observable<> Emits the found bitstream based on the parameters in the current route, + * or an error if something went wrong + */ + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable> { + return this.bitstreamService.findById(route.params.id) + .pipe( + find((RD) => hasValue(RD.error) || RD.hasSucceeded), + ); + } +} diff --git a/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.html b/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.html new file mode 100644 index 0000000000..fd13e249a0 --- /dev/null +++ b/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.html @@ -0,0 +1,29 @@ + +
+
+
+ +
+
+
+
+
+

{{bitstreamRD?.payload?.name}} ({{bitstreamRD?.payload?.sizeBytes | dsFileSize}})

+
+
+
+ +
+
+ + +
+
diff --git a/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.scss b/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.scss new file mode 100644 index 0000000000..d212b5347c --- /dev/null +++ b/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.scss @@ -0,0 +1,8 @@ +:host { + ::ng-deep { + .switch { + position: absolute; + top: $spacer*2.5; + } + } +} diff --git a/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.spec.ts b/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.spec.ts new file mode 100644 index 0000000000..c802622dc4 --- /dev/null +++ b/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.spec.ts @@ -0,0 +1,216 @@ +import { EditBitstreamPageComponent } from './edit-bitstream-page.component'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { RouterTestingModule } from '@angular/router/testing'; +import { RemoteData } from '../../core/data/remote-data'; +import { of as observableOf } from 'rxjs/internal/observable/of'; +import { ActivatedRoute } from '@angular/router'; +import { DynamicFormControlModel, DynamicFormService } from '@ng-dynamic-forms/core'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { BitstreamDataService } from '../../core/data/bitstream-data.service'; +import { ChangeDetectorRef, NO_ERRORS_SCHEMA } from '@angular/core'; +import { BitstreamFormatDataService } from '../../core/data/bitstream-format-data.service'; +import { Bitstream } from '../../core/shared/bitstream.model'; +import { NotificationType } from '../../shared/notifications/models/notification-type'; +import { INotification, Notification } from '../../shared/notifications/models/notification.model'; +import { BitstreamFormat } from '../../core/shared/bitstream-format.model'; +import { BitstreamFormatSupportLevel } from '../../core/shared/bitstream-format-support-level'; +import { hasValue } from '../../shared/empty.util'; +import { FormControl, FormGroup } from '@angular/forms'; +import { PaginatedList } from '../../core/data/paginated-list'; +import { PageInfo } from '../../core/shared/page-info.model'; +import { FileSizePipe } from '../../shared/utils/file-size-pipe'; +import { RestResponse } from '../../core/cache/response.models'; +import { VarDirective } from '../../shared/utils/var.directive'; + +const infoNotification: INotification = new Notification('id', NotificationType.Info, 'info'); +const warningNotification: INotification = new Notification('id', NotificationType.Warning, 'warning'); +const successNotification: INotification = new Notification('id', NotificationType.Success, 'success'); + +let notificationsService: NotificationsService; +let formService: DynamicFormService; +let bitstreamService: BitstreamDataService; +let bitstreamFormatService: BitstreamFormatDataService; +let bitstream: Bitstream; +let selectedFormat: BitstreamFormat; +let allFormats: BitstreamFormat[]; + +describe('EditBitstreamPageComponent', () => { + let comp: EditBitstreamPageComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + allFormats = [ + Object.assign({ + id: '1', + shortDescription: 'Unknown', + description: 'Unknown format', + supportLevel: BitstreamFormatSupportLevel.Unknown, + _links: { + self: { href: 'format-selflink-1' } + } + }), + Object.assign({ + id: '2', + shortDescription: 'PNG', + description: 'Portable Network Graphics', + supportLevel: BitstreamFormatSupportLevel.Known, + _links: { + self: { href: 'format-selflink-2' } + } + }), + Object.assign({ + id: '3', + shortDescription: 'GIF', + description: 'Graphics Interchange Format', + supportLevel: BitstreamFormatSupportLevel.Known, + _links: { + self: { href: 'format-selflink-3' } + } + }) + ] as BitstreamFormat[]; + selectedFormat = allFormats[1]; + notificationsService = jasmine.createSpyObj('notificationsService', + { + info: infoNotification, + warning: warningNotification, + success: successNotification + } + ); + formService = Object.assign({ + createFormGroup: (fModel: DynamicFormControlModel[]) => { + const controls = {}; + if (hasValue(fModel)) { + fModel.forEach((controlModel) => { + controls[controlModel.id] = new FormControl((controlModel as any).value); + }); + return new FormGroup(controls); + } + return undefined; + } + }); + bitstream = Object.assign(new Bitstream(), { + metadata: { + 'dc.description': [ + { + value: 'Bitstream description' + } + ], + 'dc.title': [ + { + value: 'Bitstream title' + } + ] + }, + format: observableOf(new RemoteData(false, false, true, null, selectedFormat)), + _links: { + self: 'bitstream-selflink' + } + }); + bitstreamService = jasmine.createSpyObj('bitstreamService', { + findById: observableOf(new RemoteData(false, false, true, null, bitstream)), + update: observableOf(new RemoteData(false, false, true, null, bitstream)), + updateFormat: observableOf(new RestResponse(true, 200, 'OK')), + commitUpdates: {}, + patch: {} + }); + bitstreamFormatService = jasmine.createSpyObj('bitstreamFormatService', { + findAll: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), allFormats))) + }); + + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), RouterTestingModule], + declarations: [EditBitstreamPageComponent, FileSizePipe, VarDirective], + providers: [ + { provide: NotificationsService, useValue: notificationsService }, + { provide: DynamicFormService, useValue: formService }, + { provide: ActivatedRoute, useValue: { data: observableOf({ bitstream: new RemoteData(false, false, true, null, bitstream) }), snapshot: { queryParams: {} } } }, + { provide: BitstreamDataService, useValue: bitstreamService }, + { provide: BitstreamFormatDataService, useValue: bitstreamFormatService }, + ChangeDetectorRef + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + + })); + + beforeEach(() => { + fixture = TestBed.createComponent(EditBitstreamPageComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + }); + + describe('on startup', () => { + let rawForm; + + beforeEach(() => { + rawForm = comp.formGroup.getRawValue(); + }); + + it('should fill in the bitstream\'s title', () => { + expect(rawForm.fileNamePrimaryContainer.fileName).toEqual(bitstream.name); + }); + + it('should fill in the bitstream\'s description', () => { + expect(rawForm.descriptionContainer.description).toEqual(bitstream.firstMetadataValue('dc.description')); + }); + + it('should select the correct format', () => { + expect(rawForm.formatContainer.selectedFormat).toEqual(selectedFormat.id); + }); + + it('should put the \"New Format\" input on invisible', () => { + expect(comp.formLayout.newFormat.grid.host).toContain('invisible'); + }); + }); + + describe('when an unknown format is selected', () => { + beforeEach(() => { + comp.updateNewFormatLayout(allFormats[0].id); + }); + + it('should remove the invisible class from the \"New Format\" input', () => { + expect(comp.formLayout.newFormat.grid.host).not.toContain('invisible'); + }); + }); + + describe('onSubmit', () => { + describe('when selected format hasn\'t changed', () => { + beforeEach(() => { + comp.onSubmit(); + }); + + it('should call update', () => { + expect(bitstreamService.update).toHaveBeenCalled(); + }); + + it('should commit the updates', () => { + expect(bitstreamService.commitUpdates).toHaveBeenCalled(); + }); + }); + + describe('when selected format has changed', () => { + beforeEach(() => { + comp.formGroup.patchValue({ + formatContainer: { + selectedFormat: allFormats[2].id + } + }); + fixture.detectChanges(); + comp.onSubmit(); + }); + + it('should call update', () => { + expect(bitstreamService.update).toHaveBeenCalled(); + }); + + it('should call updateFormat', () => { + expect(bitstreamService.updateFormat).toHaveBeenCalled(); + }); + + it('should commit the updates', () => { + expect(bitstreamService.commitUpdates).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.ts b/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.ts new file mode 100644 index 0000000000..cce6932cd1 --- /dev/null +++ b/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.ts @@ -0,0 +1,524 @@ +import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core'; +import { Bitstream } from '../../core/shared/bitstream.model'; +import { ActivatedRoute, Router } from '@angular/router'; +import { filter, map, switchMap } from 'rxjs/operators'; +import { combineLatest as observableCombineLatest, of as observableOf } from 'rxjs'; +import { Subscription } from 'rxjs/internal/Subscription'; +import { + DynamicFormControlModel, + DynamicFormGroupModel, + DynamicFormLayout, + DynamicFormService, + DynamicInputModel, + DynamicSelectModel, + DynamicTextAreaModel +} from '@ng-dynamic-forms/core'; +import { FormGroup } from '@angular/forms'; +import { TranslateService } from '@ngx-translate/core'; +import { DynamicCustomSwitchModel } from '../../shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.model'; +import { cloneDeep } from 'lodash'; +import { BitstreamDataService } from '../../core/data/bitstream-data.service'; +import { + getAllSucceededRemoteData, getAllSucceededRemoteDataPayload, + getFirstSucceededRemoteDataPayload, + getRemoteDataPayload, + getSucceededRemoteData +} from '../../core/shared/operators'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { BitstreamFormatDataService } from '../../core/data/bitstream-format-data.service'; +import { BitstreamFormat } from '../../core/shared/bitstream-format.model'; +import { BitstreamFormatSupportLevel } from '../../core/shared/bitstream-format-support-level'; +import { RestResponse } from '../../core/cache/response.models'; +import { hasValue, isNotEmpty } from '../../shared/empty.util'; +import { Metadata } from '../../core/shared/metadata.utils'; +import { Location } from '@angular/common'; +import { Observable } from 'rxjs/internal/Observable'; +import { RemoteData } from '../../core/data/remote-data'; +import { PaginatedList } from '../../core/data/paginated-list'; +import { followLink } from '../../shared/utils/follow-link-config.model'; +import { getItemEditPath } from '../../+item-page/item-page-routing.module'; + +@Component({ + selector: 'ds-edit-bitstream-page', + styleUrls: ['./edit-bitstream-page.component.scss'], + templateUrl: './edit-bitstream-page.component.html', + changeDetection: ChangeDetectionStrategy.OnPush +}) +/** + * Page component for editing a bitstream + */ +export class EditBitstreamPageComponent implements OnInit, OnDestroy { + + /** + * The bitstream's remote data observable + * Tracks changes and updates the view + */ + bitstreamRD$: Observable>; + + /** + * The formats their remote data observable + * Tracks changes and updates the view + */ + bitstreamFormatsRD$: Observable>>; + + /** + * The bitstream to edit + */ + bitstream: Bitstream; + + /** + * The originally selected format + */ + originalFormat: BitstreamFormat; + + /** + * A list of all available bitstream formats + */ + formats: BitstreamFormat[]; + + /** + * @type {string} Key prefix used to generate form messages + */ + KEY_PREFIX = 'bitstream.edit.form.'; + + /** + * @type {string} Key suffix used to generate form labels + */ + LABEL_KEY_SUFFIX = '.label'; + + /** + * @type {string} Key suffix used to generate form labels + */ + HINT_KEY_SUFFIX = '.hint'; + + /** + * @type {string} Key prefix used to generate notification messages + */ + NOTIFICATIONS_PREFIX = 'bitstream.edit.notifications.'; + + /** + * Options for fetching all bitstream formats + */ + findAllOptions = { elementsPerPage: 9999 }; + + /** + * The Dynamic Input Model for the file's name + */ + fileNameModel = new DynamicInputModel({ + id: 'fileName', + name: 'fileName', + required: true, + validators: { + required: null + }, + errorMessages: { + required: 'You must provide a file name for the bitstream' + } + }); + + /** + * The Dynamic Switch Model for the file's name + */ + primaryBitstreamModel = new DynamicCustomSwitchModel({ + id: 'primaryBitstream', + name: 'primaryBitstream' + }); + + /** + * The Dynamic TextArea Model for the file's description + */ + descriptionModel = new DynamicTextAreaModel({ + id: 'description', + name: 'description', + rows: 10 + }); + + /** + * The Dynamic Input Model for the file's embargo (disabled on this page) + */ + embargoModel = new DynamicInputModel({ + id: 'embargo', + name: 'embargo', + disabled: true + }); + + /** + * The Dynamic Input Model for the selected format + */ + selectedFormatModel = new DynamicSelectModel({ + id: 'selectedFormat', + name: 'selectedFormat' + }); + + /** + * The Dynamic Input Model for supplying more format information + */ + newFormatModel = new DynamicInputModel({ + id: 'newFormat', + name: 'newFormat' + }); + + /** + * All input models in a simple array for easier iterations + */ + inputModels = [this.fileNameModel, this.primaryBitstreamModel, this.descriptionModel, this.embargoModel, this.selectedFormatModel, this.newFormatModel]; + + /** + * The dynamic form fields used for editing the information of a bitstream + * @type {(DynamicInputModel | DynamicTextAreaModel)[]} + */ + formModel: DynamicFormControlModel[] = [ + new DynamicFormGroupModel({ + id: 'fileNamePrimaryContainer', + group: [ + this.fileNameModel, + this.primaryBitstreamModel + ] + }), + new DynamicFormGroupModel({ + id: 'descriptionContainer', + group: [ + this.descriptionModel + ] + }), + new DynamicFormGroupModel({ + id: 'embargoContainer', + group: [ + this.embargoModel + ] + }), + new DynamicFormGroupModel({ + id: 'formatContainer', + group: [ + this.selectedFormatModel, + this.newFormatModel + ] + }) + ]; + + /** + * The base layout of the "Other Format" input + */ + newFormatBaseLayout = 'col col-sm-6 d-inline-block'; + + /** + * Layout used for structuring the form inputs + */ + formLayout: DynamicFormLayout = { + fileName: { + grid: { + host: 'col col-sm-8 d-inline-block' + } + }, + primaryBitstream: { + grid: { + host: 'col col-sm-4 d-inline-block switch' + } + }, + description: { + grid: { + host: 'col-12 d-inline-block' + } + }, + embargo: { + grid: { + host: 'col-12 d-inline-block' + } + }, + selectedFormat: { + grid: { + host: 'col col-sm-6 d-inline-block' + } + }, + newFormat: { + grid: { + host: this.newFormatBaseLayout + ' invisible' + } + }, + fileNamePrimaryContainer: { + grid: { + host: 'row position-relative' + } + }, + descriptionContainer: { + grid: { + host: 'row' + } + }, + embargoContainer: { + grid: { + host: 'row' + } + }, + formatContainer: { + grid: { + host: 'row' + } + } + }; + + /** + * The form group of this form + */ + formGroup: FormGroup; + + /** + * The ID of the item the bitstream originates from + * Taken from the current query parameters when present + */ + itemId: string; + + /** + * Array to track all subscriptions and unsubscribe them onDestroy + * @type {Array} + */ + protected subs: Subscription[] = []; + + constructor(private route: ActivatedRoute, + private router: Router, + private location: Location, + private formService: DynamicFormService, + private translate: TranslateService, + private bitstreamService: BitstreamDataService, + private notificationsService: NotificationsService, + private bitstreamFormatService: BitstreamFormatDataService) { + } + + /** + * Initialize the component + * - Create a FormGroup using the FormModel defined earlier + * - Subscribe on the route data to fetch the bitstream to edit and update the form values + * - Translate the form labels and hints + */ + ngOnInit(): void { + this.formGroup = this.formService.createFormGroup(this.formModel); + + this.itemId = this.route.snapshot.queryParams.itemId; + this.bitstreamRD$ = this.route.data.pipe(map((data) => data.bitstream)); + this.bitstreamFormatsRD$ = this.bitstreamFormatService.findAll(this.findAllOptions); + + const bitstream$ = this.bitstreamRD$.pipe( + getSucceededRemoteData(), + getRemoteDataPayload(), + switchMap((bitstream: Bitstream) => this.bitstreamService.findById(bitstream.id, followLink('format')).pipe( + getAllSucceededRemoteData(), + getRemoteDataPayload(), + filter((bs: Bitstream) => hasValue(bs))) + ) + ); + + const allFormats$ = this.bitstreamFormatsRD$.pipe( + getSucceededRemoteData(), + getRemoteDataPayload() + ); + + this.subs.push( + observableCombineLatest( + bitstream$, + allFormats$ + ).subscribe(([bitstream, allFormats]) => { + this.bitstream = bitstream as Bitstream; + this.formats = allFormats.page; + this.updateFormatModel(); + this.updateForm(this.bitstream); + }) + ); + + this.updateFieldTranslations(); + + this.subs.push( + this.translate.onLangChange + .subscribe(() => { + this.updateFieldTranslations(); + }) + ); + }; + + /** + * Update the current form values with bitstream properties + * @param bitstream + */ + updateForm(bitstream: Bitstream) { + this.formGroup.patchValue({ + fileNamePrimaryContainer: { + fileName: bitstream.name, + primaryBitstream: false + }, + descriptionContainer: { + description: bitstream.firstMetadataValue('dc.description') + }, + formatContainer: { + newFormat: hasValue(bitstream.firstMetadata('dc.format')) ? bitstream.firstMetadata('dc.format').value : undefined + } + }); + this.bitstream.format.pipe( + getAllSucceededRemoteDataPayload() + ).subscribe((format: BitstreamFormat) => { + this.originalFormat = format; + this.formGroup.patchValue({ + formatContainer: { + selectedFormat: format.id + } + }); + this.updateNewFormatLayout(format.id); + }); + } + + /** + * Create the list of unknown format IDs an add options to the selectedFormatModel + */ + updateFormatModel() { + this.selectedFormatModel.options = this.formats.map((format: BitstreamFormat) => + Object.assign({ + value: format.id, + label: this.isUnknownFormat(format.id) ? this.translate.instant(this.KEY_PREFIX + 'selectedFormat.unknown') : format.shortDescription + })); + } + + /** + * Update the layout of the "Other Format" input depending on the selected format + * @param selectedId + */ + updateNewFormatLayout(selectedId: string) { + if (this.isUnknownFormat(selectedId)) { + this.formLayout.newFormat.grid.host = this.newFormatBaseLayout; + } else { + this.formLayout.newFormat.grid.host = this.newFormatBaseLayout + ' invisible'; + } + } + + /** + * Is the provided format (id) part of the list of unknown formats? + * @param id + */ + isUnknownFormat(id: string): boolean { + const format = this.formats.find((f: BitstreamFormat) => f.id === id); + return hasValue(format) && format.supportLevel === BitstreamFormatSupportLevel.Unknown; + } + + /** + * Used to update translations of labels and hints on init and on language change + */ + private updateFieldTranslations() { + this.inputModels.forEach( + (fieldModel: DynamicFormControlModel) => { + this.updateFieldTranslation(fieldModel); + } + ); + } + + /** + * Update the translations of a DynamicFormControlModel + * @param fieldModel + */ + private updateFieldTranslation(fieldModel) { + fieldModel.label = this.translate.instant(this.KEY_PREFIX + fieldModel.id + this.LABEL_KEY_SUFFIX); + if (fieldModel.id !== this.primaryBitstreamModel.id) { + fieldModel.hint = this.translate.instant(this.KEY_PREFIX + fieldModel.id + this.HINT_KEY_SUFFIX); + } + } + + /** + * Fired whenever the form receives an update and changes the layout of the "Other Format" input, depending on the selected format + * @param event + */ + onChange(event) { + const model = event.model; + if (model.id === this.selectedFormatModel.id) { + this.updateNewFormatLayout(model.value); + } + } + + /** + * Check for changes against the bitstream and send update requests to the REST API + */ + onSubmit() { + const updatedValues = this.formGroup.getRawValue(); + const updatedBitstream = this.formToBitstream(updatedValues); + const selectedFormat = this.formats.find((f: BitstreamFormat) => f.id === updatedValues.formatContainer.selectedFormat); + const isNewFormat = selectedFormat.id !== this.originalFormat.id; + + let bitstream$; + + if (isNewFormat) { + bitstream$ = this.bitstreamService.updateFormat(this.bitstream, selectedFormat).pipe( + switchMap((formatResponse: RestResponse) => { + if (hasValue(formatResponse) && !formatResponse.isSuccessful) { + this.notificationsService.error( + this.translate.instant(this.NOTIFICATIONS_PREFIX + 'error.format.title'), + formatResponse.statusText + ); + } else { + return this.bitstreamService.findById(this.bitstream.id).pipe( + getFirstSucceededRemoteDataPayload() + ); + } + }) + ); + } else { + bitstream$ = observableOf(this.bitstream); + } + + bitstream$.pipe( + switchMap(() => { + return this.bitstreamService.update(updatedBitstream).pipe( + getFirstSucceededRemoteDataPayload() + ); + }) + ).subscribe(() => { + this.bitstreamService.commitUpdates(); + this.notificationsService.success( + this.translate.instant(this.NOTIFICATIONS_PREFIX + 'saved.title'), + this.translate.instant(this.NOTIFICATIONS_PREFIX + 'saved.content') + ); + this.navigateToItemEditBitstreams(); + }); + } + + /** + * Parse form data to an updated bitstream object + * @param rawForm Raw form data + */ + formToBitstream(rawForm): Bitstream { + const updatedBitstream = cloneDeep(this.bitstream); + const newMetadata = updatedBitstream.metadata; + // TODO: Set bitstream to primary when supported + const primary = rawForm.fileNamePrimaryContainer.primaryBitstream; + Metadata.setFirstValue(newMetadata, 'dc.title', rawForm.fileNamePrimaryContainer.fileName); + Metadata.setFirstValue(newMetadata, 'dc.description', rawForm.descriptionContainer.description); + if (isNotEmpty(rawForm.formatContainer.newFormat)) { + Metadata.setFirstValue(newMetadata, 'dc.format', rawForm.formatContainer.newFormat); + } + updatedBitstream.metadata = newMetadata; + return updatedBitstream; + } + + /** + * Cancel the form and return to the previous page + */ + onCancel() { + this.navigateToItemEditBitstreams(); + } + + /** + * When the item ID is present, navigate back to the item's edit bitstreams page, otherwise go back to the previous + * page the user came from + */ + navigateToItemEditBitstreams() { + if (hasValue(this.itemId)) { + this.router.navigate([getItemEditPath(this.itemId), 'bitstreams']); + } else { + this.location.back(); + } + } + + /** + * Unsubscribe from open subscriptions + */ + ngOnDestroy(): void { + this.subs + .filter((subscription) => hasValue(subscription)) + .forEach((subscription) => subscription.unsubscribe()); + } + +} diff --git a/src/app/+collection-page/collection-page-routing.module.ts b/src/app/+collection-page/collection-page-routing.module.ts index b51c74b457..589defa331 100644 --- a/src/app/+collection-page/collection-page-routing.module.ts +++ b/src/app/+collection-page/collection-page-routing.module.ts @@ -34,6 +34,11 @@ const COLLECTION_EDIT_PATH = 'edit'; @NgModule({ imports: [ RouterModule.forChild([ + { + path: COLLECTION_CREATE_PATH, + component: CreateCollectionPageComponent, + canActivate: [AuthenticatedGuard, CreateCollectionPageGuard] + }, { path: ':id', resolve: { @@ -66,11 +71,6 @@ const COLLECTION_EDIT_PATH = 'edit'; } ] }, - { - path: COLLECTION_CREATE_PATH, - component: CreateCollectionPageComponent, - canActivate: [AuthenticatedGuard, CreateCollectionPageGuard] - }, ]) ], providers: [ diff --git a/src/app/+community-page/community-page-routing.module.ts b/src/app/+community-page/community-page-routing.module.ts index 0e2407577c..9922bc2c01 100644 --- a/src/app/+community-page/community-page-routing.module.ts +++ b/src/app/+community-page/community-page-routing.module.ts @@ -33,6 +33,11 @@ const COMMUNITY_EDIT_PATH = 'edit'; @NgModule({ imports: [ RouterModule.forChild([ + { + path: COMMUNITY_CREATE_PATH, + component: CreateCommunityPageComponent, + canActivate: [AuthenticatedGuard, CreateCommunityPageGuard] + }, { path: ':id', resolve: { @@ -59,11 +64,6 @@ const COMMUNITY_EDIT_PATH = 'edit'; } ] }, - { - path: COMMUNITY_CREATE_PATH, - component: CreateCommunityPageComponent, - canActivate: [AuthenticatedGuard, CreateCommunityPageGuard] - }, ]) ], providers: [ diff --git a/src/app/+item-page/bitstreams/upload/upload-bitstream.component.html b/src/app/+item-page/bitstreams/upload/upload-bitstream.component.html new file mode 100644 index 0000000000..289ede209a --- /dev/null +++ b/src/app/+item-page/bitstreams/upload/upload-bitstream.component.html @@ -0,0 +1,41 @@ +
+ +
+
+

{{'item.bitstreams.upload.title' | translate}}

+ +
+ {{'item.bitstreams.upload.item' | translate}} + {{item.name}} +
+
+
+
+ + + + + + +
+
+
+
diff --git a/src/app/+item-page/bitstreams/upload/upload-bitstream.component.spec.ts b/src/app/+item-page/bitstreams/upload/upload-bitstream.component.spec.ts new file mode 100644 index 0000000000..061d7014a8 --- /dev/null +++ b/src/app/+item-page/bitstreams/upload/upload-bitstream.component.spec.ts @@ -0,0 +1,236 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { CommonModule } from '@angular/common'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { ActivatedRoute, Router } from '@angular/router'; +import { ItemDataService } from '../../../core/data/item-data.service'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { UploadBitstreamComponent } from './upload-bitstream.component'; +import { AuthService } from '../../../core/auth/auth.service'; +import { AuthServiceStub } from '../../../shared/testing/auth-service-stub'; +import { Item } from '../../../core/shared/item.model'; +import { of as observableOf } from 'rxjs'; +import { + createPaginatedList, + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$ +} from '../../../shared/testing/utils'; +import { RouterStub } from '../../../shared/testing/router-stub'; +import { NotificationsServiceStub } from '../../../shared/testing/notifications-service-stub'; +import { VarDirective } from '../../../shared/utils/var.directive'; +import { Bitstream } from '../../../core/shared/bitstream.model'; +import { BundleDataService } from '../../../core/data/bundle-data.service'; +import { Bundle } from '../../../core/shared/bundle.model'; +import { RequestService } from '../../../core/data/request.service'; + +describe('UploadBistreamComponent', () => { + let comp: UploadBitstreamComponent; + let fixture: ComponentFixture; + + const bundle = Object.assign(new Bundle(), { + id: 'bundle', + uuid: 'bundle', + metadata: { + 'dc.title': [ + { + value: 'bundleName', + language: null + } + ] + }, + _links: { + self: { href: 'bundle-selflink' } + } + }); + const customName = 'Custom Name'; + const createdBundle = Object.assign(new Bundle(), { + id: 'created-bundle', + uuid: 'created-bundle', + metadata: { + 'dc.title': [ + { + value: customName, + language: null + } + ] + }, + _links: { + self: { href: 'created-bundle-selflink' } + } + }); + const itemName = 'fake-name'; + const mockItem = Object.assign(new Item(), { + id: 'fake-id', + handle: 'fake/handle', + metadata: { + 'dc.title': [ + { + language: null, + value: itemName + } + ] + }, + bundles: createSuccessfulRemoteDataObject$(createPaginatedList([bundle])) + }); + let routeStub; + const routerStub = new RouterStub(); + const restEndpoint = 'fake-rest-endpoint'; + const mockItemDataService = jasmine.createSpyObj('mockItemDataService', { + getBitstreamsEndpoint: observableOf(restEndpoint), + createBundle: createSuccessfulRemoteDataObject$(createdBundle) + }); + const bundleService = jasmine.createSpyObj('bundleService', { + getBitstreamsEndpoint: observableOf(restEndpoint), + findById: createSuccessfulRemoteDataObject$(bundle) + }); + const authToken = 'fake-auth-token'; + const authServiceStub = Object.assign(new AuthServiceStub(), { + buildAuthHeader: () => authToken + }); + const notificationsServiceStub = new NotificationsServiceStub(); + const uploaderComponent = jasmine.createSpyObj('uploaderComponent', ['ngOnInit']); + const requestService = jasmine.createSpyObj('requestService', { + removeByHrefSubstring: {} + }); + + describe('when a file is uploaded', () => { + beforeEach(async(() => { + createUploadBitstreamTestingModule({}); + })); + + beforeEach(() => { + loadFixtureAndComp(); + }); + + describe('and it fails, calling onUploadError', () => { + beforeEach(() => { + comp.onUploadError(); + }); + + it('should display an error notification', () => { + expect(notificationsServiceStub.error).toHaveBeenCalled(); + }); + }); + + describe('and it succeeds, calling onCompleteItem', () => { + const createdBitstream = Object.assign(new Bitstream(), { + id: 'fake-bitstream' + }); + + beforeEach(() => { + comp.onCompleteItem(createdBitstream); + }); + + it('should navigate the user to the next page', () => { + expect(routerStub.navigate).toHaveBeenCalled(); + }); + }); + }); + + describe('when a bundle url parameter is present', () => { + beforeEach(async(() => { + createUploadBitstreamTestingModule({ + bundle: bundle.id + }); + })); + + beforeEach(() => { + loadFixtureAndComp(); + }); + + it('should set the selected id to the bundle\'s id', () => { + expect(comp.selectedBundleId).toEqual(bundle.id); + }); + + it('should set the selected name to the bundle\'s name', () => { + expect(comp.selectedBundleName).toEqual(bundle.name); + }); + + describe('and bundle name changed', () => { + beforeEach(() => { + comp.bundleNameChange(); + }); + + it('should clear out the selected id', () => { + expect(comp.selectedBundleId).toBeUndefined(); + }); + }); + }); + + describe('when a name is filled in, but no ID is selected', () => { + beforeEach(async(() => { + createUploadBitstreamTestingModule({}); + })); + + beforeEach(() => { + loadFixtureAndComp(); + comp.selectedBundleName = customName; + }); + + describe('createBundle', () => { + beforeEach(() => { + comp.createBundle(); + }); + + it('should create a new bundle', () => { + expect(mockItemDataService.createBundle).toHaveBeenCalledWith(mockItem.id, customName); + }); + + it('should set the selected id to the id of the new bundle', () => { + expect(comp.selectedBundleId).toEqual(createdBundle.id); + }); + + it('should display a success notification', () => { + expect(notificationsServiceStub.success).toHaveBeenCalled(); + }); + }); + }); + + /** + * Setup an UploadBitstreamComponent testing module with custom queryParams for the route + * @param queryParams + */ + function createUploadBitstreamTestingModule(queryParams) { + routeStub = { + data: observableOf({ + item: createSuccessfulRemoteDataObject(mockItem) + }), + queryParams: observableOf(queryParams), + snapshot: { + queryParams: queryParams, + params: { + id: mockItem.id + } + } + }; + + TestBed.configureTestingModule({ + imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule], + declarations: [UploadBitstreamComponent, VarDirective], + providers: [ + { provide: ActivatedRoute, useValue: routeStub }, + { provide: Router, useValue: routerStub }, + { provide: ItemDataService, useValue: mockItemDataService }, + { provide: NotificationsService, useValue: notificationsServiceStub }, + { provide: AuthService, useValue: authServiceStub }, + { provide: BundleDataService, useValue: bundleService }, + { provide: RequestService, useValue: requestService } + ], schemas: [ + NO_ERRORS_SCHEMA + ] + }).compileComponents(); + } + + /** + * Load the TestBed's fixture and component + */ + function loadFixtureAndComp() { + fixture = TestBed.createComponent(UploadBitstreamComponent); + comp = fixture.componentInstance; + comp.uploaderComponent = uploaderComponent; + fixture.detectChanges(); + } + +}); diff --git a/src/app/+item-page/bitstreams/upload/upload-bitstream.component.ts b/src/app/+item-page/bitstreams/upload/upload-bitstream.component.ts new file mode 100644 index 0000000000..57f6b4dc55 --- /dev/null +++ b/src/app/+item-page/bitstreams/upload/upload-bitstream.component.ts @@ -0,0 +1,217 @@ +import { ChangeDetectionStrategy, Component, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { Observable } from 'rxjs/internal/Observable'; +import { RemoteData } from '../../../core/data/remote-data'; +import { Item } from '../../../core/shared/item.model'; +import { map, switchMap, take } from 'rxjs/operators'; +import { ActivatedRoute, Router } from '@angular/router'; +import { UploaderOptions } from '../../../shared/uploader/uploader-options.model'; +import { Subscription } from 'rxjs/internal/Subscription'; +import { hasValue, isEmpty, isNotEmpty } from '../../../shared/empty.util'; +import { ItemDataService } from '../../../core/data/item-data.service'; +import { AuthService } from '../../../core/auth/auth.service'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; +import { getBitstreamModulePath } from '../../../app-routing.module'; +import { PaginatedList } from '../../../core/data/paginated-list'; +import { Bundle } from '../../../core/shared/bundle.model'; +import { BundleDataService } from '../../../core/data/bundle-data.service'; +import { + getFirstSucceededRemoteDataPayload +} from '../../../core/shared/operators'; +import { UploaderComponent } from '../../../shared/uploader/uploader.component'; +import { getItemEditPath } from '../../item-page-routing.module'; +import { RequestService } from '../../../core/data/request.service'; + +@Component({ + selector: 'ds-upload-bitstream', + templateUrl: './upload-bitstream.component.html' +}) +/** + * Page component for uploading a bitstream to an item + */ +export class UploadBitstreamComponent implements OnInit, OnDestroy { + /** + * The file uploader component + */ + @ViewChild(UploaderComponent, {static: false}) uploaderComponent: UploaderComponent; + + /** + * The ID of the item to upload a bitstream to + */ + itemId: string; + + /** + * The item to upload a bitstream to + */ + itemRD$: Observable>; + + /** + * The item's bundles + */ + bundlesRD$: Observable>>; + + /** + * The ID of the currently selected bundle to upload a bitstream to + */ + selectedBundleId: string; + + /** + * The name of the currently selected bundle to upload a bitstream to + */ + selectedBundleName: string; + + /** + * The uploader configuration options + * @type {UploaderOptions} + */ + uploadFilesOptions: UploaderOptions = Object.assign(new UploaderOptions(), { + // URL needs to contain something to not produce any errors. This will be replaced once a bundle has been selected. + url: 'placeholder', + authToken: null, + disableMultipart: false, + itemAlias: null + }); + + /** + * The prefix for all i18n notification messages within this component + */ + NOTIFICATIONS_PREFIX = 'item.bitstreams.upload.notifications.'; + + /** + * Array to track all subscriptions and unsubscribe them onDestroy + * @type {Array} + */ + subs: Subscription[] = []; + + constructor(protected route: ActivatedRoute, + protected router: Router, + protected itemService: ItemDataService, + protected bundleService: BundleDataService, + protected authService: AuthService, + protected notificationsService: NotificationsService, + protected translate: TranslateService, + protected requestService: RequestService) { + } + + /** + * Initialize component properties: + * itemRD$ Fetched from the current route data (populated by BitstreamPageResolver) + * bundlesRD$ List of bundles on the item + * selectedBundleId Starts off by checking if the route's queryParams contain a "bundle" parameter. If none is found, + * the ID of the first bundle in the list is selected. + * Calls setUploadUrl after setting the selected bundle + */ + ngOnInit(): void { + this.itemId = this.route.snapshot.params.id; + this.itemRD$ = this.route.data.pipe(map((data) => data.item)); + this.bundlesRD$ = this.itemRD$.pipe( + switchMap((itemRD: RemoteData) => itemRD.payload.bundles) + ); + this.selectedBundleId = this.route.snapshot.queryParams.bundle; + if (isNotEmpty(this.selectedBundleId)) { + this.bundleService.findById(this.selectedBundleId).pipe( + getFirstSucceededRemoteDataPayload() + ).subscribe((bundle: Bundle) => { + this.selectedBundleName = bundle.name; + }); + this.setUploadUrl(); + } + } + + /** + * Create a new bundle with the filled in name on the current item + */ + createBundle() { + this.itemService.createBundle(this.itemId, this.selectedBundleName).pipe( + getFirstSucceededRemoteDataPayload() + ).subscribe((bundle: Bundle) => { + this.selectedBundleId = bundle.id; + this.notificationsService.success( + this.translate.instant(this.NOTIFICATIONS_PREFIX + 'bundle.created.title'), + this.translate.instant(this.NOTIFICATIONS_PREFIX + 'bundle.created.content') + ); + this.setUploadUrl(); + }); + } + + /** + * The user changed the bundle name + * Reset the bundle ID + */ + bundleNameChange() { + this.selectedBundleId = undefined; + } + + /** + * Set the upload url to match the selected bundle ID + */ + setUploadUrl() { + this.bundleService.getBitstreamsEndpoint(this.selectedBundleId).pipe(take(1)).subscribe((href: string) => { + this.uploadFilesOptions.url = href; + if (isEmpty(this.uploadFilesOptions.authToken)) { + this.uploadFilesOptions.authToken = this.authService.buildAuthHeader(); + } + // Re-initialize the uploader component to ensure the latest changes to the options are applied + if (this.uploaderComponent) { + this.uploaderComponent.ngOnInit(); + } + }); + } + + /** + * The request was successful, redirect the user to the new bitstream's edit page + * @param bitstream + */ + public onCompleteItem(bitstream) { + // Clear cached requests for this bundle's bitstreams to ensure lists on all pages are up-to-date + this.bundleService.getBitstreamsEndpoint(this.selectedBundleId).pipe(take(1)).subscribe((href: string) => { + this.requestService.removeByHrefSubstring(href); + }); + + // Bring over the item ID as a query parameter + const queryParams = { itemId: this.itemId }; + this.router.navigate([getBitstreamModulePath(), bitstream.id, 'edit'], { queryParams: queryParams }); + } + + /** + * The request was unsuccessful, display an error notification + */ + public onUploadError() { + this.notificationsService.error(null, this.translate.get(this.NOTIFICATIONS_PREFIX + 'upload.failed')); + } + + /** + * The user selected a bundle from the input suggestions + * Set the bundle ID and Name properties, as well as the upload URL + * @param bundle + */ + onClick(bundle: Bundle) { + this.selectedBundleId = bundle.id; + this.selectedBundleName = bundle.name; + this.setUploadUrl(); + } + + /** + * When cancel is clicked, navigate back to the item's edit bitstreams page + */ + onCancel() { + this.router.navigate([getItemEditPath(this.itemId), 'bitstreams']); + } + + /** + * @returns {string} the current URL + */ + getCurrentUrl() { + return this.router.url; + } + + /** + * Unsubscribe from all open subscriptions when the component is destroyed + */ + ngOnDestroy(): void { + this.subs + .filter((subscription) => hasValue(subscription)) + .forEach((subscription) => subscription.unsubscribe()); + } + +} diff --git a/src/app/+item-page/edit-item-page/abstract-item-update/abstract-item-update.component.ts b/src/app/+item-page/edit-item-page/abstract-item-update/abstract-item-update.component.ts index c49def3dd2..3cffcf91b6 100644 --- a/src/app/+item-page/edit-item-page/abstract-item-update/abstract-item-update.component.ts +++ b/src/app/+item-page/edit-item-page/abstract-item-update/abstract-item-update.component.ts @@ -10,12 +10,16 @@ import { TranslateService } from '@ngx-translate/core'; import { GLOBAL_CONFIG, GlobalConfig } from '../../../../config'; import { first, map } from 'rxjs/operators'; import { RemoteData } from '../../../core/data/remote-data'; +import { AbstractTrackableComponent } from '../../../shared/trackable/abstract-trackable.component'; -@Injectable() +@Component({ + selector: 'ds-abstract-item-update', + template: '' +}) /** * Abstract component for managing object updates of an item */ -export abstract class AbstractItemUpdateComponent implements OnInit { +export class AbstractItemUpdateComponent extends AbstractTrackableComponent implements OnInit { /** * The item to display the edit page for */ @@ -25,30 +29,17 @@ export abstract class AbstractItemUpdateComponent implements OnInit { * Should be initialized in the initializeUpdates method of the child component */ updates$: Observable; - /** - * The current url of this page - */ - url: string; - /** - * Prefix for this component's notification translate keys - * Should be initialized in the initializeNotificationsPrefix method of the child component - */ - notificationsPrefix; - /** - * The time span for being able to undo discarding changes - */ - discardTimeOut: number; constructor( - protected itemService: ItemDataService, - protected objectUpdatesService: ObjectUpdatesService, - protected router: Router, - protected notificationsService: NotificationsService, - protected translateService: TranslateService, - @Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, - protected route: ActivatedRoute + public itemService: ItemDataService, + public objectUpdatesService: ObjectUpdatesService, + public router: Router, + public notificationsService: NotificationsService, + public translateService: TranslateService, + @Inject(GLOBAL_CONFIG) public EnvConfig: GlobalConfig, + public route: ActivatedRoute ) { - + super(objectUpdatesService, notificationsService, translateService) } /** @@ -61,6 +52,7 @@ export abstract class AbstractItemUpdateComponent implements OnInit { map((data: RemoteData) => data.payload) ).subscribe((item: Item) => { this.item = item; + this.postItemInit(); }); this.discardTimeOut = this.EnvConfig.item.edit.undoTimeout; @@ -81,19 +73,44 @@ export abstract class AbstractItemUpdateComponent implements OnInit { } /** - * Initialize the values and updates of the current item's fields + * Actions to perform after the item has been initialized + * Abstract method: Should be overwritten in the sub class */ - abstract initializeUpdates(): void; + postItemInit(): void { + // Overwrite in subclasses + } + + /** + * Initialize the values and updates of the current item's fields + * Abstract method: Should be overwritten in the sub class + */ + initializeUpdates(): void { + // Overwrite in subclasses + } /** * Initialize the prefix for notification messages + * Abstract method: Should be overwritten in the sub class */ - abstract initializeNotificationsPrefix(): void; + initializeNotificationsPrefix(): void { + // Overwrite in subclasses + } /** * Sends all initial values of this item to the object updates service + * Abstract method: Should be overwritten in the sub class */ - abstract initializeOriginalFields(): void; + initializeOriginalFields(): void { + // Overwrite in subclasses + } + + /** + * Submit the current changes + * Abstract method: Should be overwritten in the sub class + */ + submit(): void { + // Overwrite in subclasses + } /** * Prevent unnecessary rerendering so fields don't lose focus @@ -102,13 +119,6 @@ export abstract class AbstractItemUpdateComponent implements OnInit { return update && update.field ? update.field.uuid : undefined; } - /** - * Checks whether or not there are currently updates for this item - */ - hasChanges(): Observable { - return this.objectUpdatesService.hasUpdates(this.url); - } - /** * Check if the current page is entirely valid */ @@ -131,49 +141,4 @@ export abstract class AbstractItemUpdateComponent implements OnInit { } ); } - - /** - * Submit the current changes - */ - abstract submit(): void; - - /** - * Request the object updates service to discard all current changes to this item - * Shows a notification to remind the user that they can undo this - */ - discard() { - const undoNotification = this.notificationsService.info(this.getNotificationTitle('discarded'), this.getNotificationContent('discarded'), { timeOut: this.discardTimeOut }); - this.objectUpdatesService.discardFieldUpdates(this.url, undoNotification); - } - - /** - * Request the object updates service to undo discarding all changes to this item - */ - reinstate() { - this.objectUpdatesService.reinstateFieldUpdates(this.url); - } - - /** - * Checks whether or not the item is currently reinstatable - */ - isReinstatable(): Observable { - return this.objectUpdatesService.isReinstatable(this.url); - } - - /** - * Get translated notification title - * @param key - */ - protected getNotificationTitle(key: string) { - return this.translateService.instant(this.notificationsPrefix + key + '.title'); - } - - /** - * Get translated notification content - * @param key - */ - protected getNotificationContent(key: string) { - return this.translateService.instant(this.notificationsPrefix + key + '.content'); - - } } diff --git a/src/app/+item-page/edit-item-page/edit-item-page.module.ts b/src/app/+item-page/edit-item-page/edit-item-page.module.ts index 2cbd0c57d1..d02aafcfa1 100644 --- a/src/app/+item-page/edit-item-page/edit-item-page.module.ts +++ b/src/app/+item-page/edit-item-page/edit-item-page.module.ts @@ -15,12 +15,19 @@ import { ItemDeleteComponent } from './item-delete/item-delete.component'; import { ItemMetadataComponent } from './item-metadata/item-metadata.component'; import { EditInPlaceFieldComponent } from './item-metadata/edit-in-place-field/edit-in-place-field.component'; import { ItemBitstreamsComponent } from './item-bitstreams/item-bitstreams.component'; +import { ItemEditBitstreamComponent } from './item-bitstreams/item-edit-bitstream/item-edit-bitstream.component'; import { SearchPageModule } from '../../+search-page/search-page.module'; import { ItemCollectionMapperComponent } from './item-collection-mapper/item-collection-mapper.component'; import { ItemRelationshipsComponent } from './item-relationships/item-relationships.component'; import { EditRelationshipComponent } from './item-relationships/edit-relationship/edit-relationship.component'; import { EditRelationshipListComponent } from './item-relationships/edit-relationship-list/edit-relationship-list.component'; +import { AbstractItemUpdateComponent } from './abstract-item-update/abstract-item-update.component'; import { ItemMoveComponent } from './item-move/item-move.component'; +import { ItemEditBitstreamBundleComponent } from './item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component'; +import { BundleDataService } from '../../core/data/bundle-data.service'; +import { DragDropModule } from '@angular/cdk/drag-drop'; +import { ItemEditBitstreamDragHandleComponent } from './item-bitstreams/item-edit-bitstream-drag-handle/item-edit-bitstream-drag-handle.component'; +import { PaginatedDragAndDropBitstreamListComponent } from './item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component'; import { VirtualMetadataComponent } from './virtual-metadata/virtual-metadata.component'; import { ItemVersionHistoryComponent } from './item-version-history/item-version-history.component'; @@ -32,12 +39,14 @@ import { ItemVersionHistoryComponent } from './item-version-history/item-version CommonModule, SharedModule, EditItemPageRoutingModule, - SearchPageModule + SearchPageModule, + DragDropModule ], declarations: [ EditItemPageComponent, ItemOperationComponent, AbstractSimpleItemActionComponent, + AbstractItemUpdateComponent, ModifyItemOverviewComponent, ItemWithdrawComponent, ItemReinstateComponent, @@ -50,11 +59,19 @@ import { ItemVersionHistoryComponent } from './item-version-history/item-version ItemBitstreamsComponent, ItemVersionHistoryComponent, EditInPlaceFieldComponent, + ItemEditBitstreamComponent, + ItemEditBitstreamBundleComponent, + PaginatedDragAndDropBitstreamListComponent, + EditInPlaceFieldComponent, EditRelationshipComponent, EditRelationshipListComponent, ItemCollectionMapperComponent, ItemMoveComponent, + ItemEditBitstreamDragHandleComponent, VirtualMetadataComponent, + ], + providers: [ + BundleDataService ] }) export class EditItemPageModule { diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.html b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.html index b80e6e0678..dc017a9f92 100644 --- a/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.html +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.html @@ -1,3 +1,68 @@ -
+
+
+ + + + +
+
+
+
+ + {{'item.edit.bitstreams.headers.name' | translate}} +
+
{{'item.edit.bitstreams.headers.description' | translate}}
+
{{'item.edit.bitstreams.headers.format' | translate}}
+
{{'item.edit.bitstreams.headers.actions' | translate}}
+
+ + +
+ + + +
+
+ + + +
+
diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.scss b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.scss index e69de29bb2..0400e765de 100644 --- a/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.scss +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.scss @@ -0,0 +1,42 @@ +.header-row { + color: $table-dark-color; + background-color: $table-dark-bg; + border-color: $table-dark-border-color; +} + +.bundle-row { + color: $table-head-color; + background-color: $table-head-bg; + border-color: $table-border-color; +} + +.row-element { + padding: 12px; + padding: 0.75em; + border-bottom: $table-border-width solid $table-border-color; +} + +.drag-handle { + visibility: hidden; + &:hover { + cursor: grab; + } +} + +:host ::ng-deep .bitstream-row:hover .drag-handle { + visibility: visible !important; +} + +.cdk-drag-preview { + margin-left: 0; + box-sizing: border-box; + box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2), 0 8px 10px 1px rgba(0, 0, 0, 0.14), 0 3px 14px 2px rgba(0, 0, 0, 0.12); +} + +.cdk-drag-placeholder { + opacity: 0; +} + +.cdk-drag-animating { + transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); +} diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.spec.ts b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.spec.ts new file mode 100644 index 0000000000..9184889257 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.spec.ts @@ -0,0 +1,224 @@ +import { Bitstream } from '../../../core/shared/bitstream.model'; +import { of as observableOf } from 'rxjs/internal/observable/of'; +import { RemoteData } from '../../../core/data/remote-data'; +import { PaginatedList } from '../../../core/data/paginated-list'; +import { PageInfo } from '../../../core/shared/page-info.model'; +import { Item } from '../../../core/shared/item.model'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ItemBitstreamsComponent } from './item-bitstreams.component'; +import { ItemDataService } from '../../../core/data/item-data.service'; +import { TranslateModule } from '@ngx-translate/core'; +import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service'; +import { ActivatedRoute, Router } from '@angular/router'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { GLOBAL_CONFIG } from '../../../../config'; +import { ChangeDetectorRef, NO_ERRORS_SCHEMA } from '@angular/core'; +import { FieldChangeType } from '../../../core/data/object-updates/object-updates.actions'; +import { RouterStub } from '../../../shared/testing/router-stub'; +import { INotification, Notification } from '../../../shared/notifications/models/notification.model'; +import { NotificationType } from '../../../shared/notifications/models/notification-type'; +import { BitstreamDataService } from '../../../core/data/bitstream-data.service'; +import { getMockRequestService } from '../../../shared/mocks/mock-request.service'; +import { ObjectCacheService } from '../../../core/cache/object-cache.service'; +import { RequestService } from '../../../core/data/request.service'; +import { ObjectValuesPipe } from '../../../shared/utils/object-values-pipe'; +import { VarDirective } from '../../../shared/utils/var.directive'; +import { BundleDataService } from '../../../core/data/bundle-data.service'; +import { Bundle } from '../../../core/shared/bundle.model'; +import { RestResponse } from '../../../core/cache/response.models'; +import { SearchConfigurationService } from '../../../core/shared/search/search-configuration.service'; + +let comp: ItemBitstreamsComponent; +let fixture: ComponentFixture; + +const infoNotification: INotification = new Notification('id', NotificationType.Info, 'info'); +const warningNotification: INotification = new Notification('id', NotificationType.Warning, 'warning'); +const successNotification: INotification = new Notification('id', NotificationType.Success, 'success'); +const bitstream1 = Object.assign(new Bitstream(), { + id: 'bitstream1', + uuid: 'bitstream1' +}); +const bitstream2 = Object.assign(new Bitstream(), { + id: 'bitstream2', + uuid: 'bitstream2' +}); +const fieldUpdate1 = { + field: bitstream1, + changeType: undefined +}; +const fieldUpdate2 = { + field: bitstream2, + changeType: FieldChangeType.REMOVE +}; +const bundle = Object.assign(new Bundle(), { + id: 'bundle1', + uuid: 'bundle1', + _links: { + self: { href: 'bundle1-selflink' } + }, + bitstreams: createMockRDPaginatedObs([bitstream1, bitstream2]) +}); +const moveOperations = [ + { + op: 'move', + from: '/0', + path: '/1' + } +]; +const date = new Date(); +const url = 'thisUrl'; +let item: Item; +let itemService: ItemDataService; +let objectUpdatesService: ObjectUpdatesService; +let router: any; +let route: ActivatedRoute; +let notificationsService: NotificationsService; +let bitstreamService: BitstreamDataService; +let objectCache: ObjectCacheService; +let requestService: RequestService; +let searchConfig: SearchConfigurationService; +let bundleService: BundleDataService; + +describe('ItemBitstreamsComponent', () => { + beforeEach(async(() => { + objectUpdatesService = jasmine.createSpyObj('objectUpdatesService', + { + getFieldUpdates: observableOf({ + [bitstream1.uuid]: fieldUpdate1, + [bitstream2.uuid]: fieldUpdate2, + }), + getFieldUpdatesExclusive: observableOf({ + [bitstream1.uuid]: fieldUpdate1, + [bitstream2.uuid]: fieldUpdate2, + }), + saveAddFieldUpdate: {}, + discardFieldUpdates: {}, + discardAllFieldUpdates: {}, + reinstateFieldUpdates: observableOf(true), + initialize: {}, + getUpdatedFields: observableOf([bitstream1, bitstream2]), + getLastModified: observableOf(date), + hasUpdates: observableOf(true), + isReinstatable: observableOf(false), + isValidPage: observableOf(true), + getMoveOperations: observableOf(moveOperations) + } + ); + router = Object.assign(new RouterStub(), { + url: url + }); + notificationsService = jasmine.createSpyObj('notificationsService', + { + info: infoNotification, + warning: warningNotification, + success: successNotification + } + ); + bitstreamService = jasmine.createSpyObj('bitstreamService', { + deleteAndReturnResponse: jasmine.createSpy('deleteAndReturnResponse') + }); + objectCache = jasmine.createSpyObj('objectCache', { + remove: jasmine.createSpy('remove') + }); + requestService = getMockRequestService(); + searchConfig = Object.assign( { + paginatedSearchOptions: observableOf({}) + }); + + item = Object.assign(new Item(), { + uuid: 'item', + id: 'item', + _links: { + self: { href: 'item-selflink' } + }, + bundles: createMockRDPaginatedObs([bundle]), + lastModified: date + }); + itemService = Object.assign( { + getBitstreams: () => createMockRDPaginatedObs([bitstream1, bitstream2]), + findById: () => createMockRDObs(item), + getBundles: () => createMockRDPaginatedObs([bundle]) + }); + route = Object.assign({ + parent: { + data: observableOf({ item: createMockRD(item) }) + }, + url: url + }); + bundleService = jasmine.createSpyObj('bundleService', { + patch: observableOf(new RestResponse(true, 200, 'OK')) + }); + + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [ItemBitstreamsComponent, ObjectValuesPipe, VarDirective], + providers: [ + { provide: ItemDataService, useValue: itemService }, + { provide: ObjectUpdatesService, useValue: objectUpdatesService }, + { provide: Router, useValue: router }, + { provide: ActivatedRoute, useValue: route }, + { provide: NotificationsService, useValue: notificationsService }, + { provide: GLOBAL_CONFIG, useValue: { item: { edit: { undoTimeout: 10 } } } as any }, + { provide: BitstreamDataService, useValue: bitstreamService }, + { provide: ObjectCacheService, useValue: objectCache }, + { provide: RequestService, useValue: requestService }, + { provide: SearchConfigurationService, useValue: searchConfig }, + { provide: BundleDataService, useValue: bundleService }, + ChangeDetectorRef + ], schemas: [ + NO_ERRORS_SCHEMA + ] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ItemBitstreamsComponent); + comp = fixture.componentInstance; + comp.url = url; + fixture.detectChanges(); + }); + + describe('when submit is called', () => { + beforeEach(() => { + comp.submit(); + }); + + it('should call deleteAndReturnResponse on the bitstreamService for the marked field', () => { + expect(bitstreamService.deleteAndReturnResponse).toHaveBeenCalledWith(bitstream2.id); + }); + + it('should not call deleteAndReturnResponse on the bitstreamService for the unmarked field', () => { + expect(bitstreamService.deleteAndReturnResponse).not.toHaveBeenCalledWith(bitstream1.id); + }); + + it('should send out a patch for the move operations', () => { + expect(bundleService.patch).toHaveBeenCalled(); + }); + }); + + describe('discard', () => { + it('should discard ALL field updates', () => { + comp.discard(); + expect(objectUpdatesService.discardAllFieldUpdates).toHaveBeenCalled(); + }); + }); + + describe('reinstate', () => { + it('should reinstate field updates on the bundle', () => { + comp.reinstate(); + expect(objectUpdatesService.reinstateFieldUpdates).toHaveBeenCalledWith(bundle.self); + }); + }); +}); + +export function createMockRDPaginatedObs(list: any[]) { + return createMockRDObs(new PaginatedList(new PageInfo(), list)); +} + +export function createMockRDObs(obj: any) { + return observableOf(createMockRD(obj)); +} + +export function createMockRD(obj: any) { + return new RemoteData(false, false, true, null, obj); +} diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts index 71f25cd5cf..bdb1ec23a5 100644 --- a/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts @@ -1,4 +1,34 @@ -import { Component } from '@angular/core'; +import { ChangeDetectorRef, Component, Inject, OnDestroy } from '@angular/core'; +import { AbstractItemUpdateComponent } from '../abstract-item-update/abstract-item-update.component'; +import { filter, map, switchMap, take, tap } from 'rxjs/operators'; +import { Observable } from 'rxjs/internal/Observable'; +import { Subscription } from 'rxjs/internal/Subscription'; +import { ItemDataService } from '../../../core/data/item-data.service'; +import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service'; +import { ActivatedRoute, Router } from '@angular/router'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; +import { GLOBAL_CONFIG, GlobalConfig } from '../../../../config'; +import { BitstreamDataService } from '../../../core/data/bitstream-data.service'; +import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../../shared/empty.util'; +import { zip as observableZip, combineLatest as observableCombineLatest, of as observableOf } from 'rxjs'; +import { ErrorResponse, RestResponse } from '../../../core/cache/response.models'; +import { ObjectCacheService } from '../../../core/cache/object-cache.service'; +import { RequestService } from '../../../core/data/request.service'; +import { getRemoteDataPayload, getSucceededRemoteData } from '../../../core/shared/operators'; +import { Item } from '../../../core/shared/item.model'; +import { RemoteData } from '../../../core/data/remote-data'; +import { PaginatedList } from '../../../core/data/paginated-list'; +import { Bundle } from '../../../core/shared/bundle.model'; +import { FieldUpdate, FieldUpdates } from '../../../core/data/object-updates/object-updates.reducer'; +import { Bitstream } from '../../../core/shared/bitstream.model'; +import { FieldChangeType } from '../../../core/data/object-updates/object-updates.actions'; +import { Operation } from 'fast-json-patch'; +import { MoveOperation } from 'fast-json-patch/lib/core'; +import { BundleDataService } from '../../../core/data/bundle-data.service'; +import { PaginatedSearchOptions } from '../../../shared/search/paginated-search-options.model'; +import { ResponsiveColumnSizes } from '../../../shared/responsive-table-sizes/responsive-column-sizes'; +import { ResponsiveTableSizes } from '../../../shared/responsive-table-sizes/responsive-table-sizes'; @Component({ selector: 'ds-item-bitstreams', @@ -8,6 +38,273 @@ import { Component } from '@angular/core'; /** * Component for displaying an item's bitstreams edit page */ -export class ItemBitstreamsComponent { - /* TODO implement */ +export class ItemBitstreamsComponent extends AbstractItemUpdateComponent implements OnDestroy { + + /** + * The currently listed bundles + */ + bundles$: Observable; + + /** + * The page options to use for fetching the bundles + */ + bundlesOptions = { + id: 'bundles-pagination-options', + currentPage: 1, + pageSize: 9999 + } as any; + + /** + * The bootstrap sizes used for the columns within this table + */ + columnSizes = new ResponsiveTableSizes([ + // Name column + new ResponsiveColumnSizes(2, 2, 3, 4, 4), + // Description column + new ResponsiveColumnSizes(2, 3, 3, 3, 3), + // Format column + new ResponsiveColumnSizes(2, 2, 2, 2, 2), + // Actions column + new ResponsiveColumnSizes(6, 5, 4, 3, 3) + ]); + + /** + * Are we currently submitting the changes? + * Used to disable any action buttons until the submit finishes + */ + submitting = false; + + /** + * A subscription that checks when the item is deleted in cache and reloads the item by sending a new request + * This is used to update the item in cache after bitstreams are deleted + */ + itemUpdateSubscription: Subscription; + + constructor( + public itemService: ItemDataService, + public objectUpdatesService: ObjectUpdatesService, + public router: Router, + public notificationsService: NotificationsService, + public translateService: TranslateService, + @Inject(GLOBAL_CONFIG) public EnvConfig: GlobalConfig, + public route: ActivatedRoute, + public bitstreamService: BitstreamDataService, + public objectCache: ObjectCacheService, + public requestService: RequestService, + public cdRef: ChangeDetectorRef, + public bundleService: BundleDataService + ) { + super(itemService, objectUpdatesService, router, notificationsService, translateService, EnvConfig, route); + } + + /** + * Set up and initialize all fields + */ + ngOnInit(): void { + super.ngOnInit(); + this.initializeItemUpdate(); + } + + /** + * Actions to perform after the item has been initialized + */ + postItemInit(): void { + this.bundles$ = this.itemService.getBundles(this.item.id, new PaginatedSearchOptions({pagination: this.bundlesOptions})).pipe( + getSucceededRemoteData(), + getRemoteDataPayload(), + map((bundlePage: PaginatedList) => bundlePage.page) + ); + } + + /** + * Initialize the notification messages prefix + */ + initializeNotificationsPrefix(): void { + this.notificationsPrefix = 'item.edit.bitstreams.notifications.'; + } + + /** + * Update the item (and view) when it's removed in the request cache + * Also re-initialize the original fields and updates + */ + initializeItemUpdate(): void { + this.itemUpdateSubscription = this.requestService.hasByHrefObservable(this.item.self).pipe( + filter((exists: boolean) => !exists), + switchMap(() => this.itemService.findById(this.item.uuid)), + getSucceededRemoteData(), + ).subscribe((itemRD: RemoteData) => { + if (hasValue(itemRD)) { + this.item = itemRD.payload; + this.postItemInit(); + this.initializeOriginalFields(); + this.initializeUpdates(); + this.cdRef.detectChanges(); + } + }); + } + + /** + * Submit the current changes + * Bitstreams that were dragged around send out a patch request with move operations to the rest API + * Bitstreams marked as deleted send out a delete request to the rest API + * Display notifications and reset the current item/updates + */ + submit() { + this.submitting = true; + const bundlesOnce$ = this.bundles$.pipe(take(1)); + + // Fetch all move operations for each bundle + const moveOperations$ = bundlesOnce$.pipe( + switchMap((bundles: Bundle[]) => observableZip(...bundles.map((bundle: Bundle) => + this.objectUpdatesService.getMoveOperations(bundle.self).pipe( + take(1), + map((operations: MoveOperation[]) => [...operations.map((operation: MoveOperation) => Object.assign(operation, { + from: `/_links/bitstreams${operation.from}/href`, + path: `/_links/bitstreams${operation.path}/href` + }))]) + ) + ))) + ); + + // Send out an immediate patch request for each bundle + const patchResponses$ = observableCombineLatest(bundlesOnce$, moveOperations$).pipe( + switchMap(([bundles, moveOperationList]: [Bundle[], Operation[][]]) => + observableZip(...bundles.map((bundle: Bundle, index: number) => { + if (isNotEmpty(moveOperationList[index])) { + return this.bundleService.patch(bundle, moveOperationList[index]); + } else { + return observableOf(undefined); + } + })) + ) + ); + + // Fetch all removed bitstreams from the object update service + const removedBitstreams$ = bundlesOnce$.pipe( + switchMap((bundles: Bundle[]) => observableZip( + ...bundles.map((bundle: Bundle) => this.objectUpdatesService.getFieldUpdates(bundle.self, [], true)) + )), + map((fieldUpdates: FieldUpdates[]) => ([] as FieldUpdate[]).concat( + ...fieldUpdates.map((updates: FieldUpdates) => Object.values(updates).filter((fieldUpdate: FieldUpdate) => fieldUpdate.changeType === FieldChangeType.REMOVE)) + )), + map((fieldUpdates: FieldUpdate[]) => fieldUpdates.map((fieldUpdate: FieldUpdate) => fieldUpdate.field)) + ); + + // Send out delete requests for all deleted bitstreams + const removedResponses$ = removedBitstreams$.pipe( + take(1), + switchMap((removedBistreams: Bitstream[]) => { + if (isNotEmpty(removedBistreams)) { + return observableZip(...removedBistreams.map((bitstream: Bitstream) => this.bitstreamService.deleteAndReturnResponse(bitstream.id))); + } else { + return observableOf(undefined); + } + }) + ); + + // Perform the setup actions from above in order and display notifications + patchResponses$.pipe( + switchMap((responses: RestResponse[]) => { + this.displayNotifications('item.edit.bitstreams.notifications.move', responses); + return removedResponses$ + }), + take(1) + ).subscribe((responses: RestResponse[]) => { + this.displayNotifications('item.edit.bitstreams.notifications.remove', responses); + this.reset(); + this.submitting = false; + }); + } + + /** + * Display notifications + * - Error notification for each failed response with their message + * - Success notification in case there's at least one successful response + * @param key The i18n key for the notification messages + * @param responses The returned responses to display notifications for + */ + displayNotifications(key: string, responses: RestResponse[]) { + if (isNotEmpty(responses)) { + const failedResponses = responses.filter((response: RestResponse) => hasValue(response) && !response.isSuccessful); + const successfulResponses = responses.filter((response: RestResponse) => hasValue(response) && response.isSuccessful); + + failedResponses.forEach((response: ErrorResponse) => { + this.notificationsService.error(this.translateService.instant(`${key}.failed.title`), response.errorMessage); + }); + if (successfulResponses.length > 0) { + this.notificationsService.success(this.translateService.instant(`${key}.saved.title`), this.translateService.instant(`${key}.saved.content`)); + } + } + } + + /** + * Request the object updates service to discard all current changes to this item + * Shows a notification to remind the user that they can undo this + */ + discard() { + const undoNotification = this.notificationsService.info(this.getNotificationTitle('discarded'), this.getNotificationContent('discarded'), {timeOut: this.discardTimeOut}); + this.objectUpdatesService.discardAllFieldUpdates(this.url, undoNotification); + } + + /** + * Request the object updates service to undo discarding all changes to this item + */ + reinstate() { + this.bundles$.pipe(take(1)).subscribe((bundles: Bundle[]) => { + bundles.forEach((bundle: Bundle) => { + this.objectUpdatesService.reinstateFieldUpdates(bundle.self); + }); + }); + } + + /** + * Checks whether or not the object is currently reinstatable + */ + isReinstatable(): Observable { + return this.bundles$.pipe( + switchMap((bundles: Bundle[]) => observableZip(...bundles.map((bundle: Bundle) => this.objectUpdatesService.isReinstatable(bundle.self)))), + map((reinstatable: boolean[]) => reinstatable.includes(true)) + ); + } + + /** + * Checks whether or not there are currently updates for this object + */ + hasChanges(): Observable { + return this.bundles$.pipe( + switchMap((bundles: Bundle[]) => observableZip(...bundles.map((bundle: Bundle) => this.objectUpdatesService.hasUpdates(bundle.self)))), + map((hasChanges: boolean[]) => hasChanges.includes(true)) + ); + } + + /** + * De-cache the current item (it should automatically reload due to itemUpdateSubscription) + */ + reset() { + this.refreshItemCache(); + this.initializeItemUpdate(); + } + + /** + * Remove the current item's cache from object- and request-cache + */ + refreshItemCache() { + this.bundles$.pipe(take(1)).subscribe((bundles: Bundle[]) => { + bundles.forEach((bundle: Bundle) => { + this.objectCache.remove(bundle.self); + this.requestService.removeByHrefSubstring(bundle.self); + }); + this.objectCache.remove(this.item.self); + this.requestService.removeByHrefSubstring(this.item.self); + }); + } + + /** + * Unsubscribe from open subscriptions whenever the component gets destroyed + */ + ngOnDestroy(): void { + if (this.itemUpdateSubscription) { + this.itemUpdateSubscription.unsubscribe(); + } + } } diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.html b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.html new file mode 100644 index 0000000000..58273bb931 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.html @@ -0,0 +1,21 @@ + +
+
+ +
+ {{'item.edit.bitstreams.bundle.name' | translate:{ name: bundle.name } }} +
+
+
+
+ +
+
+
+ +
diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.spec.ts b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.spec.ts new file mode 100644 index 0000000000..e15a9d7996 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.spec.ts @@ -0,0 +1,58 @@ +import { ItemEditBitstreamBundleComponent } from './item-edit-bitstream-bundle.component'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { NO_ERRORS_SCHEMA, ViewContainerRef } from '@angular/core'; +import { Item } from '../../../../core/shared/item.model'; +import { Bundle } from '../../../../core/shared/bundle.model'; +import { ResponsiveTableSizes } from '../../../../shared/responsive-table-sizes/responsive-table-sizes'; +import { ResponsiveColumnSizes } from '../../../../shared/responsive-table-sizes/responsive-column-sizes'; + +describe('ItemEditBitstreamBundleComponent', () => { + let comp: ItemEditBitstreamBundleComponent; + let fixture: ComponentFixture; + let viewContainerRef: ViewContainerRef; + + const columnSizes = new ResponsiveTableSizes([ + new ResponsiveColumnSizes(2, 2, 3, 4, 4), + new ResponsiveColumnSizes(2, 3, 3, 3, 3), + new ResponsiveColumnSizes(2, 2, 2, 2, 2), + new ResponsiveColumnSizes(6, 5, 4, 3, 3) + ]); + + const item = Object.assign(new Item(), { + id: 'item-1', + uuid: 'item-1' + }); + const bundle = Object.assign(new Bundle(), { + id: 'bundle-1', + uuid: 'bundle-1', + _links: { + self: { href: 'bundle-1-selflink' } + } + }); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [ItemEditBitstreamBundleComponent], + schemas: [ + NO_ERRORS_SCHEMA + ] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ItemEditBitstreamBundleComponent); + comp = fixture.componentInstance; + comp.item = item; + comp.bundle = bundle; + comp.columnSizes = columnSizes; + viewContainerRef = (comp as any).viewContainerRef; + spyOn(viewContainerRef, 'createEmbeddedView'); + fixture.detectChanges(); + }); + + it('should create an embedded view of the component', () => { + expect(viewContainerRef.createEmbeddedView).toHaveBeenCalled(); + }); +}); diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.ts b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.ts new file mode 100644 index 0000000000..115e326241 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.ts @@ -0,0 +1,52 @@ +import { Component, Input, OnInit, ViewChild, ViewContainerRef } from '@angular/core'; +import { Bundle } from '../../../../core/shared/bundle.model'; +import { Item } from '../../../../core/shared/item.model'; +import { ResponsiveColumnSizes } from '../../../../shared/responsive-table-sizes/responsive-column-sizes'; +import { ResponsiveTableSizes } from '../../../../shared/responsive-table-sizes/responsive-table-sizes'; + +@Component({ + selector: 'ds-item-edit-bitstream-bundle', + styleUrls: ['../item-bitstreams.component.scss'], + templateUrl: './item-edit-bitstream-bundle.component.html', +}) +/** + * Component that displays a single bundle of an item on the item bitstreams edit page + * Creates an embedded view of the contents. This is to ensure the table structure won't break. + * (which means it'll be added to the parents html without a wrapping ds-item-edit-bitstream-bundle element) + */ +export class ItemEditBitstreamBundleComponent implements OnInit { + + /** + * The view on the bundle information and bitstreams + */ + @ViewChild('bundleView', {static: true}) bundleView; + + /** + * The bundle to display bitstreams for + */ + @Input() bundle: Bundle; + + /** + * The item the bundle belongs to + */ + @Input() item: Item; + + /** + * The bootstrap sizes used for the columns within this table + */ + @Input() columnSizes: ResponsiveTableSizes; + + /** + * The bootstrap sizes used for the Bundle Name column + * This column stretches over the first 3 columns and thus is a combination of their sizes processed in ngOnInit + */ + bundleNameColumn: ResponsiveColumnSizes; + + constructor(private viewContainerRef: ViewContainerRef) { + } + + ngOnInit(): void { + this.bundleNameColumn = this.columnSizes.combineColumns(0, 2); + this.viewContainerRef.createEmbeddedView(this.bundleView); + } +} diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component.html b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component.html new file mode 100644 index 0000000000..25941f472e --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component.html @@ -0,0 +1,30 @@ + +
+
+ +
+ +
+
+
+
+
diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component.spec.ts b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component.spec.ts new file mode 100644 index 0000000000..704fa0122e --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component.spec.ts @@ -0,0 +1,132 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { Bundle } from '../../../../../core/shared/bundle.model'; +import { TranslateModule } from '@ngx-translate/core'; +import { PaginatedDragAndDropBitstreamListComponent } from './paginated-drag-and-drop-bitstream-list.component'; +import { VarDirective } from '../../../../../shared/utils/var.directive'; +import { ObjectValuesPipe } from '../../../../../shared/utils/object-values-pipe'; +import { ObjectUpdatesService } from '../../../../../core/data/object-updates/object-updates.service'; +import { BundleDataService } from '../../../../../core/data/bundle-data.service'; +import { createMockRDObs } from '../../item-bitstreams.component.spec'; +import { Bitstream } from '../../../../../core/shared/bitstream.model'; +import { BitstreamFormat } from '../../../../../core/shared/bitstream-format.model'; +import { createPaginatedList, createSuccessfulRemoteDataObject$ } from '../../../../../shared/testing/utils'; +import { of as observableOf } from 'rxjs/internal/observable/of'; +import { take } from 'rxjs/operators'; +import { ResponsiveTableSizes } from '../../../../../shared/responsive-table-sizes/responsive-table-sizes'; +import { ResponsiveColumnSizes } from '../../../../../shared/responsive-table-sizes/responsive-column-sizes'; + +describe('PaginatedDragAndDropBitstreamListComponent', () => { + let comp: PaginatedDragAndDropBitstreamListComponent; + let fixture: ComponentFixture; + let objectUpdatesService: ObjectUpdatesService; + let bundleService: BundleDataService; + + const columnSizes = new ResponsiveTableSizes([ + new ResponsiveColumnSizes(2, 2, 3, 4, 4), + new ResponsiveColumnSizes(2, 3, 3, 3, 3), + new ResponsiveColumnSizes(2, 2, 2, 2, 2), + new ResponsiveColumnSizes(6, 5, 4, 3, 3) + ]); + + const bundle = Object.assign(new Bundle(), { + id: 'bundle-1', + uuid: 'bundle-1', + _links: { + self: { href: 'bundle-1-selflink' } + } + }); + const date = new Date(); + const format = Object.assign(new BitstreamFormat(), { + shortDescription: 'PDF' + }); + const bitstream1 = Object.assign(new Bitstream(), { + uuid: 'bitstreamUUID1', + name: 'Fake Bitstream 1', + bundleName: 'ORIGINAL', + description: 'Description', + format: createMockRDObs(format) + }); + const fieldUpdate1 = { + field: bitstream1, + changeType: undefined + }; + const bitstream2 = Object.assign(new Bitstream(), { + uuid: 'bitstreamUUID2', + name: 'Fake Bitstream 2', + bundleName: 'ORIGINAL', + description: 'Description', + format: createMockRDObs(format) + }); + const fieldUpdate2 = { + field: bitstream2, + changeType: undefined + }; + + beforeEach(async(() => { + objectUpdatesService = jasmine.createSpyObj('objectUpdatesService', + { + getFieldUpdates: observableOf({ + [bitstream1.uuid]: fieldUpdate1, + [bitstream2.uuid]: fieldUpdate2, + }), + getFieldUpdatesExclusive: observableOf({ + [bitstream1.uuid]: fieldUpdate1, + [bitstream2.uuid]: fieldUpdate2, + }), + getFieldUpdatesByCustomOrder: observableOf({ + [bitstream1.uuid]: fieldUpdate1, + [bitstream2.uuid]: fieldUpdate2, + }), + saveMoveFieldUpdate: {}, + saveRemoveFieldUpdate: {}, + removeSingleFieldUpdate: {}, + saveAddFieldUpdate: {}, + discardFieldUpdates: {}, + reinstateFieldUpdates: observableOf(true), + initialize: {}, + getUpdatedFields: observableOf([bitstream1, bitstream2]), + getLastModified: observableOf(date), + hasUpdates: observableOf(true), + isReinstatable: observableOf(false), + isValidPage: observableOf(true), + initializeWithCustomOrder: {}, + addPageToCustomOrder: {} + } + ); + + bundleService = jasmine.createSpyObj('bundleService', { + getBitstreams: createSuccessfulRemoteDataObject$(createPaginatedList([bitstream1, bitstream2])) + }); + + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [PaginatedDragAndDropBitstreamListComponent, VarDirective, ObjectValuesPipe], + providers: [ + { provide: ObjectUpdatesService, useValue: objectUpdatesService }, + { provide: BundleDataService, useValue: bundleService } + ], schemas: [ + NO_ERRORS_SCHEMA + ] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(PaginatedDragAndDropBitstreamListComponent); + comp = fixture.componentInstance; + comp.bundle = bundle; + comp.columnSizes = columnSizes; + fixture.detectChanges(); + }); + + it('should initialize the objectsRD$', (done) => { + comp.objectsRD$.pipe(take(1)).subscribe((objects) => { + expect(objects.payload.page).toEqual([bitstream1, bitstream2]); + done(); + }); + }); + + it('should initialize the URL', () => { + expect(comp.url).toEqual(bundle.self); + }); +}); diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component.ts b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component.ts new file mode 100644 index 0000000000..5548da4029 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component.ts @@ -0,0 +1,63 @@ +import { AbstractPaginatedDragAndDropListComponent } from '../../../../../shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component'; +import { Component, ElementRef, Input, OnInit } from '@angular/core'; +import { Bundle } from '../../../../../core/shared/bundle.model'; +import { Bitstream } from '../../../../../core/shared/bitstream.model'; +import { ObjectUpdatesService } from '../../../../../core/data/object-updates/object-updates.service'; +import { BundleDataService } from '../../../../../core/data/bundle-data.service'; +import { switchMap } from 'rxjs/operators'; +import { PaginatedSearchOptions } from '../../../../../shared/search/paginated-search-options.model'; +import { ResponsiveTableSizes } from '../../../../../shared/responsive-table-sizes/responsive-table-sizes'; +import { followLink } from '../../../../../shared/utils/follow-link-config.model'; + +@Component({ + selector: 'ds-paginated-drag-and-drop-bitstream-list', + styleUrls: ['../../item-bitstreams.component.scss'], + templateUrl: './paginated-drag-and-drop-bitstream-list.component.html', +}) +/** + * A component listing edit-bitstream rows for each bitstream within the given bundle. + * This component makes use of the AbstractPaginatedDragAndDropListComponent, allowing for users to drag and drop + * bitstreams within the paginated list. To drag and drop a bitstream between two pages, drag the row on top of the + * page number you want the bitstream to end up at. Doing so will add the bitstream to the top of that page. + */ +export class PaginatedDragAndDropBitstreamListComponent extends AbstractPaginatedDragAndDropListComponent implements OnInit { + /** + * The bundle to display bitstreams for + */ + @Input() bundle: Bundle; + + /** + * The bootstrap sizes used for the columns within this table + */ + @Input() columnSizes: ResponsiveTableSizes; + + constructor(protected objectUpdatesService: ObjectUpdatesService, + protected elRef: ElementRef, + protected bundleService: BundleDataService) { + super(objectUpdatesService, elRef); + } + + ngOnInit() { + super.ngOnInit(); + } + + /** + * Initialize the bitstreams observable depending on currentPage$ + */ + initializeObjectsRD(): void { + this.objectsRD$ = this.currentPage$.pipe( + switchMap((page: number) => this.bundleService.getBitstreams( + this.bundle.id, + new PaginatedSearchOptions({pagination: Object.assign({}, this.options, { currentPage: page })}), + followLink('format') + )) + ); + } + + /** + * Initialize the URL used for the field-update store, in this case the bundle's self-link + */ + initializeURL(): void { + this.url = this.bundle.self; + } +} diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-drag-handle/item-edit-bitstream-drag-handle.component.html b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-drag-handle/item-edit-bitstream-drag-handle.component.html new file mode 100644 index 0000000000..0561f78e97 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-drag-handle/item-edit-bitstream-drag-handle.component.html @@ -0,0 +1,5 @@ + +
+ +
+
diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-drag-handle/item-edit-bitstream-drag-handle.component.ts b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-drag-handle/item-edit-bitstream-drag-handle.component.ts new file mode 100644 index 0000000000..e6d72cbd57 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-drag-handle/item-edit-bitstream-drag-handle.component.ts @@ -0,0 +1,26 @@ +import { Component, OnInit, TemplateRef, ViewChild, ViewContainerRef } from '@angular/core'; + +@Component({ + selector: 'ds-item-edit-bitstream-drag-handle', + styleUrls: ['../item-bitstreams.component.scss'], + templateUrl: './item-edit-bitstream-drag-handle.component.html', +}) +/** + * Component displaying a drag handle for the item-edit-bitstream page + * Creates an embedded view of the contents + * (which means it'll be added to the parents html without a wrapping ds-item-edit-bitstream-drag-handle element) + */ +export class ItemEditBitstreamDragHandleComponent implements OnInit { + /** + * The view on the drag-handle + */ + @ViewChild('handleView', {static: true}) handleView; + + constructor(private viewContainerRef: ViewContainerRef) { + } + + ngOnInit(): void { + this.viewContainerRef.createEmbeddedView(this.handleView); + } + +} diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream/item-edit-bitstream.component.html b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream/item-edit-bitstream.component.html new file mode 100644 index 0000000000..62014f06bd --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream/item-edit-bitstream.component.html @@ -0,0 +1,43 @@ + +
+ +
+ {{ bitstreamName }} +
+
+
+
+ {{ bitstream?.firstMetadataValue('dc.description') }} +
+
+
+
+ {{ (format$ | async)?.shortDescription }} +
+
+
+
+
+ + + + + + +
+
+
+
diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream/item-edit-bitstream.component.spec.ts b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream/item-edit-bitstream.component.spec.ts new file mode 100644 index 0000000000..30b5e0d376 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream/item-edit-bitstream.component.spec.ts @@ -0,0 +1,119 @@ +import { ItemEditBitstreamComponent } from './item-edit-bitstream.component'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service'; +import { of as observableOf } from 'rxjs/internal/observable/of'; +import { Bitstream } from '../../../../core/shared/bitstream.model'; +import { TranslateModule } from '@ngx-translate/core'; +import { VarDirective } from '../../../../shared/utils/var.directive'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { createMockRDObs } from '../item-bitstreams.component.spec'; +import { BitstreamFormat } from '../../../../core/shared/bitstream-format.model'; +import { ResponsiveTableSizes } from '../../../../shared/responsive-table-sizes/responsive-table-sizes'; +import { ResponsiveColumnSizes } from '../../../../shared/responsive-table-sizes/responsive-column-sizes'; + +let comp: ItemEditBitstreamComponent; +let fixture: ComponentFixture; + +const columnSizes = new ResponsiveTableSizes([ + new ResponsiveColumnSizes(2, 2, 3, 4, 4), + new ResponsiveColumnSizes(2, 3, 3, 3, 3), + new ResponsiveColumnSizes(2, 2, 2, 2, 2), + new ResponsiveColumnSizes(6, 5, 4, 3, 3) +]); + +const format = Object.assign(new BitstreamFormat(), { + shortDescription: 'PDF' +}); +const bitstream = Object.assign(new Bitstream(), { + uuid: 'bitstreamUUID', + name: 'Fake Bitstream', + bundleName: 'ORIGINAL', + description: 'Description', + format: createMockRDObs(format) +}); +const fieldUpdate = { + field: bitstream, + changeType: undefined +}; +const date = new Date(); +const url = 'thisUrl'; + +let objectUpdatesService: ObjectUpdatesService; + +describe('ItemEditBitstreamComponent', () => { + beforeEach(async(() => { + objectUpdatesService = jasmine.createSpyObj('objectUpdatesService', + { + getFieldUpdates: observableOf({ + [bitstream.uuid]: fieldUpdate, + }), + getFieldUpdatesExclusive: observableOf({ + [bitstream.uuid]: fieldUpdate, + }), + saveRemoveFieldUpdate: {}, + removeSingleFieldUpdate: {}, + saveAddFieldUpdate: {}, + discardFieldUpdates: {}, + reinstateFieldUpdates: observableOf(true), + initialize: {}, + getUpdatedFields: observableOf([bitstream]), + getLastModified: observableOf(date), + hasUpdates: observableOf(true), + isReinstatable: observableOf(false), + isValidPage: observableOf(true) + } + ); + + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [ItemEditBitstreamComponent, VarDirective], + providers: [ + { provide: ObjectUpdatesService, useValue: objectUpdatesService } + ], schemas: [ + NO_ERRORS_SCHEMA + ] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ItemEditBitstreamComponent); + comp = fixture.componentInstance; + comp.fieldUpdate = fieldUpdate; + comp.bundleUrl = url; + comp.columnSizes = columnSizes; + comp.ngOnChanges(undefined); + fixture.detectChanges(); + }); + + describe('when remove is called', () => { + beforeEach(() => { + comp.remove(); + }); + + it('should call saveRemoveFieldUpdate on objectUpdatesService', () => { + expect(objectUpdatesService.saveRemoveFieldUpdate).toHaveBeenCalledWith(url, bitstream); + }); + }); + + describe('when undo is called', () => { + beforeEach(() => { + comp.undo(); + }); + + it('should call removeSingleFieldUpdate on objectUpdatesService', () => { + expect(objectUpdatesService.removeSingleFieldUpdate).toHaveBeenCalledWith(url, bitstream.uuid); + }); + }); + + describe('when canRemove is called', () => { + it('should return true', () => { + expect(comp.canRemove()).toEqual(true) + }); + }); + + describe('when canUndo is called', () => { + it('should return false', () => { + expect(comp.canUndo()).toEqual(false) + }); + }); +}); diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream/item-edit-bitstream.component.ts b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream/item-edit-bitstream.component.ts new file mode 100644 index 0000000000..5a02b9cac4 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream/item-edit-bitstream.component.ts @@ -0,0 +1,110 @@ +import { Component, Input, OnChanges, OnInit, SimpleChanges, ViewChild, ViewContainerRef } from '@angular/core'; +import { FieldUpdate } from '../../../../core/data/object-updates/object-updates.reducer'; +import { Bitstream } from '../../../../core/shared/bitstream.model'; +import { cloneDeep } from 'lodash'; +import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service'; +import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions'; +import { Observable } from 'rxjs/internal/Observable'; +import { BitstreamFormat } from '../../../../core/shared/bitstream-format.model'; +import { getRemoteDataPayload, getSucceededRemoteData } from '../../../../core/shared/operators'; +import { ResponsiveTableSizes } from '../../../../shared/responsive-table-sizes/responsive-table-sizes'; +import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service'; + +@Component({ + selector: 'ds-item-edit-bitstream', + styleUrls: ['../item-bitstreams.component.scss'], + templateUrl: './item-edit-bitstream.component.html', +}) +/** + * Component that displays a single bitstream of an item on the edit page + * Creates an embedded view of the contents + * (which means it'll be added to the parents html without a wrapping ds-item-edit-bitstream element) + */ +export class ItemEditBitstreamComponent implements OnChanges, OnInit { + + /** + * The view on the bitstream + */ + @ViewChild('bitstreamView', {static: true}) bitstreamView; + + /** + * The current field, value and state of the bitstream + */ + @Input() fieldUpdate: FieldUpdate; + + /** + * The url of the bundle + */ + @Input() bundleUrl: string; + + /** + * The bootstrap sizes used for the columns within this table + */ + @Input() columnSizes: ResponsiveTableSizes; + + /** + * The bitstream of this field + */ + bitstream: Bitstream; + + /** + * The bitstream's name + */ + bitstreamName: string; + + /** + * The format of the bitstream + */ + format$: Observable; + + constructor(private objectUpdatesService: ObjectUpdatesService, + private dsoNameService: DSONameService, + private viewContainerRef: ViewContainerRef) { + } + + ngOnInit(): void { + this.viewContainerRef.createEmbeddedView(this.bitstreamView); + } + + /** + * Update the current bitstream and its format on changes + * @param changes + */ + ngOnChanges(changes: SimpleChanges): void { + this.bitstream = cloneDeep(this.fieldUpdate.field) as Bitstream; + this.bitstreamName = this.dsoNameService.getName(this.bitstream); + this.format$ = this.bitstream.format.pipe( + getSucceededRemoteData(), + getRemoteDataPayload() + ); + } + + /** + * Sends a new remove update for this field to the object updates service + */ + remove(): void { + this.objectUpdatesService.saveRemoveFieldUpdate(this.bundleUrl, this.bitstream); + } + + /** + * Cancels the current update for this field in the object updates service + */ + undo(): void { + this.objectUpdatesService.removeSingleFieldUpdate(this.bundleUrl, this.bitstream.uuid); + } + + /** + * Check if a user should be allowed to remove this field + */ + canRemove(): boolean { + return this.fieldUpdate.changeType !== FieldChangeType.REMOVE; + } + + /** + * Check if a user should be allowed to cancel the update to this field + */ + canUndo(): boolean { + return this.fieldUpdate.changeType >= 0; + } + +} diff --git a/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.ts b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.ts index 71acceeb4c..3111e23589 100644 --- a/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.ts +++ b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.ts @@ -37,14 +37,14 @@ export class ItemMetadataComponent extends AbstractItemUpdateComponent { metadataFields$: Observable; constructor( - protected itemService: ItemDataService, - protected objectUpdatesService: ObjectUpdatesService, - protected router: Router, - protected notificationsService: NotificationsService, - protected translateService: TranslateService, - @Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, - protected route: ActivatedRoute, - protected metadataFieldService: RegistryService, + public itemService: ItemDataService, + public objectUpdatesService: ObjectUpdatesService, + public router: Router, + public notificationsService: NotificationsService, + public translateService: TranslateService, + @Inject(GLOBAL_CONFIG) public EnvConfig: GlobalConfig, + public route: ActivatedRoute, + public metadataFieldService: RegistryService, ) { super(itemService, objectUpdatesService, router, notificationsService, translateService, EnvConfig, route); } @@ -61,8 +61,8 @@ export class ItemMetadataComponent extends AbstractItemUpdateComponent { * Initialize the values and updates of the current item's metadata fields */ public initializeUpdates(): void { - this.updates$ = this.objectUpdatesService.getFieldUpdates(this.url, this.getMetadataAsListExcludingRelationships()); - } + this.updates$ = this.objectUpdatesService.getFieldUpdates(this.url, this.item.metadataAsList); + } /** * Initialize the prefix for notification messages @@ -83,7 +83,7 @@ export class ItemMetadataComponent extends AbstractItemUpdateComponent { * Sends all initial values of this item to the object updates service */ public initializeOriginalFields() { - this.objectUpdatesService.initialize(this.url, this.getMetadataAsListExcludingRelationships(), this.item.lastModified); + this.objectUpdatesService.initialize(this.url, this.item.metadataAsList, this.item.lastModified); } /** diff --git a/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.ts b/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.ts index 36ccca357c..1958dd0f88 100644 --- a/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.ts +++ b/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.ts @@ -49,18 +49,18 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent impl entityType$: Observable; constructor( - protected itemService: ItemDataService, - protected objectUpdatesService: ObjectUpdatesService, - protected router: Router, - protected notificationsService: NotificationsService, - protected translateService: TranslateService, - @Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, - protected route: ActivatedRoute, - protected relationshipService: RelationshipService, - protected objectCache: ObjectCacheService, - protected requestService: RequestService, - protected entityTypeService: EntityTypeService, - protected cdr: ChangeDetectorRef, + public itemService: ItemDataService, + public objectUpdatesService: ObjectUpdatesService, + public router: Router, + public notificationsService: NotificationsService, + public translateService: TranslateService, + @Inject(GLOBAL_CONFIG) public EnvConfig: GlobalConfig, + public route: ActivatedRoute, + public relationshipService: RelationshipService, + public objectCache: ObjectCacheService, + public requestService: RequestService, + public entityTypeService: EntityTypeService, + public cdr: ChangeDetectorRef, ) { super(itemService, objectUpdatesService, router, notificationsService, translateService, EnvConfig, route); } diff --git a/src/app/+item-page/item-page-routing.module.ts b/src/app/+item-page/item-page-routing.module.ts index 5caf0e3036..52faf96236 100644 --- a/src/app/+item-page/item-page-routing.module.ts +++ b/src/app/+item-page/item-page-routing.module.ts @@ -10,6 +10,7 @@ import { AuthenticatedGuard } from '../core/auth/authenticated.guard'; import { ItemBreadcrumbResolver } from '../core/breadcrumbs/item-breadcrumb.resolver'; import { DSOBreadcrumbsService } from '../core/breadcrumbs/dso-breadcrumbs.service'; import { LinkService } from '../core/cache/builders/link.service'; +import { UploadBitstreamComponent } from './bitstreams/upload/upload-bitstream.component'; export function getItemPageRoute(itemId: string) { return new URLCombiner(getItemModulePath(), itemId).toString(); @@ -20,6 +21,7 @@ export function getItemEditPath(id: string) { } const ITEM_EDIT_PATH = 'edit'; +const UPLOAD_BITSTREAM_PATH = 'bitstreams/new'; @NgModule({ imports: [ @@ -45,6 +47,11 @@ const ITEM_EDIT_PATH = 'edit'; path: ITEM_EDIT_PATH, loadChildren: './edit-item-page/edit-item-page.module#EditItemPageModule', canActivate: [AuthenticatedGuard] + }, + { + path: UPLOAD_BITSTREAM_PATH, + component: UploadBitstreamComponent, + canActivate: [AuthenticatedGuard] } ], } diff --git a/src/app/+item-page/item-page.module.ts b/src/app/+item-page/item-page.module.ts index 8d5d78ddd1..4c3a64e117 100644 --- a/src/app/+item-page/item-page.module.ts +++ b/src/app/+item-page/item-page.module.ts @@ -26,6 +26,7 @@ import { MetadataRepresentationListComponent } from './simple/metadata-represent import { RelatedEntitiesSearchComponent } from './simple/related-entities/related-entities-search/related-entities-search.component'; import { MetadataValuesComponent } from './field-components/metadata-values/metadata-values.component'; import { MetadataFieldWrapperComponent } from './field-components/metadata-field-wrapper/metadata-field-wrapper.component'; +import { UploadBitstreamComponent } from './bitstreams/upload/upload-bitstream.component'; import { TabbedRelatedEntitiesSearchComponent } from './simple/related-entities/tabbed-related-entities-search/tabbed-related-entities-search.component'; import { StatisticsModule } from '../statistics/statistics.module'; import { AbstractIncrementalListComponent } from './simple/abstract-incremental-list/abstract-incremental-list.component'; @@ -58,6 +59,7 @@ import { AbstractIncrementalListComponent } from './simple/abstract-incremental- GenericItemPageFieldComponent, MetadataRepresentationListComponent, RelatedEntitiesSearchComponent, + UploadBitstreamComponent, TabbedRelatedEntitiesSearchComponent, AbstractIncrementalListComponent, ], diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index fda558a5dd..258848ce83 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -28,6 +28,10 @@ const COMMUNITY_MODULE_PATH = 'communities'; export function getCommunityModulePath() { return `/${COMMUNITY_MODULE_PATH}`; } +const BITSTREAM_MODULE_PATH = 'bitstreams'; +export function getBitstreamModulePath() { + return `/${BITSTREAM_MODULE_PATH}`; +} const ADMIN_MODULE_PATH = 'admin'; @@ -63,16 +67,30 @@ export function getDSOPath(dso: DSpaceObject): string { { path: COMMUNITY_MODULE_PATH, loadChildren: './+community-page/community-page.module#CommunityPageModule' }, { path: COLLECTION_MODULE_PATH, loadChildren: './+collection-page/collection-page.module#CollectionPageModule' }, { path: ITEM_MODULE_PATH, loadChildren: './+item-page/item-page.module#ItemPageModule' }, - { path: 'mydspace', loadChildren: './+my-dspace-page/my-dspace-page.module#MyDSpacePageModule', canActivate: [AuthenticatedGuard] }, + { path: BITSTREAM_MODULE_PATH, loadChildren: './+bitstream-page/bitstream-page.module#BitstreamPageModule' }, + { + path: 'mydspace', + loadChildren: './+my-dspace-page/my-dspace-page.module#MyDSpacePageModule', + canActivate: [AuthenticatedGuard] + }, { path: 'search', loadChildren: './+search-page/search-page.module#SearchPageModule' }, { path: 'browse', loadChildren: './+browse-by/browse-by.module#BrowseByModule'}, { path: ADMIN_MODULE_PATH, 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: PROFILE_MODULE_PATH, loadChildren: './profile-page/profile-page.module#ProfilePageModule', canActivate: [AuthenticatedGuard] }, + { + path: 'workspaceitems', + loadChildren: './+workspaceitems-edit-page/workspaceitems-edit-page.module#WorkspaceitemsEditPageModule' + }, + { + path: 'workflowitems', + loadChildren: './+workflowitems-edit-page/workflowitems-edit-page.module#WorkflowItemsEditPageModule' + }, + { + path: PROFILE_MODULE_PATH, + loadChildren: './profile-page/profile-page.module#ProfilePageModule', canActivate: [AuthenticatedGuard] + }, { path: '**', pathMatch: 'full', component: PageNotFoundComponent }, ], { diff --git a/src/app/core/auth/auth-request.service.ts b/src/app/core/auth/auth-request.service.ts index 8773b1a9fb..e5c9210769 100644 --- a/src/app/core/auth/auth-request.service.ts +++ b/src/app/core/auth/auth-request.service.ts @@ -10,6 +10,7 @@ import { AuthGetRequest, AuthPostRequest, GetRequest, PostRequest, RestRequest } import { AuthStatusResponse, ErrorResponse } from '../cache/response.models'; import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; import { getResponseFromEntry } from '../shared/operators'; +import { HttpClient } from '@angular/common/http'; @Injectable() export class AuthRequestService { @@ -18,7 +19,8 @@ export class AuthRequestService { constructor(@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, protected halService: HALEndpointService, - protected requestService: RequestService) { + protected requestService: RequestService, + private http: HttpClient) { } protected fetchRequest(request: RestRequest): Observable { @@ -38,7 +40,7 @@ export class AuthRequestService { return isNotEmpty(method) ? `${endpoint}/${method}` : `${endpoint}`; } - public postToEndpoint(method: string, body: any, options?: HttpOptions): Observable { + public postToEndpoint(method: string, body?: any, options?: HttpOptions): Observable { return this.halService.getEndpoint(this.linkName).pipe( filter((href: string) => isNotEmpty(href)), map((endpointURL) => this.getEndpointByMethod(endpointURL, method)), @@ -67,4 +69,5 @@ export class AuthRequestService { mergeMap((request: GetRequest) => this.fetchRequest(request)), distinctUntilChanged()); } + } diff --git a/src/app/core/auth/auth.actions.ts b/src/app/core/auth/auth.actions.ts index 2681ed39a2..2c2224e878 100644 --- a/src/app/core/auth/auth.actions.ts +++ b/src/app/core/auth/auth.actions.ts @@ -5,6 +5,8 @@ import { type } from '../../shared/ngrx/type'; // import models import { EPerson } from '../eperson/models/eperson.model'; import { AuthTokenInfo } from './models/auth-token-info.model'; +import { AuthMethod } from './models/auth.method'; +import { AuthStatus } from './models/auth-status.model'; export const AuthActionTypes = { AUTHENTICATE: type('dspace/auth/AUTHENTICATE'), @@ -14,12 +16,16 @@ export const AuthActionTypes = { AUTHENTICATED_ERROR: type('dspace/auth/AUTHENTICATED_ERROR'), AUTHENTICATED_SUCCESS: type('dspace/auth/AUTHENTICATED_SUCCESS'), CHECK_AUTHENTICATION_TOKEN: type('dspace/auth/CHECK_AUTHENTICATION_TOKEN'), - CHECK_AUTHENTICATION_TOKEN_ERROR: type('dspace/auth/CHECK_AUTHENTICATION_TOKEN_ERROR'), + CHECK_AUTHENTICATION_TOKEN_COOKIE: type('dspace/auth/CHECK_AUTHENTICATION_TOKEN_COOKIE'), + RETRIEVE_AUTH_METHODS: type('dspace/auth/RETRIEVE_AUTH_METHODS'), + RETRIEVE_AUTH_METHODS_SUCCESS: type('dspace/auth/RETRIEVE_AUTH_METHODS_SUCCESS'), + RETRIEVE_AUTH_METHODS_ERROR: type('dspace/auth/RETRIEVE_AUTH_METHODS_ERROR'), REDIRECT_TOKEN_EXPIRED: type('dspace/auth/REDIRECT_TOKEN_EXPIRED'), REDIRECT_AUTHENTICATION_REQUIRED: type('dspace/auth/REDIRECT_AUTHENTICATION_REQUIRED'), REFRESH_TOKEN: type('dspace/auth/REFRESH_TOKEN'), REFRESH_TOKEN_SUCCESS: type('dspace/auth/REFRESH_TOKEN_SUCCESS'), REFRESH_TOKEN_ERROR: type('dspace/auth/REFRESH_TOKEN_ERROR'), + RETRIEVE_TOKEN: type('dspace/auth/RETRIEVE_TOKEN'), ADD_MESSAGE: type('dspace/auth/ADD_MESSAGE'), RESET_MESSAGES: type('dspace/auth/RESET_MESSAGES'), LOG_OUT: type('dspace/auth/LOG_OUT'), @@ -95,7 +101,7 @@ export class AuthenticatedErrorAction implements Action { payload: Error; constructor(payload: Error) { - this.payload = payload ; + this.payload = payload; } } @@ -109,7 +115,7 @@ export class AuthenticationErrorAction implements Action { payload: Error; constructor(payload: Error) { - this.payload = payload ; + this.payload = payload; } } @@ -138,11 +144,11 @@ export class CheckAuthenticationTokenAction implements Action { /** * Check Authentication Token Error. - * @class CheckAuthenticationTokenErrorAction + * @class CheckAuthenticationTokenCookieAction * @implements {Action} */ -export class CheckAuthenticationTokenErrorAction implements Action { - public type: string = AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_ERROR; +export class CheckAuthenticationTokenCookieAction implements Action { + public type: string = AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE; } /** @@ -152,7 +158,9 @@ export class CheckAuthenticationTokenErrorAction implements Action { */ export class LogOutAction implements Action { public type: string = AuthActionTypes.LOG_OUT; - constructor(public payload?: any) {} + + constructor(public payload?: any) { + } } /** @@ -165,7 +173,7 @@ export class LogOutErrorAction implements Action { payload: Error; constructor(payload: Error) { - this.payload = payload ; + this.payload = payload; } } @@ -176,7 +184,9 @@ export class LogOutErrorAction implements Action { */ export class LogOutSuccessAction implements Action { public type: string = AuthActionTypes.LOG_OUT_SUCCESS; - constructor(public payload?: any) {} + + constructor(public payload?: any) { + } } /** @@ -189,7 +199,7 @@ export class RedirectWhenAuthenticationIsRequiredAction implements Action { payload: string; constructor(message: string) { - this.payload = message ; + this.payload = message; } } @@ -203,7 +213,7 @@ export class RedirectWhenTokenExpiredAction implements Action { payload: string; constructor(message: string) { - this.payload = message ; + this.payload = message; } } @@ -244,6 +254,15 @@ export class RefreshTokenErrorAction implements Action { public type: string = AuthActionTypes.REFRESH_TOKEN_ERROR; } +/** + * Retrieve authentication token. + * @class RetrieveTokenAction + * @implements {Action} + */ +export class RetrieveTokenAction implements Action { + public type: string = AuthActionTypes.RETRIEVE_TOKEN; +} + /** * Sign up. * @class RegistrationAction @@ -268,7 +287,7 @@ export class RegistrationErrorAction implements Action { payload: Error; constructor(payload: Error) { - this.payload = payload ; + this.payload = payload; } } @@ -309,6 +328,45 @@ export class ResetAuthenticationMessagesAction implements Action { public type: string = AuthActionTypes.RESET_MESSAGES; } +// // Next three Actions are used by dynamic login methods +/** + * Action that triggers an effect fetching the authentication methods enabled ant the backend + * @class RetrieveAuthMethodsAction + * @implements {Action} + */ +export class RetrieveAuthMethodsAction implements Action { + public type: string = AuthActionTypes.RETRIEVE_AUTH_METHODS; + + payload: AuthStatus; + + constructor(authStatus: AuthStatus) { + this.payload = authStatus; + } +} + +/** + * Get Authentication methods enabled at the backend + * @class RetrieveAuthMethodsSuccessAction + * @implements {Action} + */ +export class RetrieveAuthMethodsSuccessAction implements Action { + public type: string = AuthActionTypes.RETRIEVE_AUTH_METHODS_SUCCESS; + payload: AuthMethod[]; + + constructor(authMethods: AuthMethod[] ) { + this.payload = authMethods; + } +} + +/** + * Set password as default authentication method on error + * @class RetrieveAuthMethodsErrorAction + * @implements {Action} + */ +export class RetrieveAuthMethodsErrorAction implements Action { + public type: string = AuthActionTypes.RETRIEVE_AUTH_METHODS_ERROR; +} + /** * Change the redirect url. * @class SetRedirectUrlAction @@ -319,7 +377,7 @@ export class SetRedirectUrlAction implements Action { payload: string; constructor(url: string) { - this.payload = url ; + this.payload = url; } } @@ -378,13 +436,21 @@ export type AuthActions | AuthenticationErrorAction | AuthenticationSuccessAction | CheckAuthenticationTokenAction - | CheckAuthenticationTokenErrorAction + | CheckAuthenticationTokenCookieAction | RedirectWhenAuthenticationIsRequiredAction | RedirectWhenTokenExpiredAction | RegistrationAction | RegistrationErrorAction | RegistrationSuccessAction | AddAuthenticationMessageAction + | RefreshTokenAction + | RefreshTokenErrorAction + | RefreshTokenSuccessAction + | ResetAuthenticationMessagesAction + | RetrieveAuthMethodsAction + | RetrieveAuthMethodsSuccessAction + | RetrieveAuthMethodsErrorAction + | RetrieveTokenAction | ResetAuthenticationMessagesAction | RetrieveAuthenticatedEpersonAction | RetrieveAuthenticatedEpersonErrorAction diff --git a/src/app/core/auth/auth.effects.spec.ts b/src/app/core/auth/auth.effects.spec.ts index 34b900fe7e..1f6fa51afd 100644 --- a/src/app/core/auth/auth.effects.spec.ts +++ b/src/app/core/auth/auth.effects.spec.ts @@ -14,19 +14,24 @@ import { AuthenticatedSuccessAction, AuthenticationErrorAction, AuthenticationSuccessAction, - CheckAuthenticationTokenErrorAction, + CheckAuthenticationTokenCookieAction, LogOutErrorAction, LogOutSuccessAction, RefreshTokenErrorAction, RefreshTokenSuccessAction, RetrieveAuthenticatedEpersonAction, RetrieveAuthenticatedEpersonErrorAction, - RetrieveAuthenticatedEpersonSuccessAction + RetrieveAuthenticatedEpersonSuccessAction, + RetrieveAuthMethodsAction, + RetrieveAuthMethodsErrorAction, + RetrieveAuthMethodsSuccessAction, + RetrieveTokenAction } from './auth.actions'; -import { AuthServiceStub } from '../../shared/testing/auth-service-stub'; +import { authMethodsMock, AuthServiceStub } from '../../shared/testing/auth-service-stub'; import { AuthService } from './auth.service'; import { AuthState } from './auth.reducer'; import { EPersonMock } from '../../shared/testing/eperson-mock'; +import { AuthStatus } from './models/auth-status.model'; describe('AuthEffects', () => { let authEffects: AuthEffects; @@ -168,13 +173,56 @@ describe('AuthEffects', () => { actions = hot('--a-', { a: { type: AuthActionTypes.CHECK_AUTHENTICATION_TOKEN, payload: token } }); - const expected = cold('--b-', { b: new CheckAuthenticationTokenErrorAction() }); + const expected = cold('--b-', { b: new CheckAuthenticationTokenCookieAction() }); expect(authEffects.checkToken$).toBeObservable(expected); }); }) }); + describe('checkTokenCookie$', () => { + + describe('when check token succeeded', () => { + it('should return a RETRIEVE_TOKEN action in response to a CHECK_AUTHENTICATION_TOKEN_COOKIE action when authenticated is true', () => { + spyOn((authEffects as any).authService, 'checkAuthenticationCookie').and.returnValue( + observableOf( + { + authenticated: true + }) + ); + actions = hot('--a-', { a: { type: AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE } }); + + const expected = cold('--b-', { b: new RetrieveTokenAction() }); + + expect(authEffects.checkTokenCookie$).toBeObservable(expected); + }); + + it('should return a RETRIEVE_AUTH_METHODS action in response to a CHECK_AUTHENTICATION_TOKEN_COOKIE action when authenticated is false', () => { + spyOn((authEffects as any).authService, 'checkAuthenticationCookie').and.returnValue( + observableOf( + { authenticated: false }) + ); + actions = hot('--a-', { a: { type: AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE } }); + + const expected = cold('--b-', { b: new RetrieveAuthMethodsAction({ authenticated: false } as AuthStatus) }); + + expect(authEffects.checkTokenCookie$).toBeObservable(expected); + }); + }); + + describe('when check token failed', () => { + it('should return a AUTHENTICATED_ERROR action in response to a CHECK_AUTHENTICATION_TOKEN_COOKIE action', () => { + spyOn((authEffects as any).authService, 'checkAuthenticationCookie').and.returnValue(observableThrow(new Error('Message Error test'))); + + actions = hot('--a-', { a: { type: AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE, payload: token } }); + + const expected = cold('--b-', { b: new AuthenticatedErrorAction(new Error('Message Error test')) }); + + expect(authEffects.checkTokenCookie$).toBeObservable(expected); + }); + }) + }); + describe('retrieveAuthenticatedEperson$', () => { describe('when request is successful', () => { @@ -231,6 +279,38 @@ describe('AuthEffects', () => { }) }); + describe('retrieveToken$', () => { + describe('when user is authenticated', () => { + it('should return a AUTHENTICATE_SUCCESS action in response to a RETRIEVE_TOKEN action', () => { + actions = hot('--a-', { + a: { + type: AuthActionTypes.RETRIEVE_TOKEN + } + }); + + const expected = cold('--b-', { b: new AuthenticationSuccessAction(token) }); + + expect(authEffects.retrieveToken$).toBeObservable(expected); + }); + }); + + describe('when user is not authenticated', () => { + it('should return a AUTHENTICATE_ERROR action in response to a RETRIEVE_TOKEN action', () => { + spyOn((authEffects as any).authService, 'refreshAuthenticationToken').and.returnValue(observableThrow(new Error('Message Error test'))); + + actions = hot('--a-', { + a: { + type: AuthActionTypes.RETRIEVE_TOKEN + } + }); + + const expected = cold('--b-', { b: new AuthenticationErrorAction(new Error('Message Error test')) }); + + expect(authEffects.retrieveToken$).toBeObservable(expected); + }); + }); + }); + describe('logOut$', () => { describe('when refresh token succeeded', () => { @@ -256,4 +336,29 @@ describe('AuthEffects', () => { }); }) }); + + describe('retrieveMethods$', () => { + + describe('when retrieve authentication methods succeeded', () => { + it('should return a RETRIEVE_AUTH_METHODS_SUCCESS action in response to a RETRIEVE_AUTH_METHODS action', () => { + actions = hot('--a-', { a: { type: AuthActionTypes.RETRIEVE_AUTH_METHODS } }); + + const expected = cold('--b-', { b: new RetrieveAuthMethodsSuccessAction(authMethodsMock) }); + + expect(authEffects.retrieveMethods$).toBeObservable(expected); + }); + }); + + describe('when retrieve authentication methods failed', () => { + it('should return a RETRIEVE_AUTH_METHODS_ERROR action in response to a RETRIEVE_AUTH_METHODS action', () => { + spyOn((authEffects as any).authService, 'retrieveAuthMethodsFromAuthStatus').and.returnValue(observableThrow('')); + + actions = hot('--a-', { a: { type: AuthActionTypes.RETRIEVE_AUTH_METHODS } }); + + const expected = cold('--b-', { b: new RetrieveAuthMethodsErrorAction() }); + + expect(authEffects.retrieveMethods$).toBeObservable(expected); + }); + }) + }); }); diff --git a/src/app/core/auth/auth.effects.ts b/src/app/core/auth/auth.effects.ts index 5ee63ccd92..d153748fb9 100644 --- a/src/app/core/auth/auth.effects.ts +++ b/src/app/core/auth/auth.effects.ts @@ -1,6 +1,6 @@ -import { of as observableOf, Observable } from 'rxjs'; +import { Observable, of as observableOf } from 'rxjs'; -import { filter, debounceTime, switchMap, take, tap, catchError, map } from 'rxjs/operators'; +import { catchError, debounceTime, filter, map, switchMap, take, tap } from 'rxjs/operators'; import { Injectable } from '@angular/core'; // import @ngrx @@ -9,6 +9,14 @@ import { Action, select, Store } from '@ngrx/store'; // import services import { AuthService } from './auth.service'; + +import { EPerson } from '../eperson/models/eperson.model'; +import { AuthStatus } from './models/auth-status.model'; +import { AuthTokenInfo } from './models/auth-token-info.model'; +import { AppState } from '../../app.reducer'; +import { isAuthenticated } from './selectors'; +import { StoreActionTypes } from '../../store.actions'; +import { AuthMethod } from './models/auth.method'; // import actions import { AuthActionTypes, @@ -18,7 +26,7 @@ import { AuthenticatedSuccessAction, AuthenticationErrorAction, AuthenticationSuccessAction, - CheckAuthenticationTokenErrorAction, + CheckAuthenticationTokenCookieAction, LogOutErrorAction, LogOutSuccessAction, RefreshTokenAction, @@ -29,14 +37,12 @@ import { RegistrationSuccessAction, RetrieveAuthenticatedEpersonAction, RetrieveAuthenticatedEpersonErrorAction, - RetrieveAuthenticatedEpersonSuccessAction + RetrieveAuthenticatedEpersonSuccessAction, + RetrieveAuthMethodsAction, + RetrieveAuthMethodsErrorAction, + RetrieveAuthMethodsSuccessAction, + RetrieveTokenAction } from './auth.actions'; -import { EPerson } from '../eperson/models/eperson.model'; -import { AuthStatus } from './models/auth-status.model'; -import { AuthTokenInfo } from './models/auth-token-info.model'; -import { AppState } from '../../app.reducer'; -import { isAuthenticated } from './selectors'; -import { StoreActionTypes } from '../../store.actions'; @Injectable() export class AuthEffects { @@ -47,45 +53,45 @@ export class AuthEffects { */ @Effect() public authenticate$: Observable = this.actions$.pipe( - ofType(AuthActionTypes.AUTHENTICATE), - switchMap((action: AuthenticateAction) => { - return this.authService.authenticate(action.payload.email, action.payload.password).pipe( - take(1), - map((response: AuthStatus) => new AuthenticationSuccessAction(response.token)), - catchError((error) => observableOf(new AuthenticationErrorAction(error))) - ); - }) - ); + ofType(AuthActionTypes.AUTHENTICATE), + switchMap((action: AuthenticateAction) => { + return this.authService.authenticate(action.payload.email, action.payload.password).pipe( + take(1), + map((response: AuthStatus) => new AuthenticationSuccessAction(response.token)), + catchError((error) => observableOf(new AuthenticationErrorAction(error))) + ); + }) + ); @Effect() public authenticateSuccess$: Observable = this.actions$.pipe( - ofType(AuthActionTypes.AUTHENTICATE_SUCCESS), - tap((action: AuthenticationSuccessAction) => this.authService.storeToken(action.payload)), - map((action: AuthenticationSuccessAction) => new AuthenticatedAction(action.payload)) - ); + ofType(AuthActionTypes.AUTHENTICATE_SUCCESS), + tap((action: AuthenticationSuccessAction) => this.authService.storeToken(action.payload)), + map((action: AuthenticationSuccessAction) => new AuthenticatedAction(action.payload)) + ); @Effect() public authenticated$: Observable = this.actions$.pipe( - ofType(AuthActionTypes.AUTHENTICATED), - switchMap((action: AuthenticatedAction) => { - return this.authService.authenticatedUser(action.payload).pipe( - map((userHref: string) => new AuthenticatedSuccessAction((userHref !== null), action.payload, userHref)), - catchError((error) => observableOf(new AuthenticatedErrorAction(error))),); - }) - ); + ofType(AuthActionTypes.AUTHENTICATED), + switchMap((action: AuthenticatedAction) => { + return this.authService.authenticatedUser(action.payload).pipe( + map((userHref: string) => new AuthenticatedSuccessAction((userHref !== null), action.payload, userHref)), + catchError((error) => observableOf(new AuthenticatedErrorAction(error))),); + }) + ); @Effect() public authenticatedSuccess$: Observable = this.actions$.pipe( - ofType(AuthActionTypes.AUTHENTICATED_SUCCESS), - map((action: AuthenticatedSuccessAction) => new RetrieveAuthenticatedEpersonAction(action.payload.userHref)) - ); + ofType(AuthActionTypes.AUTHENTICATED_SUCCESS), + map((action: AuthenticatedSuccessAction) => new RetrieveAuthenticatedEpersonAction(action.payload.userHref)) + ); // It means "reacts to this action but don't send another" @Effect({ dispatch: false }) public authenticatedError$: Observable = this.actions$.pipe( - ofType(AuthActionTypes.AUTHENTICATED_ERROR), - tap((action: LogOutSuccessAction) => this.authService.removeToken()) - ); + ofType(AuthActionTypes.AUTHENTICATED_ERROR), + tap((action: LogOutSuccessAction) => this.authService.removeToken()) + ); @Effect() public retrieveAuthenticatedEperson$: Observable = this.actions$.pipe( @@ -99,42 +105,71 @@ export class AuthEffects { @Effect() public checkToken$: Observable = this.actions$.pipe(ofType(AuthActionTypes.CHECK_AUTHENTICATION_TOKEN), - switchMap(() => { - return this.authService.hasValidAuthenticationToken().pipe( - map((token: AuthTokenInfo) => new AuthenticatedAction(token)), - catchError((error) => observableOf(new CheckAuthenticationTokenErrorAction())) - ); - }) - ); + switchMap(() => { + return this.authService.hasValidAuthenticationToken().pipe( + map((token: AuthTokenInfo) => new AuthenticatedAction(token)), + catchError((error) => observableOf(new CheckAuthenticationTokenCookieAction())) + ); + }) + ); + + @Effect() + public checkTokenCookie$: Observable = this.actions$.pipe( + ofType(AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE), + switchMap(() => { + return this.authService.checkAuthenticationCookie().pipe( + map((response: AuthStatus) => { + if (response.authenticated) { + return new RetrieveTokenAction(); + } else { + return new RetrieveAuthMethodsAction(response); + } + }), + catchError((error) => observableOf(new AuthenticatedErrorAction(error))) + ); + }) + ); @Effect() public createUser$: Observable = this.actions$.pipe( - ofType(AuthActionTypes.REGISTRATION), - debounceTime(500), // to remove when functionality is implemented - switchMap((action: RegistrationAction) => { - return this.authService.create(action.payload).pipe( - map((user: EPerson) => new RegistrationSuccessAction(user)), - catchError((error) => observableOf(new RegistrationErrorAction(error))) - ); - }) - ); + ofType(AuthActionTypes.REGISTRATION), + debounceTime(500), // to remove when functionality is implemented + switchMap((action: RegistrationAction) => { + return this.authService.create(action.payload).pipe( + map((user: EPerson) => new RegistrationSuccessAction(user)), + catchError((error) => observableOf(new RegistrationErrorAction(error))) + ); + }) + ); + + @Effect() + public retrieveToken$: Observable = this.actions$.pipe( + ofType(AuthActionTypes.RETRIEVE_TOKEN), + switchMap((action: AuthenticateAction) => { + return this.authService.refreshAuthenticationToken(null).pipe( + take(1), + map((token: AuthTokenInfo) => new AuthenticationSuccessAction(token)), + catchError((error) => observableOf(new AuthenticationErrorAction(error))) + ); + }) + ); @Effect() public refreshToken$: Observable = this.actions$.pipe(ofType(AuthActionTypes.REFRESH_TOKEN), - switchMap((action: RefreshTokenAction) => { - return this.authService.refreshAuthenticationToken(action.payload).pipe( - map((token: AuthTokenInfo) => new RefreshTokenSuccessAction(token)), - catchError((error) => observableOf(new RefreshTokenErrorAction())) - ); - }) - ); + switchMap((action: RefreshTokenAction) => { + return this.authService.refreshAuthenticationToken(action.payload).pipe( + map((token: AuthTokenInfo) => new RefreshTokenSuccessAction(token)), + catchError((error) => observableOf(new RefreshTokenErrorAction())) + ); + }) + ); // It means "reacts to this action but don't send another" @Effect({ dispatch: false }) public refreshTokenSuccess$: Observable = this.actions$.pipe( - ofType(AuthActionTypes.REFRESH_TOKEN_SUCCESS), - tap((action: RefreshTokenSuccessAction) => this.authService.replaceToken(action.payload)) - ); + ofType(AuthActionTypes.REFRESH_TOKEN_SUCCESS), + tap((action: RefreshTokenSuccessAction) => this.authService.replaceToken(action.payload)) + ); /** * When the store is rehydrated in the browser, @@ -188,6 +223,19 @@ export class AuthEffects { tap(() => this.authService.redirectToLoginWhenTokenExpired()) ); + @Effect() + public retrieveMethods$: Observable = this.actions$ + .pipe( + ofType(AuthActionTypes.RETRIEVE_AUTH_METHODS), + switchMap((action: RetrieveAuthMethodsAction) => { + return this.authService.retrieveAuthMethodsFromAuthStatus(action.payload) + .pipe( + map((authMethodModels: AuthMethod[]) => new RetrieveAuthMethodsSuccessAction(authMethodModels)), + catchError((error) => observableOf(new RetrieveAuthMethodsErrorAction())) + ) + }) + ); + /** * @constructor * @param {Actions} actions$ diff --git a/src/app/core/auth/auth.interceptor.ts b/src/app/core/auth/auth.interceptor.ts index 08e892bbd9..6d609a4ea3 100644 --- a/src/app/core/auth/auth.interceptor.ts +++ b/src/app/core/auth/auth.interceptor.ts @@ -6,6 +6,7 @@ import { HttpErrorResponse, HttpEvent, HttpHandler, + HttpHeaders, HttpInterceptor, HttpRequest, HttpResponse, @@ -17,10 +18,12 @@ 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, isNotNull } from '../../shared/empty.util'; +import { isNotEmpty, isNotNull, isUndefined } from '../../shared/empty.util'; import { RedirectWhenTokenExpiredAction, RefreshTokenAction } from './auth.actions'; import { Store } from '@ngrx/store'; import { Router } from '@angular/router'; +import { AuthMethod } from './models/auth.method'; +import { AuthMethodType } from './models/auth.method-type'; @Injectable() export class AuthInterceptor implements HttpInterceptor { @@ -30,17 +33,33 @@ export class AuthInterceptor implements HttpInterceptor { // we're creating a refresh token request list protected refreshTokenRequestUrls = []; - constructor(private inj: Injector, private router: Router, private store: Store) { } + constructor(private inj: Injector, private router: Router, private store: Store) { + } + /** + * Check if response status code is 401 + * + * @param response + */ private isUnauthorized(response: HttpResponseBase): boolean { // invalid_token The access token provided is expired, revoked, malformed, or invalid for other reasons return response.status === 401; } + /** + * Check if response status code is 200 or 204 + * + * @param response + */ private isSuccess(response: HttpResponseBase): boolean { return (response.status === 200 || response.status === 204); } + /** + * Check if http request is to authn endpoint + * + * @param http + */ private isAuthRequest(http: HttpRequest | HttpResponseBase): boolean { return http && http.url && (http.url.endsWith('/authn/login') @@ -48,18 +67,131 @@ export class AuthInterceptor implements HttpInterceptor { || http.url.endsWith('/authn/status')); } + /** + * Check if response is from a login request + * + * @param http + */ private isLoginResponse(http: HttpRequest | HttpResponseBase): boolean { - return http.url && http.url.endsWith('/authn/login'); + return http.url && http.url.endsWith('/authn/login') } + /** + * Check if response is from a logout request + * + * @param http + */ private isLogoutResponse(http: HttpRequest | HttpResponseBase): boolean { return http.url && http.url.endsWith('/authn/logout'); } - private makeAuthStatusObject(authenticated: boolean, accessToken?: string, error?: string): AuthStatus { + /** + * Check if response is from a status request + * + * @param http + */ + private isStatusResponse(http: HttpRequest | HttpResponseBase): boolean { + return http.url && http.url.endsWith('/authn/status'); + } + + /** + * Extract location url from the WWW-Authenticate header + * + * @param header + */ + private parseLocation(header: string): string { + let location = header.trim(); + location = location.replace('location="', ''); + location = location.replace('"', ''); + let re = /%3A%2F%2F/g; + location = location.replace(re, '://'); + re = /%3A/g; + location = location.replace(re, ':'); + return location.trim(); + } + + /** + * Sort authentication methods list + * + * @param authMethodModels + */ + private sortAuthMethods(authMethodModels: AuthMethod[]): AuthMethod[] { + const sortedAuthMethodModels: AuthMethod[] = []; + authMethodModels.forEach((method) => { + if (method.authMethodType === AuthMethodType.Password) { + sortedAuthMethodModels.push(method); + } + }); + + authMethodModels.forEach((method) => { + if (method.authMethodType !== AuthMethodType.Password) { + sortedAuthMethodModels.push(method); + } + }); + + return sortedAuthMethodModels; + } + + /** + * Extract authentication methods list from the WWW-Authenticate headers + * + * @param headers + */ + private parseAuthMethodsFromHeaders(headers: HttpHeaders): AuthMethod[] { + let authMethodModels: AuthMethod[] = []; + if (isNotEmpty(headers.get('www-authenticate'))) { + // get the realms from the header - a realm is a single auth method + const completeWWWauthenticateHeader = headers.get('www-authenticate'); + const regex = /(\w+ (\w+=((".*?")|[^,]*)(, )?)*)/g; + const realms = completeWWWauthenticateHeader.match(regex); + + // tslint:disable-next-line:forin + for (const j in realms) { + + const splittedRealm = realms[j].split(', '); + const methodName = splittedRealm[0].split(' ')[0].trim(); + + let authMethodModel: AuthMethod; + if (splittedRealm.length === 1) { + authMethodModel = new AuthMethod(methodName); + authMethodModels.push(authMethodModel); + } else if (splittedRealm.length > 1) { + let location = splittedRealm[1]; + location = this.parseLocation(location); + authMethodModel = new AuthMethod(methodName, location); + authMethodModels.push(authMethodModel); + } + } + + // make sure the email + password login component gets rendered first + authMethodModels = this.sortAuthMethods(authMethodModels); + } else { + authMethodModels.push(new AuthMethod(AuthMethodType.Password)); + } + + return authMethodModels; + } + + /** + * Generate an AuthStatus object + * + * @param authenticated + * @param accessToken + * @param error + * @param httpHeaders + */ + private makeAuthStatusObject(authenticated: boolean, accessToken?: string, error?: string, httpHeaders?: HttpHeaders): AuthStatus { const authStatus = new AuthStatus(); + // let authMethods: AuthMethodModel[]; + if (httpHeaders) { + authStatus.authMethods = this.parseAuthMethodsFromHeaders(httpHeaders); + } + authStatus.id = null; + authStatus.okay = true; + // authStatus.authMethods = authMethods; + if (authenticated) { authStatus.authenticated = true; authStatus.token = new AuthTokenInfo(accessToken); @@ -70,12 +202,18 @@ export class AuthInterceptor implements HttpInterceptor { return authStatus; } + /** + * Intercept method + * @param req + * @param next + */ intercept(req: HttpRequest, next: HttpHandler): Observable> { const authService = this.inj.get(AuthService); - const token = authService.getToken(); - let newReq; + const token: AuthTokenInfo = authService.getToken(); + let newReq: HttpRequest; + let authorization: string; if (authService.isTokenExpired()) { authService.setRedirectUrl(this.router.url); @@ -96,30 +234,41 @@ export class AuthInterceptor implements HttpInterceptor { } }); // Get the auth header from the service. - const Authorization = authService.buildAuthHeader(token); + authorization = authService.buildAuthHeader(token); // Clone the request to add the new header. - newReq = req.clone({headers: req.headers.set('authorization', Authorization)}); + newReq = req.clone({ headers: req.headers.set('authorization', authorization) }); } else { - newReq = req; + newReq = req.clone(); } // Pass on the new request instead of the original request. return next.handle(newReq).pipe( + // tap((response) => console.log('next.handle: ', response)), map((response) => { // Intercept a Login/Logout response - if (response instanceof HttpResponse && this.isSuccess(response) && (this.isLoginResponse(response) || this.isLogoutResponse(response))) { + if (response instanceof HttpResponse && this.isSuccess(response) && this.isAuthRequest(response)) { // It's a success Login/Logout response let authRes: HttpResponse; if (this.isLoginResponse(response)) { // login successfully const newToken = response.headers.get('authorization'); - authRes = response.clone({body: this.makeAuthStatusObject(true, newToken)}); + authRes = response.clone({ + body: this.makeAuthStatusObject(true, newToken) + }); // clean eventually refresh Requests list this.refreshTokenRequestUrls = []; + } else if (this.isStatusResponse(response)) { + authRes = response.clone({ + body: Object.assign(response.body, { + authMethods: this.parseAuthMethodsFromHeaders(response.headers) + }) + }) } else { // logout successfully - authRes = response.clone({body: this.makeAuthStatusObject(false)}); + authRes = response.clone({ + body: this.makeAuthStatusObject(false) + }); } return authRes; } else { @@ -129,13 +278,15 @@ export class AuthInterceptor implements HttpInterceptor { catchError((error, caught) => { // Intercept an error response if (error instanceof HttpErrorResponse) { + // Checks if is a response from a request to an authentication endpoint if (this.isAuthRequest(error)) { // clean eventually refresh Requests list this.refreshTokenRequestUrls = []; + // Create a new HttpResponse and return it, so it can be handle properly by AuthService. const authResponse = new HttpResponse({ - body: this.makeAuthStatusObject(false, null, error.error), + body: this.makeAuthStatusObject(false, null, error.error, error.headers), headers: error.headers, status: error.status, statusText: error.statusText, diff --git a/src/app/core/auth/auth.reducer.spec.ts b/src/app/core/auth/auth.reducer.spec.ts index f299696007..7a39ef3da4 100644 --- a/src/app/core/auth/auth.reducer.spec.ts +++ b/src/app/core/auth/auth.reducer.spec.ts @@ -8,7 +8,7 @@ import { AuthenticationErrorAction, AuthenticationSuccessAction, CheckAuthenticationTokenAction, - CheckAuthenticationTokenErrorAction, + CheckAuthenticationTokenCookieAction, LogOutAction, LogOutErrorAction, LogOutSuccessAction, @@ -17,11 +17,19 @@ import { RefreshTokenAction, RefreshTokenErrorAction, RefreshTokenSuccessAction, - ResetAuthenticationMessagesAction, RetrieveAuthenticatedEpersonErrorAction, RetrieveAuthenticatedEpersonSuccessAction, + ResetAuthenticationMessagesAction, + RetrieveAuthenticatedEpersonErrorAction, + RetrieveAuthenticatedEpersonSuccessAction, + RetrieveAuthMethodsAction, + RetrieveAuthMethodsErrorAction, + RetrieveAuthMethodsSuccessAction, SetRedirectUrlAction } from './auth.actions'; import { AuthTokenInfo } from './models/auth-token-info.model'; import { EPersonMock } from '../../shared/testing/eperson-mock'; +import { AuthStatus } from './models/auth-status.model'; +import { AuthMethod } from './models/auth.method'; +import { AuthMethodType } from './models/auth.method-type'; describe('authReducer', () => { @@ -157,18 +165,18 @@ describe('authReducer', () => { expect(newState).toEqual(state); }); - it('should properly set the state, in response to a CHECK_AUTHENTICATION_TOKEN_ERROR action', () => { + it('should properly set the state, in response to a CHECK_AUTHENTICATION_TOKEN_COOKIE action', () => { initialState = { authenticated: false, loaded: false, loading: true, }; - const action = new CheckAuthenticationTokenErrorAction(); + const action = new CheckAuthenticationTokenCookieAction(); const newState = authReducer(initialState, action); state = { authenticated: false, loaded: false, - loading: false, + loading: true, }; expect(newState).toEqual(state); }); @@ -451,4 +459,63 @@ describe('authReducer', () => { }; expect(newState).toEqual(state); }); + + it('should properly set the state, in response to a RETRIEVE_AUTH_METHODS action', () => { + initialState = { + authenticated: false, + loaded: false, + loading: false, + authMethods: [] + }; + const action = new RetrieveAuthMethodsAction(new AuthStatus()); + const newState = authReducer(initialState, action); + state = { + authenticated: false, + loaded: false, + loading: true, + authMethods: [] + }; + expect(newState).toEqual(state); + }); + + it('should properly set the state, in response to a RETRIEVE_AUTH_METHODS_SUCCESS action', () => { + initialState = { + authenticated: false, + loaded: false, + loading: true, + authMethods: [] + }; + const authMethods = [ + new AuthMethod(AuthMethodType.Password), + new AuthMethod(AuthMethodType.Shibboleth, 'location') + ]; + const action = new RetrieveAuthMethodsSuccessAction(authMethods); + const newState = authReducer(initialState, action); + state = { + authenticated: false, + loaded: false, + loading: false, + authMethods: authMethods + }; + expect(newState).toEqual(state); + }); + + it('should properly set the state, in response to a RETRIEVE_AUTH_METHODS_ERROR action', () => { + initialState = { + authenticated: false, + loaded: false, + loading: true, + authMethods: [] + }; + + const action = new RetrieveAuthMethodsErrorAction(); + const newState = authReducer(initialState, action); + state = { + authenticated: false, + loaded: false, + loading: false, + authMethods: [new AuthMethod(AuthMethodType.Password)] + }; + expect(newState).toEqual(state); + }); }); diff --git a/src/app/core/auth/auth.reducer.ts b/src/app/core/auth/auth.reducer.ts index 7d5e50c432..19fd162d3f 100644 --- a/src/app/core/auth/auth.reducer.ts +++ b/src/app/core/auth/auth.reducer.ts @@ -8,12 +8,16 @@ import { LogOutErrorAction, RedirectWhenAuthenticationIsRequiredAction, RedirectWhenTokenExpiredAction, - RefreshTokenSuccessAction, RetrieveAuthenticatedEpersonSuccessAction, + RefreshTokenSuccessAction, + RetrieveAuthenticatedEpersonSuccessAction, + RetrieveAuthMethodsSuccessAction, SetRedirectUrlAction } from './auth.actions'; // import models import { EPerson } from '../eperson/models/eperson.model'; import { AuthTokenInfo } from './models/auth-token-info.model'; +import { AuthMethod } from './models/auth.method'; +import { AuthMethodType } from './models/auth.method-type'; /** * The auth state. @@ -47,6 +51,10 @@ export interface AuthState { // the authenticated user user?: EPerson; + + // all authentication Methods enabled at the backend + authMethods?: AuthMethod[]; + } /** @@ -56,6 +64,7 @@ const initialState: AuthState = { authenticated: false, loaded: false, loading: false, + authMethods: [] }; /** @@ -75,6 +84,8 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut }); case AuthActionTypes.AUTHENTICATED: + case AuthActionTypes.CHECK_AUTHENTICATION_TOKEN: + case AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE: return Object.assign({}, state, { loading: true }); @@ -113,21 +124,10 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut loading: false }); - case AuthActionTypes.AUTHENTICATED: case AuthActionTypes.AUTHENTICATE_SUCCESS: case AuthActionTypes.LOG_OUT: return state; - case AuthActionTypes.CHECK_AUTHENTICATION_TOKEN: - return Object.assign({}, state, { - loading: true - }); - - case AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_ERROR: - return Object.assign({}, state, { - loading: false - }); - case AuthActionTypes.LOG_OUT_ERROR: return Object.assign({}, state, { authenticated: true, @@ -192,6 +192,24 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut info: undefined, }); + // next three cases are used by dynamic rendering of login methods + case AuthActionTypes.RETRIEVE_AUTH_METHODS: + return Object.assign({}, state, { + loading: true + }); + + case AuthActionTypes.RETRIEVE_AUTH_METHODS_SUCCESS: + return Object.assign({}, state, { + loading: false, + authMethods: (action as RetrieveAuthMethodsSuccessAction).payload + }); + + case AuthActionTypes.RETRIEVE_AUTH_METHODS_ERROR: + return Object.assign({}, state, { + loading: false, + authMethods: [new AuthMethod(AuthMethodType.Password)] + }); + case AuthActionTypes.SET_REDIRECT_URL: return Object.assign({}, state, { redirectUrl: (action as SetRedirectUrlAction).payload, diff --git a/src/app/core/auth/auth.service.spec.ts b/src/app/core/auth/auth.service.spec.ts index 31649abe32..03759987bf 100644 --- a/src/app/core/auth/auth.service.spec.ts +++ b/src/app/core/auth/auth.service.spec.ts @@ -27,6 +27,8 @@ import { Observable } from 'rxjs/internal/Observable'; import { RemoteData } from '../data/remote-data'; import { createSuccessfulRemoteDataObject$ } from '../../shared/testing/utils'; import { EPersonDataService } from '../eperson/eperson-data.service'; +import { authMethodsMock } from '../../shared/testing/auth-service-stub'; +import { AuthMethod } from './models/auth.method'; describe('AuthService test', () => { @@ -144,6 +146,26 @@ describe('AuthService test', () => { expect(authService.logout.bind(null)).toThrow(); }); + it('should return the authentication status object to check an Authentication Cookie', () => { + authService.checkAuthenticationCookie().subscribe((status: AuthStatus) => { + expect(status).toBeDefined(); + }); + }); + + it('should return the authentication methods available', () => { + const authStatus = new AuthStatus(); + + authService.retrieveAuthMethodsFromAuthStatus(authStatus).subscribe((authMethods: AuthMethod[]) => { + expect(authMethods).toBeDefined(); + expect(authMethods.length).toBe(0); + }); + + authStatus.authMethods = authMethodsMock; + authService.retrieveAuthMethodsFromAuthStatus(authStatus).subscribe((authMethods: AuthMethod[]) => { + expect(authMethods).toBeDefined(); + expect(authMethods.length).toBe(2); + }); + }); }); describe('', () => { diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index f8847b0b2e..0f5c06bbc9 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -18,16 +18,20 @@ import { isEmpty, isNotEmpty, isNotNull, isNotUndefined } from '../../shared/emp import { CookieService } from '../services/cookie.service'; import { getAuthenticationToken, getRedirectUrl, isAuthenticated, isTokenRefreshing } from './selectors'; import { AppState, routerStateSelector } from '../../app.reducer'; -import { ResetAuthenticationMessagesAction, SetRedirectUrlAction } from './auth.actions'; +import { + CheckAuthenticationTokenAction, + ResetAuthenticationMessagesAction, + SetRedirectUrlAction +} from './auth.actions'; import { NativeWindowRef, NativeWindowService } from '../services/window.service'; import { Base64EncodeUrl } from '../../shared/utils/encode-decode.util'; import { RouteService } from '../services/route.service'; import { EPersonDataService } from '../eperson/eperson-data.service'; -import { getAllSucceededRemoteDataPayload, getFirstSucceededRemoteDataPayload } from '../shared/operators'; +import { getAllSucceededRemoteDataPayload } from '../shared/operators'; +import { AuthMethod } from './models/auth.method'; export const LOGIN_ROUTE = '/login'; export const LOGOUT_ROUTE = '/logout'; - export const REDIRECT_COOKIE = 'dsRedirectUrl'; /** @@ -114,6 +118,21 @@ export class AuthService { } + /** + * Checks if token is present into the request cookie + */ + public checkAuthenticationCookie(): Observable { + // Determine if the user has an existing auth session on the server + const options: HttpOptions = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Accept', 'application/json'); + options.headers = headers; + options.withCredentials = true; + return this.authRequestService.getRequest('status', options).pipe( + map((status: AuthStatus) => Object.assign(new AuthStatus(), status)) + ); + } + /** * Determines if the user is authenticated * @returns {Observable} @@ -154,10 +173,10 @@ export class AuthService { } /** - * Checks if token is present into browser storage and is valid. (NB Check is done only on SSR) + * Checks if token is present into browser storage and is valid. */ public checkAuthenticationToken() { - return + this.store.dispatch(new CheckAuthenticationTokenAction()); } /** @@ -187,8 +206,11 @@ export class AuthService { const options: HttpOptions = Object.create({}); let headers = new HttpHeaders(); headers = headers.append('Accept', 'application/json'); - headers = headers.append('Authorization', `Bearer ${token.accessToken}`); + if (token && token.accessToken) { + headers = headers.append('Authorization', `Bearer ${token.accessToken}`); + } options.headers = headers; + options.withCredentials = true; return this.authRequestService.postToEndpoint('login', {}, options).pipe( map((status: AuthStatus) => { if (status.authenticated) { @@ -206,6 +228,18 @@ export class AuthService { this.store.dispatch(new ResetAuthenticationMessagesAction()); } + /** + * Retrieve authentication methods available + * @returns {User} + */ + public retrieveAuthMethodsFromAuthStatus(status: AuthStatus): Observable { + let authMethods: AuthMethod[] = []; + if (isNotEmpty(status.authMethods)) { + authMethods = status.authMethods; + } + return observableOf(authMethods); + } + /** * Create a new user * @returns {User} diff --git a/src/app/core/auth/authenticated.guard.ts b/src/app/core/auth/authenticated.guard.ts index af0622cd19..7a2f39854c 100644 --- a/src/app/core/auth/authenticated.guard.ts +++ b/src/app/core/auth/authenticated.guard.ts @@ -1,17 +1,14 @@ - -import {take} from 'rxjs/operators'; import { Injectable } from '@angular/core'; import { ActivatedRouteSnapshot, CanActivate, CanLoad, Route, Router, RouterStateSnapshot } from '@angular/router'; -import {Observable, of} from 'rxjs'; +import { Observable } from 'rxjs'; +import { take } from 'rxjs/operators'; import { select, Store } from '@ngrx/store'; -// reducers import { CoreState } from '../core.reducers'; -import { isAuthenticated, isAuthenticationLoading } from './selectors'; +import { isAuthenticated } from './selectors'; import { AuthService } from './auth.service'; import { RedirectWhenAuthenticationIsRequiredAction } from './auth.actions'; -import { isEmpty } from '../../shared/empty.util'; /** * Prevent unauthorized activating and loading of routes diff --git a/src/app/core/auth/models/auth-status.model.ts b/src/app/core/auth/models/auth-status.model.ts index edad46a7bc..197c025407 100644 --- a/src/app/core/auth/models/auth-status.model.ts +++ b/src/app/core/auth/models/auth-status.model.ts @@ -12,6 +12,7 @@ import { excludeFromEquals } from '../../utilities/equals.decorators'; import { AuthError } from './auth-error.model'; import { AUTH_STATUS } from './auth-status.resource-type'; import { AuthTokenInfo } from './auth-token-info.model'; +import { AuthMethod } from './auth.method'; /** * Object that represents the authenticated status of a user @@ -79,5 +80,13 @@ export class AuthStatus implements CacheableObject { * Authentication error if there was one for this status */ // TODO should be refactored to use the RemoteData error + @autoserialize error?: AuthError; + + /** + * All authentication methods enabled at the backend + */ + @autoserialize + authMethods: AuthMethod[]; + } diff --git a/src/app/core/auth/models/auth.method-type.ts b/src/app/core/auth/models/auth.method-type.ts new file mode 100644 index 0000000000..f053515065 --- /dev/null +++ b/src/app/core/auth/models/auth.method-type.ts @@ -0,0 +1,7 @@ +export enum AuthMethodType { + Password = 'password', + Shibboleth = 'shibboleth', + Ldap = 'ldap', + Ip = 'ip', + X509 = 'x509' +} diff --git a/src/app/core/auth/models/auth.method.ts b/src/app/core/auth/models/auth.method.ts new file mode 100644 index 0000000000..617154080b --- /dev/null +++ b/src/app/core/auth/models/auth.method.ts @@ -0,0 +1,38 @@ +import { AuthMethodType } from './auth.method-type'; + +export class AuthMethod { + authMethodType: AuthMethodType; + location?: string; + + // isStandalonePage? = true; + + constructor(authMethodName: string, location?: string) { + switch (authMethodName) { + case 'ip': { + this.authMethodType = AuthMethodType.Ip; + break; + } + case 'ldap': { + this.authMethodType = AuthMethodType.Ldap; + break; + } + case 'shibboleth': { + this.authMethodType = AuthMethodType.Shibboleth; + this.location = location; + break; + } + case 'x509': { + this.authMethodType = AuthMethodType.X509; + break; + } + case 'password': { + this.authMethodType = AuthMethodType.Password; + break; + } + + default: { + break; + } + } + } +} diff --git a/src/app/core/auth/models/normalized-auth-status.model.ts b/src/app/core/auth/models/normalized-auth-status.model.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/core/auth/selectors.ts b/src/app/core/auth/selectors.ts index 8c88e0fce5..4e51bc1fc9 100644 --- a/src/app/core/auth/selectors.ts +++ b/src/app/core/auth/selectors.ts @@ -107,6 +107,17 @@ const _getRegistrationError = (state: AuthState) => state.error; */ const _getRedirectUrl = (state: AuthState) => state.redirectUrl; +const _getAuthenticationMethods = (state: AuthState) => state.authMethods; + +/** + * Returns the authentication methods enabled at the backend + * @function getAuthenticationMethods + * @param {AuthState} state + * @param {any} props + * @return {any} + */ +export const getAuthenticationMethods = createSelector(getAuthState, _getAuthenticationMethods); + /** * Returns the authenticated user * @function getAuthenticatedUser diff --git a/src/app/core/auth/server-auth.service.ts b/src/app/core/auth/server-auth.service.ts index c8cba0206b..30767be85a 100644 --- a/src/app/core/auth/server-auth.service.ts +++ b/src/app/core/auth/server-auth.service.ts @@ -1,11 +1,11 @@ -import { HttpHeaders } from '@angular/common/http'; import { Injectable } from '@angular/core'; +import { HttpHeaders } from '@angular/common/http'; import { Observable } from 'rxjs'; import { filter, map, take } from 'rxjs/operators'; + import { isNotEmpty } from '../../shared/empty.util'; import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; -import { CheckAuthenticationTokenAction } from './auth.actions'; import { AuthService } from './auth.service'; import { AuthStatus } from './models/auth-status.model'; import { AuthTokenInfo } from './models/auth-token-info.model'; @@ -43,10 +43,23 @@ export class ServerAuthService extends AuthService { } /** - * Checks if token is present into browser storage and is valid. (NB Check is done only on SSR) + * Checks if token is present into the request cookie */ - public checkAuthenticationToken() { - this.store.dispatch(new CheckAuthenticationTokenAction()) + public checkAuthenticationCookie(): Observable { + // Determine if the user has an existing auth session on the server + const options: HttpOptions = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Accept', 'application/json'); + if (isNotEmpty(this.req.protocol) && isNotEmpty(this.req.header('host'))) { + const referer = this.req.protocol + '://' + this.req.header('host') + this.req.path; + // use to allow the rest server to identify the real origin on SSR + headers = headers.append('X-Requested-With', referer); + } + options.headers = headers; + options.withCredentials = true; + return this.authRequestService.getRequest('status', options).pipe( + map((status: AuthStatus) => Object.assign(new AuthStatus(), status)) + ); } /** diff --git a/src/app/core/cache/server-sync-buffer.effects.ts b/src/app/core/cache/server-sync-buffer.effects.ts index fd398f2971..84f0312385 100644 --- a/src/app/core/cache/server-sync-buffer.effects.ts +++ b/src/app/core/cache/server-sync-buffer.effects.ts @@ -2,7 +2,6 @@ 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 { DSpaceSerializer } from '../dspace-rest-v2/dspace.serializer'; import { AddToSSBAction, CommitSSBAction, @@ -16,10 +15,9 @@ import { Action, createSelector, MemoizedSelector, select, Store } from '@ngrx/s import { ServerSyncBufferEntry, ServerSyncBufferState } from './server-sync-buffer.reducer'; import { combineLatest as observableCombineLatest, of as observableOf } from 'rxjs'; import { RequestService } from '../data/request.service'; -import { PatchRequest, PutRequest } from '../data/request.models'; +import { PatchRequest } from '../data/request.models'; import { ObjectCacheService } from './object-cache.service'; import { ApplyPatchObjectCacheAction } from './object-cache.actions'; -import { GenericConstructor } from '../shared/generic-constructor'; import { hasValue, isNotEmpty, isNotUndefined } from '../../shared/empty.util'; import { Observable } from 'rxjs/internal/Observable'; import { RestRequestMethod } from '../data/rest-request-method'; diff --git a/src/app/core/cache/server-sync-buffer.reducer.ts b/src/app/core/cache/server-sync-buffer.reducer.ts index c86a0d5654..d79dd51da4 100644 --- a/src/app/core/cache/server-sync-buffer.reducer.ts +++ b/src/app/core/cache/server-sync-buffer.reducer.ts @@ -68,6 +68,8 @@ function addToServerSyncQueue(state: ServerSyncBufferState, action: AddToSSBActi const actionEntry = action.payload as ServerSyncBufferEntry; if (hasNoValue(state.buffer.find((entry) => entry.href === actionEntry.href && entry.method === actionEntry.method))) { return Object.assign({}, state, { buffer: state.buffer.concat(actionEntry) }); + } else { + return state; } } diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 3a544fdf80..783b169291 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -1,24 +1,19 @@ import { CommonModule } from '@angular/common'; import { HTTP_INTERCEPTORS, HttpClient } from '@angular/common/http'; import { ModuleWithProviders, NgModule, Optional, SkipSelf } from '@angular/core'; -import { - DynamicFormLayoutService, - DynamicFormService, - DynamicFormValidationService -} from '@ng-dynamic-forms/core'; -import { EffectsModule } from '@ngrx/effects'; +import { DynamicFormLayoutService, DynamicFormService, DynamicFormValidationService } from '@ng-dynamic-forms/core'; +import { EffectsModule } from '@ngrx/effects'; import { StoreModule } from '@ngrx/store'; + import { MyDSpaceGuard } from '../+my-dspace-page/my-dspace.guard'; import { ENV_CONFIG, GLOBAL_CONFIG, GlobalConfig } from '../../config'; - import { isNotEmpty } from '../shared/empty.util'; import { FormBuilderService } from '../shared/form/builder/form-builder.service'; import { FormService } from '../shared/form/form.service'; import { HostWindowService } from '../shared/host-window.service'; import { MenuService } from '../shared/menu/menu.service'; import { EndpointMockingRestService } from '../shared/mocks/dspace-rest-v2/endpoint-mocking-rest.service'; - import { MOCK_RESPONSE_MAP, MockResponseMap, @@ -48,7 +43,6 @@ import { SubmissionUploadsModel } from './config/models/config-submission-upload 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 { coreEffects } from './core.effects'; import { coreReducers } from './core.reducers'; import { BitstreamFormatDataService } from './data/bitstream-format-data.service'; @@ -103,7 +97,6 @@ import { RegistryService } from './registry/registry.service'; import { RoleService } from './roles/role.service'; import { ApiService } from './services/api.service'; -import { RouteService } from './services/route.service'; import { ServerResponseService } from './services/server-response.service'; import { NativeWindowFactory, NativeWindowService } from './services/window.service'; import { BitstreamFormat } from './shared/bitstream-format.model'; @@ -142,6 +135,8 @@ import { PoolTask } from './tasks/models/pool-task-object.model'; import { TaskObject } from './tasks/models/task-object.model'; import { PoolTaskDataService } from './tasks/pool-task-data.service'; import { TaskResponseParsingService } from './tasks/task-response-parsing.service'; +import { ArrayMoveChangeAnalyzer } from './data/array-move-change-analyzer.service'; +import { BitstreamDataService } from './data/bitstream-data.service'; import { VersionDataService } from './data/version-data.service'; import { VersionHistoryDataService } from './data/version-history-data.service'; import { Version } from './shared/version.model'; @@ -181,7 +176,11 @@ const PROVIDERS = [ SiteDataService, DSOResponseParsingService, { provide: MOCK_RESPONSE_MAP, useValue: mockResponseMap }, - { provide: DSpaceRESTv2Service, useFactory: restServiceFactory, deps: [GLOBAL_CONFIG, MOCK_RESPONSE_MAP, HttpClient]}, + { + provide: DSpaceRESTv2Service, + useFactory: restServiceFactory, + deps: [GLOBAL_CONFIG, MOCK_RESPONSE_MAP, HttpClient] + }, DynamicFormLayoutService, DynamicFormService, DynamicFormValidationService, @@ -217,7 +216,6 @@ const PROVIDERS = [ BrowseItemsResponseParsingService, BrowseService, ConfigResponseParsingService, - RouteService, SubmissionDefinitionsConfigService, SubmissionFormsConfigService, SubmissionRestService, @@ -239,6 +237,7 @@ const PROVIDERS = [ DSpaceObjectDataService, DSOChangeAnalyzer, DefaultChangeAnalyzer, + ArrayMoveChangeAnalyzer, ObjectSelectService, CSSVariableService, MenuService, @@ -250,6 +249,7 @@ const PROVIDERS = [ TaskResponseParsingService, ClaimedTaskDataService, PoolTaskDataService, + BitstreamDataService, EntityTypeService, ContentSourceResponseParsingService, SearchService, diff --git a/src/app/core/data/array-move-change-analyzer.service.spec.ts b/src/app/core/data/array-move-change-analyzer.service.spec.ts new file mode 100644 index 0000000000..5f5388d935 --- /dev/null +++ b/src/app/core/data/array-move-change-analyzer.service.spec.ts @@ -0,0 +1,107 @@ +import { ArrayMoveChangeAnalyzer } from './array-move-change-analyzer.service'; +import { moveItemInArray } from '@angular/cdk/drag-drop'; +import { Operation } from 'fast-json-patch'; + +/** + * Helper class for creating move tests + * Define a "from" and "to" index to move objects within the array before comparing + */ +class MoveTest { + from: number; + to: number; + + constructor(from: number, to: number) { + this.from = from; + this.to = to; + } +} + +describe('ArrayMoveChangeAnalyzer', () => { + const comparator = new ArrayMoveChangeAnalyzer(); + + let originalArray = []; + + describe('when all values are defined', () => { + beforeEach(() => { + originalArray = [ + '98700118-d65d-4636-b1d0-dba83fc932e1', + '4d7d0798-a8fa-45b8-b4fc-deb2819606c8', + 'e56eb99e-2f7c-4bee-9b3f-d3dcc83386b1', + '0f608168-cdfc-46b0-92ce-889f7d3ac684', + '546f9f5c-15dc-4eec-86fe-648007ac9e1c' + ]; + }); + + testMove([ + { op: 'move', from: '/2', path: '/4' }, + ], new MoveTest(2, 4)); + + testMove([ + { op: 'move', from: '/0', path: '/3' }, + ], new MoveTest(0, 3)); + + testMove([ + { op: 'move', from: '/0', path: '/3' }, + { op: 'move', from: '/2', path: '/1' } + ], new MoveTest(0, 3), new MoveTest(1, 2)); + + testMove([ + { op: 'move', from: '/0', path: '/1' }, + { op: 'move', from: '/3', path: '/4' } + ], new MoveTest(0, 1), new MoveTest(3, 4)); + + testMove([], new MoveTest(0, 4), new MoveTest(4, 0)); + + testMove([ + { op: 'move', from: '/0', path: '/3' }, + { op: 'move', from: '/2', path: '/1' } + ], new MoveTest(0, 4), new MoveTest(1, 3), new MoveTest(2, 4)); + }); + + describe('when some values are undefined (index 2 and 3)', () => { + beforeEach(() => { + originalArray = [ + '98700118-d65d-4636-b1d0-dba83fc932e1', + '4d7d0798-a8fa-45b8-b4fc-deb2819606c8', + undefined, + undefined, + '546f9f5c-15dc-4eec-86fe-648007ac9e1c' + ]; + }); + + // It can't create a move operation for undefined values, so it should create move operations for the defined values instead + testMove([ + { op: 'move', from: '/4', path: '/3' }, + ], new MoveTest(2, 4)); + + // Moving a defined value should result in the same operations + testMove([ + { op: 'move', from: '/0', path: '/3' }, + ], new MoveTest(0, 3)); + }); + + /** + * Helper function for creating a move test + * + * @param expectedOperations An array of expected operations after comparing the original array with the array + * created using the provided MoveTests + * @param moves An array of MoveTest objects telling the test where to move objects before comparing + */ + function testMove(expectedOperations: Operation[], ...moves: MoveTest[]) { + describe(`move ${moves.map((move) => `${move.from} to ${move.to}`).join(' and ')}`, () => { + let result; + + beforeEach(() => { + const movedArray = [...originalArray]; + moves.forEach((move) => { + moveItemInArray(movedArray, move.from, move.to); + }); + result = comparator.diff(originalArray, movedArray); + }); + + it('should create the expected move operations', () => { + expect(result).toEqual(expectedOperations); + }); + }); + } +}); diff --git a/src/app/core/data/array-move-change-analyzer.service.ts b/src/app/core/data/array-move-change-analyzer.service.ts new file mode 100644 index 0000000000..39d22fc463 --- /dev/null +++ b/src/app/core/data/array-move-change-analyzer.service.ts @@ -0,0 +1,37 @@ +import { MoveOperation } from 'fast-json-patch/lib/core'; +import { Injectable } from '@angular/core'; +import { moveItemInArray } from '@angular/cdk/drag-drop'; +import { hasValue } from '../../shared/empty.util'; + +/** + * A class to determine move operations between two arrays + */ +@Injectable() +export class ArrayMoveChangeAnalyzer { + + /** + * Compare two arrays detecting and returning move operations + * + * @param array1 The original array + * @param array2 The custom array to compare with the original + */ + diff(array1: T[], array2: T[]): MoveOperation[] { + const result = []; + const moved = [...array1]; + array1.forEach((value: T, index: number) => { + if (hasValue(value)) { + const otherIndex = array2.indexOf(value); + const movedIndex = moved.indexOf(value); + if (index !== otherIndex && movedIndex !== otherIndex) { + moveItemInArray(moved, movedIndex, otherIndex); + result.push(Object.assign({ + op: 'move', + from: '/' + movedIndex, + path: '/' + otherIndex + }) as MoveOperation) + } + } + }); + return result; + } +} diff --git a/src/app/core/data/bitstream-data.service.spec.ts b/src/app/core/data/bitstream-data.service.spec.ts new file mode 100644 index 0000000000..fca0f6b650 --- /dev/null +++ b/src/app/core/data/bitstream-data.service.spec.ts @@ -0,0 +1,58 @@ +import { BitstreamDataService } from './bitstream-data.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { RequestService } from './request.service'; +import { Bitstream } from '../shared/bitstream.model'; +import { getMockRequestService } from '../../shared/mocks/mock-request.service'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { BitstreamFormatDataService } from './bitstream-format-data.service'; +import { of as observableOf } from 'rxjs/internal/observable/of'; +import { BitstreamFormat } from '../shared/bitstream-format.model'; +import { BitstreamFormatSupportLevel } from '../shared/bitstream-format-support-level'; +import { PutRequest } from './request.models'; + +describe('BitstreamDataService', () => { + let service: BitstreamDataService; + let objectCache: ObjectCacheService; + let requestService: RequestService; + let halService: HALEndpointService; + let bitstreamFormatService: BitstreamFormatDataService; + const bitstreamFormatHref = 'rest-api/bitstreamformats'; + + const bitstream = Object.assign(new Bitstream(), { + uuid: 'fake-bitstream', + _links: { + self: { href: 'fake-bitstream-self' } + } + }); + const format = Object.assign(new BitstreamFormat(), { + id: '2', + shortDescription: 'PNG', + description: 'Portable Network Graphics', + supportLevel: BitstreamFormatSupportLevel.Known + }); + const url = 'fake-bitstream-url'; + + beforeEach(() => { + objectCache = jasmine.createSpyObj('objectCache', { + remove: jasmine.createSpy('remove') + }); + requestService = getMockRequestService(); + halService = Object.assign(new HALEndpointServiceStub(url)); + bitstreamFormatService = jasmine.createSpyObj('bistreamFormatService', { + getBrowseEndpoint: observableOf(bitstreamFormatHref) + }); + + service = new BitstreamDataService(requestService, null, null, null, objectCache, halService, null, null, null, null, bitstreamFormatService); + }); + + describe('when updating the bitstream\'s format', () => { + beforeEach(() => { + service.updateFormat(bitstream, format); + }); + + it('should configure a put request', () => { + expect(requestService.configure).toHaveBeenCalledWith(jasmine.any(PutRequest)); + }); + }); +}); diff --git a/src/app/core/data/bitstream-data.service.ts b/src/app/core/data/bitstream-data.service.ts index c571c7f96c..4c24f5d78b 100644 --- a/src/app/core/data/bitstream-data.service.ts +++ b/src/app/core/data/bitstream-data.service.ts @@ -1,8 +1,8 @@ -import { HttpClient } from '@angular/common/http'; +import { HttpClient, HttpHeaders } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Store } from '@ngrx/store'; import { Observable } from 'rxjs/internal/Observable'; -import { map, switchMap } from 'rxjs/operators'; +import { map, switchMap, take } from 'rxjs/operators'; import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; @@ -22,8 +22,14 @@ import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; import { PaginatedList } from './paginated-list'; import { RemoteData } from './remote-data'; import { RemoteDataError } from './remote-data-error'; -import { FindListOptions } from './request.models'; +import { FindListOptions, PutRequest } from './request.models'; import { RequestService } from './request.service'; +import { BitstreamFormatDataService } from './bitstream-format-data.service'; +import { BitstreamFormat } from '../shared/bitstream-format.model'; +import { RestResponse } from '../cache/response.models'; +import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; +import { configureRequest, getResponseFromEntry } from '../shared/operators'; +import { combineLatest as observableCombineLatest } from 'rxjs'; /** * A service to retrieve {@link Bitstream}s from the REST API @@ -50,6 +56,7 @@ export class BitstreamDataService extends DataService { protected http: HttpClient, protected comparator: DSOChangeAnalyzer, protected bundleService: BundleDataService, + protected bitstreamFormatService: BitstreamFormatDataService ) { super(); } @@ -167,4 +174,37 @@ export class BitstreamDataService extends DataService { ); } + /** + * Set the format of a bitstream + * @param bitstream + * @param format + */ + updateFormat(bitstream: Bitstream, format: BitstreamFormat): Observable { + const requestId = this.requestService.generateRequestId(); + const bitstreamHref$ = this.getBrowseEndpoint().pipe( + map((href: string) => `${href}/${bitstream.id}`), + switchMap((href: string) => this.halService.getEndpoint('format', href)) + ); + const formatHref$ = this.bitstreamFormatService.getBrowseEndpoint().pipe( + map((href: string) => `${href}/${format.id}`) + ); + observableCombineLatest([bitstreamHref$, formatHref$]).pipe( + map(([bitstreamHref, formatHref]) => { + const options: HttpOptions = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Content-Type', 'text/uri-list'); + options.headers = headers; + return new PutRequest(requestId, bitstreamHref, formatHref, options); + }), + configureRequest(this.requestService), + take(1) + ).subscribe(() => { + this.requestService.removeByHrefSubstring(bitstream.self + '/format'); + }); + + return this.requestService.getByUUID(requestId).pipe( + getResponseFromEntry() + ); + } + } diff --git a/src/app/core/data/bundle-data.service.ts b/src/app/core/data/bundle-data.service.ts index 64d58eb8ec..160ea0ff0d 100644 --- a/src/app/core/data/bundle-data.service.ts +++ b/src/app/core/data/bundle-data.service.ts @@ -2,7 +2,7 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Store } from '@ngrx/store'; import { Observable } from 'rxjs/internal/Observable'; -import { map } from 'rxjs/operators'; +import { map, switchMap, take } from 'rxjs/operators'; import { hasValue } from '../../shared/empty.util'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; @@ -18,8 +18,10 @@ import { DataService } from './data.service'; import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; import { PaginatedList } from './paginated-list'; import { RemoteData } from './remote-data'; -import { FindListOptions } from './request.models'; +import { FindListOptions, GetRequest } from './request.models'; import { RequestService } from './request.service'; +import { PaginatedSearchOptions } from '../../shared/search/paginated-search-options.model'; +import { Bitstream } from '../shared/bitstream.model'; /** * A service to retrieve {@link Bundle}s from the REST API @@ -30,6 +32,7 @@ import { RequestService } from './request.service'; @dataService(BUNDLE) export class BundleDataService extends DataService { protected linkPath = 'bundles'; + protected bitstreamsEndpoint = 'bitstreams'; constructor( protected requestService: RequestService, @@ -81,4 +84,34 @@ export class BundleDataService extends DataService { }), ); } + + /** + * Get the bitstreams endpoint for a bundle + * @param bundleId + */ + getBitstreamsEndpoint(bundleId: string): Observable { + return this.getBrowseEndpoint().pipe( + switchMap((href: string) => this.halService.getEndpoint(this.bitstreamsEndpoint, `${href}/${bundleId}`)) + ); + } + + /** + * Get a bundle's bitstreams using paginated search options + * @param bundleId The bundle's ID + * @param searchOptions The search options to use + * @param linksToFollow The {@link FollowLinkConfig}s for the request + */ + getBitstreams(bundleId: string, searchOptions?: PaginatedSearchOptions, ...linksToFollow: Array>): Observable>> { + const hrefObs = this.getBitstreamsEndpoint(bundleId).pipe( + map((href) => searchOptions ? searchOptions.toRestUrl(href) : href) + ); + hrefObs.pipe( + take(1) + ).subscribe((href) => { + const request = new GetRequest(this.requestService.generateRequestId(), href); + this.requestService.configure(request); + }); + + return this.rdbService.buildList(hrefObs, ...linksToFollow); + } } diff --git a/src/app/core/data/data.service.ts b/src/app/core/data/data.service.ts index 135834b430..7cbfb2ad03 100644 --- a/src/app/core/data/data.service.ts +++ b/src/app/core/data/data.service.ts @@ -14,7 +14,7 @@ import { take, tap } from 'rxjs/operators'; -import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; +import { hasValue, hasValueOperator, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; import { NotificationOptions } from '../../shared/notifications/models/notification-options.model'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; @@ -44,7 +44,8 @@ import { FindByIDRequest, FindListOptions, FindListRequest, - GetRequest, PatchRequest + GetRequest, + PatchRequest } from './request.models'; import { RequestEntry } from './request.reducer'; import { RequestService } from './request.service'; @@ -475,6 +476,39 @@ export abstract class DataService { * @return an observable that emits true when the deletion was successful, false when it failed */ delete(dsoID: string, copyVirtualMetadata?: string[]): Observable { + const requestId = this.deleteAndReturnRequestId(dsoID, copyVirtualMetadata); + + return this.requestService.getByUUID(requestId).pipe( + find((request: RequestEntry) => request.completed), + map((request: RequestEntry) => request.response.isSuccessful) + ); + } + + /** + * Delete an existing DSpace Object on the server + * @param dsoID The DSpace Object' id to be removed + * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual + * metadata should be saved as real metadata + * Return an observable of the completed response + */ + deleteAndReturnResponse(dsoID: string, copyVirtualMetadata?: string[]): Observable { + const requestId = this.deleteAndReturnRequestId(dsoID, copyVirtualMetadata); + + return this.requestService.getByUUID(requestId).pipe( + hasValueOperator(), + find((request: RequestEntry) => request.completed), + map((request: RequestEntry) => request.response) + ); + } + + /** + * Delete an existing DSpace Object on the server + * @param dsoID The DSpace Object' id to be removed + * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual + * metadata should be saved as real metadata + * Return the delete request's ID + */ + private deleteAndReturnRequestId(dsoID: string, copyVirtualMetadata?: string[]): string { const requestId = this.requestService.generateRequestId(); const hrefObs = this.halService.getEndpoint(this.linkPath).pipe( @@ -495,10 +529,7 @@ export abstract class DataService { }) ).subscribe(); - return this.requestService.getByUUID(requestId).pipe( - find((request: RequestEntry) => request.completed), - map((request: RequestEntry) => request.response.isSuccessful) - ); + return requestId; } /** diff --git a/src/app/core/data/item-data.service.spec.ts b/src/app/core/data/item-data.service.spec.ts index 06adfd5143..2519c90973 100644 --- a/src/app/core/data/item-data.service.spec.ts +++ b/src/app/core/data/item-data.service.spec.ts @@ -47,6 +47,9 @@ describe('ItemDataService', () => { return cold('a', { a: itemEndpoint }); } } as HALEndpointService; + const bundleService = jasmine.createSpyObj('bundleService', { + findByHref: {} + }); const scopeID = '4af28e99-6a9c-4036-a199-e1b587046d39'; const options = Object.assign(new FindListOptions(), { @@ -87,7 +90,8 @@ describe('ItemDataService', () => { halEndpointService, notificationsService, http, - comparator + comparator, + bundleService ); } @@ -212,4 +216,20 @@ describe('ItemDataService', () => { }); }); + describe('createBundle', () => { + const itemId = '3de6ea60-ec39-419b-ae6f-065930ac1429'; + const bundleName = 'ORIGINAL'; + let result; + + beforeEach(() => { + service = initTestService(); + spyOn(requestService, 'configure'); + result = service.createBundle(itemId, bundleName); + }); + + it('should configure a POST request', () => { + result.subscribe(() => expect(requestService.configure).toHaveBeenCalledWith(jasmine.any(PostRequest))); + }); + }); + }); diff --git a/src/app/core/data/item-data.service.ts b/src/app/core/data/item-data.service.ts index a23eb27f4a..562050c802 100644 --- a/src/app/core/data/item-data.service.ts +++ b/src/app/core/data/item-data.service.ts @@ -2,7 +2,7 @@ import { HttpClient, HttpHeaders } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Store } from '@ngrx/store'; import { Observable } from 'rxjs'; -import { distinctUntilChanged, filter, find, map, switchMap, tap } from 'rxjs/operators'; +import { distinctUntilChanged, filter, find, map, switchMap, take } from 'rxjs/operators'; import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { BrowseService } from '../browse/browse.service'; @@ -32,6 +32,7 @@ import { RemoteData } from './remote-data'; import { DeleteRequest, FindListOptions, + GetRequest, MappedCollectionsRequest, PatchRequest, PostRequest, @@ -40,6 +41,10 @@ import { } from './request.models'; import { RequestEntry } from './request.reducer'; import { RequestService } from './request.service'; +import { PaginatedSearchOptions } from '../../shared/search/paginated-search-options.model'; +import { Bundle } from '../shared/bundle.model'; +import { MetadataMap } from '../shared/metadata.models'; +import { BundleDataService } from './bundle-data.service'; @Injectable() @dataService(ITEM) @@ -56,6 +61,7 @@ export class ItemDataService extends DataService { protected notificationsService: NotificationsService, protected http: HttpClient, protected comparator: DSOChangeAnalyzer, + protected bundleService: BundleDataService ) { super(); } @@ -219,6 +225,76 @@ export class ItemDataService extends DataService { ); } + /** + * Get the endpoint for an item's bundles + * @param itemId + */ + public getBundlesEndpoint(itemId: string): Observable { + return this.halService.getEndpoint(this.linkPath).pipe( + switchMap((url: string) => this.halService.getEndpoint('bundles', `${url}/${itemId}`)) + ); + } + + /** + * Get an item's bundles using paginated search options + * @param itemId The item's ID + * @param searchOptions The search options to use + */ + public getBundles(itemId: string, searchOptions?: PaginatedSearchOptions): Observable>> { + const hrefObs = this.getBundlesEndpoint(itemId).pipe( + map((href) => searchOptions ? searchOptions.toRestUrl(href) : href) + ); + hrefObs.pipe( + take(1) + ).subscribe((href) => { + const request = new GetRequest(this.requestService.generateRequestId(), href); + this.requestService.configure(request); + }); + + return this.rdbService.buildList(hrefObs); + } + + /** + * Create a new bundle on an item + * @param itemId The item's ID + * @param bundleName The new bundle's name + * @param metadata Optional metadata for the bundle + */ + public createBundle(itemId: string, bundleName: string, metadata?: MetadataMap): Observable> { + const requestId = this.requestService.generateRequestId(); + const hrefObs = this.getBundlesEndpoint(itemId); + + const bundleJson = { + name: bundleName, + metadata: metadata ? metadata : {} + }; + + hrefObs.pipe( + take(1) + ).subscribe((href) => { + const options: HttpOptions = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Content-Type', 'application/json'); + options.headers = headers; + const request = new PostRequest(requestId, href, JSON.stringify(bundleJson), options); + this.requestService.configure(request); + }); + + const selfLink$ = this.requestService.getByUUID(requestId).pipe( + getResponseFromEntry(), + map((response: any) => { + if (isNotEmpty(response.resourceSelfLinks)) { + return response.resourceSelfLinks[0]; + } + }), + distinctUntilChanged() + ) as Observable; + + return selfLink$.pipe( + switchMap((selfLink: string) => this.bundleService.findByHref(selfLink)), + ); + } + /** * Get the endpoint to move the item * @param itemId diff --git a/src/app/core/data/object-updates/object-updates.actions.ts b/src/app/core/data/object-updates/object-updates.actions.ts index 9df9acec8f..94918157ee 100644 --- a/src/app/core/data/object-updates/object-updates.actions.ts +++ b/src/app/core/data/object-updates/object-updates.actions.ts @@ -8,6 +8,7 @@ import {INotification} from '../../../shared/notifications/models/notification.m */ export const ObjectUpdatesActionTypes = { INITIALIZE_FIELDS: type('dspace/core/cache/object-updates/INITIALIZE_FIELDS'), + ADD_PAGE_TO_CUSTOM_ORDER: type('dspace/core/cache/object-updates/ADD_PAGE_TO_CUSTOM_ORDER'), SET_EDITABLE_FIELD: type('dspace/core/cache/object-updates/SET_EDITABLE_FIELD'), SET_VALID_FIELD: type('dspace/core/cache/object-updates/SET_VALID_FIELD'), ADD_FIELD: type('dspace/core/cache/object-updates/ADD_FIELD'), @@ -15,7 +16,9 @@ export const ObjectUpdatesActionTypes = { DISCARD: type('dspace/core/cache/object-updates/DISCARD'), REINSTATE: type('dspace/core/cache/object-updates/REINSTATE'), REMOVE: type('dspace/core/cache/object-updates/REMOVE'), + REMOVE_ALL: type('dspace/core/cache/object-updates/REMOVE_ALL'), REMOVE_FIELD: type('dspace/core/cache/object-updates/REMOVE_FIELD'), + MOVE: type('dspace/core/cache/object-updates/MOVE'), }; /* tslint:disable:max-classes-per-file */ @@ -26,7 +29,8 @@ export const ObjectUpdatesActionTypes = { export enum FieldChangeType { UPDATE = 0, ADD = 1, - REMOVE = 2 + REMOVE = 2, + MOVE = 3 } /** @@ -37,7 +41,10 @@ export class InitializeFieldsAction implements Action { payload: { url: string, fields: Identifiable[], - lastModified: Date + lastModified: Date, + order: string[], + pageSize: number, + page: number }; /** @@ -47,13 +54,49 @@ export class InitializeFieldsAction implements Action { * the unique url of the page for which the fields are being initialized * @param fields The identifiable fields of which the updates are kept track of * @param lastModified The last modified date of the object that belongs to the page + * @param order A custom order to keep track of objects moving around + * @param pageSize The page size used to fill empty pages for the custom order + * @param page The first page to populate in the custom order */ constructor( url: string, fields: Identifiable[], - lastModified: Date + lastModified: Date, + order: string[] = [], + pageSize: number = 9999, + page: number = 0 ) { - this.payload = { url, fields, lastModified }; + this.payload = { url, fields, lastModified, order, pageSize, page }; + } +} + +/** + * An ngrx action to initialize a new page's fields in the ObjectUpdates state + */ +export class AddPageToCustomOrderAction implements Action { + type = ObjectUpdatesActionTypes.ADD_PAGE_TO_CUSTOM_ORDER; + payload: { + url: string, + fields: Identifiable[], + order: string[], + page: number + }; + + /** + * Create a new AddPageToCustomOrderAction + * + * @param url The unique url of the page for which the fields are being added + * @param fields The identifiable fields of which the updates are kept track of + * @param order A custom order to keep track of objects moving around + * @param page The page to populate in the custom order + */ + constructor( + url: string, + fields: Identifiable[], + order: string[] = [], + page: number = 0 + ) { + this.payload = { url, fields, order, page }; } } @@ -180,7 +223,8 @@ export class DiscardObjectUpdatesAction implements Action { type = ObjectUpdatesActionTypes.DISCARD; payload: { url: string, - notification: INotification + notification: INotification, + discardAll: boolean; }; /** @@ -189,12 +233,14 @@ export class DiscardObjectUpdatesAction implements Action { * @param url * the unique url of the page for which the changes should be discarded * @param notification The notification that is raised when changes are discarded + * @param discardAll discard all */ constructor( url: string, - notification: INotification + notification: INotification, + discardAll = false ) { - this.payload = { url, notification }; + this.payload = { url, notification, discardAll }; } } @@ -242,6 +288,13 @@ export class RemoveObjectUpdatesAction implements Action { } } +/** + * An ngrx action to remove all previously discarded updates in the ObjectUpdates state + */ +export class RemoveAllObjectUpdatesAction implements Action { + type = ObjectUpdatesActionTypes.REMOVE_ALL; +} + /** * An ngrx action to remove a single field update in the ObjectUpdates state for a certain page url and field uuid */ @@ -267,6 +320,43 @@ export class RemoveFieldUpdateAction implements Action { } } +/** + * An ngrx action to remove a single field update in the ObjectUpdates state for a certain page url and field uuid + */ +export class MoveFieldUpdateAction implements Action { + type = ObjectUpdatesActionTypes.MOVE; + payload: { + url: string, + from: number, + to: number, + fromPage: number, + toPage: number, + field?: Identifiable + }; + + /** + * Create a new RemoveObjectUpdatesAction + * + * @param url + * the unique url of the page for which a field's change should be removed + * @param from The index of the object to move + * @param to The index to move the object to + * @param fromPage The page to move the object from + * @param toPage The page to move the object to + * @param field Optional field to add to the fieldUpdates list (useful when we want to track updates across multiple pages) + */ + constructor( + url: string, + from: number, + to: number, + fromPage: number, + toPage: number, + field?: Identifiable + ) { + this.payload = { url, from, to, fromPage, toPage, field }; + } +} + /* tslint:enable:max-classes-per-file */ /** @@ -279,6 +369,9 @@ export type ObjectUpdatesAction | ReinstateObjectUpdatesAction | RemoveObjectUpdatesAction | RemoveFieldUpdateAction + | MoveFieldUpdateAction + | AddPageToCustomOrderAction + | RemoveAllObjectUpdatesAction | SelectVirtualMetadataAction | SetEditableFieldUpdateAction | SetValidFieldUpdateAction; diff --git a/src/app/core/data/object-updates/object-updates.effects.ts b/src/app/core/data/object-updates/object-updates.effects.ts index 88cd3bc718..239fee9477 100644 --- a/src/app/core/data/object-updates/object-updates.effects.ts +++ b/src/app/core/data/object-updates/object-updates.effects.ts @@ -3,12 +3,12 @@ import { Actions, Effect, ofType } from '@ngrx/effects'; import { DiscardObjectUpdatesAction, ObjectUpdatesAction, - ObjectUpdatesActionTypes, + ObjectUpdatesActionTypes, RemoveAllObjectUpdatesAction, RemoveObjectUpdatesAction } from './object-updates.actions'; import { delay, filter, map, switchMap, take, tap } from 'rxjs/operators'; import { of as observableOf, race as observableRace, Subject } from 'rxjs'; -import { hasNoValue } from '../../../shared/empty.util'; +import { hasNoValue, hasValue } from '../../../shared/empty.util'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { INotification } from '../../../shared/notifications/models/notification.model'; import { @@ -16,6 +16,7 @@ import { NotificationsActionTypes, RemoveNotificationAction } from '../../../shared/notifications/notifications.actions'; +import { Action } from '@ngrx/store'; /** * NGRX effects for ObjectUpdatesActions @@ -53,13 +54,14 @@ export class ObjectUpdatesEffects { .pipe( ofType(...Object.values(ObjectUpdatesActionTypes)), map((action: ObjectUpdatesAction) => { - const url: string = action.payload.url; + if (hasValue((action as any).payload)) { + const url: string = (action as any).payload.url; if (hasNoValue(this.actionMap$[url])) { this.actionMap$[url] = new Subject(); } this.actionMap$[url].next(action); } - ) + }) ); /** @@ -91,9 +93,15 @@ export class ObjectUpdatesEffects { const url: string = action.payload.url; const notification: INotification = action.payload.notification; const timeOut = notification.options.timeOut; + + let removeAction: Action = new RemoveObjectUpdatesAction(action.payload.url); + if (action.payload.discardAll) { + removeAction = new RemoveAllObjectUpdatesAction(); + } + return observableRace( // Either wait for the delay and perform a remove action - observableOf(new RemoveObjectUpdatesAction(action.payload.url)).pipe(delay(timeOut)), + observableOf(removeAction).pipe(delay(timeOut)), // Or wait for a a user action this.actionMap$[url].pipe( take(1), @@ -106,19 +114,19 @@ export class ObjectUpdatesEffects { return { type: 'NO_ACTION' } } // If someone performed another action, assume the user does not want to reinstate and remove all changes - return new RemoveObjectUpdatesAction(action.payload.url); + return removeAction }) ), this.notificationActionMap$[notification.id].pipe( filter((notificationsAction: NotificationsActions) => notificationsAction.type === NotificationsActionTypes.REMOVE_NOTIFICATION), map(() => { - return new RemoveObjectUpdatesAction(action.payload.url); + return removeAction; }) ), this.notificationActionMap$[this.allIdentifier].pipe( filter((notificationsAction: NotificationsActions) => notificationsAction.type === NotificationsActionTypes.REMOVE_ALL_NOTIFICATIONS), map(() => { - return new RemoveObjectUpdatesAction(action.payload.url); + return removeAction; }) ) ) diff --git a/src/app/core/data/object-updates/object-updates.reducer.spec.ts b/src/app/core/data/object-updates/object-updates.reducer.spec.ts index faae4732bc..bdf202049e 100644 --- a/src/app/core/data/object-updates/object-updates.reducer.spec.ts +++ b/src/app/core/data/object-updates/object-updates.reducer.spec.ts @@ -1,10 +1,10 @@ import * as deepFreeze from 'deep-freeze'; import { - AddFieldUpdateAction, + AddFieldUpdateAction, AddPageToCustomOrderAction, DiscardObjectUpdatesAction, FieldChangeType, - InitializeFieldsAction, - ReinstateObjectUpdatesAction, + InitializeFieldsAction, MoveFieldUpdateAction, + ReinstateObjectUpdatesAction, RemoveAllObjectUpdatesAction, RemoveFieldUpdateAction, RemoveObjectUpdatesAction, SelectVirtualMetadataAction, SetEditableFieldUpdateAction, SetValidFieldUpdateAction } from './object-updates.actions'; @@ -85,6 +85,16 @@ describe('objectUpdatesReducer', () => { virtualMetadataSources: { [relationship.uuid]: {[identifiable1.uuid]: true} }, + customOrder: { + initialOrderPages: [ + { order: [identifiable1.uuid, identifiable2.uuid, identifiable3.uuid] } + ], + newOrderPages: [ + { order: [identifiable1.uuid, identifiable2.uuid, identifiable3.uuid] } + ], + pageSize: 10, + changed: false + } } }; @@ -111,6 +121,16 @@ describe('objectUpdatesReducer', () => { virtualMetadataSources: { [relationship.uuid]: {[identifiable1.uuid]: true} }, + customOrder: { + initialOrderPages: [ + { order: [identifiable1.uuid, identifiable2.uuid, identifiable3.uuid] } + ], + newOrderPages: [ + { order: [identifiable1.uuid, identifiable2.uuid, identifiable3.uuid] } + ], + pageSize: 10, + changed: false + } }, [url + OBJECT_UPDATES_TRASH_PATH]: { fieldStates: { @@ -145,6 +165,16 @@ describe('objectUpdatesReducer', () => { virtualMetadataSources: { [relationship.uuid]: {[identifiable1.uuid]: true} }, + customOrder: { + initialOrderPages: [ + { order: [identifiable1.uuid, identifiable2.uuid, identifiable3.uuid] } + ], + newOrderPages: [ + { order: [identifiable1.uuid, identifiable2.uuid, identifiable3.uuid] } + ], + pageSize: 10, + changed: false + } } }; @@ -213,7 +243,7 @@ describe('objectUpdatesReducer', () => { }); it('should initialize all fields when the INITIALIZE action is dispatched, based on the payload', () => { - const action = new InitializeFieldsAction(url, [identifiable1, identifiable3], modDate); + const action = new InitializeFieldsAction(url, [identifiable1, identifiable3], modDate, [identifiable1.uuid, identifiable3.uuid], 10, 0); const expectedState = { [url]: { @@ -231,7 +261,17 @@ describe('objectUpdatesReducer', () => { }, fieldUpdates: {}, virtualMetadataSources: {}, - lastModified: modDate + lastModified: modDate, + customOrder: { + initialOrderPages: [ + { order: [identifiable1.uuid, identifiable3.uuid] } + ], + newOrderPages: [ + { order: [identifiable1.uuid, identifiable3.uuid] } + ], + pageSize: 10, + changed: false + } } }; const newState = objectUpdatesReducer(testState, action); @@ -283,10 +323,44 @@ describe('objectUpdatesReducer', () => { expect(newState[url + OBJECT_UPDATES_TRASH_PATH]).toBeUndefined(); }); + it('should remove all updates from the state when the REMOVE_ALL action is dispatched', () => { + const action = new RemoveAllObjectUpdatesAction(); + + const newState = objectUpdatesReducer(discardedTestState, action as any); + expect(newState[url].fieldUpdates).toBeUndefined(); + expect(newState[url + OBJECT_UPDATES_TRASH_PATH]).toBeUndefined(); + }); + it('should remove a given field\'s update from the state when the REMOVE_FIELD action is dispatched, based on the payload', () => { const action = new RemoveFieldUpdateAction(url, uuid); const newState = objectUpdatesReducer(testState, action); expect(newState[url].fieldUpdates[uuid]).toBeUndefined(); }); + + it('should move the custom order from the state when the MOVE action is dispatched', () => { + const action = new MoveFieldUpdateAction(url, 0, 1, 0, 0); + + const newState = objectUpdatesReducer(testState, action); + expect(newState[url].customOrder.newOrderPages[0].order[0]).toEqual(testState[url].customOrder.newOrderPages[0].order[1]); + expect(newState[url].customOrder.newOrderPages[0].order[1]).toEqual(testState[url].customOrder.newOrderPages[0].order[0]); + expect(newState[url].customOrder.changed).toEqual(true); + }); + + it('should add a new page to the custom order and add empty pages in between when the ADD_PAGE_TO_CUSTOM_ORDER action is dispatched', () => { + const identifiable4 = { + uuid: 'a23eae5a-7857-4ef9-8e52-989436ad2955', + key: 'dc.description.abstract', + language: null, + value: 'Extra value' + }; + const action = new AddPageToCustomOrderAction(url, [identifiable4], [identifiable4.uuid], 2); + + const newState = objectUpdatesReducer(testState, action); + // Confirm the page in between the two pages (index 1) has been filled with 10 (page size) undefined values + expect(newState[url].customOrder.newOrderPages[1].order.length).toEqual(10); + expect(newState[url].customOrder.newOrderPages[1].order[0]).toBeUndefined(); + // Verify the new page is correct + expect(newState[url].customOrder.newOrderPages[2].order[0]).toEqual(identifiable4.uuid); + }); }); diff --git a/src/app/core/data/object-updates/object-updates.reducer.ts b/src/app/core/data/object-updates/object-updates.reducer.ts index cffd41856d..759a9f5c87 100644 --- a/src/app/core/data/object-updates/object-updates.reducer.ts +++ b/src/app/core/data/object-updates/object-updates.reducer.ts @@ -1,8 +1,8 @@ import { - AddFieldUpdateAction, + AddFieldUpdateAction, AddPageToCustomOrderAction, DiscardObjectUpdatesAction, FieldChangeType, - InitializeFieldsAction, + InitializeFieldsAction, MoveFieldUpdateAction, ObjectUpdatesAction, ObjectUpdatesActionTypes, ReinstateObjectUpdatesAction, @@ -12,7 +12,9 @@ import { SetValidFieldUpdateAction, SelectVirtualMetadataAction, } from './object-updates.actions'; -import { hasNoValue, hasValue } from '../../../shared/empty.util'; +import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../../shared/empty.util'; +import { moveItemInArray, transferArrayItem } from '@angular/cdk/drag-drop'; +import { from } from 'rxjs/internal/observable/from'; import {Relationship} from '../../shared/item-relationships/relationship.model'; /** @@ -46,7 +48,7 @@ export interface Identifiable { /** * The state of a single field update */ -export interface FieldUpdate { +export interface FieldUpdate { field: Identifiable, changeType: FieldChangeType } @@ -81,6 +83,20 @@ export interface DeleteRelationship extends Relationship { keepRightVirtualMetadata: boolean, } +/** + * A custom order given to the list of objects + */ +export interface CustomOrder { + initialOrderPages: OrderPage[], + newOrderPages: OrderPage[], + pageSize: number; + changed: boolean +} + +export interface OrderPage { + order: string[] +} + /** * The updated state of a single page */ @@ -89,6 +105,7 @@ export interface ObjectUpdatesEntry { fieldUpdates: FieldUpdates; virtualMetadataSources: VirtualMetadataSources; lastModified: Date; + customOrder: CustomOrder } /** @@ -121,6 +138,9 @@ export function objectUpdatesReducer(state = initialState, action: ObjectUpdates case ObjectUpdatesActionTypes.INITIALIZE_FIELDS: { return initializeFieldsUpdate(state, action as InitializeFieldsAction); } + case ObjectUpdatesActionTypes.ADD_PAGE_TO_CUSTOM_ORDER: { + return addPageToCustomOrder(state, action as AddPageToCustomOrderAction); + } case ObjectUpdatesActionTypes.ADD_FIELD: { return addFieldUpdate(state, action as AddFieldUpdateAction); } @@ -136,6 +156,9 @@ export function objectUpdatesReducer(state = initialState, action: ObjectUpdates case ObjectUpdatesActionTypes.REMOVE: { return removeObjectUpdates(state, action as RemoveObjectUpdatesAction); } + case ObjectUpdatesActionTypes.REMOVE_ALL: { + return removeAllObjectUpdates(state); + } case ObjectUpdatesActionTypes.REMOVE_FIELD: { return removeFieldUpdate(state, action as RemoveFieldUpdateAction); } @@ -145,6 +168,9 @@ export function objectUpdatesReducer(state = initialState, action: ObjectUpdates case ObjectUpdatesActionTypes.SET_VALID_FIELD: { return setValidFieldUpdate(state, action as SetValidFieldUpdateAction); } + case ObjectUpdatesActionTypes.MOVE: { + return moveFieldUpdate(state, action as MoveFieldUpdateAction); + } default: { return state; } @@ -160,18 +186,50 @@ function initializeFieldsUpdate(state: any, action: InitializeFieldsAction) { const url: string = action.payload.url; const fields: Identifiable[] = action.payload.fields; const lastModifiedServer: Date = action.payload.lastModified; + const order = action.payload.order; + const pageSize = action.payload.pageSize; + const page = action.payload.page; const fieldStates = createInitialFieldStates(fields); + const initialOrderPages = addOrderToPages([], order, pageSize, page); const newPageState = Object.assign( {}, state[url], { fieldStates: fieldStates }, { fieldUpdates: {} }, { virtualMetadataSources: {} }, - { lastModified: lastModifiedServer } + { lastModified: lastModifiedServer }, + { customOrder: { + initialOrderPages: initialOrderPages, + newOrderPages: initialOrderPages, + pageSize: pageSize, + changed: false } + } ); return Object.assign({}, state, { [url]: newPageState }); } +/** + * Add a page of objects to the state of a specific url and update a specific page of the custom order + * @param state The current state + * @param action The action to perform on the current state + */ +function addPageToCustomOrder(state: any, action: AddPageToCustomOrderAction) { + const url: string = action.payload.url; + const fields: Identifiable[] = action.payload.fields; + const fieldStates = createInitialFieldStates(fields); + const order = action.payload.order; + const page = action.payload.page; + const pageState: ObjectUpdatesEntry = state[url] || {}; + const newPageState = Object.assign({}, pageState, { + fieldStates: Object.assign({}, pageState.fieldStates, fieldStates), + customOrder: Object.assign({}, pageState.customOrder, { + newOrderPages: addOrderToPages(pageState.customOrder.newOrderPages, order, pageState.customOrder.pageSize, page), + initialOrderPages: addOrderToPages(pageState.customOrder.initialOrderPages, order, pageState.customOrder.pageSize, page) + }) + }); + return Object.assign({}, state, { [url]: newPageState }); +} + /** * Add a new update for a specific field to the store * @param state The current state @@ -252,7 +310,24 @@ function selectVirtualMetadata(state: any, action: SelectVirtualMetadataAction) * @param action The action to perform on the current state */ function discardObjectUpdates(state: any, action: DiscardObjectUpdatesAction) { - const url: string = action.payload.url; + if (action.payload.discardAll) { + let newState = Object.assign({}, state); + Object.keys(state).filter((path: string) => !path.endsWith(OBJECT_UPDATES_TRASH_PATH)).forEach((path: string) => { + newState = discardObjectUpdatesFor(path, newState); + }); + return newState; + } else { + const url: string = action.payload.url; + return discardObjectUpdatesFor(url, state); + } +} + +/** + * Discard all updates for a specific action's url in the store + * @param url The action's url + * @param state The current state + */ +function discardObjectUpdatesFor(url: string, state: any) { const pageState: ObjectUpdatesEntry = state[url]; const newFieldStates = {}; Object.keys(pageState.fieldStates).forEach((uuid: string) => { @@ -263,9 +338,19 @@ function discardObjectUpdates(state: any, action: DiscardObjectUpdatesAction) { } }); + const newCustomOrder = Object.assign({}, pageState.customOrder); + if (pageState.customOrder.changed) { + const initialOrder = pageState.customOrder.initialOrderPages; + if (isNotEmpty(initialOrder)) { + newCustomOrder.newOrderPages = initialOrder; + newCustomOrder.changed = false; + } + } + const discardedPageState = Object.assign({}, pageState, { fieldUpdates: {}, - fieldStates: newFieldStates + fieldStates: newFieldStates, + customOrder: newCustomOrder }); return Object.assign({}, state, { [url]: discardedPageState }, { [url + OBJECT_UPDATES_TRASH_PATH]: pageState }); } @@ -305,6 +390,18 @@ function removeObjectUpdatesByURL(state: any, url: string) { return newState; } +/** + * Remove all updates in the store + * @param state The current state + */ +function removeAllObjectUpdates(state: any) { + const newState = Object.assign({}, state); + Object.keys(state).filter((path: string) => path.endsWith(OBJECT_UPDATES_TRASH_PATH)).forEach((path: string) => { + delete newState[path]; + }); + return newState; +} + /** * Discard the update for a specific action's url and field UUID in the store * @param state The current state @@ -407,3 +504,121 @@ function createInitialFieldStates(fields: Identifiable[]) { uuids.forEach((uuid: string) => fieldStates[uuid] = initialFieldState); return fieldStates; } + +/** + * Method to add a list of objects to an existing FieldStates object + * @param fieldStates FieldStates to add states to + * @param fields Identifiable objects The list of objects to add to the FieldStates + */ +function addFieldStates(fieldStates: FieldStates, fields: Identifiable[]) { + const uuids = fields.map((field: Identifiable) => field.uuid); + uuids.forEach((uuid: string) => fieldStates[uuid] = initialFieldState); + return fieldStates; +} + +/** + * Move an object within the custom order of a page state + * @param state The current state + * @param action The move action to perform + */ +function moveFieldUpdate(state: any, action: MoveFieldUpdateAction) { + const url = action.payload.url; + const fromIndex = action.payload.from; + const toIndex = action.payload.to; + const fromPage = action.payload.fromPage; + const toPage = action.payload.toPage; + const field = action.payload.field; + + const pageState: ObjectUpdatesEntry = state[url]; + const initialOrderPages = pageState.customOrder.initialOrderPages; + const customOrderPages = [...pageState.customOrder.newOrderPages]; + + // Create a copy of the custom orders for the from- and to-pages + const fromPageOrder = [...customOrderPages[fromPage].order]; + const toPageOrder = [...customOrderPages[toPage].order]; + if (fromPage === toPage) { + if (isNotEmpty(customOrderPages[fromPage]) && isNotEmpty(customOrderPages[fromPage].order[fromIndex]) && isNotEmpty(customOrderPages[fromPage].order[toIndex])) { + // Move an item from one index to another within the same page + moveItemInArray(fromPageOrder, fromIndex, toIndex); + // Update the custom order for this page + customOrderPages[fromPage] = { order: fromPageOrder }; + } + } else { + if (isNotEmpty(customOrderPages[fromPage]) && hasValue(customOrderPages[toPage]) && isNotEmpty(customOrderPages[fromPage].order[fromIndex])) { + // Move an item from one index of one page to an index in another page + transferArrayItem(fromPageOrder, toPageOrder, fromIndex, toIndex); + // Update the custom order for both pages + customOrderPages[fromPage] = { order: fromPageOrder }; + customOrderPages[toPage] = { order: toPageOrder }; + } + } + + // Create a field update if it doesn't exist for this field yet + let fieldUpdate = {}; + if (hasValue(field)) { + fieldUpdate = pageState.fieldUpdates[field.uuid]; + if (hasNoValue(fieldUpdate)) { + fieldUpdate = { field: field, changeType: undefined } + } + } + + // Update the store's state with new values and return + return Object.assign({}, state, { [url]: Object.assign({}, pageState, { + fieldUpdates: Object.assign({}, pageState.fieldUpdates, hasValue(field) ? { [field.uuid]: fieldUpdate } : {}), + customOrder: Object.assign({}, pageState.customOrder, { newOrderPages: customOrderPages, changed: checkForOrderChanges(initialOrderPages, customOrderPages) }) + })}) +} + +/** + * Compare two lists of OrderPage objects and return whether there's at least one change in the order of objects within + * @param initialOrderPages The initial list of OrderPages + * @param customOrderPages The changed list of OrderPages + */ +function checkForOrderChanges(initialOrderPages: OrderPage[], customOrderPages: OrderPage[]) { + let changed = false; + initialOrderPages.forEach((orderPage: OrderPage, page: number) => { + if (isNotEmpty(orderPage) && isNotEmpty(orderPage.order) && isNotEmpty(customOrderPages[page]) && isNotEmpty(customOrderPages[page].order)) { + orderPage.order.forEach((id: string, index: number) => { + if (id !== customOrderPages[page].order[index]) { + changed = true; + return; + } + }); + if (changed) { + return; + } + } + }); + return changed; +} + +/** + * Initialize a custom order page by providing the list of all pages, a list of UUIDs, pageSize and the page to populate + * @param initialPages The initial list of OrderPage objects + * @param order The list of UUIDs to create a page for + * @param pageSize The pageSize used to populate empty spacer pages + * @param page The index of the page to add + */ +function addOrderToPages(initialPages: OrderPage[], order: string[], pageSize: number, page: number): OrderPage[] { + const result = [...initialPages]; + const orderPage: OrderPage = { order: order }; + if (page < result.length) { + // The page we're trying to add already exists in the list. Overwrite it. + result[page] = orderPage; + } else if (page === result.length) { + // The page we're trying to add is the next page in the list, add it. + result.push(orderPage); + } else { + // The page we're trying to add is at least one page ahead of the list, fill the list with empty pages before adding the page. + const emptyOrder = []; + for (let i = 0; i < pageSize; i++) { + emptyOrder.push(undefined); + } + const emptyOrderPage: OrderPage = { order: emptyOrder }; + for (let i = result.length; i < page; i++) { + result.push(emptyOrderPage); + } + result.push(orderPage); + } + return result; +} diff --git a/src/app/core/data/object-updates/object-updates.service.spec.ts b/src/app/core/data/object-updates/object-updates.service.spec.ts index 730ee5ad43..780a402a84 100644 --- a/src/app/core/data/object-updates/object-updates.service.spec.ts +++ b/src/app/core/data/object-updates/object-updates.service.spec.ts @@ -2,6 +2,7 @@ import { Store } from '@ngrx/store'; import { CoreState } from '../../core.reducers'; import { ObjectUpdatesService } from './object-updates.service'; import { + AddPageToCustomOrderAction, DiscardObjectUpdatesAction, FieldChangeType, InitializeFieldsAction, ReinstateObjectUpdatesAction, RemoveFieldUpdateAction, SelectVirtualMetadataAction, @@ -12,6 +13,8 @@ import { Notification } from '../../../shared/notifications/models/notification. import { NotificationType } from '../../../shared/notifications/models/notification-type'; import { OBJECT_UPDATES_TRASH_PATH } from './object-updates.reducer'; import {Relationship} from '../../shared/item-relationships/relationship.model'; +import { MoveOperation } from 'fast-json-patch/lib/core'; +import { ArrayMoveChangeAnalyzer } from '../array-move-change-analyzer.service'; describe('ObjectUpdatesService', () => { let service: ObjectUpdatesService; @@ -44,7 +47,7 @@ describe('ObjectUpdatesService', () => { }; store = new Store(undefined, undefined, undefined); spyOn(store, 'dispatch'); - service = (new ObjectUpdatesService(store)); + service = new ObjectUpdatesService(store, new ArrayMoveChangeAnalyzer()); spyOn(service as any, 'getObjectEntry').and.returnValue(observableOf(objectEntry)); spyOn(service as any, 'getFieldState').and.callFake((uuid) => { @@ -60,6 +63,25 @@ describe('ObjectUpdatesService', () => { }); }); + describe('initializeWithCustomOrder', () => { + const pageSize = 20; + const page = 0; + + it('should dispatch an INITIALIZE action with the correct URL, initial identifiables, last modified , custom order, page size and page', () => { + service.initializeWithCustomOrder(url, identifiables, modDate, pageSize, page); + expect(store.dispatch).toHaveBeenCalledWith(new InitializeFieldsAction(url, identifiables, modDate, identifiables.map((identifiable) => identifiable.uuid), pageSize, page)); + }); + }); + + describe('addPageToCustomOrder', () => { + const page = 2; + + it('should dispatch an ADD_PAGE_TO_CUSTOM_ORDER action with the correct URL, identifiables, custom order and page number to add', () => { + service.addPageToCustomOrder(url, identifiables, page); + expect(store.dispatch).toHaveBeenCalledWith(new AddPageToCustomOrderAction(url, identifiables, identifiables.map((identifiable) => identifiable.uuid), page)); + }); + }); + describe('getFieldUpdates', () => { it('should return the list of all fields, including their update if there is one', () => { const result$ = service.getFieldUpdates(url, identifiables); @@ -77,6 +99,66 @@ describe('ObjectUpdatesService', () => { }); }); + describe('getFieldUpdatesExclusive', () => { + it('should return the list of all fields, including their update if there is one, excluding updates that aren\'t part of the initial values provided', (done) => { + const result$ = service.getFieldUpdatesExclusive(url, identifiables); + expect((service as any).getObjectEntry).toHaveBeenCalledWith(url); + + const expectedResult = { + [identifiable1.uuid]: { field: identifiable1Updated, changeType: FieldChangeType.UPDATE }, + [identifiable2.uuid]: { field: identifiable2, changeType: undefined } + }; + + result$.subscribe((result) => { + expect(result).toEqual(expectedResult); + done(); + }); + }); + }); + + describe('getFieldUpdatesByCustomOrder', () => { + beforeEach(() => { + const fieldStates = { + [identifiable1.uuid]: { editable: false, isNew: false, isValid: true }, + [identifiable2.uuid]: { editable: true, isNew: false, isValid: false }, + [identifiable3.uuid]: { editable: true, isNew: true, isValid: true }, + }; + + const customOrder = { + initialOrderPages: [{ + order: [identifiable1.uuid, identifiable2.uuid, identifiable3.uuid] + }], + newOrderPages: [{ + order: [identifiable2.uuid, identifiable3.uuid, identifiable1.uuid] + }], + pageSize: 20, + changed: true + }; + + const objectEntry = { + fieldStates, fieldUpdates, lastModified: modDate, virtualMetadataSources: {}, customOrder + }; + + (service as any).getObjectEntry.and.returnValue(observableOf(objectEntry)) + }); + + it('should return the list of all fields, including their update if there is one, ordered by their custom order', (done) => { + const result$ = service.getFieldUpdatesByCustomOrder(url, identifiables); + expect((service as any).getObjectEntry).toHaveBeenCalledWith(url); + + const expectedResult = { + [identifiable2.uuid]: { field: identifiable2, changeType: undefined }, + [identifiable3.uuid]: { field: identifiable3, changeType: FieldChangeType.ADD }, + [identifiable1.uuid]: { field: identifiable1Updated, changeType: FieldChangeType.UPDATE } + }; + + result$.subscribe((result) => { + expect(result).toEqual(expectedResult); + done(); + }); + }); + }); + describe('isEditable', () => { it('should return false if this identifiable is currently not editable in the store', () => { const result$ = service.isEditable(url, identifiable1.uuid); @@ -192,7 +274,11 @@ describe('ObjectUpdatesService', () => { }); describe('when updates are emtpy', () => { beforeEach(() => { - (service as any).getObjectEntry.and.returnValue(observableOf({})) + (service as any).getObjectEntry.and.returnValue(observableOf({ + customOrder: { + changed: false + } + })) }); it('should return false when there are no updates', () => { @@ -259,4 +345,45 @@ describe('ObjectUpdatesService', () => { expect(store.dispatch).toHaveBeenCalledWith(new SelectVirtualMetadataAction(url, relationship.uuid, identifiable1.uuid, true)); }); }); + + describe('getMoveOperations', () => { + beforeEach(() => { + const fieldStates = { + [identifiable1.uuid]: { editable: false, isNew: false, isValid: true }, + [identifiable2.uuid]: { editable: true, isNew: false, isValid: false }, + [identifiable3.uuid]: { editable: true, isNew: true, isValid: true }, + }; + + const customOrder = { + initialOrderPages: [{ + order: [identifiable1.uuid, identifiable2.uuid, identifiable3.uuid] + }], + newOrderPages: [{ + order: [identifiable2.uuid, identifiable3.uuid, identifiable1.uuid] + }], + pageSize: 20, + changed: true + }; + + const objectEntry = { + fieldStates, fieldUpdates, lastModified: modDate, virtualMetadataSources: {}, customOrder + }; + + (service as any).getObjectEntry.and.returnValue(observableOf(objectEntry)) + }); + + it('should return the expected move operations', (done) => { + const result$ = service.getMoveOperations(url); + + const expectedResult = [ + { op: 'move', from: '/0', path: '/2' } + ] as MoveOperation[]; + + result$.subscribe((result) => { + expect(result).toEqual(expectedResult); + done(); + }); + }); + }); + }); 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 367b73ee30..c9a7f47e81 100644 --- a/src/app/core/data/object-updates/object-updates.service.ts +++ b/src/app/core/data/object-updates/object-updates.service.ts @@ -8,15 +8,16 @@ import { Identifiable, OBJECT_UPDATES_TRASH_PATH, ObjectUpdatesEntry, - ObjectUpdatesState, + ObjectUpdatesState, OrderPage, VirtualMetadataSource } from './object-updates.reducer'; import { Observable } from 'rxjs'; import { - AddFieldUpdateAction, + AddFieldUpdateAction, AddPageToCustomOrderAction, DiscardObjectUpdatesAction, FieldChangeType, InitializeFieldsAction, + MoveFieldUpdateAction, ReinstateObjectUpdatesAction, RemoveFieldUpdateAction, SelectVirtualMetadataAction, @@ -26,6 +27,9 @@ import { import { distinctUntilChanged, filter, map, switchMap } from 'rxjs/operators'; import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../../shared/empty.util'; import { INotification } from '../../../shared/notifications/models/notification.model'; +import { ArrayMoveChangeAnalyzer } from '../array-move-change-analyzer.service'; +import { MoveOperation } from 'fast-json-patch/lib/core'; +import { flatten } from '@angular/compiler'; function objectUpdatesStateSelector(): MemoizedSelector { return createSelector(coreSelector, (state: CoreState) => state['cache/object-updates']); @@ -48,7 +52,8 @@ function virtualMetadataSourceSelector(url: string, source: string): MemoizedSel */ @Injectable() export class ObjectUpdatesService { - constructor(private store: Store) { + constructor(private store: Store, + private comparator: ArrayMoveChangeAnalyzer) { } @@ -62,6 +67,28 @@ export class ObjectUpdatesService { this.store.dispatch(new InitializeFieldsAction(url, fields, lastModified)); } + /** + * Method to dispatch an InitializeFieldsAction to the store and keeping track of the order objects are stored + * @param url The page's URL for which the changes are being mapped + * @param fields The initial fields for the page's object + * @param lastModified The date the object was last modified + * @param pageSize The page size to use for adding pages to the custom order + * @param page The first page to populate the custom order with + */ + initializeWithCustomOrder(url, fields: Identifiable[], lastModified: Date, pageSize = 9999, page = 0): void { + this.store.dispatch(new InitializeFieldsAction(url, fields, lastModified, fields.map((field) => field.uuid), pageSize, page)); + } + + /** + * Method to dispatch an AddPageToCustomOrderAction, adding a new page to an already existing custom order tracking + * @param url The URL for which the changes are being mapped + * @param fields The fields to add a new page for + * @param page The page number (starting from index 0) + */ + addPageToCustomOrder(url, fields: Identifiable[], page: number): void { + this.store.dispatch(new AddPageToCustomOrderAction(url, fields, fields.map((field) => field.uuid), page)); + } + /** * Method to dispatch an AddFieldUpdateAction to the store * @param url The page's URL for which the changes are saved @@ -94,14 +121,15 @@ export class ObjectUpdatesService { * a FieldUpdates object * @param url The URL of the page for which the FieldUpdates should be requested * @param initialFields The initial values of the fields + * @param ignoreStates Ignore the fieldStates to loop over the fieldUpdates instead */ - getFieldUpdates(url: string, initialFields: Identifiable[]): Observable { + getFieldUpdates(url: string, initialFields: Identifiable[], ignoreStates?: boolean): Observable { const objectUpdates = this.getObjectEntry(url); return objectUpdates.pipe( switchMap((objectEntry) => { const fieldUpdates: FieldUpdates = {}; if (hasValue(objectEntry)) { - Object.keys(objectEntry.fieldStates).forEach((uuid) => { + Object.keys(ignoreStates ? objectEntry.fieldUpdates : objectEntry.fieldStates).forEach((uuid) => { fieldUpdates[uuid] = objectEntry.fieldUpdates[uuid]; }); } @@ -138,6 +166,31 @@ export class ObjectUpdatesService { })) } + /** + * Method that combines the state's updates with the initial values (when there's no update), + * sorted by their custom order to create a FieldUpdates object + * @param url The URL of the page for which the FieldUpdates should be requested + * @param initialFields The initial values of the fields + * @param page The page to retrieve + */ + getFieldUpdatesByCustomOrder(url: string, initialFields: Identifiable[], page = 0): Observable { + const objectUpdates = this.getObjectEntry(url); + return objectUpdates.pipe(map((objectEntry) => { + const fieldUpdates: FieldUpdates = {}; + if (hasValue(objectEntry) && hasValue(objectEntry.customOrder) && isNotEmpty(objectEntry.customOrder.newOrderPages) && page < objectEntry.customOrder.newOrderPages.length) { + for (const uuid of objectEntry.customOrder.newOrderPages[page].order) { + let fieldUpdate = objectEntry.fieldUpdates[uuid]; + if (isEmpty(fieldUpdate)) { + const identifiable = initialFields.find((object: Identifiable) => object.uuid === uuid); + fieldUpdate = {field: identifiable, changeType: undefined}; + } + fieldUpdates[uuid] = fieldUpdate; + } + } + return fieldUpdates; + })) + } + /** * Method to check if a specific field is currently editable in the store * @param url The URL of the page on which the field resides @@ -207,6 +260,19 @@ export class ObjectUpdatesService { this.saveFieldUpdate(url, field, FieldChangeType.UPDATE); } + /** + * Dispatches a MoveFieldUpdateAction + * @param url The page's URL for which the changes are saved + * @param from The index of the object to move + * @param to The index to move the object to + * @param fromPage The page to move the object from + * @param toPage The page to move the object to + * @param field Optional field to add to the fieldUpdates list (useful if we want to track updates across multiple pages) + */ + saveMoveFieldUpdate(url: string, from: number, to: number, fromPage = 0, toPage = 0, field?: Identifiable) { + this.store.dispatch(new MoveFieldUpdateAction(url, from, to, fromPage, toPage, field)); + } + /** * Check whether the virtual metadata of a given item is selected to be saved as real metadata * @param url The URL of the page on which the field resides @@ -264,6 +330,15 @@ export class ObjectUpdatesService { this.store.dispatch(new DiscardObjectUpdatesAction(url, undoNotification)); } + /** + * Method to dispatch a DiscardObjectUpdatesAction to the store with discardAll set to true + * @param url The page's URL for which the changes should be discarded + * @param undoNotification The notification which is should possibly be canceled + */ + discardAllFieldUpdates(url: string, undoNotification: INotification) { + this.store.dispatch(new DiscardObjectUpdatesAction(url, undoNotification, true)); + } + /** * Method to dispatch an ReinstateObjectUpdatesAction to the store * @param url The page's URL for which the changes should be reinstated @@ -312,7 +387,7 @@ export class ObjectUpdatesService { * @param url The page's url to check for in the store */ hasUpdates(url: string): Observable { - return this.getObjectEntry(url).pipe(map((objectEntry) => hasValue(objectEntry) && isNotEmpty(objectEntry.fieldUpdates))); + return this.getObjectEntry(url).pipe(map((objectEntry) => hasValue(objectEntry) && (isNotEmpty(objectEntry.fieldUpdates) || objectEntry.customOrder.changed))); } /** @@ -330,4 +405,19 @@ export class ObjectUpdatesService { getLastModified(url: string): Observable { return this.getObjectEntry(url).pipe(map((entry: ObjectUpdatesEntry) => entry.lastModified)); } + + /** + * Get move operations based on the custom order + * @param url The page's url + */ + getMoveOperations(url: string): Observable { + return this.getObjectEntry(url).pipe( + map((objectEntry) => objectEntry.customOrder), + map((customOrder) => this.comparator.diff( + flatten(customOrder.initialOrderPages.map((orderPage: OrderPage) => orderPage.order)), + flatten(customOrder.newOrderPages.map((orderPage: OrderPage) => orderPage.order))) + ) + ); + } + } diff --git a/src/app/core/data/request.models.ts b/src/app/core/data/request.models.ts index e17ffcac3f..0655333502 100644 --- a/src/app/core/data/request.models.ts +++ b/src/app/core/data/request.models.ts @@ -230,6 +230,8 @@ export class AuthPostRequest extends PostRequest { } export class AuthGetRequest extends GetRequest { + forceBypassCache = true; + constructor(uuid: string, href: string, public options?: HttpOptions) { super(uuid, href, null, options); } 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 91756d412c..6eb144580c 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 @@ -90,6 +90,14 @@ export class DSpaceRESTv2Service { requestOptions.headers = options.headers; } + if (options && options.params) { + requestOptions.params = options.params; + } + + if (options && options.withCredentials) { + requestOptions.withCredentials = options.withCredentials; + } + if (!requestOptions.headers.has('Content-Type')) { // Because HttpHeaders is immutable, the set method returns a new object instead of updating the existing headers requestOptions.headers = requestOptions.headers.set('Content-Type', DEFAULT_CONTENT_TYPE); diff --git a/src/app/core/services/route.service.ts b/src/app/core/services/route.service.ts index 441d058c4c..59ec899576 100644 --- a/src/app/core/services/route.service.ts +++ b/src/app/core/services/route.service.ts @@ -59,7 +59,9 @@ export function parameterSelector(key: string, paramsSelector: (state: CoreState /** * Service to keep track of the current query parameters */ -@Injectable() +@Injectable({ + providedIn: 'root' +}) export class RouteService { constructor(private route: ActivatedRoute, private router: Router, private store: Store) { this.saveRouting(); diff --git a/src/app/core/shared/bitstream.model.ts b/src/app/core/shared/bitstream.model.ts index 231d44eeff..ab9d1548b7 100644 --- a/src/app/core/shared/bitstream.model.ts +++ b/src/app/core/shared/bitstream.model.ts @@ -54,7 +54,7 @@ export class Bitstream extends DSpaceObject implements HALResource { * The BitstreamFormat of this Bitstream * Will be undefined unless the format {@link HALLink} has been resolved. */ - @link(BITSTREAM_FORMAT) + @link(BITSTREAM_FORMAT, false, 'format') format?: Observable>; } diff --git a/src/app/core/shared/dspace-object.model.ts b/src/app/core/shared/dspace-object.model.ts index 60a1160d3e..a9256fbb7f 100644 --- a/src/app/core/shared/dspace-object.model.ts +++ b/src/app/core/shared/dspace-object.model.ts @@ -1,5 +1,5 @@ import { autoserialize, autoserializeAs, deserialize, deserializeAs } from 'cerialize'; -import { hasNoValue, isUndefined } from '../../shared/empty.util'; +import { hasNoValue, hasValue, isUndefined } from '../../shared/empty.util'; import { ListableObject } from '../../shared/object-collection/shared/listable-object.model'; import { typedObject } from '../cache/builders/build-decorators'; import { CacheableObject } from '../cache/object-cache.reducer'; @@ -79,6 +79,9 @@ export class DSpaceObject extends ListableObject implements CacheableObject { * The name for this DSpaceObject */ set name(name) { + if (hasValue(this.firstMetadata('dc.title'))) { + this.firstMetadata('dc.title').value = name; + } this._name = name; } diff --git a/src/app/core/shared/metadata.utils.spec.ts b/src/app/core/shared/metadata.utils.spec.ts index f4b3517649..016ef594b1 100644 --- a/src/app/core/shared/metadata.utils.spec.ts +++ b/src/app/core/shared/metadata.utils.spec.ts @@ -7,6 +7,7 @@ import { MetadatumViewModel } from './metadata.models'; import { Metadata } from './metadata.utils'; +import { beforeEach } from 'selenium-webdriver/testing'; const mdValue = (value: string, language?: string, authority?: string): MetadataValue => { return Object.assign(new MetadataValue(), { uuid: uuidv4(), value: value, language: isUndefined(language) ? null : language, place: 0, authority: isUndefined(authority) ? null : authority, confidence: undefined }); @@ -216,4 +217,26 @@ describe('Metadata', () => { testToMetadataMap(multiViewModelList, multiMap); }); + describe('setFirstValue method', () => { + + const metadataMap = { + 'dc.description': [mdValue('Test description')], + 'dc.title': [mdValue('Test title 1'), mdValue('Test title 2')] + }; + + const testSetFirstValue = (map: MetadataMap, key: string, value: string) => { + describe(`with field ${key} and value ${value}`, () => { + Metadata.setFirstValue(map, key, value); + it(`should set first value of ${key} to ${value}`, () => { + expect(map[key][0].value).toEqual(value); + }); + }); + }; + + testSetFirstValue(metadataMap, 'dc.description', 'New Description'); + testSetFirstValue(metadataMap, 'dc.title', 'New Title'); + testSetFirstValue(metadataMap, 'dc.format', 'Completely new field and value'); + + }); + }); diff --git a/src/app/core/shared/metadata.utils.ts b/src/app/core/shared/metadata.utils.ts index 334c430968..24ff06f4c9 100644 --- a/src/app/core/shared/metadata.utils.ts +++ b/src/app/core/shared/metadata.utils.ts @@ -1,4 +1,4 @@ -import { isEmpty, isNotUndefined, isUndefined } from '../../shared/empty.util'; +import { isEmpty, isNotEmpty, isNotUndefined, isUndefined } from '../../shared/empty.util'; import { MetadataMapInterface, MetadataValue, @@ -217,4 +217,19 @@ export class Metadata { }); return metadataMap; } + + /** + * Set the first value of a metadata by field key + * Creates a new MetadataValue if the field doesn't exist yet + * @param mdMap The map to add/change values in + * @param key The metadata field + * @param value The value to add + */ + public static setFirstValue(mdMap: MetadataMapInterface, key: string, value: string) { + if (isNotEmpty(mdMap[key])) { + mdMap[key][0].value = value; + } else { + mdMap[key] = [Object.assign(new MetadataValue(), { value: value })] + } + } } diff --git a/src/app/core/shared/operators.ts b/src/app/core/shared/operators.ts index 14d101a448..a51e711d26 100644 --- a/src/app/core/shared/operators.ts +++ b/src/app/core/shared/operators.ts @@ -1,6 +1,6 @@ import { Router } from '@angular/router'; import { Observable } from 'rxjs'; -import { filter, find, flatMap, map, tap } from 'rxjs/operators'; +import { filter, find, flatMap, map, take, tap } from 'rxjs/operators'; import { hasValue, hasValueOperator, isNotEmpty } from '../../shared/empty.util'; import { SearchResult } from '../../shared/search/search-result.model'; import { DSOSuccessResponse, RestResponse } from '../cache/response.models'; @@ -207,3 +207,13 @@ export const getFirstOccurrence = () => source.pipe( map((rd) => Object.assign(rd, { payload: rd.payload.page.length > 0 ? rd.payload.page[0] : undefined })) ); + +/** + * Operator for turning the current page of bitstreams into an array + */ +export const paginatedListToArray = () => + (source: Observable>>): Observable => + source.pipe( + hasValueOperator(), + map((objectRD: RemoteData>) => objectRD.payload.page.filter((object: T) => hasValue(object))) + ); diff --git a/src/app/shared/auth-nav-menu/auth-nav-menu.component.html b/src/app/shared/auth-nav-menu/auth-nav-menu.component.html index 86de30c23e..a05381fee8 100644 --- a/src/app/shared/auth-nav-menu/auth-nav-menu.component.html +++ b/src/app/shared/auth-nav-menu/auth-nav-menu.component.html @@ -1,26 +1,33 @@ diff --git a/src/app/shared/comcol-forms/comcol-form/comcol-form.component.spec.ts b/src/app/shared/comcol-forms/comcol-form/comcol-form.component.spec.ts index 06f9843c6d..454a036b15 100644 --- a/src/app/shared/comcol-forms/comcol-form/comcol-form.component.spec.ts +++ b/src/app/shared/comcol-forms/comcol-form/comcol-form.component.spec.ts @@ -137,7 +137,7 @@ describe('ComColFormComponent', () => { type: Community.type }, ), - uploader: {} as any, + uploader: undefined, deleteLogo: false } ); diff --git a/src/app/shared/comcol-forms/comcol-form/comcol-form.component.ts b/src/app/shared/comcol-forms/comcol-form/comcol-form.component.ts index 35c6f50969..f8199d2aad 100644 --- a/src/app/shared/comcol-forms/comcol-form/comcol-form.component.ts +++ b/src/app/shared/comcol-forms/comcol-form/comcol-form.component.ts @@ -39,7 +39,7 @@ export class ComColFormComponent implements OnInit, OnDe /** * The logo uploader component */ - @ViewChild(UploaderComponent, {static: true}) uploaderComponent: UploaderComponent; + @ViewChild(UploaderComponent, {static: false}) uploaderComponent: UploaderComponent; /** * DSpaceObject that the form represents diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts index 4d26f3948d..2089ce8bca 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts @@ -76,6 +76,8 @@ import { DsDynamicFormArrayComponent } from './models/array-group/dynamic-form-a import { DsDynamicRelationGroupComponent } from './models/relation-group/dynamic-relation-group.components'; import { DYNAMIC_FORM_CONTROL_TYPE_RELATION_GROUP } from './models/relation-group/dynamic-relation-group.model'; import { DsDatePickerInlineComponent } from './models/date-picker-inline/dynamic-date-picker-inline.component'; +import { DYNAMIC_FORM_CONTROL_TYPE_CUSTOM_SWITCH } from './models/custom-switch/custom-switch.model'; +import { CustomSwitchComponent } from './models/custom-switch/custom-switch.component'; import { map, startWith, switchMap, find } from 'rxjs/operators'; import { combineLatest as observableCombineLatest, Observable, of as observableOf, Subscription } from 'rxjs'; import { SearchResult } from '../../../search/search-result.model'; @@ -158,6 +160,9 @@ export function dsDynamicFormControlMapFn(model: DynamicFormControlModel): Type< case DYNAMIC_FORM_CONTROL_TYPE_DISABLED: return DsDynamicDisabledComponent; + case DYNAMIC_FORM_CONTROL_TYPE_CUSTOM_SWITCH: + return CustomSwitchComponent; + default: return null; } @@ -293,6 +298,10 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo } } + get isCheckbox(): boolean { + return this.model.type === DYNAMIC_FORM_CONTROL_TYPE_CHECKBOX || this.model.type === DYNAMIC_FORM_CONTROL_TYPE_CUSTOM_SWITCH; + } + ngOnChanges(changes: SimpleChanges) { if (changes) { super.ngOnChanges(changes); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.component.html new file mode 100644 index 0000000000..9d059b4bee --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.component.html @@ -0,0 +1,20 @@ +
+ + +
diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.component.scss b/src/app/shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.component.spec.ts new file mode 100644 index 0000000000..6c2502a92b --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.component.spec.ts @@ -0,0 +1,99 @@ +import { DynamicFormsCoreModule, DynamicFormService } from '@ng-dynamic-forms/core'; +import { FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; +import { DebugElement } from '@angular/core'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TextMaskModule } from 'angular2-text-mask'; +import { By } from '@angular/platform-browser'; +import { DynamicCustomSwitchModel } from './custom-switch.model'; +import { CustomSwitchComponent } from './custom-switch.component'; + +describe('CustomSwitchComponent', () => { + + const testModel = new DynamicCustomSwitchModel({id: 'switch'}); + const formModel = [testModel]; + let formGroup: FormGroup; + let fixture: ComponentFixture; + let component: CustomSwitchComponent; + let debugElement: DebugElement; + let testElement: DebugElement; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + ReactiveFormsModule, + NoopAnimationsModule, + TextMaskModule, + DynamicFormsCoreModule.forRoot() + ], + declarations: [CustomSwitchComponent] + + }).compileComponents().then(() => { + fixture = TestBed.createComponent(CustomSwitchComponent); + + component = fixture.componentInstance; + debugElement = fixture.debugElement; + }); + })); + + beforeEach(inject([DynamicFormService], (service: DynamicFormService) => { + formGroup = service.createFormGroup(formModel); + + component.group = formGroup; + component.model = testModel; + + fixture.detectChanges(); + + testElement = debugElement.query(By.css(`input[id='${testModel.id}']`)); + })); + + it('should initialize correctly', () => { + expect(component.bindId).toBe(true); + expect(component.group instanceof FormGroup).toBe(true); + expect(component.model instanceof DynamicCustomSwitchModel).toBe(true); + + expect(component.blur).toBeDefined(); + expect(component.change).toBeDefined(); + expect(component.focus).toBeDefined(); + + expect(component.onBlur).toBeDefined(); + expect(component.onChange).toBeDefined(); + expect(component.onFocus).toBeDefined(); + + expect(component.hasFocus).toBe(false); + expect(component.isValid).toBe(true); + expect(component.isInvalid).toBe(false); + }); + + it('should have an input element', () => { + expect(testElement instanceof DebugElement).toBe(true); + }); + + it('should have an input element of type checkbox', () => { + expect(testElement.nativeElement.getAttribute('type')).toEqual('checkbox'); + }); + + it('should emit blur event', () => { + spyOn(component.blur, 'emit'); + + component.onBlur(null); + + expect(component.blur.emit).toHaveBeenCalled(); + }); + + it('should emit change event', () => { + spyOn(component.change, 'emit'); + + component.onChange(null); + + expect(component.change.emit).toHaveBeenCalled(); + }); + + it('should emit focus event', () => { + spyOn(component.focus, 'emit'); + + component.onFocus(null); + + expect(component.focus.emit).toHaveBeenCalled(); + }); +}); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.component.ts new file mode 100644 index 0000000000..ab02fc159d --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.component.ts @@ -0,0 +1,55 @@ +import { DynamicNGBootstrapCheckboxComponent } from '@ng-dynamic-forms/ui-ng-bootstrap'; +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { FormGroup } from '@angular/forms'; +import { DynamicCustomSwitchModel } from './custom-switch.model'; + +@Component({ + selector: 'ds-custom-switch', + styleUrls: ['./custom-switch.component.scss'], + templateUrl: './custom-switch.component.html', +}) +/** + * Component displaying a custom switch usable in dynamic forms + * Extends from bootstrap's checkbox component but displays a switch instead + */ +export class CustomSwitchComponent extends DynamicNGBootstrapCheckboxComponent { + /** + * Use the model's ID for the input element + */ + @Input() bindId = true; + + /** + * The formgroup containing this component + */ + @Input() group: FormGroup; + + /** + * The model used for displaying the switch + */ + @Input() model: DynamicCustomSwitchModel; + + /** + * Emit an event when the input is selected + */ + @Output() selected = new EventEmitter(); + + /** + * Emit an event when the input value is removed + */ + @Output() remove = new EventEmitter(); + + /** + * Emit an event when the input is blurred out + */ + @Output() blur = new EventEmitter(); + + /** + * Emit an event when the input value changes + */ + @Output() change = new EventEmitter(); + + /** + * Emit an event when the input is focused + */ + @Output() focus = new EventEmitter(); +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.model.ts new file mode 100644 index 0000000000..97cf71c4a0 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.model.ts @@ -0,0 +1,20 @@ +import { + DynamicCheckboxModel, + DynamicCheckboxModelConfig, + DynamicFormControlLayout, + serializable +} from '@ng-dynamic-forms/core'; + +export const DYNAMIC_FORM_CONTROL_TYPE_CUSTOM_SWITCH = 'CUSTOM_SWITCH'; + +/** + * Model class for displaying a custom switch input in a form + * Functions like a checkbox, but displays a switch instead + */ +export class DynamicCustomSwitchModel extends DynamicCheckboxModel { + @serializable() readonly type: string = DYNAMIC_FORM_CONTROL_TYPE_CUSTOM_SWITCH; + + constructor(config: DynamicCheckboxModelConfig, layout?: DynamicFormControlLayout) { + super(config, layout); + } +} diff --git a/src/app/shared/form/form.component.html b/src/app/shared/form/form.component.html index 510bf7291b..24948680c7 100644 --- a/src/app/shared/form/form.component.html +++ b/src/app/shared/form/form.component.html @@ -50,9 +50,9 @@
- +
diff --git a/src/app/shared/form/form.component.ts b/src/app/shared/form/form.component.ts index 077def0060..def61cb5b2 100644 --- a/src/app/shared/form/form.component.ts +++ b/src/app/shared/form/form.component.ts @@ -53,6 +53,16 @@ export class FormComponent implements OnDestroy, OnInit { */ @Input() formId: string; + /** + * i18n key for the submit button + */ + @Input() submitLabel = 'form.submit'; + + /** + * i18n key for the cancel button + */ + @Input() cancelLabel = 'form.cancel'; + /** * An array of DynamicFormControlModel type */ diff --git a/src/app/shared/log-in/container/log-in-container.component.html b/src/app/shared/log-in/container/log-in-container.component.html new file mode 100644 index 0000000000..bef6f43b66 --- /dev/null +++ b/src/app/shared/log-in/container/log-in-container.component.html @@ -0,0 +1,5 @@ + + + diff --git a/src/app/shared/log-in/container/log-in-container.component.scss b/src/app/shared/log-in/container/log-in-container.component.scss new file mode 100644 index 0000000000..0255b71dac --- /dev/null +++ b/src/app/shared/log-in/container/log-in-container.component.scss @@ -0,0 +1,21 @@ +:host ::ng-deep .card { + margin-bottom: $submission-sections-margin-bottom; + overflow: unset; +} + +.section-focus { + border-radius: $border-radius; + box-shadow: $btn-focus-box-shadow; +} + +// TODO to remove the following when upgrading @ng-bootstrap +:host ::ng-deep .card:first-of-type { + border-bottom: $card-border-width solid $card-border-color !important; + border-bottom-left-radius: $card-border-radius !important; + border-bottom-right-radius: $card-border-radius !important; +} + +:host ::ng-deep .card-header button { + box-shadow: none !important; + width: 100%; +} diff --git a/src/app/shared/log-in/container/log-in-container.component.spec.ts b/src/app/shared/log-in/container/log-in-container.component.spec.ts new file mode 100644 index 0000000000..c819b0cc8d --- /dev/null +++ b/src/app/shared/log-in/container/log-in-container.component.spec.ts @@ -0,0 +1,108 @@ +import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; + +import { StoreModule } from '@ngrx/store'; +import { TranslateModule } from '@ngx-translate/core'; + +import { LogInContainerComponent } from './log-in-container.component'; +import { authReducer } from '../../../core/auth/auth.reducer'; +import { SharedModule } from '../../shared.module'; +import { createTestComponent } from '../../testing/utils'; +import { AuthService } from '../../../core/auth/auth.service'; +import { AuthMethod } from '../../../core/auth/models/auth.method'; +import { AuthServiceStub } from '../../testing/auth-service-stub'; + +describe('LogInContainerComponent', () => { + + let component: LogInContainerComponent; + let fixture: ComponentFixture; + + const authMethod = new AuthMethod('password'); + + beforeEach(async(() => { + // refine the test module by declaring the test component + TestBed.configureTestingModule({ + imports: [ + FormsModule, + ReactiveFormsModule, + StoreModule.forRoot(authReducer), + SharedModule, + TranslateModule.forRoot() + ], + declarations: [ + TestComponent + ], + providers: [ + {provide: AuthService, useClass: AuthServiceStub}, + LogInContainerComponent + ], + schemas: [ + CUSTOM_ELEMENTS_SCHEMA + ] + }) + .compileComponents(); + + })); + + 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 LogInContainerComponent', inject([LogInContainerComponent], (app: LogInContainerComponent) => { + + expect(app).toBeDefined(); + + })); + }); + + describe('', () => { + beforeEach(() => { + fixture = TestBed.createComponent(LogInContainerComponent); + component = fixture.componentInstance; + + spyOn(component, 'getAuthMethodContent').and.callThrough(); + component.authMethod = authMethod; + fixture.detectChanges(); + }); + + afterEach(() => { + fixture.destroy(); + component = null; + }); + + it('should inject component properly', () => { + + component.ngOnInit(); + fixture.detectChanges(); + + expect(component.getAuthMethodContent).toHaveBeenCalled(); + + }); + + }); + +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { + + isStandalonePage = true; + +} diff --git a/src/app/shared/log-in/container/log-in-container.component.ts b/src/app/shared/log-in/container/log-in-container.component.ts new file mode 100644 index 0000000000..660e616b9d --- /dev/null +++ b/src/app/shared/log-in/container/log-in-container.component.ts @@ -0,0 +1,51 @@ +import { Component, Injector, Input, OnInit } from '@angular/core'; + +import { rendersAuthMethodType } from '../methods/log-in.methods-decorator'; +import { AuthMethod } from '../../../core/auth/models/auth.method'; + +/** + * This component represents a component container for log-in methods available. + */ +@Component({ + selector: 'ds-log-in-container', + templateUrl: './log-in-container.component.html', + styleUrls: ['./log-in-container.component.scss'] +}) +export class LogInContainerComponent implements OnInit { + + @Input() authMethod: AuthMethod; + + /** + * Injector to inject a section component with the @Input parameters + * @type {Injector} + */ + public objectInjector: Injector; + + /** + * Initialize instance variables + * + * @param {Injector} injector + */ + constructor(private injector: Injector) { + } + + /** + * Initialize all instance variables + */ + ngOnInit() { + this.objectInjector = Injector.create({ + providers: [ + { provide: 'authMethodProvider', useFactory: () => (this.authMethod), deps: [] }, + ], + parent: this.injector + }); + } + + /** + * Find the correct component based on the AuthMethod's type + */ + getAuthMethodContent(): string { + return rendersAuthMethodType(this.authMethod.authMethodType) + } + +} diff --git a/src/app/shared/log-in/log-in.component.html b/src/app/shared/log-in/log-in.component.html index fe9a506e71..8e23f00d9b 100644 --- a/src/app/shared/log-in/log-in.component.html +++ b/src/app/shared/log-in/log-in.component.html @@ -1,28 +1,13 @@ - diff --git a/src/app/shared/log-in/log-in.component.scss b/src/app/shared/log-in/log-in.component.scss index 0eda382c0a..caaeef3dc7 100644 --- a/src/app/shared/log-in/log-in.component.scss +++ b/src/app/shared/log-in/log-in.component.scss @@ -1,13 +1,3 @@ -.form-login .form-control:focus { - z-index: 2; +.login-container { + max-width: 350px; } -.form-login input[type="email"] { - margin-bottom: -1px; - border-bottom-right-radius: 0; - border-bottom-left-radius: 0; -} -.form-login input[type="password"] { - border-top-left-radius: 0; - border-top-right-radius: 0; -} - diff --git a/src/app/shared/log-in/log-in.component.spec.ts b/src/app/shared/log-in/log-in.component.spec.ts index 13f9e5369a..0be04d4ddf 100644 --- a/src/app/shared/log-in/log-in.component.spec.ts +++ b/src/app/shared/log-in/log-in.component.spec.ts @@ -1,50 +1,58 @@ -import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; -import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; - import { By } from '@angular/platform-browser'; -import { Store, StoreModule } from '@ngrx/store'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { StoreModule } from '@ngrx/store'; +import { provideMockStore } from '@ngrx/store/testing'; +import { TranslateModule } from '@ngx-translate/core'; import { LogInComponent } from './log-in.component'; -import { authReducer } from '../../core/auth/auth.reducer'; -import { EPersonMock } from '../testing/eperson-mock'; -import { EPerson } from '../../core/eperson/models/eperson.model'; -import { TranslateModule } from '@ngx-translate/core'; import { AuthService } from '../../core/auth/auth.service'; -import { AuthServiceStub } from '../testing/auth-service-stub'; -import { AppState } from '../../app.reducer'; +import { authMethodsMock, AuthServiceStub } from '../testing/auth-service-stub'; +import { createTestComponent } from '../testing/utils'; +import { SharedModule } from '../shared.module'; +import { appReducers } from '../../app.reducer'; +import { NativeWindowService } from '../../core/services/window.service'; +import { NativeWindowMockFactory } from '../mocks/mock-native-window-ref'; +import { ActivatedRoute, Router } from '@angular/router'; +import { RouterStub } from '../testing/router-stub'; +import { ActivatedRouteStub } from '../testing/active-router-stub'; describe('LogInComponent', () => { let component: LogInComponent; let fixture: ComponentFixture; - let page: Page; - let user: EPerson; - - const authState = { - authenticated: false, - loaded: false, - loading: false, + const initialState = { + core: { + auth: { + authenticated: false, + loaded: false, + loading: false, + authMethods: authMethodsMock + } + } }; - beforeEach(() => { - user = EPersonMock; - }); - beforeEach(async(() => { // refine the test module by declaring the test component TestBed.configureTestingModule({ imports: [ FormsModule, ReactiveFormsModule, - StoreModule.forRoot(authReducer), + StoreModule.forRoot(appReducers), + SharedModule, TranslateModule.forRoot() ], declarations: [ - LogInComponent + TestComponent ], providers: [ - {provide: AuthService, useClass: AuthServiceStub} + { provide: AuthService, useClass: AuthServiceStub }, + { provide: NativeWindowService, useFactory: NativeWindowMockFactory }, + { provide: Router, useValue: new RouterStub() }, + { provide: ActivatedRoute, useValue: new ActivatedRouteStub() }, + provideMockStore({ initialState }), + LogInComponent ], schemas: [ CUSTOM_ELEMENTS_SCHEMA @@ -54,75 +62,58 @@ describe('LogInComponent', () => { })); - beforeEach(inject([Store], (store: Store) => { - store - .subscribe((state) => { - (state as any).core = Object.create({}); - (state as any).core.auth = authState; - }); + describe('', () => { + let testComp: TestComponent; + let testFixture: ComponentFixture; - // create component and test fixture - fixture = TestBed.createComponent(LogInComponent); + // synchronous beforeEach + beforeEach(() => { + const html = ` `; - // get test component from the fixture - component = fixture.componentInstance; - - // create page - page = new Page(component, fixture); - - // verify the fixture is stable (no pending tasks) - fixture.whenStable().then(() => { - page.addPageElements(); + testFixture = createTestComponent(html, TestComponent) as ComponentFixture; + testComp = testFixture.componentInstance; }); - })); + afterEach(() => { + testFixture.destroy(); + }); - it('should create a FormGroup comprised of FormControls', () => { - fixture.detectChanges(); - expect(component.form instanceof FormGroup).toBe(true); + it('should create LogInComponent', inject([LogInComponent], (app: LogInComponent) => { + + expect(app).toBeDefined(); + + })); }); - it('should authenticate', () => { - fixture.detectChanges(); + describe('', () => { + beforeEach(() => { + fixture = TestBed.createComponent(LogInComponent); + component = fixture.componentInstance; - // set FormControl values - component.form.controls.email.setValue('user'); - component.form.controls.password.setValue('password'); + fixture.detectChanges(); + }); - // submit form - component.submit(); + afterEach(() => { + fixture.destroy(); + component = null; + }); - // verify Store.dispatch() is invoked - expect(page.navigateSpy.calls.any()).toBe(true, 'Store.dispatch not invoked'); + it('should render a log-in container component for each auth method available', () => { + const loginContainers = fixture.debugElement.queryAll(By.css('ds-log-in-container')); + expect(loginContainers.length).toBe(2); + + }); }); }); -/** - * I represent the DOM elements and attach spies. - * - * @class Page - */ -class Page { +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { - public emailInput: HTMLInputElement; - public navigateSpy: jasmine.Spy; - public passwordInput: HTMLInputElement; + isStandalonePage = true; - constructor(private component: LogInComponent, private fixture: ComponentFixture) { - // use injector to get services - const injector = fixture.debugElement.injector; - const store = injector.get(Store); - - // add spies - this.navigateSpy = spyOn(store, 'dispatch'); - } - - public addPageElements() { - const emailInputSelector = 'input[formcontrolname=\'email\']'; - this.emailInput = this.fixture.debugElement.query(By.css(emailInputSelector)).nativeElement; - - const passwordInputSelector = 'input[formcontrolname=\'password\']'; - this.passwordInput = this.fixture.debugElement.query(By.css(passwordInputSelector)).nativeElement; - } } diff --git a/src/app/shared/log-in/log-in.component.ts b/src/app/shared/log-in/log-in.component.ts index b6b97230dd..92350de442 100644 --- a/src/app/shared/log-in/log-in.component.ts +++ b/src/app/shared/log-in/log-in.component.ts @@ -1,26 +1,13 @@ -import { filter, map, takeWhile } from 'rxjs/operators'; import { Component, Input, OnDestroy, OnInit } from '@angular/core'; -import { FormBuilder, FormGroup, Validators } from '@angular/forms'; -import { select, Store } from '@ngrx/store'; import { Observable } from 'rxjs'; -import { - AuthenticateAction, - ResetAuthenticationMessagesAction -} from '../../core/auth/auth.actions'; +import { filter, takeWhile, } from 'rxjs/operators'; +import { select, Store } from '@ngrx/store'; -import { - getAuthenticationError, - getAuthenticationInfo, - isAuthenticated, - isAuthenticationLoading, -} from '../../core/auth/selectors'; +import { AuthMethod } from '../../core/auth/models/auth.method'; +import { getAuthenticationMethods, isAuthenticated, isAuthenticationLoading } from '../../core/auth/selectors'; import { CoreState } from '../../core/core.reducers'; - -import { isNotEmpty } from '../empty.util'; -import { fadeOut } from '../animations/fade'; import { AuthService } from '../../core/auth/auth.service'; -import { Router } from '@angular/router'; /** * /users/sign-in @@ -29,34 +16,21 @@ import { Router } from '@angular/router'; @Component({ selector: 'ds-log-in', templateUrl: './log-in.component.html', - styleUrls: ['./log-in.component.scss'], - animations: [fadeOut] + styleUrls: ['./log-in.component.scss'] }) -export class LogInComponent implements OnDestroy, OnInit { +export class LogInComponent implements OnInit, OnDestroy { /** - * The error if authentication fails. - * @type {Observable} - */ - public error: Observable; - - /** - * Has authentication error. + * A boolean representing if LogInComponent is in a standalone page * @type {boolean} */ - public hasError = false; + @Input() isStandalonePage: boolean; /** - * The authentication info message. - * @type {Observable} + * The list of authentication methods available + * @type {AuthMethod[]} */ - public message: Observable; - - /** - * Has authentication message. - * @type {boolean} - */ - public hasMessage = false; + public authMethods: Observable; /** * Whether user is authenticated. @@ -70,69 +44,28 @@ export class LogInComponent implements OnDestroy, OnInit { */ public loading: Observable; - /** - * The authentication form. - * @type {FormGroup} - */ - public form: FormGroup; - /** * Component state. * @type {boolean} */ private alive = true; - @Input() isStandalonePage: boolean; - - /** - * @constructor - * @param {AuthService} authService - * @param {FormBuilder} formBuilder - * @param {Router} router - * @param {Store} store - */ - constructor( - private authService: AuthService, - private formBuilder: FormBuilder, - private store: Store - ) { + constructor(private store: Store, + private authService: AuthService,) { } - /** - * Lifecycle hook that is called after data-bound properties of a directive are initialized. - * @method ngOnInit - */ - public ngOnInit() { - // set isAuthenticated - this.isAuthenticated = this.store.pipe(select(isAuthenticated)); + ngOnInit(): void { - // set formGroup - this.form = this.formBuilder.group({ - email: ['', Validators.required], - password: ['', Validators.required] - }); - - // set error - this.error = this.store.pipe(select( - getAuthenticationError), - map((error) => { - this.hasError = (isNotEmpty(error)); - return error; - }) - ); - - // set error - this.message = this.store.pipe( - select(getAuthenticationInfo), - map((message) => { - this.hasMessage = (isNotEmpty(message)); - return message; - }) + this.authMethods = this.store.pipe( + select(getAuthenticationMethods), ); // set loading this.loading = this.store.pipe(select(isAuthenticationLoading)); + // set isAuthenticated + this.isAuthenticated = this.store.pipe(select(isAuthenticated)); + // subscribe to success this.store.pipe( select(isAuthenticated), @@ -142,55 +75,11 @@ export class LogInComponent implements OnDestroy, OnInit { this.authService.redirectAfterLoginSuccess(this.isStandalonePage); } ); + } - /** - * Lifecycle hook that is called when a directive, pipe or service is destroyed. - * @method ngOnDestroy - */ - public ngOnDestroy() { + ngOnDestroy(): void { this.alive = false; } - /** - * Reset error or message. - */ - public resetErrorOrMessage() { - if (this.hasError || this.hasMessage) { - this.store.dispatch(new ResetAuthenticationMessagesAction()); - this.hasError = false; - this.hasMessage = false; - } - } - - /** - * To the registration page. - * @method register - */ - public register() { - // TODO enable after registration process is done - // this.router.navigate(['/register']); - } - - /** - * Submit the authentication form. - * @method submit - */ - public submit() { - this.resetErrorOrMessage(); - // get email and password values - const email: string = this.form.get('email').value; - const password: string = this.form.get('password').value; - - // trim values - email.trim(); - password.trim(); - - // dispatch AuthenticationAction - this.store.dispatch(new AuthenticateAction(email, password)); - - // clear form - this.form.reset(); - } - } diff --git a/src/app/shared/log-in/methods/log-in.methods-decorator.ts b/src/app/shared/log-in/methods/log-in.methods-decorator.ts new file mode 100644 index 0000000000..0614bdeb51 --- /dev/null +++ b/src/app/shared/log-in/methods/log-in.methods-decorator.ts @@ -0,0 +1,16 @@ +import { AuthMethodType } from '../../../core/auth/models/auth.method-type'; + +const authMethodsMap = new Map(); + +export function renderAuthMethodFor(authMethodType: AuthMethodType) { + return function decorator(objectElement: any) { + if (!objectElement) { + return; + } + authMethodsMap.set(authMethodType, objectElement); + }; +} + +export function rendersAuthMethodType(authMethodType: AuthMethodType) { + return authMethodsMap.get(authMethodType); +} diff --git a/src/app/shared/log-in/methods/password/log-in-password.component.html b/src/app/shared/log-in/methods/password/log-in-password.component.html new file mode 100644 index 0000000000..ddd5083d44 --- /dev/null +++ b/src/app/shared/log-in/methods/password/log-in-password.component.html @@ -0,0 +1,27 @@ +
+ + + + + + + + +
diff --git a/src/app/shared/log-in/methods/password/log-in-password.component.scss b/src/app/shared/log-in/methods/password/log-in-password.component.scss new file mode 100644 index 0000000000..0eda382c0a --- /dev/null +++ b/src/app/shared/log-in/methods/password/log-in-password.component.scss @@ -0,0 +1,13 @@ +.form-login .form-control:focus { + z-index: 2; +} +.form-login input[type="email"] { + margin-bottom: -1px; + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; +} +.form-login input[type="password"] { + border-top-left-radius: 0; + border-top-right-radius: 0; +} + diff --git a/src/app/shared/log-in/methods/password/log-in-password.component.spec.ts b/src/app/shared/log-in/methods/password/log-in-password.component.spec.ts new file mode 100644 index 0000000000..ff65a240c8 --- /dev/null +++ b/src/app/shared/log-in/methods/password/log-in-password.component.spec.ts @@ -0,0 +1,131 @@ +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; +import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; + +import { By } from '@angular/platform-browser'; +import { Store, StoreModule } from '@ngrx/store'; +import { TranslateModule } from '@ngx-translate/core'; + +import { LogInPasswordComponent } from './log-in-password.component'; +import { EPerson } from '../../../../core/eperson/models/eperson.model'; +import { EPersonMock } from '../../../testing/eperson-mock'; +import { authReducer } from '../../../../core/auth/auth.reducer'; +import { AuthService } from '../../../../core/auth/auth.service'; +import { AuthServiceStub } from '../../../testing/auth-service-stub'; +import { AppState } from '../../../../app.reducer'; +import { AuthMethod } from '../../../../core/auth/models/auth.method'; +import { AuthMethodType } from '../../../../core/auth/models/auth.method-type'; + +describe('LogInPasswordComponent', () => { + + let component: LogInPasswordComponent; + let fixture: ComponentFixture; + let page: Page; + let user: EPerson; + + const authState = { + authenticated: false, + loaded: false, + loading: false, + }; + + beforeEach(() => { + user = EPersonMock; + }); + + beforeEach(async(() => { + // refine the test module by declaring the test component + TestBed.configureTestingModule({ + imports: [ + FormsModule, + ReactiveFormsModule, + StoreModule.forRoot(authReducer), + TranslateModule.forRoot() + ], + declarations: [ + LogInPasswordComponent + ], + providers: [ + { provide: AuthService, useClass: AuthServiceStub }, + { provide: 'authMethodProvider', useValue: new AuthMethod(AuthMethodType.Password) } + ], + schemas: [ + CUSTOM_ELEMENTS_SCHEMA + ] + }) + .compileComponents(); + + })); + + beforeEach(inject([Store], (store: Store) => { + store + .subscribe((state) => { + (state as any).core = Object.create({}); + (state as any).core.auth = authState; + }); + + // create component and test fixture + fixture = TestBed.createComponent(LogInPasswordComponent); + + // get test component from the fixture + component = fixture.componentInstance; + + // create page + page = new Page(component, fixture); + + // verify the fixture is stable (no pending tasks) + fixture.whenStable().then(() => { + page.addPageElements(); + }); + + })); + + it('should create a FormGroup comprised of FormControls', () => { + fixture.detectChanges(); + expect(component.form instanceof FormGroup).toBe(true); + }); + + it('should authenticate', () => { + fixture.detectChanges(); + + // set FormControl values + component.form.controls.email.setValue('user'); + component.form.controls.password.setValue('password'); + + // submit form + component.submit(); + + // verify Store.dispatch() is invoked + expect(page.navigateSpy.calls.any()).toBe(true, 'Store.dispatch not invoked'); + }); + +}); + +/** + * I represent the DOM elements and attach spies. + * + * @class Page + */ +class Page { + + public emailInput: HTMLInputElement; + public navigateSpy: jasmine.Spy; + public passwordInput: HTMLInputElement; + + constructor(private component: LogInPasswordComponent, private fixture: ComponentFixture) { + // use injector to get services + const injector = fixture.debugElement.injector; + const store = injector.get(Store); + + // add spies + this.navigateSpy = spyOn(store, 'dispatch'); + } + + public addPageElements() { + const emailInputSelector = 'input[formcontrolname=\'email\']'; + this.emailInput = this.fixture.debugElement.query(By.css(emailInputSelector)).nativeElement; + + const passwordInputSelector = 'input[formcontrolname=\'password\']'; + this.passwordInput = this.fixture.debugElement.query(By.css(passwordInputSelector)).nativeElement; + } +} diff --git a/src/app/shared/log-in/methods/password/log-in-password.component.ts b/src/app/shared/log-in/methods/password/log-in-password.component.ts new file mode 100644 index 0000000000..8b0dd8cc04 --- /dev/null +++ b/src/app/shared/log-in/methods/password/log-in-password.component.ts @@ -0,0 +1,144 @@ +import { map } from 'rxjs/operators'; +import { Component, Inject, OnInit } from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; + +import { select, Store } from '@ngrx/store'; +import { Observable } from 'rxjs'; +import { AuthenticateAction, ResetAuthenticationMessagesAction } from '../../../../core/auth/auth.actions'; + +import { getAuthenticationError, getAuthenticationInfo, } from '../../../../core/auth/selectors'; +import { CoreState } from '../../../../core/core.reducers'; +import { isNotEmpty } from '../../../empty.util'; +import { fadeOut } from '../../../animations/fade'; +import { AuthMethodType } from '../../../../core/auth/models/auth.method-type'; +import { renderAuthMethodFor } from '../log-in.methods-decorator'; +import { AuthMethod } from '../../../../core/auth/models/auth.method'; + +/** + * /users/sign-in + * @class LogInPasswordComponent + */ +@Component({ + selector: 'ds-log-in-password', + templateUrl: './log-in-password.component.html', + styleUrls: ['./log-in-password.component.scss'], + animations: [fadeOut] +}) +@renderAuthMethodFor(AuthMethodType.Password) +export class LogInPasswordComponent implements OnInit { + + /** + * The authentication method data. + * @type {AuthMethod} + */ + public authMethod: AuthMethod; + + /** + * The error if authentication fails. + * @type {Observable} + */ + public error: Observable; + + /** + * Has authentication error. + * @type {boolean} + */ + public hasError = false; + + /** + * The authentication info message. + * @type {Observable} + */ + public message: Observable; + + /** + * Has authentication message. + * @type {boolean} + */ + public hasMessage = false; + + /** + * The authentication form. + * @type {FormGroup} + */ + public form: FormGroup; + + /** + * @constructor + * @param {AuthMethod} injectedAuthMethodModel + * @param {FormBuilder} formBuilder + * @param {Store} store + */ + constructor( + @Inject('authMethodProvider') public injectedAuthMethodModel: AuthMethod, + private formBuilder: FormBuilder, + private store: Store + ) { + this.authMethod = injectedAuthMethodModel; + } + + /** + * Lifecycle hook that is called after data-bound properties of a directive are initialized. + * @method ngOnInit + */ + public ngOnInit() { + + // set formGroup + this.form = this.formBuilder.group({ + email: ['', Validators.required], + password: ['', Validators.required] + }); + + // set error + this.error = this.store.pipe(select( + getAuthenticationError), + map((error) => { + this.hasError = (isNotEmpty(error)); + return error; + }) + ); + + // set error + this.message = this.store.pipe( + select(getAuthenticationInfo), + map((message) => { + this.hasMessage = (isNotEmpty(message)); + return message; + }) + ); + + } + + /** + * Reset error or message. + */ + public resetErrorOrMessage() { + if (this.hasError || this.hasMessage) { + this.store.dispatch(new ResetAuthenticationMessagesAction()); + this.hasError = false; + this.hasMessage = false; + } + } + + /** + * Submit the authentication form. + * @method submit + */ + public submit() { + this.resetErrorOrMessage(); + // get email and password values + const email: string = this.form.get('email').value; + const password: string = this.form.get('password').value; + + // trim values + email.trim(); + password.trim(); + + // dispatch AuthenticationAction + this.store.dispatch(new AuthenticateAction(email, password)); + + // clear form + this.form.reset(); + } + +} diff --git a/src/app/shared/log-in/methods/shibboleth/log-in-shibboleth.component.html b/src/app/shared/log-in/methods/shibboleth/log-in-shibboleth.component.html new file mode 100644 index 0000000000..713970f05b --- /dev/null +++ b/src/app/shared/log-in/methods/shibboleth/log-in-shibboleth.component.html @@ -0,0 +1,7 @@ + + + + + diff --git a/src/app/shared/log-in/methods/shibboleth/log-in-shibboleth.component.scss b/src/app/shared/log-in/methods/shibboleth/log-in-shibboleth.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/shared/log-in/methods/shibboleth/log-in-shibboleth.component.spec.ts b/src/app/shared/log-in/methods/shibboleth/log-in-shibboleth.component.spec.ts new file mode 100644 index 0000000000..29723d0f65 --- /dev/null +++ b/src/app/shared/log-in/methods/shibboleth/log-in-shibboleth.component.spec.ts @@ -0,0 +1,139 @@ +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; +import { Store, StoreModule } from '@ngrx/store'; +import { TranslateModule } from '@ngx-translate/core'; + +import { EPerson } from '../../../../core/eperson/models/eperson.model'; +import { EPersonMock } from '../../../testing/eperson-mock'; +import { authReducer } from '../../../../core/auth/auth.reducer'; +import { AuthService } from '../../../../core/auth/auth.service'; +import { AuthServiceStub } from '../../../testing/auth-service-stub'; +import { AppState } from '../../../../app.reducer'; +import { AuthMethod } from '../../../../core/auth/models/auth.method'; +import { AuthMethodType } from '../../../../core/auth/models/auth.method-type'; +import { LogInShibbolethComponent } from './log-in-shibboleth.component'; +import { NativeWindowService } from '../../../../core/services/window.service'; +import { RouterStub } from '../../../testing/router-stub'; +import { ActivatedRouteStub } from '../../../testing/active-router-stub'; +import { NativeWindowMockFactory } from '../../../mocks/mock-native-window-ref'; + +describe('LogInShibbolethComponent', () => { + + let component: LogInShibbolethComponent; + let fixture: ComponentFixture; + let page: Page; + let user: EPerson; + let componentAsAny: any; + let setHrefSpy; + const shibbolethBaseUrl = 'dspace-rest.test/shibboleth?redirectUrl='; + const location = shibbolethBaseUrl + 'http://dspace-angular.test/home'; + + const authState = { + authenticated: false, + loaded: false, + loading: false, + }; + + beforeEach(() => { + user = EPersonMock; + }); + + beforeEach(async(() => { + // refine the test module by declaring the test component + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot(authReducer), + TranslateModule.forRoot() + ], + declarations: [ + LogInShibbolethComponent + ], + providers: [ + { provide: AuthService, useClass: AuthServiceStub }, + { provide: 'authMethodProvider', useValue: new AuthMethod(AuthMethodType.Shibboleth, location) }, + { provide: NativeWindowService, useFactory: NativeWindowMockFactory }, + { provide: Router, useValue: new RouterStub() }, + { provide: ActivatedRoute, useValue: new ActivatedRouteStub() }, + ], + schemas: [ + CUSTOM_ELEMENTS_SCHEMA + ] + }) + .compileComponents(); + + })); + + beforeEach(inject([Store], (store: Store) => { + store + .subscribe((state) => { + (state as any).core = Object.create({}); + (state as any).core.auth = authState; + }); + + // create component and test fixture + fixture = TestBed.createComponent(LogInShibbolethComponent); + + // get test component from the fixture + component = fixture.componentInstance; + componentAsAny = component; + + // create page + page = new Page(component, fixture); + setHrefSpy = spyOnProperty(componentAsAny._window.nativeWindow.location, 'href', 'set').and.callThrough(); + + })); + + it('should set the properly a new redirectUrl', () => { + const currentUrl = 'http://dspace-angular.test/collections/12345'; + componentAsAny._window.nativeWindow.location.href = currentUrl; + + fixture.detectChanges(); + + expect(componentAsAny.injectedAuthMethodModel.location).toBe(location); + expect(componentAsAny._window.nativeWindow.location.href).toBe(currentUrl); + + component.redirectToShibboleth(); + + expect(setHrefSpy).toHaveBeenCalledWith(shibbolethBaseUrl + currentUrl) + + }); + + it('should not set a new redirectUrl', () => { + const currentUrl = 'http://dspace-angular.test/home'; + componentAsAny._window.nativeWindow.location.href = currentUrl; + + fixture.detectChanges(); + + expect(componentAsAny.injectedAuthMethodModel.location).toBe(location); + expect(componentAsAny._window.nativeWindow.location.href).toBe(currentUrl); + + component.redirectToShibboleth(); + + expect(setHrefSpy).toHaveBeenCalledWith(shibbolethBaseUrl + currentUrl) + + }); + +}); + +/** + * I represent the DOM elements and attach spies. + * + * @class Page + */ +class Page { + + public emailInput: HTMLInputElement; + public navigateSpy: jasmine.Spy; + public passwordInput: HTMLInputElement; + + constructor(private component: LogInShibbolethComponent, private fixture: ComponentFixture) { + // use injector to get services + const injector = fixture.debugElement.injector; + const store = injector.get(Store); + + // add spies + this.navigateSpy = spyOn(store, 'dispatch'); + } + +} diff --git a/src/app/shared/log-in/methods/shibboleth/log-in-shibboleth.component.ts b/src/app/shared/log-in/methods/shibboleth/log-in-shibboleth.component.ts new file mode 100644 index 0000000000..6321e6119f --- /dev/null +++ b/src/app/shared/log-in/methods/shibboleth/log-in-shibboleth.component.ts @@ -0,0 +1,95 @@ +import { Component, Inject, OnInit, } from '@angular/core'; + +import { Observable } from 'rxjs'; +import { select, Store } from '@ngrx/store'; + +import { renderAuthMethodFor } from '../log-in.methods-decorator'; +import { AuthMethodType } from '../../../../core/auth/models/auth.method-type'; +import { AuthMethod } from '../../../../core/auth/models/auth.method'; + +import { CoreState } from '../../../../core/core.reducers'; +import { isAuthenticated, isAuthenticationLoading } from '../../../../core/auth/selectors'; +import { RouteService } from '../../../../core/services/route.service'; +import { NativeWindowRef, NativeWindowService } from '../../../../core/services/window.service'; +import { isNotNull } from '../../../empty.util'; + +@Component({ + selector: 'ds-log-in-shibboleth', + templateUrl: './log-in-shibboleth.component.html', + styleUrls: ['./log-in-shibboleth.component.scss'], + +}) +@renderAuthMethodFor(AuthMethodType.Shibboleth) +export class LogInShibbolethComponent implements OnInit { + + /** + * The authentication method data. + * @type {AuthMethod} + */ + public authMethod: AuthMethod; + + /** + * True if the authentication is loading. + * @type {boolean} + */ + public loading: Observable; + + /** + * The shibboleth authentication location url. + * @type {string} + */ + public location: string; + + /** + * Whether user is authenticated. + * @type {Observable} + */ + public isAuthenticated: Observable; + + /** + * @constructor + * @param {AuthMethod} injectedAuthMethodModel + * @param {NativeWindowRef} _window + * @param {RouteService} route + * @param {Store} store + */ + constructor( + @Inject('authMethodProvider') public injectedAuthMethodModel: AuthMethod, + @Inject(NativeWindowService) protected _window: NativeWindowRef, + private route: RouteService, + private store: Store + ) { + this.authMethod = injectedAuthMethodModel; + } + + ngOnInit(): void { + // set isAuthenticated + this.isAuthenticated = this.store.pipe(select(isAuthenticated)); + + // set loading + this.loading = this.store.pipe(select(isAuthenticationLoading)); + + // set location + this.location = decodeURIComponent(this.injectedAuthMethodModel.location); + + } + + redirectToShibboleth() { + let newLocationUrl = this.location; + const currentUrl = this._window.nativeWindow.location.href; + const myRegexp = /\?redirectUrl=(.*)/g; + const match = myRegexp.exec(this.location); + const redirectUrl = (match && match[1]) ? match[1] : null; + + // Check whether the current page is different from the redirect url received from rest + if (isNotNull(redirectUrl) && redirectUrl !== currentUrl) { + // change the redirect url with the current page url + const newRedirectUrl = `?redirectUrl=${currentUrl}`; + newLocationUrl = this.location.replace(/\?redirectUrl=(.*)/g, newRedirectUrl); + } + + // redirect to shibboleth authentication url + this._window.nativeWindow.location.href = newLocationUrl; + } + +} diff --git a/src/app/shared/log-out/log-out.component.scss b/src/app/shared/log-out/log-out.component.scss index dcd67e092f..1514130db6 100644 --- a/src/app/shared/log-out/log-out.component.scss +++ b/src/app/shared/log-out/log-out.component.scss @@ -1 +1 @@ -@import '../log-in/log-in.component.scss'; +@import '../log-in/methods/password/log-in-password.component'; diff --git a/src/app/shared/mocks/mock-native-window-ref.ts b/src/app/shared/mocks/mock-native-window-ref.ts new file mode 100644 index 0000000000..5546bd5ccc --- /dev/null +++ b/src/app/shared/mocks/mock-native-window-ref.ts @@ -0,0 +1,21 @@ +export const MockWindow = { + location: { + _href: '', + set href(url: string) { + this._href = url; + }, + get href() { + return this._href; + } + } +}; + +export class NativeWindowRefMock { + get nativeWindow(): any { + return MockWindow; + } +} + +export function NativeWindowMockFactory() { + return new NativeWindowRefMock(); +} diff --git a/src/app/shared/mocks/mock-request.service.ts b/src/app/shared/mocks/mock-request.service.ts index 23101b6feb..da297f56ac 100644 --- a/src/app/shared/mocks/mock-request.service.ts +++ b/src/app/shared/mocks/mock-request.service.ts @@ -11,9 +11,7 @@ export function getMockRequestService(requestEntry$: Observable = getByUUID: requestEntry$, uriEncodeBody: jasmine.createSpy('uriEncodeBody'), isCachedOrPending: false, - hasByHrefObservable: observableOf(false), - /* tslint:disable:no-empty */ - removeByHrefSubstring: () => {} - /* tslint:enable:no-empty */ + removeByHrefSubstring: jasmine.createSpy('removeByHrefSubstring'), + hasByHrefObservable: observableOf(false) }); } diff --git a/src/app/shared/object-grid/search-result-grid-element/collection-search-result/collection-search-result-grid-element.component.spec.ts b/src/app/shared/object-grid/search-result-grid-element/collection-search-result/collection-search-result-grid-element.component.spec.ts index de19f5b74a..d065f9c7e4 100644 --- a/src/app/shared/object-grid/search-result-grid-element/collection-search-result/collection-search-result-grid-element.component.spec.ts +++ b/src/app/shared/object-grid/search-result-grid-element/collection-search-result/collection-search-result-grid-element.component.spec.ts @@ -18,6 +18,7 @@ import { CollectionSearchResult } from '../../../object-collection/shared/collec import { TruncatableService } from '../../../truncatable/truncatable.service'; import { TruncatePipe } from '../../../utils/truncate.pipe'; import { CollectionSearchResultGridElementComponent } from './collection-search-result-grid-element.component'; +import { BitstreamFormatDataService } from '../../../../core/data/bitstream-format-data.service'; let collectionSearchResultGridElementComponent: CollectionSearchResultGridElementComponent; let fixture: ComponentFixture; @@ -70,6 +71,7 @@ describe('CollectionSearchResultGridElementComponent', () => { { provide: HttpClient, useValue: {} }, { provide: DSOChangeAnalyzer, useValue: {} }, { provide: DefaultChangeAnalyzer, useValue: {} }, + { provide: BitstreamFormatDataService, useValue: {} }, ], schemas: [ NO_ERRORS_SCHEMA ] diff --git a/src/app/shared/object-grid/search-result-grid-element/community-search-result/community-search-result-grid-element.component.spec.ts b/src/app/shared/object-grid/search-result-grid-element/community-search-result/community-search-result-grid-element.component.spec.ts index b97c574970..0d59273111 100644 --- a/src/app/shared/object-grid/search-result-grid-element/community-search-result/community-search-result-grid-element.component.spec.ts +++ b/src/app/shared/object-grid/search-result-grid-element/community-search-result/community-search-result-grid-element.component.spec.ts @@ -18,6 +18,7 @@ import { CommunitySearchResult } from '../../../object-collection/shared/communi import { TruncatableService } from '../../../truncatable/truncatable.service'; import { TruncatePipe } from '../../../utils/truncate.pipe'; import { CommunitySearchResultGridElementComponent } from './community-search-result-grid-element.component'; +import { BitstreamFormatDataService } from '../../../../core/data/bitstream-format-data.service'; let communitySearchResultGridElementComponent: CommunitySearchResultGridElementComponent; let fixture: ComponentFixture; @@ -70,6 +71,7 @@ describe('CommunitySearchResultGridElementComponent', () => { { provide: HttpClient, useValue: {} }, { provide: DSOChangeAnalyzer, useValue: {} }, { provide: DefaultChangeAnalyzer, useValue: {} }, + { provide: BitstreamFormatDataService, useValue: {} }, ], schemas: [ NO_ERRORS_SCHEMA ] diff --git a/src/app/shared/object-list/bundle-list-element/bundle-list-element.component.html b/src/app/shared/object-list/bundle-list-element/bundle-list-element.component.html new file mode 100644 index 0000000000..dfe08144a8 --- /dev/null +++ b/src/app/shared/object-list/bundle-list-element/bundle-list-element.component.html @@ -0,0 +1 @@ +
{{object.name}}
diff --git a/src/app/shared/object-list/bundle-list-element/bundle-list-element.component.ts b/src/app/shared/object-list/bundle-list-element/bundle-list-element.component.ts new file mode 100644 index 0000000000..55eb5b116e --- /dev/null +++ b/src/app/shared/object-list/bundle-list-element/bundle-list-element.component.ts @@ -0,0 +1,16 @@ +import { AbstractListableElementComponent } from '../../object-collection/shared/object-collection-element/abstract-listable-element.component'; +import { Bundle } from '../../../core/shared/bundle.model'; +import { Component } from '@angular/core'; +import { listableObjectComponent } from '../../object-collection/shared/listable-object/listable-object.decorator'; +import { ViewMode } from '../../../core/shared/view-mode.model'; + +@Component({ + selector: 'ds-bundle-list-element', + templateUrl: './bundle-list-element.component.html' +}) +/** + * This component is automatically used to create a list view for Bundle objects + */ +@listableObjectComponent(Bundle, ViewMode.ListElement) +export class BundleListElementComponent extends AbstractListableElementComponent { +} diff --git a/src/app/shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component.spec.ts b/src/app/shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component.spec.ts new file mode 100644 index 0000000000..84f3381880 --- /dev/null +++ b/src/app/shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component.spec.ts @@ -0,0 +1,178 @@ +import { AbstractPaginatedDragAndDropListComponent } from './abstract-paginated-drag-and-drop-list.component'; +import { DSpaceObject } from '../../core/shared/dspace-object.model'; +import { ObjectUpdatesService } from '../../core/data/object-updates/object-updates.service'; +import { ElementRef } from '@angular/core'; +import { Observable } from 'rxjs/internal/Observable'; +import { PaginatedList } from '../../core/data/paginated-list'; +import { RemoteData } from '../../core/data/remote-data'; +import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject'; +import { createPaginatedList, createSuccessfulRemoteDataObject } from '../testing/utils'; +import { FieldUpdates } from '../../core/data/object-updates/object-updates.reducer'; +import { of as observableOf } from 'rxjs'; +import { take } from 'rxjs/operators'; +import { PaginationComponent } from '../pagination/pagination.component'; + +class MockAbstractPaginatedDragAndDropListComponent extends AbstractPaginatedDragAndDropListComponent { + + constructor(protected objectUpdatesService: ObjectUpdatesService, + protected elRef: ElementRef, + protected mockUrl: string, + protected mockObjectsRD$: Observable>>) { + super(objectUpdatesService, elRef); + } + + initializeObjectsRD(): void { + this.objectsRD$ = this.mockObjectsRD$; + } + + initializeURL(): void { + this.url = this.mockUrl; + } +} + +describe('AbstractPaginatedDragAndDropListComponent', () => { + let component: MockAbstractPaginatedDragAndDropListComponent; + let objectUpdatesService: ObjectUpdatesService; + let elRef: ElementRef; + + const url = 'mock-abstract-paginated-drag-and-drop-list-component'; + + const object1 = Object.assign(new DSpaceObject(), { uuid: 'object-1' }); + const object2 = Object.assign(new DSpaceObject(), { uuid: 'object-2' }); + const objectsRD = createSuccessfulRemoteDataObject(createPaginatedList([object1, object2])); + let objectsRD$: BehaviorSubject>>; + + const updates = { + [object1.uuid]: { field: object1, changeType: undefined }, + [object2.uuid]: { field: object2, changeType: undefined } + } as FieldUpdates; + + let paginationComponent: PaginationComponent; + + beforeEach(() => { + objectUpdatesService = jasmine.createSpyObj('objectUpdatesService', { + initializeWithCustomOrder: {}, + addPageToCustomOrder: {}, + getFieldUpdatesByCustomOrder: observableOf(updates), + saveMoveFieldUpdate: {} + }); + elRef = { + nativeElement: jasmine.createSpyObj('nativeElement', { + querySelector: {} + }) + }; + paginationComponent = jasmine.createSpyObj('paginationComponent', { + doPageChange: {} + }); + objectsRD$ = new BehaviorSubject(objectsRD); + component = new MockAbstractPaginatedDragAndDropListComponent(objectUpdatesService, elRef, url, objectsRD$); + component.paginationComponent = paginationComponent; + component.ngOnInit(); + }); + + it('should call initializeWithCustomOrder to initialize the first page and add it to initializedPages', (done) => { + expect(component.initializedPages.indexOf(0)).toBeLessThan(0); + component.updates$.pipe(take(1)).subscribe(() => { + expect(objectUpdatesService.initializeWithCustomOrder).toHaveBeenCalled(); + expect(component.initializedPages.indexOf(0)).toBeGreaterThanOrEqual(0); + done(); + }); + }); + + it('should initialize the updates correctly', (done) => { + component.updates$.pipe(take(1)).subscribe((fieldUpdates) => { + expect(fieldUpdates).toEqual(updates); + done(); + }); + }); + + describe('when a new page is loaded', () => { + const page = 5; + + beforeEach((done) => { + component.updates$.pipe(take(1)).subscribe(() => { + component.currentPage$.next(page); + objectsRD$.next(objectsRD); + done(); + }); + }); + + it('should call addPageToCustomOrder to initialize the new page and add it to initializedPages', (done) => { + component.updates$.pipe(take(1)).subscribe(() => { + expect(objectUpdatesService.addPageToCustomOrder).toHaveBeenCalled(); + expect(component.initializedPages.indexOf(page - 1)).toBeGreaterThanOrEqual(0); + done(); + }); + }); + + describe('twice', () => { + beforeEach((done) => { + component.updates$.pipe(take(1)).subscribe(() => { + component.currentPage$.next(page); + objectsRD$.next(objectsRD); + done(); + }); + }); + + it('shouldn\'t call addPageToCustomOrder again, as the page has already been initialized', (done) => { + component.updates$.pipe(take(1)).subscribe(() => { + expect(objectUpdatesService.addPageToCustomOrder).toHaveBeenCalledTimes(1); + done(); + }); + }); + }); + }); + + describe('switchPage', () => { + const page = 3; + + beforeEach(() => { + component.switchPage(page); + }); + + it('should set currentPage$ to the new page', () => { + expect(component.currentPage$.value).toEqual(page); + }); + }); + + describe('drop', () => { + const event = { + previousIndex: 0, + currentIndex: 1, + item: { element: { nativeElement: { id: object1.uuid } } } + } as any; + + describe('when the user is hovering over a new page', () => { + const hoverPage = 3; + const hoverElement = { textContent: '' + hoverPage }; + + beforeEach(() => { + elRef.nativeElement.querySelector.and.returnValue(hoverElement); + component.initializedPages.push(hoverPage - 1); + component.drop(event); + }); + + it('should detect the page and set currentPage$ to its value', () => { + expect(component.currentPage$.value).toEqual(hoverPage); + }); + + it('should detect the page and update the pagination component with its value', () => { + expect(paginationComponent.doPageChange).toHaveBeenCalledWith(hoverPage); + }); + + it('should send out a saveMoveFieldUpdate with the correct values', () => { + expect(objectUpdatesService.saveMoveFieldUpdate).toHaveBeenCalledWith(url, event.previousIndex, 0, 0, hoverPage - 1, object1); + }); + }); + + describe('when the user is not hovering over a new page', () => { + beforeEach(() => { + component.drop(event); + }); + + it('should send out a saveMoveFieldUpdate with the correct values', () => { + expect(objectUpdatesService.saveMoveFieldUpdate).toHaveBeenCalledWith(url, event.previousIndex, event.currentIndex, 0, 0); + }); + }); + }); +}); diff --git a/src/app/shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component.ts b/src/app/shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component.ts new file mode 100644 index 0000000000..a34b5d5bc0 --- /dev/null +++ b/src/app/shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component.ts @@ -0,0 +1,195 @@ +import { FieldUpdates } from '../../core/data/object-updates/object-updates.reducer'; +import { Observable } from 'rxjs/internal/Observable'; +import { RemoteData } from '../../core/data/remote-data'; +import { PaginatedList } from '../../core/data/paginated-list'; +import { PaginationComponentOptions } from '../pagination/pagination-component-options.model'; +import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject'; +import { ObjectUpdatesService } from '../../core/data/object-updates/object-updates.service'; +import { switchMap, take, tap } from 'rxjs/operators'; +import { hasValue, isEmpty, isNotEmpty } from '../empty.util'; +import { paginatedListToArray } from '../../core/shared/operators'; +import { DSpaceObject } from '../../core/shared/dspace-object.model'; +import { CdkDragDrop } from '@angular/cdk/drag-drop'; +import { ElementRef, ViewChild } from '@angular/core'; +import { PaginationComponent } from '../pagination/pagination.component'; + +/** + * An abstract component containing general methods and logic to be able to drag and drop objects within a paginated + * list. This implementation supports being able to drag and drop objects between pages. + * Dragging an object on top of a page number will automatically detect the page it's being dropped on, send an update + * to the store and add the object on top of that page. + * + * To extend this component, it is important to make sure to: + * - Initialize objectsRD$ within the initializeObjectsRD() method + * - Initialize a unique URL for this component/page within the initializeURL() method + * - Add (cdkDropListDropped)="drop($event)" to the cdkDropList element in your template + * - Add (pageChange)="switchPage($event)" to the ds-pagination element in your template + * - Use the updates$ observable for building your list of cdkDrag elements in your template + * + * An example component extending from this abstract component: PaginatedDragAndDropBitstreamListComponent + */ +export abstract class AbstractPaginatedDragAndDropListComponent { + /** + * A view on the child pagination component + */ + @ViewChild(PaginationComponent, {static: false}) paginationComponent: PaginationComponent; + + /** + * The URL to use for accessing the object updates from this list + */ + url: string; + + /** + * The objects to retrieve data for and transform into field updates + */ + objectsRD$: Observable>>; + + /** + * The updates to the current list + */ + updates$: Observable; + + /** + * The amount of objects to display per page + */ + pageSize = 10; + + /** + * The page options to use for fetching the objects + * Start at page 1 and always use the set page size + */ + options = Object.assign(new PaginationComponentOptions(),{ + id: 'paginated-drag-and-drop-options', + currentPage: 1, + pageSize: this.pageSize + }); + + /** + * The current page being displayed + */ + currentPage$ = new BehaviorSubject(1); + + /** + * A list of pages that have been initialized in the field-update store + */ + initializedPages: number[] = []; + + /** + * An object storing information about an update that should be fired whenever fireToUpdate is called + */ + toUpdate: { + fromIndex: number, + toIndex: number, + fromPage: number, + toPage: number, + field?: T + }; + + protected constructor(protected objectUpdatesService: ObjectUpdatesService, + protected elRef: ElementRef) { + } + + /** + * Initialize the observables + */ + ngOnInit() { + this.initializeObjectsRD(); + this.initializeURL(); + this.initializeUpdates(); + } + + /** + * Overwrite this method to define how the list of objects is initialized and updated + */ + abstract initializeObjectsRD(): void; + + /** + * Overwrite this method to define how the URL is set + */ + abstract initializeURL(): void; + + /** + * Initialize the field-updates in the store + * This method ensures (new) pages displayed are automatically added to the field-update store when the objectsRD updates + */ + initializeUpdates(): void { + this.updates$ = this.objectsRD$.pipe( + paginatedListToArray(), + tap((objects: T[]) => { + // Pages in the field-update store are indexed starting at 0 (because they're stored in an array of pages) + const updatesPage = this.currentPage$.value - 1; + if (isEmpty(this.initializedPages)) { + // No updates have been initialized yet for this list, initialize the first page + this.objectUpdatesService.initializeWithCustomOrder(this.url, objects, new Date(), this.pageSize, updatesPage); + this.initializedPages.push(updatesPage); + } else if (this.initializedPages.indexOf(updatesPage) < 0) { + // Updates were initialized for this list, but not the page we're on. Add the current page to the field-update store for this list + this.objectUpdatesService.addPageToCustomOrder(this.url, objects, updatesPage); + this.initializedPages.push(updatesPage); + } + + // The new page is loaded into the store, check if there are any updates waiting and fire those as well + this.fireToUpdate(); + }), + switchMap((objects: T[]) => this.objectUpdatesService.getFieldUpdatesByCustomOrder(this.url, objects, this.currentPage$.value - 1)) + ); + } + + /** + * Update the current page + * @param page + */ + switchPage(page: number) { + this.currentPage$.next(page); + } + + /** + * An object was moved, send updates to the store. + * When the object is dropped on a page within the pagination of this component, the object moves to the top of that + * page and the pagination automatically loads and switches the view to that page. + * @param event + */ + drop(event: CdkDragDrop) { + // Check if the user is hovering over any of the pagination's pages at the time of dropping the object + const droppedOnElement = this.elRef.nativeElement.querySelector('.page-item:hover'); + if (hasValue(droppedOnElement) && hasValue(droppedOnElement.textContent)) { + // The user is hovering over a page, fetch the page's number from the element + const page = Number(droppedOnElement.textContent); + if (hasValue(page) && !Number.isNaN(page)) { + const id = event.item.element.nativeElement.id; + this.updates$.pipe(take(1)).subscribe((updates: FieldUpdates) => { + const field = hasValue(updates[id]) ? updates[id].field : undefined; + this.toUpdate = Object.assign({ + fromIndex: event.previousIndex, + toIndex: 0, + fromPage: this.currentPage$.value - 1, + toPage: page - 1, + field + }); + // Switch to the dropped-on page and force a page update for the pagination component + this.currentPage$.next(page); + this.paginationComponent.doPageChange(page); + if (this.initializedPages.indexOf(page - 1) >= 0) { + // The page the object is being dropped to has already been loaded before, directly fire an update to the store. + // For pages that haven't been loaded before, the updates$ observable will call fireToUpdate after the new page + // has loaded + this.fireToUpdate(); + } + }); + } + } else { + this.objectUpdatesService.saveMoveFieldUpdate(this.url, event.previousIndex, event.currentIndex, this.currentPage$.value - 1, this.currentPage$.value - 1); + } + } + + /** + * Method checking if there's an update ready to be fired. Send out a MoveFieldUpdate to the store if there's an + * update present and clear the update afterwards. + */ + fireToUpdate() { + if (hasValue(this.toUpdate)) { + this.objectUpdatesService.saveMoveFieldUpdate(this.url, this.toUpdate.fromIndex, this.toUpdate.toIndex, this.toUpdate.fromPage, this.toUpdate.toPage, this.toUpdate.field); + this.toUpdate = undefined; + } + } +} diff --git a/src/app/shared/responsive-table-sizes/responsive-column-sizes.spec.ts b/src/app/shared/responsive-table-sizes/responsive-column-sizes.spec.ts new file mode 100644 index 0000000000..843d0f043e --- /dev/null +++ b/src/app/shared/responsive-table-sizes/responsive-column-sizes.spec.ts @@ -0,0 +1,22 @@ +import { ResponsiveColumnSizes } from './responsive-column-sizes'; + +describe('ResponsiveColumnSizes', () => { + const xs = 2; + const sm = 3; + const md = 4; + const lg = 6; + const xl = 8; + const column = new ResponsiveColumnSizes(xs, sm, md, lg, xl); + + describe('buildClasses', () => { + let classes: string; + + beforeEach(() => { + classes = column.buildClasses(); + }); + + it('should return the correct bootstrap classes', () => { + expect(classes).toEqual(`col-${xs} col-sm-${sm} col-md-${md} col-lg-${lg} col-xl-${xl}`); + }); + }); +}); diff --git a/src/app/shared/responsive-table-sizes/responsive-column-sizes.ts b/src/app/shared/responsive-table-sizes/responsive-column-sizes.ts new file mode 100644 index 0000000000..84651f3ef5 --- /dev/null +++ b/src/app/shared/responsive-table-sizes/responsive-column-sizes.ts @@ -0,0 +1,46 @@ +/** + * A helper class storing the sizes in which to render a single column + * The values in this class are expected to be between 1 and 12 + * There are used to be added to bootstrap classes such as col-xs-{this.xs} + */ +export class ResponsiveColumnSizes { + /** + * The extra small bootstrap size + */ + xs: number; + + /** + * The small bootstrap size + */ + sm: number; + + /** + * The medium bootstrap size + */ + md: number; + + /** + * The large bootstrap size + */ + lg: number; + + /** + * The extra large bootstrap size + */ + xl: number; + + constructor(xs: number, sm: number, md: number, lg: number, xl: number) { + this.xs = xs; + this.sm = sm; + this.md = md; + this.lg = lg; + this.xl = xl; + } + + /** + * Build the bootstrap responsive column classes matching the values of this object + */ + buildClasses(): string { + return `col-${this.xs} col-sm-${this.sm} col-md-${this.md} col-lg-${this.lg} col-xl-${this.xl}` + } +} diff --git a/src/app/shared/responsive-table-sizes/responsive-table-sizes.spec.ts b/src/app/shared/responsive-table-sizes/responsive-table-sizes.spec.ts new file mode 100644 index 0000000000..23df9b1c25 --- /dev/null +++ b/src/app/shared/responsive-table-sizes/responsive-table-sizes.spec.ts @@ -0,0 +1,76 @@ +import { ResponsiveColumnSizes } from './responsive-column-sizes'; +import { ResponsiveTableSizes } from './responsive-table-sizes'; + +describe('ResponsiveColumnSizes', () => { + const column0 = new ResponsiveColumnSizes(2, 3, 4, 6, 8); + const column1 = new ResponsiveColumnSizes(8, 7, 4, 2, 1); + const column2 = new ResponsiveColumnSizes(1, 1, 4, 2, 1); + const column3 = new ResponsiveColumnSizes(1, 1, 4, 2, 2); + const table = new ResponsiveTableSizes([column0, column1, column2, column3]); + + describe('combineColumns', () => { + describe('when start value is out of bounds', () => { + let combined: ResponsiveColumnSizes; + + beforeEach(() => { + combined = table.combineColumns(-1, 2); + }); + + it('should return undefined', () => { + expect(combined).toBeUndefined(); + }); + }); + + describe('when end value is out of bounds', () => { + let combined: ResponsiveColumnSizes; + + beforeEach(() => { + combined = table.combineColumns(0, 5); + }); + + it('should return undefined', () => { + expect(combined).toBeUndefined(); + }); + }); + + describe('when start value is greater than end value', () => { + let combined: ResponsiveColumnSizes; + + beforeEach(() => { + combined = table.combineColumns(2, 0); + }); + + it('should return undefined', () => { + expect(combined).toBeUndefined(); + }); + }); + + describe('when start value is equal to end value', () => { + let combined: ResponsiveColumnSizes; + + beforeEach(() => { + combined = table.combineColumns(0, 0); + }); + + it('should return undefined', () => { + expect(combined).toBeUndefined(); + }); + }); + + describe('when provided with valid values', () => { + let combined: ResponsiveColumnSizes; + + beforeEach(() => { + combined = table.combineColumns(0, 2); + }); + + it('should combine the sizes of each column within the range into one', () => { + expect(combined.xs).toEqual(column0.xs + column1.xs + column2.xs); + expect(combined.sm).toEqual(column0.sm + column1.sm + column2.sm); + expect(combined.md).toEqual(column0.md + column1.md + column2.md); + expect(combined.lg).toEqual(column0.lg + column1.lg + column2.lg); + expect(combined.xl).toEqual(column0.xl + column1.xl + column2.xl); + }); + }); + }); +}); diff --git a/src/app/shared/responsive-table-sizes/responsive-table-sizes.ts b/src/app/shared/responsive-table-sizes/responsive-table-sizes.ts new file mode 100644 index 0000000000..b68774d46f --- /dev/null +++ b/src/app/shared/responsive-table-sizes/responsive-table-sizes.ts @@ -0,0 +1,42 @@ +import { ResponsiveColumnSizes } from './responsive-column-sizes'; +import { hasValue } from '../empty.util'; + +/** + * A helper class storing the sizes in which to render a table + * It stores a list of columns, which in turn store their own bootstrap column sizes + */ +export class ResponsiveTableSizes { + /** + * A list of all the columns and their responsive sizes within this table + */ + columns: ResponsiveColumnSizes[]; + + constructor(columns: ResponsiveColumnSizes[]) { + this.columns = columns; + } + + /** + * Combine the values of multiple columns into a single ResponsiveColumnSizes + * Useful when a row element stretches over multiple columns + * @param start Index of the first column + * @param end Index of the last column (inclusive) + */ + combineColumns(start: number, end: number): ResponsiveColumnSizes { + if (start < end && hasValue(this.columns[start]) && hasValue(this.columns[end])) { + let xs = this.columns[start].xs; + let sm = this.columns[start].sm; + let md = this.columns[start].md; + let lg = this.columns[start].lg; + let xl = this.columns[start].xl; + for (let i = start + 1; i < end + 1; i++) { + xs += this.columns[i].xs; + sm += this.columns[i].sm; + md += this.columns[i].md; + lg += this.columns[i].lg; + xl += this.columns[i].xl; + } + return new ResponsiveColumnSizes(xs, sm, md, lg, xl); + } + return undefined; + } +} diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index 54b0e69ee5..a136b7826c 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -42,13 +42,15 @@ import { SearchResultGridElementComponent } from './object-grid/search-result-gr import { ViewModeSwitchComponent } from './view-mode-switch/view-mode-switch.component'; import { GridThumbnailComponent } from './object-grid/grid-thumbnail/grid-thumbnail.component'; import { VarDirective } from './utils/var.directive'; -import { LogInComponent } from './log-in/log-in.component'; import { AuthNavMenuComponent } from './auth-nav-menu/auth-nav-menu.component'; import { LogOutComponent } from './log-out/log-out.component'; import { FormComponent } from './form/form.component'; import { DsDynamicTypeaheadComponent } from './form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component'; import { DsDynamicScrollableDropdownComponent } from './form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component'; -import { DsDynamicFormControlContainerComponent, dsDynamicFormControlMapFn } from './form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component'; +import { + DsDynamicFormControlContainerComponent, + dsDynamicFormControlMapFn +} from './form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component'; import { DsDynamicFormComponent } from './form/builder/ds-dynamic-form-ui/ds-dynamic-form.component'; import { DYNAMIC_FORM_CONTROL_MAP_FN, DynamicFormsCoreModule } from '@ng-dynamic-forms/core'; import { DynamicFormsNGBootstrapUIModule } from '@ng-dynamic-forms/ui-ng-bootstrap'; @@ -178,6 +180,12 @@ import { DragDropModule } from '@angular/cdk/drag-drop'; import { ExistingMetadataListElementComponent } from './form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component'; import { ItemVersionsComponent } from './item/item-versions/item-versions.component'; import { SortablejsModule } from 'ngx-sortablejs'; +import { LogInContainerComponent } from './log-in/container/log-in-container.component'; +import { LogInShibbolethComponent } from './log-in/methods/shibboleth/log-in-shibboleth.component'; +import { LogInPasswordComponent } from './log-in/methods/password/log-in-password.component'; +import { LogInComponent } from './log-in/log-in.component'; +import { CustomSwitchComponent } from './form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.component'; +import { BundleListElementComponent } from './object-list/bundle-list-element/bundle-list-element.component'; import { MissingTranslationHelper } from './translate/missing-translation.helper'; import { ItemVersionsNoticeComponent } from './item/item-versions/notice/item-versions-notice.component'; import { ClaimedTaskActionsLoaderComponent } from './mydspace-actions/claimed-task/switcher/claimed-task-actions-loader.component'; @@ -344,6 +352,9 @@ const COMPONENTS = [ AbstractTrackableComponent, ComcolMetadataComponent, ItemTypeBadgeComponent, + BrowseByComponent, + AbstractTrackableComponent, + CustomSwitchComponent, ItemSelectComponent, CollectionSelectComponent, MetadataRepresentationLoaderComponent, @@ -351,6 +362,9 @@ const COMPONENTS = [ ExternalSourceEntryImportModalComponent, ImportableListItemControlComponent, ExistingMetadataListElementComponent, + LogInShibbolethComponent, + LogInPasswordComponent, + LogInContainerComponent, ItemVersionsComponent, PublicationSearchResultListElementComponent, ItemVersionsNoticeComponent @@ -400,6 +414,7 @@ const ENTRY_COMPONENTS = [ PlainTextMetadataListElementComponent, ItemMetadataListElementComponent, MetadataRepresentationListElementComponent, + CustomSwitchComponent, ItemMetadataRepresentationListElementComponent, SearchResultsComponent, CollectionSearchResultGridElementComponent, @@ -417,7 +432,10 @@ const ENTRY_COMPONENTS = [ DsDynamicLookupRelationSelectionTabComponent, DsDynamicLookupRelationExternalSourceTabComponent, ExternalSourceEntryImportModalComponent, + LogInPasswordComponent, + LogInShibbolethComponent, ItemVersionsComponent, + BundleListElementComponent, ItemVersionsNoticeComponent, ClaimedTaskActionsApproveComponent, ClaimedTaskActionsRejectComponent, diff --git a/src/app/shared/testing/auth-request-service-stub.ts b/src/app/shared/testing/auth-request-service-stub.ts index b32b5395ba..e89af1d666 100644 --- a/src/app/shared/testing/auth-request-service-stub.ts +++ b/src/app/shared/testing/auth-request-service-stub.ts @@ -21,7 +21,7 @@ export class AuthRequestServiceStub { } else { authStatusStub.authenticated = false; } - } else { + } else if (isNotEmpty(options)) { const token = (options.headers as any).lazyUpdate[1].value; if (this.validateToken(token)) { authStatusStub.authenticated = true; @@ -37,6 +37,8 @@ export class AuthRequestServiceStub { } else { authStatusStub.authenticated = false; } + } else { + authStatusStub.authenticated = false; } return observableOf(authStatusStub); } @@ -48,7 +50,7 @@ export class AuthRequestServiceStub { authStatusStub.authenticated = false; break; case 'status': - const token = (options.headers as any).lazyUpdate[1].value; + const token = ((options.headers as any).lazyUpdate[1]) ? (options.headers as any).lazyUpdate[1].value : null; if (this.validateToken(token)) { authStatusStub.authenticated = true; authStatusStub.token = this.mockTokenInfo; diff --git a/src/app/shared/testing/auth-service-stub.ts b/src/app/shared/testing/auth-service-stub.ts index a3c6351ccd..26ce79cb5f 100644 --- a/src/app/shared/testing/auth-service-stub.ts +++ b/src/app/shared/testing/auth-service-stub.ts @@ -4,6 +4,12 @@ import { AuthTokenInfo } from '../../core/auth/models/auth-token-info.model'; import { EPersonMock } from './eperson-mock'; import { EPerson } from '../../core/eperson/models/eperson.model'; import { createSuccessfulRemoteDataObject$ } from './utils'; +import { AuthMethod } from '../../core/auth/models/auth.method'; + +export const authMethodsMock = [ + new AuthMethod('password'), + new AuthMethod('shibboleth', 'dspace.test/shibboleth') +]; export class AuthServiceStub { @@ -106,4 +112,12 @@ export class AuthServiceStub { isAuthenticated() { return observableOf(true); } + + checkAuthenticationCookie() { + return; + } + + retrieveAuthMethodsFromAuthStatus(status: AuthStatus) { + return observableOf(authMethodsMock); + } } diff --git a/src/app/shared/trackable/abstract-trackable.component.ts b/src/app/shared/trackable/abstract-trackable.component.ts index bb1f4b31b4..e1a99d90b9 100644 --- a/src/app/shared/trackable/abstract-trackable.component.ts +++ b/src/app/shared/trackable/abstract-trackable.component.ts @@ -63,7 +63,7 @@ export class AbstractTrackableComponent { * Get translated notification title * @param key */ - protected getNotificationTitle(key: string) { + getNotificationTitle(key: string) { return this.translateService.instant(this.notificationsPrefix + key + '.title'); } @@ -71,7 +71,7 @@ export class AbstractTrackableComponent { * Get translated notification content * @param key */ - protected getNotificationContent(key: string) { + getNotificationContent(key: string) { return this.translateService.instant(this.notificationsPrefix + key + '.content'); } diff --git a/src/app/shared/uploader/uploader-properties.model.ts b/src/app/shared/uploader/uploader-properties.model.ts new file mode 100644 index 0000000000..bc0376b809 --- /dev/null +++ b/src/app/shared/uploader/uploader-properties.model.ts @@ -0,0 +1,21 @@ +import { MetadataMap } from '../../core/shared/metadata.models'; + +/** + * Properties to send to the REST API for uploading a bitstream + */ +export class UploaderProperties { + /** + * A custom name for the bitstream + */ + name: string; + + /** + * Metadata for the bitstream (e.g. dc.description) + */ + metadata: MetadataMap; + + /** + * The name of the bundle to upload the bitstream to + */ + bundleName: string; +} diff --git a/src/app/shared/uploader/uploader.component.ts b/src/app/shared/uploader/uploader.component.ts index 935d196d08..72a38d1eb1 100644 --- a/src/app/shared/uploader/uploader.component.ts +++ b/src/app/shared/uploader/uploader.component.ts @@ -15,8 +15,9 @@ import { uniqueId } from 'lodash'; import { ScrollToConfigOptions, ScrollToService } from '@nicky-lenaers/ngx-scroll-to'; import { UploaderOptions } from './uploader-options.model'; -import { isNotEmpty, isUndefined } from '../empty.util'; +import { hasValue, isNotEmpty, isUndefined } from '../empty.util'; import { UploaderService } from './uploader.service'; +import { UploaderProperties } from './uploader-properties.model'; @Component({ selector: 'ds-uploader', @@ -53,6 +54,11 @@ export class UploaderComponent { */ @Input() uploadFilesOptions: UploaderOptions; + /** + * Extra properties to be passed with the form-data of the upload + */ + @Input() uploadProperties: UploaderProperties; + /** * The function to call when upload is completed */ @@ -131,6 +137,11 @@ export class UploaderComponent { }; this.scrollToService.scrollTo(config); }; + if (hasValue(this.uploadProperties)) { + this.uploader.onBuildItemForm = (item, form) => { + form.append('properties', JSON.stringify(this.uploadProperties)) + }; + } this.uploader.onCompleteItem = (item: any, response: any, status: any, headers: any) => { if (isNotEmpty(response)) { const responsePath = JSON.parse(response); diff --git a/src/app/shared/utils/object-values-pipe.ts b/src/app/shared/utils/object-values-pipe.ts index bb511b4e5c..8c9d863372 100644 --- a/src/app/shared/utils/object-values-pipe.ts +++ b/src/app/shared/utils/object-values-pipe.ts @@ -1,6 +1,10 @@ import { PipeTransform, Pipe } from '@angular/core'; +import { isNotEmpty } from '../empty.util'; -@Pipe({name: 'dsObjectValues'}) +@Pipe({ + name: 'dsObjectValues', + pure: true +}) /** * Pipe for parsing all values of an object to an array of values */ @@ -12,7 +16,9 @@ export class ObjectValuesPipe implements PipeTransform { */ transform(value, args: string[]): any { const values = []; - Object.values(value).forEach((v) => values.push(v)); + if (isNotEmpty(value)) { + Object.values(value).forEach((v) => values.push(v)); + } return values; } } diff --git a/src/config/auth-config.interfaces.ts b/src/config/auth-config.interfaces.ts new file mode 100644 index 0000000000..cc3d97c6b8 --- /dev/null +++ b/src/config/auth-config.interfaces.ts @@ -0,0 +1,10 @@ +import { Config } from './config.interface'; + +export interface AuthTarget { + host: string; + page: string; +} + +export interface AuthConfig extends Config { + target: AuthTarget; +} diff --git a/src/config/global-config.interface.ts b/src/config/global-config.interface.ts index dec23ff676..f361e6def6 100644 --- a/src/config/global-config.interface.ts +++ b/src/config/global-config.interface.ts @@ -10,12 +10,14 @@ import { BrowseByConfig } from './browse-by-config.interface'; import { ItemPageConfig } from './item-page-config.interface'; import { CollectionPageConfig } from './collection-page-config.interface'; import { Theme } from './theme.inferface'; +import {AuthConfig} from './auth-config.interfaces'; export interface GlobalConfig extends Config { ui: ServerConfig; rest: ServerConfig; production: boolean; cache: CacheConfig; + auth: AuthConfig; form: FormConfig; notifications: INotificationBoardOptions; submission: SubmissionConfig;