diff --git a/config/environment.default.js b/config/environment.default.js index 24386d6cf7..58193d31bc 100644 --- a/config/environment.default.js +++ b/config/environment.default.js @@ -185,6 +185,11 @@ module.exports = { undoTimeout: 10000 // 10 seconds } }, + collection: { + edit: { + undoTimeout: 10000 // 10 seconds + } + }, theme: { name: 'default', } diff --git a/resources/i18n/en.json5 b/resources/i18n/en.json5 index 50a53eb7e6..39d30859bc 100644 --- a/resources/i18n/en.json5 +++ b/resources/i18n/en.json5 @@ -338,8 +338,40 @@ "collection.edit.tabs.roles.title": "Collection Edit - Roles", + "collection.edit.tabs.source.external": "This collection harvests its content from an external source", + + "collection.edit.tabs.source.form.errors.oaiSource.required": "You must provide a set id of the target collection.", + + "collection.edit.tabs.source.form.harvestType": "Content being harvested", + + "collection.edit.tabs.source.form.head": "Configure an external source", + + "collection.edit.tabs.source.form.metadataConfigId": "Metadata Format", + + "collection.edit.tabs.source.form.oaiSetId": "OAI specific set id", + + "collection.edit.tabs.source.form.oaiSource": "OAI Provider", + + "collection.edit.tabs.source.form.options.harvestType.METADATA_AND_BITSTREAMS": "Harvest metadata and bitstreams (requires ORE support)", + + "collection.edit.tabs.source.form.options.harvestType.METADATA_AND_REF": "Harvest metadata and references to bitstreams (requires ORE support)", + + "collection.edit.tabs.source.form.options.harvestType.METADATA_ONLY": "Harvest metadata only", + "collection.edit.tabs.source.head": "Content Source", + "collection.edit.tabs.source.notifications.discarded.content": "Your changes were discarded. To reinstate your changes click the 'Undo' button", + + "collection.edit.tabs.source.notifications.discarded.title": "Changed discarded", + + "collection.edit.tabs.source.notifications.invalid.content": "Your changes were not saved. Please make sure all fields are valid before you save.", + + "collection.edit.tabs.source.notifications.invalid.title": "Metadata invalid", + + "collection.edit.tabs.source.notifications.saved.content": "Your changes to this collection's content source were saved.", + + "collection.edit.tabs.source.notifications.saved.title": "Content Source saved", + "collection.edit.tabs.source.title": "Collection Edit - Content Source", @@ -382,6 +414,12 @@ + "collection.source.update.notifications.error.content": "The provided settings have been tested and didn't work.", + + "collection.source.update.notifications.error.title": "Server Error", + + + "communityList.tabTitle": "DSpace - Community List", "communityList.title": "List of Communities", @@ -997,6 +1035,8 @@ "loading.collections": "Loading collections...", + "loading.content-source": "Loading content source...", + "loading.community": "Loading community...", "loading.default": "Loading...", diff --git a/src/app/+collection-page/edit-collection-page/collection-source/collection-source.component.html b/src/app/+collection-page/edit-collection-page/collection-source/collection-source.component.html index e69de29bb2..4192922c7e 100644 --- a/src/app/+collection-page/edit-collection-page/collection-source/collection-source.component.html +++ b/src/app/+collection-page/edit-collection-page/collection-source/collection-source.component.html @@ -0,0 +1,56 @@ +
+
+ + + +
+

{{ 'collection.edit.tabs.source.head' | translate }}

+
+ + +
+ +

{{ 'collection.edit.tabs.source.form.head' | translate }}

+
+ +
+
+ + + +
+
diff --git a/src/app/+collection-page/edit-collection-page/collection-source/collection-source.component.spec.ts b/src/app/+collection-page/edit-collection-page/collection-source/collection-source.component.spec.ts new file mode 100644 index 0000000000..11ec9b1f6a --- /dev/null +++ b/src/app/+collection-page/edit-collection-page/collection-source/collection-source.component.spec.ts @@ -0,0 +1,222 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { RouterTestingModule } from '@angular/router/testing'; +import { ActivatedRoute, Router } from '@angular/router'; +import { of as observableOf } from 'rxjs/internal/observable/of'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { CollectionSourceComponent } from './collection-source.component'; +import { ContentSource, ContentSourceHarvestType } from '../../../core/shared/content-source.model'; +import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service'; +import { INotification, Notification } from '../../../shared/notifications/models/notification.model'; +import { NotificationType } from '../../../shared/notifications/models/notification-type'; +import { FieldUpdate } from '../../../core/data/object-updates/object-updates.reducer'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { DynamicFormControlModel, DynamicFormService } from '@ng-dynamic-forms/core'; +import { hasValue } from '../../../shared/empty.util'; +import { FormControl, FormGroup } from '@angular/forms'; +import { RouterStub } from '../../../shared/testing/router-stub'; +import { GLOBAL_CONFIG } from '../../../../config'; +import { By } from '@angular/platform-browser'; +import { Collection } from '../../../core/shared/collection.model'; +import { RemoteData } from '../../../core/data/remote-data'; +import { CollectionDataService } from '../../../core/data/collection-data.service'; +import { RequestService } from '../../../core/data/request.service'; + +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 uuid = '29481ed7-ae6b-409a-8c51-34dd347a0ce4'; +let date: Date; +let contentSource: ContentSource; +let fieldUpdate: FieldUpdate; +let objectUpdatesService: ObjectUpdatesService; +let notificationsService: NotificationsService; +let location: Location; +let formService: DynamicFormService; +let router: any; +let collection: Collection; +let collectionService: CollectionDataService; +let requestService: RequestService; + +describe('CollectionSourceComponent', () => { + let comp: CollectionSourceComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + date = new Date(); + contentSource = Object.assign(new ContentSource(), { + uuid: uuid, + metadataConfigs: [ + { + id: 'dc', + label: 'Simple Dublin Core', + nameSpace: 'http://www.openarchives.org/OAI/2.0/oai_dc/' + }, + { + id: 'qdc', + label: 'Qualified Dublin Core', + nameSpace: 'http://purl.org/dc/terms/' + }, + { + id: 'dim', + label: 'DSpace Intermediate Metadata', + nameSpace: 'http://www.dspace.org/xmlns/dspace/dim' + } + ] + }); + fieldUpdate = { + field: contentSource, + changeType: undefined + }; + objectUpdatesService = jasmine.createSpyObj('objectUpdatesService', + { + getFieldUpdates: observableOf({ + [contentSource.uuid]: fieldUpdate + }), + saveAddFieldUpdate: {}, + discardFieldUpdates: {}, + reinstateFieldUpdates: observableOf(true), + initialize: {}, + getUpdatedFields: observableOf([contentSource]), + getLastModified: observableOf(date), + hasUpdates: observableOf(true), + isReinstatable: observableOf(false), + isValidPage: observableOf(true) + } + ); + notificationsService = jasmine.createSpyObj('notificationsService', + { + info: infoNotification, + warning: warningNotification, + success: successNotification + } + ); + location = jasmine.createSpyObj('location', ['back']); + 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; + } + }); + router = Object.assign(new RouterStub(), { + url: 'http://test-url.com/test-url' + }); + collection = Object.assign(new Collection(), { + uuid: 'fake-collection-id' + }); + collectionService = jasmine.createSpyObj('collectionService', { + getContentSource: observableOf(contentSource), + updateContentSource: observableOf(contentSource), + getHarvesterEndpoint: observableOf('harvester-endpoint') + }); + requestService = jasmine.createSpyObj('requestService', ['removeByHrefSubstring']); + + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), RouterTestingModule], + declarations: [CollectionSourceComponent], + providers: [ + { provide: ObjectUpdatesService, useValue: objectUpdatesService }, + { provide: NotificationsService, useValue: notificationsService }, + { provide: Location, useValue: location }, + { provide: DynamicFormService, useValue: formService }, + { provide: ActivatedRoute, useValue: { parent: { data: observableOf({ dso: new RemoteData(false, false, true, null, collection) }) } } }, + { provide: Router, useValue: router }, + { provide: GLOBAL_CONFIG, useValue: { collection: { edit: { undoTimeout: 10 } } } as any }, + { provide: CollectionDataService, useValue: collectionService }, + { provide: RequestService, useValue: requestService } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(CollectionSourceComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + }); + + describe('on startup', () => { + let form; + + beforeEach(() => { + form = fixture.debugElement.query(By.css('ds-form')); + }); + + it('ContentSource should be disabled', () => { + expect(comp.contentSource.harvestType).toEqual(ContentSourceHarvestType.None); + }); + + it('the input-form should be hidden', () => { + expect(form).toBeNull(); + }); + }); + + describe('when selecting the checkbox', () => { + let input; + let form; + + beforeEach(() => { + input = fixture.debugElement.query(By.css('#externalSourceCheck')).nativeElement; + input.click(); + fixture.detectChanges(); + form = fixture.debugElement.query(By.css('ds-form')); + }); + + it('should enable ContentSource', () => { + expect(comp.contentSource.harvestType).not.toEqual(ContentSourceHarvestType.None); + }); + + it('should send a field update', () => { + expect(objectUpdatesService.saveAddFieldUpdate).toHaveBeenCalledWith(router.url, comp.contentSource) + }); + + it('should display the form', () => { + expect(form).not.toBeNull(); + }); + }); + + describe('isValid', () => { + it('should return true when ContentSource is disabled but the form invalid', () => { + spyOnProperty(comp.formGroup, 'valid').and.returnValue(false); + comp.contentSource.harvestType = ContentSourceHarvestType.None; + expect(comp.isValid()).toBe(true); + }); + + it('should return false when ContentSource is enabled but the form is invalid', () => { + spyOnProperty(comp.formGroup, 'valid').and.returnValue(false); + comp.contentSource.harvestType = ContentSourceHarvestType.Metadata; + expect(comp.isValid()).toBe(false); + }); + + it('should return true when ContentSource is enabled and the form is valid', () => { + spyOnProperty(comp.formGroup, 'valid').and.returnValue(true); + comp.contentSource.harvestType = ContentSourceHarvestType.Metadata; + expect(comp.isValid()).toBe(true); + }); + }); + + describe('onSubmit', () => { + beforeEach(() => { + comp.onSubmit(); + }); + + it('should re-initialize the field updates', () => { + expect(objectUpdatesService.initialize).toHaveBeenCalled(); + }); + + it('should display a success notification', () => { + expect(notificationsService.success).toHaveBeenCalled(); + }); + + it('should call updateContentSource on the collectionService', () => { + expect(collectionService.updateContentSource).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/app/+collection-page/edit-collection-page/collection-source/collection-source.component.ts b/src/app/+collection-page/edit-collection-page/collection-source/collection-source.component.ts index 6ec5be884d..5fcc740663 100644 --- a/src/app/+collection-page/edit-collection-page/collection-source/collection-source.component.ts +++ b/src/app/+collection-page/edit-collection-page/collection-source/collection-source.component.ts @@ -1,4 +1,37 @@ -import { Component } from '@angular/core'; +import { Component, Inject, OnDestroy, OnInit } from '@angular/core'; +import { AbstractTrackableComponent } from '../../../shared/trackable/abstract-trackable.component'; +import { + DynamicFormControlModel, + DynamicFormGroupModel, + DynamicFormLayout, + DynamicFormService, + DynamicInputModel, + DynamicOptionControlModel, + DynamicRadioGroupModel, + DynamicSelectModel, + DynamicTextAreaModel +} from '@ng-dynamic-forms/core'; +import { Location } from '@angular/common'; +import { TranslateService } from '@ngx-translate/core'; +import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { FormGroup } from '@angular/forms'; +import { hasNoValue, hasValue, isNotEmpty } from '../../../shared/empty.util'; +import { ContentSource, ContentSourceHarvestType } from '../../../core/shared/content-source.model'; +import { Observable } from 'rxjs/internal/Observable'; +import { RemoteData } from '../../../core/data/remote-data'; +import { Collection } from '../../../core/shared/collection.model'; +import { first, map, switchMap, take } from 'rxjs/operators'; +import { ActivatedRoute, Router } from '@angular/router'; +import { FieldUpdate, FieldUpdates } from '../../../core/data/object-updates/object-updates.reducer'; +import { Subscription } from 'rxjs/internal/Subscription'; +import { cloneDeep } from 'lodash'; +import { GLOBAL_CONFIG, GlobalConfig } from '../../../../config'; +import { CollectionDataService } from '../../../core/data/collection-data.service'; +import { getSucceededRemoteData } from '../../../core/shared/operators'; +import { MetadataConfig } from '../../../core/shared/metadata-config.model'; +import { INotification } from '../../../shared/notifications/models/notification.model'; +import { RequestService } from '../../../core/data/request.service'; /** * Component for managing the content source of the collection @@ -7,6 +40,440 @@ import { Component } from '@angular/core'; selector: 'ds-collection-source', templateUrl: './collection-source.component.html', }) -export class CollectionSourceComponent { - /* TODO: Implement Collection Edit - Content Source */ +export class CollectionSourceComponent extends AbstractTrackableComponent implements OnInit, OnDestroy { + /** + * The current collection's remote data + */ + collectionRD$: Observable>; + + /** + * The collection's content source + */ + contentSource: ContentSource; + + /** + * The current update to the content source + */ + update$: Observable; + + /** + * The initial harvest type we started off with + * Used to compare changes + */ + initialHarvestType: ContentSourceHarvestType; + + /** + * @type {string} Key prefix used to generate form labels + */ + LABEL_KEY_PREFIX = 'collection.edit.tabs.source.form.'; + + /** + * @type {string} Key prefix used to generate form error messages + */ + ERROR_KEY_PREFIX = 'collection.edit.tabs.source.form.errors.'; + + /** + * @type {string} Key prefix used to generate form option labels + */ + OPTIONS_KEY_PREFIX = 'collection.edit.tabs.source.form.options.'; + + /** + * The Dynamic Input Model for the OAI Provider + */ + oaiSourceModel = new DynamicInputModel({ + id: 'oaiSource', + name: 'oaiSource', + required: true, + validators: { + required: null + }, + errorMessages: { + required: 'You must provide a set id of the target collection.' + } + }); + + /** + * The Dynamic Input Model for the OAI Set + */ + oaiSetIdModel = new DynamicInputModel({ + id: 'oaiSetId', + name: 'oaiSetId' + }); + + /** + * The Dynamic Input Model for the Metadata Format used + */ + metadataConfigIdModel = new DynamicSelectModel({ + id: 'metadataConfigId', + name: 'metadataConfigId' + }); + + /** + * The Dynamic Input Model for the type of harvesting + */ + harvestTypeModel = new DynamicRadioGroupModel({ + id: 'harvestType', + name: 'harvestType', + options: [ + { + value: ContentSourceHarvestType.Metadata + }, + { + value: ContentSourceHarvestType.MetadataAndRef + }, + { + value: ContentSourceHarvestType.MetadataAndBitstreams + } + ] + }); + + /** + * All input models in a simple array for easier iterations + */ + inputModels = [this.oaiSourceModel, this.oaiSetIdModel, this.metadataConfigIdModel, this.harvestTypeModel]; + + /** + * The dynamic form fields used for editing the content source of a collection + * @type {(DynamicInputModel | DynamicTextAreaModel)[]} + */ + formModel: DynamicFormControlModel[] = [ + new DynamicFormGroupModel({ + id: 'oaiSourceContainer', + group: [ + this.oaiSourceModel + ] + }), + new DynamicFormGroupModel({ + id: 'oaiSetContainer', + group: [ + this.oaiSetIdModel, + this.metadataConfigIdModel + ] + }), + new DynamicFormGroupModel({ + id: 'harvestTypeContainer', + group: [ + this.harvestTypeModel + ] + }) + ]; + + /** + * Layout used for structuring the form inputs + */ + formLayout: DynamicFormLayout = { + oaiSource: { + grid: { + host: 'col-12 d-inline-block' + } + }, + oaiSetId: { + grid: { + host: 'col col-sm-6 d-inline-block' + } + }, + metadataConfigId: { + grid: { + host: 'col col-sm-6 d-inline-block' + } + }, + harvestType: { + grid: { + host: 'col-12', + option: 'btn-outline-secondary' + } + }, + oaiSetContainer: { + grid: { + host: 'row' + } + }, + oaiSourceContainer: { + grid: { + host: 'row' + } + }, + harvestTypeContainer: { + grid: { + host: 'row' + } + } + }; + + /** + * The form group of this form + */ + formGroup: FormGroup; + + /** + * Subscription to update the current form + */ + updateSub: Subscription; + + /** + * The content harvesting type used when harvesting is disabled + */ + harvestTypeNone = ContentSourceHarvestType.None; + + /** + * The previously selected harvesting type + * Used for switching between ContentSourceHarvestType.None and the previously selected value when enabling / disabling harvesting + * Defaults to ContentSourceHarvestType.Metadata + */ + previouslySelectedHarvestType = ContentSourceHarvestType.Metadata; + + /** + * Notifications displayed after clicking submit + * These are cleaned up every time a user submits the form to prevent error or other notifications from staying active + * while they shouldn't be. + */ + displayedNotifications: INotification[] = []; + + public constructor(public objectUpdatesService: ObjectUpdatesService, + public notificationsService: NotificationsService, + protected location: Location, + protected formService: DynamicFormService, + protected translate: TranslateService, + protected route: ActivatedRoute, + protected router: Router, + @Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, + protected collectionService: CollectionDataService, + protected requestService: RequestService) { + super(objectUpdatesService, notificationsService, translate); + } + + /** + * Initialize properties to setup the Field Update and Form + */ + ngOnInit(): void { + this.notificationsPrefix = 'collection.edit.tabs.source.notifications.'; + this.discardTimeOut = this.EnvConfig.collection.edit.undoTimeout; + this.url = this.router.url; + if (this.url.indexOf('?') > 0) { + this.url = this.url.substr(0, this.url.indexOf('?')); + } + this.formGroup = this.formService.createFormGroup(this.formModel); + this.collectionRD$ = this.route.parent.data.pipe(first(), map((data) => data.dso)); + + this.collectionRD$.pipe( + getSucceededRemoteData(), + map((col) => col.payload.uuid), + switchMap((uuid) => this.collectionService.getContentSource(uuid)), + take(1) + ).subscribe((contentSource: ContentSource) => { + this.initializeOriginalContentSource(contentSource); + }); + + this.updateFieldTranslations(); + this.translate.onLangChange + .subscribe(() => { + this.updateFieldTranslations(); + }); + } + + /** + * Initialize the Field Update and subscribe on it to fire updates to the form whenever it changes + */ + initializeOriginalContentSource(contentSource: ContentSource) { + this.contentSource = contentSource; + this.initialHarvestType = contentSource.harvestType; + this.initializeMetadataConfigs(); + const initialContentSource = cloneDeep(this.contentSource); + this.objectUpdatesService.initialize(this.url, [initialContentSource], new Date()); + this.update$ = this.objectUpdatesService.getFieldUpdates(this.url, [initialContentSource]).pipe( + map((updates: FieldUpdates) => updates[initialContentSource.uuid]) + ); + this.updateSub = this.update$.subscribe((update: FieldUpdate) => { + if (update) { + const field = update.field as ContentSource; + let configId; + if (hasValue(this.contentSource) && isNotEmpty(this.contentSource.metadataConfigs)) { + configId = this.contentSource.metadataConfigs[0].id; + } + if (hasValue(field) && hasValue(field.metadataConfigId)) { + configId = field.metadataConfigId; + } + if (hasValue(field)) { + this.formGroup.patchValue({ + oaiSourceContainer: { + oaiSource: field.oaiSource + }, + oaiSetContainer: { + oaiSetId: field.oaiSetId, + metadataConfigId: configId + }, + harvestTypeContainer: { + harvestType: field.harvestType + } + }); + this.contentSource = cloneDeep(field); + } + this.contentSource.metadataConfigId = configId; + } + }); + } + + /** + * Fill the metadataConfigIdModel's options using the contentSource's metadataConfigs property + */ + initializeMetadataConfigs() { + this.metadataConfigIdModel.options = this.contentSource.metadataConfigs + .map((metadataConfig: MetadataConfig) => Object.assign({ value: metadataConfig.id, label: metadataConfig.label })); + if (this.metadataConfigIdModel.options.length > 0) { + this.formGroup.patchValue({ + oaiSetContainer: { + metadataConfigId: this.metadataConfigIdModel.options[0].value + } + }); + } + } + + /** + * Used the update translations of errors and labels on init and on language change + */ + private updateFieldTranslations() { + this.inputModels.forEach( + (fieldModel: DynamicFormControlModel) => { + this.updateFieldTranslation(fieldModel); + } + ); + } + + /** + * Update the translations of a DynamicInputModel + * @param fieldModel + */ + private updateFieldTranslation(fieldModel: DynamicFormControlModel) { + fieldModel.label = this.translate.instant(this.LABEL_KEY_PREFIX + fieldModel.id); + if (isNotEmpty(fieldModel.validators)) { + fieldModel.errorMessages = {}; + Object.keys(fieldModel.validators).forEach((key) => { + fieldModel.errorMessages[key] = this.translate.instant(this.ERROR_KEY_PREFIX + fieldModel.id + '.' + key); + }); + } + if (fieldModel instanceof DynamicOptionControlModel) { + if (isNotEmpty(fieldModel.options)) { + fieldModel.options.forEach((option) => { + if (hasNoValue(option.label)) { + option.label = this.translate.instant(this.OPTIONS_KEY_PREFIX + fieldModel.id + '.' + option.value); + } + }); + } + } + } + + /** + * Fired whenever the form receives an update and makes sure the Content Source and field update is up-to-date with the changes + * @param event + */ + onChange(event) { + this.updateContentSourceField(event.model, true); + this.saveFieldUpdate(); + } + + /** + * Submit the edited Content Source to the REST API, re-initialize the field update and display a notification + */ + onSubmit() { + // Remove cached harvester request to allow for latest harvester to be displayed when switching tabs + this.collectionRD$.pipe( + getSucceededRemoteData(), + map((col) => col.payload.uuid), + switchMap((uuid) => this.collectionService.getHarvesterEndpoint(uuid)), + take(1) + ).subscribe((endpoint) => this.requestService.removeByHrefSubstring(endpoint)); + + // Update harvester + this.collectionRD$.pipe( + getSucceededRemoteData(), + map((col) => col.payload.uuid), + switchMap((uuid) => this.collectionService.updateContentSource(uuid, this.contentSource)), + take(1) + ).subscribe((result: ContentSource | INotification) => { + if (hasValue((result as any).harvestType)) { + this.clearNotifications(); + this.initializeOriginalContentSource(result as ContentSource); + this.displayedNotifications.push(this.notificationsService.success(this.getNotificationTitle('saved'), this.getNotificationContent('saved'))); + } else { + this.displayedNotifications.push(result as INotification); + } + }); + } + + /** + * Cancel the edit and return to the previous page + */ + onCancel() { + this.location.back(); + } + + /** + * Is the current form valid to be submitted ? + */ + isValid(): boolean { + return (this.contentSource.harvestType === ContentSourceHarvestType.None) || this.formGroup.valid; + } + + /** + * Switch the external source on or off and fire a field update + */ + changeExternalSource() { + if (this.contentSource.harvestType === ContentSourceHarvestType.None) { + this.contentSource.harvestType = this.previouslySelectedHarvestType; + } else { + this.previouslySelectedHarvestType = this.contentSource.harvestType; + this.contentSource.harvestType = ContentSourceHarvestType.None; + } + this.updateContentSource(false); + } + + /** + * Loop over all inputs and update the Content Source with their value + * @param updateHarvestType When set to false, the harvestType of the contentSource will be ignored in the update + */ + updateContentSource(updateHarvestType: boolean) { + this.inputModels.forEach( + (fieldModel: DynamicInputModel) => { + this.updateContentSourceField(fieldModel, updateHarvestType) + } + ); + this.saveFieldUpdate(); + } + + /** + * Update the Content Source with the value from a DynamicInputModel + * @param fieldModel The fieldModel to fetch the value from and update the contentSource with + * @param updateHarvestType When set to false, the harvestType of the contentSource will be ignored in the update + */ + updateContentSourceField(fieldModel: DynamicInputModel, updateHarvestType: boolean) { + if (hasValue(fieldModel.value) && !(fieldModel.id === this.harvestTypeModel.id && !updateHarvestType)) { + this.contentSource[fieldModel.id] = fieldModel.value; + } + } + + /** + * Save the current Content Source to the Object Updates cache + */ + saveFieldUpdate() { + this.objectUpdatesService.saveAddFieldUpdate(this.url, cloneDeep(this.contentSource)); + } + + /** + * Clear possible active notifications + */ + clearNotifications() { + this.displayedNotifications.forEach((notification: INotification) => { + this.notificationsService.remove(notification); + }); + this.displayedNotifications = []; + } + + /** + * Make sure open subscriptions are closed + */ + ngOnDestroy(): void { + if (this.updateSub) { + this.updateSub.unsubscribe(); + } + } } diff --git a/src/app/core/cache/response.models.ts b/src/app/core/cache/response.models.ts index 3915eca23f..5f4e15e138 100644 --- a/src/app/core/cache/response.models.ts +++ b/src/app/core/cache/response.models.ts @@ -14,6 +14,7 @@ import { DSpaceObject } from '../shared/dspace-object.model'; import { NormalizedAuthStatus } from '../auth/models/normalized-auth-status.model'; import { MetadataSchema } from '../metadata/metadata-schema.model'; import { MetadataField } from '../metadata/metadata-field.model'; +import { ContentSource } from '../shared/content-source.model'; /* tslint:disable:max-classes-per-file */ export class RestResponse { @@ -288,4 +289,17 @@ export class FilteredDiscoveryQueryResponse extends RestResponse { super(true, statusCode, statusText); } } + +/** + * A successful response containing exactly one MetadataSchema + */ +export class ContentSourceSuccessResponse extends RestResponse { + constructor( + public contentsource: ContentSource, + public statusCode: number, + public statusText: string, + ) { + super(true, statusCode, statusText); + } +} /* tslint:enable:max-classes-per-file */ diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index efd83d33d5..811ecacd38 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -119,6 +119,7 @@ import { MetadatafieldParsingService } from './data/metadatafield-parsing.servic import { NormalizedSubmissionUploadsModel } from './config/models/normalized-config-submission-uploads.model'; import { NormalizedBrowseEntry } from './shared/normalized-browse-entry.model'; import { BrowseDefinition } from './shared/browse-definition.model'; +import { ContentSourceResponseParsingService } from './data/content-source-response-parsing.service'; import { MappedCollectionsReponseParsingService } from './data/mapped-collections-reponse-parsing.service'; import { ObjectSelectService } from '../shared/object-select/object-select.service'; import { SiteDataService } from './data/site-data.service'; @@ -244,6 +245,7 @@ const PROVIDERS = [ TaskResponseParsingService, ClaimedTaskDataService, PoolTaskDataService, + ContentSourceResponseParsingService, SearchService, SidebarService, SearchFilterService, diff --git a/src/app/core/data/collection-data.service.spec.ts b/src/app/core/data/collection-data.service.spec.ts index a3e1a916e3..c8f056bf19 100644 --- a/src/app/core/data/collection-data.service.spec.ts +++ b/src/app/core/data/collection-data.service.spec.ts @@ -1,44 +1,132 @@ import { CollectionDataService } from './collection-data.service'; -import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub'; -import { getMockRequestService } from '../../shared/mocks/mock-request.service'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; import { RequestService } from './request.service'; +import { TranslateService } from '@ngx-translate/core'; +import { getMockRequestService } from '../../shared/mocks/mock-request.service'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub'; +import { NotificationsServiceStub } from '../../shared/testing/notifications-service-stub'; +import { getMockTranslateService } from '../../shared/mocks/mock-translate.service'; +import { fakeAsync, tick } from '@angular/core/testing'; +import { ContentSourceRequest, GetRequest, RequestError, UpdateContentSourceRequest } from './request.models'; +import { ContentSource } from '../shared/content-source.model'; +import { of as observableOf } from 'rxjs/internal/observable/of'; +import { RequestEntry } from './request.reducer'; +import { ErrorResponse, RestResponse } from '../cache/response.models'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { GetRequest } from './request.models'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +const url = 'fake-url'; +const collectionId = 'fake-collection-id'; + describe('CollectionDataService', () => { let service: CollectionDataService; - let objectCache: ObjectCacheService; + let requestService: RequestService; - let halService: HALEndpointService; + let translate: TranslateService; + let notificationsService: any; let rdbService: RemoteDataBuildService; + let objectCache: ObjectCacheService; + let halService: any; - const url = 'fake-collections-url'; - - beforeEach(() => { - objectCache = jasmine.createSpyObj('objectCache', { - remove: jasmine.createSpy('remove') + describe('when the requests are successful', () => { + beforeEach(() => { + createService(); }); - requestService = getMockRequestService(); - halService = Object.assign(new HALEndpointServiceStub(url)); + + describe('when calling getContentSource', () => { + let contentSource$; + + beforeEach(() => { + contentSource$ = service.getContentSource(collectionId); + }); + + it('should configure a new ContentSourceRequest', fakeAsync(() => { + contentSource$.subscribe(); + tick(); + expect(requestService.configure).toHaveBeenCalledWith(jasmine.any(ContentSourceRequest)); + })); + }); + + describe('when calling updateContentSource', () => { + let returnedContentSource$; + let contentSource; + + beforeEach(() => { + contentSource = new ContentSource(); + returnedContentSource$ = service.updateContentSource(collectionId, contentSource); + }); + + it('should configure a new UpdateContentSourceRequest', fakeAsync(() => { + returnedContentSource$.subscribe(); + tick(); + expect(requestService.configure).toHaveBeenCalledWith(jasmine.any(UpdateContentSourceRequest)); + })); + }); + + describe('getMappedItems', () => { + let result; + + beforeEach(() => { + result = service.getMappedItems('collection-id'); + }); + + it('should configure a GET request', () => { + expect(requestService.configure).toHaveBeenCalledWith(jasmine.any(GetRequest)); + }); + }); + + }); + + describe('when the requests are unsuccessful', () => { + beforeEach(() => { + createService(observableOf(Object.assign(new RequestEntry(), { + response: new ErrorResponse(Object.assign({ + statusCode: 422, + statusText: 'Unprocessable Entity', + message: 'Error message' + })) + }))); + }); + + describe('when calling updateContentSource', () => { + let returnedContentSource$; + let contentSource; + + beforeEach(() => { + contentSource = new ContentSource(); + returnedContentSource$ = service.updateContentSource(collectionId, contentSource); + }); + + it('should configure a new UpdateContentSourceRequest', fakeAsync(() => { + returnedContentSource$.subscribe(); + tick(); + expect(requestService.configure).toHaveBeenCalledWith(jasmine.any(UpdateContentSourceRequest)); + })); + + it('should display an error notification', fakeAsync(() => { + returnedContentSource$.subscribe(); + tick(); + expect(notificationsService.error).toHaveBeenCalled(); + })); + }); + }); + + /** + * Create a CollectionDataService used for testing + * @param requestEntry$ Supply a requestEntry to be returned by the REST API (optional) + */ + function createService(requestEntry$?) { + requestService = getMockRequestService(requestEntry$); rdbService = jasmine.createSpyObj('rdbService', { buildList: jasmine.createSpy('buildList') }); - - service = new CollectionDataService(requestService, rdbService, null, null, null, objectCache, halService, null, null, null); - }); - - describe('getMappedItems', () => { - let result; - - beforeEach(() => { - result = service.getMappedItems('collection-id'); + objectCache = jasmine.createSpyObj('objectCache', { + remove: jasmine.createSpy('remove') }); + halService = new HALEndpointServiceStub(url); + notificationsService = new NotificationsServiceStub(); + translate = getMockTranslateService(); - it('should configure a GET request', () => { - expect(requestService.configure).toHaveBeenCalledWith(jasmine.any(GetRequest)); - }); - }); + service = new CollectionDataService(requestService, rdbService, null, null, null, objectCache, halService, notificationsService, null, null, translate); + } }); diff --git a/src/app/core/data/collection-data.service.ts b/src/app/core/data/collection-data.service.ts index 0c032e6766..ed05c99e27 100644 --- a/src/app/core/data/collection-data.service.ts +++ b/src/app/core/data/collection-data.service.ts @@ -12,25 +12,45 @@ import { CommunityDataService } from './community-data.service'; import { RequestService } from './request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; +import { HttpClient, HttpHeaders } from '@angular/common/http'; import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; import { Observable } from 'rxjs/internal/Observable'; -import {FindListOptions, FindListRequest, GetRequest} from './request.models'; +import { + ContentSourceRequest, + RestRequest, + UpdateContentSourceRequest, + GetRequest, + FindListOptions +} from './request.models'; import { RemoteData } from './remote-data'; import { PaginatedList } from './paginated-list'; -import { configureRequest } from '../shared/operators'; +import { ContentSource } from '../shared/content-source.model'; +import { + configureRequest, + filterSuccessfulResponses, + getRequestFromRequestHref, + getResponseFromEntry +} from '../shared/operators'; +import { ContentSourceSuccessResponse, RestResponse } from '../cache/response.models'; +import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; +import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; +import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; +import { NotificationOptions } from '../../shared/notifications/models/notification-options.model'; +import { TranslateService } from '@ngx-translate/core'; +import { SearchParam } from '../cache/models/search-param.model'; import { DSOResponseParsingService } from './dso-response-parsing.service'; import { ResponseParsingService } from './parsing.service'; import { GenericConstructor } from '../shared/generic-constructor'; -import { hasValue, isNotEmptyOperator } from '../../shared/empty.util'; import { DSpaceObject } from '../shared/dspace-object.model'; -import { SearchParam } from '../cache/models/search-param.model'; +import { INotification } from '../../shared/notifications/models/notification.model'; import { PaginatedSearchOptions } from '../../shared/search/paginated-search-options.model'; @Injectable() export class CollectionDataService extends ComColDataService { protected linkPath = 'collections'; + protected errorTitle = 'collection.source.update.notifications.error.title'; + protected contentSourceError = 'collection.source.update.notifications.error.content'; constructor( protected requestService: RequestService, @@ -42,7 +62,8 @@ export class CollectionDataService extends ComColDataService { protected halService: HALEndpointService, protected notificationsService: NotificationsService, protected http: HttpClient, - protected comparator: DSOChangeAnalyzer + protected comparator: DSOChangeAnalyzer, + protected translate: TranslateService ) { super(); } @@ -97,6 +118,81 @@ export class CollectionDataService extends ComColDataService { ); } + /** + * Get the endpoint for the collection's content harvester + * @param collectionId + */ + getHarvesterEndpoint(collectionId: string): Observable { + return this.halService.getEndpoint(this.linkPath).pipe( + switchMap((href: string) => this.halService.getEndpoint('harvester', `${href}/${collectionId}`)) + ); + } + + /** + * Get the collection's content harvester + * @param collectionId + */ + getContentSource(collectionId: string): Observable { + return this.getHarvesterEndpoint(collectionId).pipe( + map((href: string) => new ContentSourceRequest(this.requestService.generateRequestId(), href)), + configureRequest(this.requestService), + map((request: RestRequest) => request.href), + getRequestFromRequestHref(this.requestService), + filterSuccessfulResponses(), + map((response: ContentSourceSuccessResponse) => response.contentsource) + ); + } + + /** + * Update the settings of the collection's content harvester + * @param collectionId + * @param contentSource + */ + updateContentSource(collectionId: string, contentSource: ContentSource): Observable { + const requestId = this.requestService.generateRequestId(); + const serializedContentSource = new DSpaceRESTv2Serializer(ContentSource).serialize(contentSource); + const request$ = this.getHarvesterEndpoint(collectionId).pipe( + take(1), + map((href: string) => { + const options: HttpOptions = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Content-Type', 'application/json'); + options.headers = headers; + return new UpdateContentSourceRequest(requestId, href, JSON.stringify(serializedContentSource), options); + }) + ); + + // Execute the post/put request + request$.pipe( + configureRequest(this.requestService) + ).subscribe(); + + // Return updated ContentSource + return this.requestService.getByUUID(requestId).pipe( + getResponseFromEntry(), + map((response: RestResponse) => { + if (!response.isSuccessful) { + if (hasValue((response as any).errorMessage)) { + if (response.statusCode === 422) { + return this.notificationsService.error(this.translate.instant(this.errorTitle), this.translate.instant(this.contentSourceError), new NotificationOptions(-1)); + } else { + return this.notificationsService.error(this.translate.instant(this.errorTitle), (response as any).errorMessage, new NotificationOptions(-1)); + } + } + } else { + return response; + } + }), + isNotEmptyOperator(), + map((response: ContentSourceSuccessResponse | INotification) => { + if (isNotEmpty((response as any).contentsource)) { + return (response as ContentSourceSuccessResponse).contentsource; + } + return response as INotification; + }) + ); + } + /** * Fetches the endpoint used for mapping items to a collection * @param collectionId The id of the collection to map items to diff --git a/src/app/core/data/content-source-response-parsing.service.ts b/src/app/core/data/content-source-response-parsing.service.ts new file mode 100644 index 0000000000..4e0490148b --- /dev/null +++ b/src/app/core/data/content-source-response-parsing.service.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@angular/core'; +import { ResponseParsingService } from './parsing.service'; +import { RestRequest } from './request.models'; +import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; +import { ContentSourceSuccessResponse, RestResponse } from '../cache/response.models'; +import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; +import { ContentSource } from '../shared/content-source.model'; +import { MetadataConfig } from '../shared/metadata-config.model'; + +@Injectable() +/** + * A ResponseParsingService used to parse DSpaceRESTV2Response coming from the REST API to a ContentSource object + * wrapped in a ContentSourceSuccessResponse + */ +export class ContentSourceResponseParsingService implements ResponseParsingService { + + parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { + const payload = data.payload; + + const deserialized = new DSpaceRESTv2Serializer(ContentSource).deserialize(payload); + + let metadataConfigs = []; + if (payload._embedded && payload._embedded.harvestermetadata && payload._embedded.harvestermetadata.configs) { + metadataConfigs = new DSpaceRESTv2Serializer(MetadataConfig).serializeArray(payload._embedded.harvestermetadata.configs); + } + deserialized.metadataConfigs = metadataConfigs; + + return new ContentSourceSuccessResponse(deserialized, data.statusCode, data.statusText); + } + +} diff --git a/src/app/core/data/object-updates/object-updates.service.ts b/src/app/core/data/object-updates/object-updates.service.ts index 08745f9223..3bbb3960b6 100644 --- a/src/app/core/data/object-updates/object-updates.service.ts +++ b/src/app/core/data/object-updates/object-updates.service.ts @@ -93,14 +93,16 @@ export class ObjectUpdatesService { const objectUpdates = this.getObjectEntry(url); return objectUpdates.pipe(map((objectEntry) => { const fieldUpdates: FieldUpdates = {}; - Object.keys(objectEntry.fieldStates).forEach((uuid) => { - 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; - }); + if (hasValue(objectEntry)) { + Object.keys(objectEntry.fieldStates).forEach((uuid) => { + 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; })) } diff --git a/src/app/core/data/request.models.ts b/src/app/core/data/request.models.ts index ca864f99de..2305fc2d5d 100644 --- a/src/app/core/data/request.models.ts +++ b/src/app/core/data/request.models.ts @@ -18,6 +18,7 @@ import { MetadataschemaParsingService } from './metadataschema-parsing.service'; import { MetadatafieldParsingService } from './metadatafield-parsing.service'; import { URLCombiner } from '../url-combiner/url-combiner'; import { TaskResponseParsingService } from '../tasks/task-response-parsing.service'; +import { ContentSourceResponseParsingService } from './content-source-response-parsing.service'; import { MappedCollectionsReponseParsingService } from './mapped-collections-reponse-parsing.service'; /* tslint:disable:max-classes-per-file */ @@ -378,6 +379,26 @@ export class CreateRequest extends PostRequest { } } +export class ContentSourceRequest extends GetRequest { + constructor(uuid: string, href: string) { + super(uuid, href); + } + + getResponseParser(): GenericConstructor { + return ContentSourceResponseParsingService; + } +} + +export class UpdateContentSourceRequest extends PutRequest { + constructor(uuid: string, href: string, public body?: any, public options?: HttpOptions) { + super(uuid, href, body, options); + } + + getResponseParser(): GenericConstructor { + return ContentSourceResponseParsingService; + } +} + /** * Request to delete an object based on its identifier */ diff --git a/src/app/core/shared/content-source.model.ts b/src/app/core/shared/content-source.model.ts new file mode 100644 index 0000000000..cd53c2d81e --- /dev/null +++ b/src/app/core/shared/content-source.model.ts @@ -0,0 +1,60 @@ +import { autoserialize, autoserializeAs, deserializeAs, deserialize } from 'cerialize'; +import { MetadataConfig } from './metadata-config.model'; + +/** + * The type of content harvesting used + */ +export enum ContentSourceHarvestType { + None = 'NONE', + Metadata = 'METADATA_ONLY', + MetadataAndRef = 'METADATA_AND_REF', + MetadataAndBitstreams = 'METADATA_AND_BITSTREAMS' +} + +/** + * A model class that holds information about the Content Source of a Collection + */ +export class ContentSource { + /** + * Unique identifier, this is necessary to store the ContentSource in FieldUpdates + * Because the ContentSource coming from the REST API doesn't have a UUID, we're using the selflink + */ + @deserializeAs('self') + uuid: string; + + /** + * OAI Provider / Source + */ + @autoserializeAs('oai_source') + oaiSource: string; + + /** + * OAI Specific set ID + */ + @autoserializeAs('oai_set_id') + oaiSetId: string; + + /** + * The ID of the metadata format used + */ + @autoserializeAs('metadata_config_id') + metadataConfigId: string; + + /** + * Type of content being harvested + * Defaults to 'NONE', meaning the collection doesn't harvest its content from an external source + */ + @autoserializeAs('harvest_type') + harvestType = ContentSourceHarvestType.None; + + /** + * The available metadata configurations + */ + metadataConfigs: MetadataConfig[]; + + /** + * The REST link to itself + */ + @deserialize + self: string; +} diff --git a/src/app/core/shared/metadata-config.model.ts b/src/app/core/shared/metadata-config.model.ts new file mode 100644 index 0000000000..861d04586e --- /dev/null +++ b/src/app/core/shared/metadata-config.model.ts @@ -0,0 +1,19 @@ +/** + * A model class that holds information about a certain metadata configuration + */ +export class MetadataConfig { + /** + * A unique indentifier + */ + id: string; + + /** + * The label used for display + */ + label: string; + + /** + * The namespace of the metadata + */ + nameSpace: string; +} diff --git a/src/app/shared/comcol-forms/edit-comcol-page/edit-comcol-page.component.ts b/src/app/shared/comcol-forms/edit-comcol-page/edit-comcol-page.component.ts index 0f9d4c55b4..2fa05fa28b 100644 --- a/src/app/shared/comcol-forms/edit-comcol-page/edit-comcol-page.component.ts +++ b/src/app/shared/comcol-forms/edit-comcol-page/edit-comcol-page.component.ts @@ -2,10 +2,8 @@ import { Component, OnInit } from '@angular/core'; import { Observable } from 'rxjs'; import { ActivatedRoute, Router } from '@angular/router'; import { RemoteData } from '../../../core/data/remote-data'; -import { isNotEmpty, isNotUndefined } from '../../empty.util'; +import { isNotEmpty } from '../../empty.util'; import { first, map } from 'rxjs/operators'; -import { getSucceededRemoteData } from '../../../core/shared/operators'; -import { DataService } from '../../../core/data/data.service'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; /** diff --git a/src/app/shared/trackable/abstract-trackable.component.ts b/src/app/shared/trackable/abstract-trackable.component.ts index cd1b425f10..bb1f4b31b4 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 */ - private getNotificationTitle(key: string) { + protected getNotificationTitle(key: string) { return this.translateService.instant(this.notificationsPrefix + key + '.title'); } @@ -71,7 +71,7 @@ export class AbstractTrackableComponent { * Get translated notification content * @param key */ - private getNotificationContent(key: string) { + protected getNotificationContent(key: string) { return this.translateService.instant(this.notificationsPrefix + key + '.content'); } diff --git a/src/config/collection-page-config.interface.ts b/src/config/collection-page-config.interface.ts new file mode 100644 index 0000000000..b0103fd176 --- /dev/null +++ b/src/config/collection-page-config.interface.ts @@ -0,0 +1,7 @@ +import { Config } from './config.interface'; + +export interface CollectionPageConfig extends Config { + edit: { + undoTimeout: number; + } +} diff --git a/src/config/global-config.interface.ts b/src/config/global-config.interface.ts index 22b4b0500f..dec23ff676 100644 --- a/src/config/global-config.interface.ts +++ b/src/config/global-config.interface.ts @@ -8,6 +8,7 @@ import { FormConfig } from './form-config.interfaces'; import {LangConfig} from './lang-config.interface'; 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'; export interface GlobalConfig extends Config { @@ -26,5 +27,6 @@ export interface GlobalConfig extends Config { languages: LangConfig[]; browseBy: BrowseByConfig; item: ItemPageConfig; + collection: CollectionPageConfig; themes: Theme[]; }