From 8188da0c7587ad15b59488cd86395c77d2b0d0cd Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Thu, 11 Jul 2019 13:24:24 +0200 Subject: [PATCH 01/70] 63669: Intermediate commit --- .../collection-source.component.html | 7 +++++ .../collection-source.component.ts | 28 +++++++++++++++++-- 2 files changed, 33 insertions(+), 2 deletions(-) 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..5837a9ed72 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,7 @@ +

Content source

+
+ + +
+

Configure an external source

+ 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..7e167805c1 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,6 @@ import { Component } from '@angular/core'; +import { AbstractTrackableComponent } from '../../../shared/trackable/abstract-trackable.component'; +import { DynamicFormControlModel, DynamicInputModel, DynamicTextAreaModel } from '@ng-dynamic-forms/core'; /** * Component for managing the content source of the collection @@ -7,6 +9,28 @@ 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 { + externalSource = false; + + /** + * The dynamic form fields used for creating/editing a collection + * @type {(DynamicInputModel | DynamicTextAreaModel)[]} + */ + formModel: DynamicFormControlModel[] = [ + new DynamicInputModel({ + id: 'title', + name: 'dc.title', + required: true, + validators: { + required: null + }, + errorMessages: { + required: 'Please enter a name for this title' + }, + }), + new DynamicTextAreaModel({ + id: 'provider', + name: 'provider', + }) + ]; } From 03887e7c41d8e23e11b2f54734b982a08af13f77 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Thu, 11 Jul 2019 17:57:54 +0200 Subject: [PATCH 02/70] 63669: Intermediate commit --- resources/i18n/en.json | 15 +- .../collection-source.component.html | 21 +- .../collection-source.component.ts | 250 ++++++++++++++++-- src/app/core/shared/content-source.model.ts | 19 ++ 4 files changed, 280 insertions(+), 25 deletions(-) create mode 100644 src/app/core/shared/content-source.model.ts diff --git a/resources/i18n/en.json b/resources/i18n/en.json index 76e925f1a2..2aeac293e4 100644 --- a/resources/i18n/en.json +++ b/resources/i18n/en.json @@ -42,7 +42,20 @@ }, "source": { "title": "Collection Edit - Content Source", - "head": "Content Source" + "head": "Content Source", + "external": "This collection harvests its content from an external source", + "form": { + "head": "Configure an external source", + "provider": "OAI Provider", + "set": "OAI specific set id", + "format": "Metadata Format", + "harvest": "Content being harvested", + "errors": { + "provider": { + "required": "You must provide a set id of the target collection." + } + } + } }, "curate": { "title": "Collection Edit - Curate", 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 5837a9ed72..51a652056b 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 @@ -1,7 +1,16 @@ -

Content source

-
- - +
+

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

+
+ + +
+

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

-

Configure an external source

- + 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 7e167805c1..cdff87c6ac 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,6 +1,23 @@ -import { Component } from '@angular/core'; +import { Component, OnInit } from '@angular/core'; import { AbstractTrackableComponent } from '../../../shared/trackable/abstract-trackable.component'; -import { DynamicFormControlModel, DynamicInputModel, DynamicTextAreaModel } from '@ng-dynamic-forms/core'; +import { + DynamicFormControlModel, DynamicFormGroupModel, DynamicFormLayout, DynamicFormService, + DynamicInputModel, 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 { isNotEmpty } from '../../../shared/empty.util'; +import { ContentSource } 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 } from 'rxjs/operators'; +import { ActivatedRoute } from '@angular/router'; /** * Component for managing the content source of the collection @@ -9,28 +26,225 @@ import { DynamicFormControlModel, DynamicInputModel, DynamicTextAreaModel } from selector: 'ds-collection-source', templateUrl: './collection-source.component.html', }) -export class CollectionSourceComponent extends AbstractTrackableComponent { - externalSource = false; +export class CollectionSourceComponent extends AbstractTrackableComponent implements OnInit { + /** + * The current collection's remote data + */ + collectionRD$: Observable>; /** - * The dynamic form fields used for creating/editing a collection + * The current collection's content source + */ + contentSource: ContentSource; + + /** + * @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.'; + + /** + * The dynamic form fields used for editing the content source of a collection * @type {(DynamicInputModel | DynamicTextAreaModel)[]} */ formModel: DynamicFormControlModel[] = [ - new DynamicInputModel({ - id: 'title', - name: 'dc.title', - required: true, - validators: { - required: null - }, - errorMessages: { - required: 'Please enter a name for this title' - }, + new DynamicFormGroupModel({ + id: 'providerContainer', + group: [ + new DynamicInputModel({ + id: 'provider', + name: 'provider', + required: true, + validators: { + required: null + }, + errorMessages: { + required: 'You must provide a set id of the target collection.' + }, + disabled: true + }) + ] }), - new DynamicTextAreaModel({ - id: 'provider', - name: 'provider', + new DynamicFormGroupModel({ + id: 'setContainer', + group: [ + new DynamicInputModel({ + id: 'set', + name: 'set', + disabled: true + }), + new DynamicSelectModel({ + id: 'format', + name: 'format', + value: 'dc', + options: [ + { + value: 'dc', + label: 'Simple Dublin Core' + }, + { + value: 'qdc', + label: 'Qualified Dublin Core' + }, + { + value: 'dim', + label: 'DSpace Intermediate Metadata' + } + ], + disabled: true + }) + ] + }), + new DynamicFormGroupModel({ + id: 'harvestContainer', + group: [ + new DynamicRadioGroupModel({ + id: 'harvest', + name: 'harvest', + value: 3, + options: [ + { + value: 1, + label: 'Harvest metadata only.' + }, + { + value: 2, + label: 'Harvest metadata and references to bitstreams (requires ORE support).' + }, + { + value: 3, + label: 'Harvest metadata and bitstreams (requires ORE support).' + } + ] + }) + ] }) ]; + + /** + * Layout used for structuring the form inputs + */ + formLayout: DynamicFormLayout = { + provider: { + grid: { + host: 'col-12 d-inline-block' + } + }, + set: { + grid: { + host: 'col col-sm-6 d-inline-block' + } + }, + format: { + grid: { + host: 'col col-sm-6 d-inline-block' + } + }, + harvest: { + grid: { + host: 'col-12' + } + }, + setContainer: { + grid: { + host: 'row' + } + }, + providerContainer: { + grid: { + host: 'row' + } + }, + harvestContainer: { + grid: { + host: 'row' + } + } + }; + + /** + * The form group of this form + */ + formGroup: FormGroup; + + public constructor(public objectUpdatesService: ObjectUpdatesService, + public notificationsService: NotificationsService, + protected location: Location, + protected formService: DynamicFormService, + protected translate: TranslateService, + protected route: ActivatedRoute) { + super(objectUpdatesService, notificationsService, translate); + } + + ngOnInit(): void { + this.formGroup = this.formService.createFormGroup(this.formModel); + this.collectionRD$ = this.route.parent.data.pipe(first(), map((data) => data.dso)); + + /* Create a new ContentSource object - TODO: Update to be fetched from the collection */ + this.contentSource = new ContentSource(); + + this.updateFieldTranslations(); + this.translate.onLangChange + .subscribe(() => { + this.updateFieldTranslations(); + }); + } + + /** + * Used the update translations of errors and labels on init and on language change + */ + private updateFieldTranslations() { + this.formModel.forEach( + (fieldModel: DynamicFormControlModel) => { + if (fieldModel instanceof DynamicFormGroupModel) { + fieldModel.group.forEach((childModel: DynamicFormControlModel) => { + this.updateFieldTranslation(childModel); + }); + } else { + 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); + }); + } + } + + onChange(event) { + // TODO: Update ContentSource object and add to field update + console.log(event); + } + + onSubmit() { + // TODO: Fetch field update and send to REST API + console.log('submit'); + } + + onCancel() { + this.location.back(); + } + + changeExternalSource() { + this.contentSource.enabled = !this.contentSource.enabled; + if (this.contentSource.enabled) { + this.formGroup.enable(); + } else { + this.formGroup.disable(); + } + } } 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..10f0986b1b --- /dev/null +++ b/src/app/core/shared/content-source.model.ts @@ -0,0 +1,19 @@ +import { v4 as uuid } from 'uuid'; + +export class ContentSource { + uuid: string; + + enabled = false; + + provider: string; + + set: string; + + format: string; + + harvestType: number; + + constructor() { + this.uuid = uuid(); + } +} From 615a47fe5bbada324184b01f5f7e312b9e1c3de8 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Fri, 12 Jul 2019 13:34:40 +0200 Subject: [PATCH 03/70] 63669: Content-Source integrated with FieldUpdate --- config/environment.default.js | 5 + resources/i18n/en.json | 14 +++ .../collection-source.component.html | 39 +++++++ .../collection-source.component.ts | 106 ++++++++++++++++-- src/app/core/shared/content-source.model.ts | 22 +++- .../trackable/abstract-trackable.component.ts | 4 +- .../collection-page-config.interface.ts | 7 ++ src/config/global-config.interface.ts | 2 + 8 files changed, 184 insertions(+), 15 deletions(-) create mode 100644 src/config/collection-page-config.interface.ts diff --git a/config/environment.default.js b/config/environment.default.js index 804d80b0f2..ce79909515 100644 --- a/config/environment.default.js +++ b/config/environment.default.js @@ -155,5 +155,10 @@ module.exports = { edit: { undoTimeout: 10000 // 10 seconds } + }, + collection: { + edit: { + undoTimeout: 10000 // 10 seconds + } } }; diff --git a/resources/i18n/en.json b/resources/i18n/en.json index 2aeac293e4..ef688cf661 100644 --- a/resources/i18n/en.json +++ b/resources/i18n/en.json @@ -55,6 +55,20 @@ "required": "You must provide a set id of the target collection." } } + }, + "notifications": { + "discarded": { + "title": "Changed discarded", + "content": "Your changes were discarded. To reinstate your changes click the 'Undo' button" + }, + "invalid": { + "title": "Metadata invalid", + "content": "Your changes were not saved. Please make sure all fields are valid before you save." + }, + "saved": { + "title": "Content Source saved", + "content": "Your changes to this collection's content source were saved." + } } }, "curate": { 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 51a652056b..2017f172c5 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 @@ -1,4 +1,22 @@
+
+ + + +

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

@@ -11,6 +29,27 @@ [formGroup]="formGroup" [formModel]="formModel" [formLayout]="formLayout" + [displaySubmit]="false" (dfChange)="onChange($event)" (submitForm)="onSubmit()" (cancel)="onCancel()"> +
+
+ + + +
+
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 cdff87c6ac..a9696a23f0 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,4 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, Inject, OnDestroy, OnInit } from '@angular/core'; import { AbstractTrackableComponent } from '../../../shared/trackable/abstract-trackable.component'; import { DynamicFormControlModel, DynamicFormGroupModel, DynamicFormLayout, DynamicFormService, @@ -11,13 +11,17 @@ 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 { isNotEmpty } from '../../../shared/empty.util'; +import { hasValue, isNotEmpty } from '../../../shared/empty.util'; import { ContentSource } 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 } from 'rxjs/operators'; -import { ActivatedRoute } from '@angular/router'; +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'; /** * Component for managing the content source of the collection @@ -26,17 +30,22 @@ import { ActivatedRoute } from '@angular/router'; selector: 'ds-collection-source', templateUrl: './collection-source.component.html', }) -export class CollectionSourceComponent extends AbstractTrackableComponent implements OnInit { +export class CollectionSourceComponent extends AbstractTrackableComponent implements OnInit, OnDestroy { /** * The current collection's remote data */ collectionRD$: Observable>; /** - * The current collection's content source + * The collection's content source */ contentSource: ContentSource; + /** + * The current update to the content source + */ + update$: Observable; + /** * @type {string} Key prefix used to generate form labels */ @@ -80,7 +89,6 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem new DynamicSelectModel({ id: 'format', name: 'format', - value: 'dc', options: [ { value: 'dc', @@ -105,7 +113,6 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem new DynamicRadioGroupModel({ id: 'harvest', name: 'harvest', - value: 3, options: [ { value: 1, @@ -171,16 +178,29 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem */ formGroup: FormGroup; + /** + * Subscription to update the current form + */ + updateSub: Subscription; + public constructor(public objectUpdatesService: ObjectUpdatesService, public notificationsService: NotificationsService, protected location: Location, protected formService: DynamicFormService, protected translate: TranslateService, - protected route: ActivatedRoute) { + protected route: ActivatedRoute, + protected router: Router, + @Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig,) { super(objectUpdatesService, notificationsService, translate); } 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)); @@ -192,6 +212,33 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem .subscribe(() => { this.updateFieldTranslations(); }); + + this.initializeOriginalContentSource(); + } + + initializeOriginalContentSource() { + 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) => { + const field = update.field as ContentSource; + this.formGroup.patchValue({ + providerContainer: { + provider: field.provider + }, + setContainer: { + set: field.set, + format: field.format + }, + harvestContainer: { + harvest: field.harvest + } + }); + this.contentSource = cloneDeep(field); + this.switchEnableForm(); + }); } /** @@ -226,13 +273,14 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem } onChange(event) { - // TODO: Update ContentSource object and add to field update - console.log(event); + this.updateContentSourceField(event.model); + this.saveFieldUpdate(); } onSubmit() { // TODO: Fetch field update and send to REST API - console.log('submit'); + this.initializeOriginalContentSource(); + this.notificationsService.success(this.getNotificationTitle('saved'), this.getNotificationContent('saved')); } onCancel() { @@ -241,10 +289,46 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem changeExternalSource() { this.contentSource.enabled = !this.contentSource.enabled; + this.updateContentSource(); + } + + /** + * Enable or disable the form depending on the Content Source's enabled property + */ + switchEnableForm() { if (this.contentSource.enabled) { this.formGroup.enable(); } else { this.formGroup.disable(); } } + + updateContentSource() { + this.formModel.forEach( + (fieldModel: DynamicFormGroupModel | DynamicInputModel) => { + if (fieldModel instanceof DynamicFormGroupModel) { + fieldModel.group.forEach((childModel: DynamicInputModel) => { + this.updateContentSourceField(childModel); + }); + } else { + this.updateContentSourceField(fieldModel); + } + } + ); + this.saveFieldUpdate(); + } + + updateContentSourceField(fieldModel: DynamicInputModel) { + if (hasValue(fieldModel)) { + this.contentSource[fieldModel.id] = fieldModel.value; + } + } + + saveFieldUpdate() { + this.objectUpdatesService.saveAddFieldUpdate(this.url, cloneDeep(this.contentSource)); + } + + ngOnDestroy(): void { + this.updateSub.unsubscribe(); + } } diff --git a/src/app/core/shared/content-source.model.ts b/src/app/core/shared/content-source.model.ts index 10f0986b1b..4c523b80e1 100644 --- a/src/app/core/shared/content-source.model.ts +++ b/src/app/core/shared/content-source.model.ts @@ -1,17 +1,35 @@ import { v4 as uuid } from 'uuid'; export class ContentSource { + /** + * Unique identifier + */ uuid: string; + /** + * Does this collection harvest its content from an external source ? + */ enabled = false; + /** + * OAI Provider + */ provider: string; + /** + * OAI Specific set ID + */ set: string; - format: string; + /** + * Metadata Format + */ + format = 'dc'; - harvestType: number; + /** + * Type of content being harvested + */ + harvest = 3; constructor() { this.uuid = uuid(); 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 d83ec6e4d8..e2409ec18b 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'; export interface GlobalConfig extends Config { ui: ServerConfig; @@ -25,4 +26,5 @@ export interface GlobalConfig extends Config { languages: LangConfig[]; browseBy: BrowseByConfig; item: ItemPageConfig; + collection: CollectionPageConfig; } From 7774c3c83c2fac48ca0e1cf1f5d64ea61f0a26b2 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Fri, 12 Jul 2019 15:11:38 +0200 Subject: [PATCH 04/70] 63669: Content Source valid check + JSDocs --- .../collection-source.component.html | 4 +- .../collection-source.component.ts | 41 ++++++++++++++++++- src/app/core/shared/content-source.model.ts | 4 ++ 3 files changed, 46 insertions(+), 3 deletions(-) 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 2017f172c5..71f03e2a9f 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 @@ -11,7 +11,7 @@ class="fas fa-undo-alt">  {{"item.edit.metadata.reinstate-button" | translate}} - -
-

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

+

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

- -
+
- - + +
+
+ + + +
+

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

+
+ + +
+

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

-

{{ '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.ts b/src/app/+collection-page/edit-collection-page/collection-source/collection-source.component.ts index 70b268d9b7..932092a4a4 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,8 +1,13 @@ import { Component, Inject, OnDestroy, OnInit } from '@angular/core'; import { AbstractTrackableComponent } from '../../../shared/trackable/abstract-trackable.component'; import { - DynamicFormControlModel, DynamicFormGroupModel, DynamicFormLayout, DynamicFormService, - DynamicInputModel, DynamicOptionControlModel, DynamicRadioGroupModel, + DynamicFormControlModel, + DynamicFormGroupModel, + DynamicFormLayout, + DynamicFormService, + DynamicInputModel, + DynamicOptionControlModel, + DynamicRadioGroupModel, DynamicSelectModel, DynamicTextAreaModel } from '@ng-dynamic-forms/core'; @@ -12,16 +17,18 @@ import { ObjectUpdatesService } from '../../../core/data/object-updates/object-u import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { FormGroup } from '@angular/forms'; import { hasValue, isNotEmpty } from '../../../shared/empty.util'; -import { ContentSource } from '../../../core/shared/content-source.model'; +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 } from 'rxjs/operators'; +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'; /** * Component for managing the content source of the collection @@ -64,9 +71,9 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem /** * The Dynamic Input Model for the OAI Provider */ - providerModel = new DynamicInputModel({ - id: 'provider', - name: 'provider', + oaiSourceModel = new DynamicInputModel({ + id: 'oaiSource', + name: 'oaiSource', required: true, validators: { required: null @@ -79,17 +86,17 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem /** * The Dynamic Input Model for the OAI Set */ - setModel = new DynamicInputModel({ - id: 'set', - name: 'set' + oaiSetIdModel = new DynamicInputModel({ + id: 'oaiSetId', + name: 'oaiSetId' }); /** * The Dynamic Input Model for the Metadata Format used */ - formatModel = new DynamicSelectModel({ - id: 'format', - name: 'format', + metadataConfigIdModel = new DynamicSelectModel({ + id: 'metadataConfigId', + name: 'metadataConfigId', options: [ { value: 'dc' @@ -100,24 +107,25 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem { value: 'dim' } - ] + ], + value: 'dc' }); /** * The Dynamic Input Model for the type of harvesting */ - harvestModel = new DynamicRadioGroupModel({ - id: 'harvest', - name: 'harvest', + harvestTypeModel = new DynamicRadioGroupModel({ + id: 'harvestType', + name: 'harvestType', options: [ { - value: 1 + value: ContentSourceHarvestType.Metadata }, { - value: 2 + value: ContentSourceHarvestType.MetadataAndRef }, { - value: 3 + value: ContentSourceHarvestType.MetadataAndBitstreams } ] }); @@ -125,7 +133,7 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem /** * All input models in a simple array for easier iterations */ - inputModels = [this.providerModel, this.setModel, this.formatModel, this.harvestModel]; + inputModels = [this.oaiSourceModel, this.oaiSetIdModel, this.metadataConfigIdModel, this.harvestTypeModel]; /** * The dynamic form fields used for editing the content source of a collection @@ -133,22 +141,22 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem */ formModel: DynamicFormControlModel[] = [ new DynamicFormGroupModel({ - id: 'providerContainer', + id: 'oaiSourceContainer', group: [ - this.providerModel + this.oaiSourceModel ] }), new DynamicFormGroupModel({ - id: 'setContainer', + id: 'oaiSetContainer', group: [ - this.setModel, - this.formatModel + this.oaiSetIdModel, + this.metadataConfigIdModel ] }), new DynamicFormGroupModel({ - id: 'harvestContainer', + id: 'harvestTypeContainer', group: [ - this.harvestModel + this.harvestTypeModel ] }) ]; @@ -157,38 +165,38 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem * Layout used for structuring the form inputs */ formLayout: DynamicFormLayout = { - provider: { + oaiSource: { grid: { host: 'col-12 d-inline-block' } }, - set: { + oaiSetId: { grid: { host: 'col col-sm-6 d-inline-block' } }, - format: { + metadataConfigId: { grid: { host: 'col col-sm-6 d-inline-block' } }, - harvest: { + harvestType: { grid: { host: 'col-12', option: 'btn-outline-secondary' } }, - setContainer: { + oaiSetContainer: { grid: { host: 'row' } }, - providerContainer: { + oaiSourceContainer: { grid: { host: 'row' } }, - harvestContainer: { + harvestTypeContainer: { grid: { host: 'row' } @@ -205,6 +213,8 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem */ updateSub: Subscription; + harvestTypeNone = ContentSourceHarvestType.None; + public constructor(public objectUpdatesService: ObjectUpdatesService, public notificationsService: NotificationsService, protected location: Location, @@ -212,7 +222,8 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem protected translate: TranslateService, protected route: ActivatedRoute, protected router: Router, - @Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig,) { + @Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, + protected collectionService: CollectionDataService) { super(objectUpdatesService, notificationsService, translate); } @@ -229,16 +240,21 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem this.formGroup = this.formService.createFormGroup(this.formModel); this.collectionRD$ = this.route.parent.data.pipe(first(), map((data) => data.dso)); - /* Create a new ContentSource object - TODO: Update to be fetched from the collection */ - this.contentSource = new ContentSource(); + this.collectionRD$.pipe( + getSucceededRemoteData(), + map((col) => col.payload.uuid), + switchMap((uuid) => this.collectionService.getContentSource(uuid)), + take(1) + ).subscribe((contentSource: ContentSource) => { + this.contentSource = contentSource; + this.initializeOriginalContentSource(); + }); this.updateFieldTranslations(); this.translate.onLangChange .subscribe(() => { this.updateFieldTranslations(); }); - - this.initializeOriginalContentSource(); } /** @@ -254,15 +270,15 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem if (update) { const field = update.field as ContentSource; this.formGroup.patchValue({ - providerContainer: { - provider: field.provider + oaiSourceContainer: { + oaiSource: field.oaiSource }, - setContainer: { - set: field.set, - format: field.format + oaiSetContainer: { + oaiSetId: field.oaiSetId, + metadataConfigId: field.metadataConfigId }, - harvestContainer: { - harvest: field.harvest + harvestTypeContainer: { + harvestType: field.harvestType } }); this.contentSource = cloneDeep(field); @@ -307,7 +323,7 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem * @param event */ onChange(event) { - this.updateContentSourceField(event.model); + this.updateContentSourceField(event.model, true); this.saveFieldUpdate(); } @@ -331,24 +347,28 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem * Is the current form valid to be submitted ? */ isValid(): boolean { - return !this.contentSource.enabled || this.formGroup.valid; + return (this.contentSource.harvestType === ContentSourceHarvestType.None) || this.formGroup.valid; } /** * Switch the external source on or off and fire a field update */ changeExternalSource() { - this.contentSource.enabled = !this.contentSource.enabled; - this.updateContentSource(); + if (this.contentSource.harvestType === ContentSourceHarvestType.None) { + this.contentSource.harvestType = ContentSourceHarvestType.Metadata; + } else { + this.contentSource.harvestType = ContentSourceHarvestType.None; + } + this.updateContentSource(false); } /** * Loop over all inputs and update the Content Source with their value */ - updateContentSource() { + updateContentSource(updateHarvestType: boolean) { this.inputModels.forEach( (fieldModel: DynamicInputModel) => { - this.updateContentSourceField(fieldModel) + this.updateContentSourceField(fieldModel, updateHarvestType) } ); this.saveFieldUpdate(); @@ -358,8 +378,8 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem * Update the Content Source with the value from a DynamicInputModel * @param fieldModel */ - updateContentSourceField(fieldModel: DynamicInputModel) { - if (hasValue(fieldModel.value)) { + updateContentSourceField(fieldModel: DynamicInputModel, updateHarvestType: boolean) { + if (hasValue(fieldModel.value) && !(fieldModel.id === this.harvestTypeModel.id && !updateHarvestType)) { this.contentSource[fieldModel.id] = fieldModel.value; } } diff --git a/src/app/core/cache/response.models.ts b/src/app/core/cache/response.models.ts index 03233e616b..c9934d6abe 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 1d22b5fefe..fe46bf88aa 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -117,6 +117,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'; const IMPORTS = [ CommonModule, @@ -203,6 +204,7 @@ const PROVIDERS = [ TaskResponseParsingService, ClaimedTaskDataService, PoolTaskDataService, + ContentSourceResponseParsingService, // register AuthInterceptor as HttpInterceptor { provide: HTTP_INTERCEPTORS, diff --git a/src/app/core/data/collection-data.service.ts b/src/app/core/data/collection-data.service.ts index 993954a360..1a59498a72 100644 --- a/src/app/core/data/collection-data.service.ts +++ b/src/app/core/data/collection-data.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core'; -import { filter, map, take } from 'rxjs/operators'; +import { filter, map, take, tap } from 'rxjs/operators'; import { Store } from '@ngrx/store'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; @@ -16,9 +16,12 @@ import { HttpClient } 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 { FindAllOptions } from './request.models'; +import { ContentSourceRequest, FindAllOptions, RestRequest } from './request.models'; import { RemoteData } from './remote-data'; import { PaginatedList } from './paginated-list'; +import { ContentSource } from '../shared/content-source.model'; +import { configureRequest, filterSuccessfulResponses, getRequestFromRequestHref } from '../shared/operators'; +import { ContentSourceSuccessResponse } from '../cache/response.models'; @Injectable() export class CollectionDataService extends ComColDataService { @@ -57,4 +60,21 @@ export class CollectionDataService extends ComColDataService { map((collections: RemoteData>) => collections.payload.totalElements > 0) ); } + + getHarvesterEndpoint(collectionId: string): Observable { + return this.halService.getEndpoint(this.linkPath).pipe( + map((href: string) => `${href}/${collectionId}/harvester`) + ); + } + + 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) + ); + } } 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..34e9360dd7 --- /dev/null +++ b/src/app/core/data/content-source-response-parsing.service.ts @@ -0,0 +1,19 @@ +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'; + +@Injectable() +export class ContentSourceResponseParsingService implements ResponseParsingService { + + parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { + const payload = data.payload; + + const deserialized = new DSpaceRESTv2Serializer(ContentSource).deserialize(payload); + 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 22d5fd3e77..2b96cb378a 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 a2b3423960..eab7af5751 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'; /* tslint:disable:max-classes-per-file */ @@ -358,6 +359,16 @@ export class CreateRequest extends PostRequest { } } +export class ContentSourceRequest extends GetRequest { + constructor(uuid: string, href: string) { + super(uuid, href); + } + + 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 index c6d6c03fab..68db549adb 100644 --- a/src/app/core/shared/content-source.model.ts +++ b/src/app/core/shared/content-source.model.ts @@ -1,41 +1,51 @@ -import { v4 as uuid } from 'uuid'; +import { autoserialize, autoserializeAs } from 'cerialize'; + +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 + * 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 */ + @autoserializeAs('self') uuid: string; /** - * Does this collection harvest its content from an external source ? + * OAI Provider / Source */ - enabled = false; - - /** - * OAI Provider - */ - provider: string; + @autoserializeAs('oai_source') + oaiSource: string; /** * OAI Specific set ID */ - set: string; + @autoserializeAs('oai_set_id') + oaiSetId: string; /** - * Metadata Format + * The ID of the metadata format used */ - format = 'dc'; + @autoserializeAs('metadata_config_id') + metadataConfigId = 'dc'; /** * Type of content being harvested + * Defaults to 'NONE', meaning the collection doesn't harvest its content from an external source */ - harvest = 3; + @autoserializeAs('harvest_type') + harvestType = ContentSourceHarvestType.None; - constructor() { - // TODO: Remove this once the Content Source is fetched from the REST API and a custom generated UUID is not necessary anymore - this.uuid = uuid(); - } + /** + * The REST link to itself + */ + @autoserialize + self: string; } From 1b9bc2f7a3fc6f03e18a1ffe15fc8be297f88ba5 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Tue, 20 Aug 2019 17:28:28 +0200 Subject: [PATCH 11/70] 64503: Content Source PUT request on form submit --- resources/i18n/en.json | 2 + .../collection-source.component.ts | 13 +++- src/app/core/data/collection-data.service.ts | 67 +++++++++++++++++-- src/app/core/data/request.models.ts | 10 +++ src/app/core/shared/content-source.model.ts | 6 +- 5 files changed, 87 insertions(+), 11 deletions(-) diff --git a/resources/i18n/en.json b/resources/i18n/en.json index 41a4bc3f29..0fb6fd4229 100644 --- a/resources/i18n/en.json +++ b/resources/i18n/en.json @@ -131,6 +131,8 @@ "collection.page.browse.recent.head": "Recent Submissions", "collection.page.license": "License", "collection.page.news": "News", + "collection.source.update.notifications.error.content": "The provided settings have been tested and didn't work.", + "collection.source.update.notifications.error.title": "Server Error", "community.create.head": "Create a Community", "community.create.sub-head": "Create a Sub-Community for Community {{ parent }}", "community.delete.cancel": "Cancel", 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 932092a4a4..92dba10ce8 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 @@ -331,9 +331,16 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem * Submit the edited Content Source to the REST API, re-initialize the field update and display a notification */ onSubmit() { - // TODO: Fetch field update and send to REST API - this.initializeOriginalContentSource(); - this.notificationsService.success(this.getNotificationTitle('saved'), this.getNotificationContent('saved')); + this.collectionRD$.pipe( + getSucceededRemoteData(), + map((col) => col.payload.uuid), + switchMap((uuid) => this.collectionService.updateContentSource(uuid, this.contentSource)), + take(1) + ).subscribe((contentSource: ContentSource) => { + this.contentSource = contentSource; + this.initializeOriginalContentSource(); + this.notificationsService.success(this.getNotificationTitle('saved'), this.getNotificationContent('saved')); + }); } /** diff --git a/src/app/core/data/collection-data.service.ts b/src/app/core/data/collection-data.service.ts index 1a59498a72..a858469466 100644 --- a/src/app/core/data/collection-data.service.ts +++ b/src/app/core/data/collection-data.service.ts @@ -12,21 +12,33 @@ 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 { ContentSourceRequest, FindAllOptions, RestRequest } from './request.models'; +import { ContentSourceRequest, FindAllOptions, RestRequest, UpdateContentSourceRequest } from './request.models'; import { RemoteData } from './remote-data'; import { PaginatedList } from './paginated-list'; import { ContentSource } from '../shared/content-source.model'; -import { configureRequest, filterSuccessfulResponses, getRequestFromRequestHref } from '../shared/operators'; -import { ContentSourceSuccessResponse } from '../cache/response.models'; +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'; @Injectable() export class CollectionDataService extends ComColDataService { protected linkPath = 'collections'; protected forceBypassCache = false; + protected errorTitle = 'collection.source.update.notifications.error.title'; + protected contentSourceError = 'collection.source.update.notifications.error.content'; constructor( protected requestService: RequestService, @@ -38,7 +50,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(); } @@ -77,4 +90,48 @@ export class CollectionDataService extends ComColDataService { map((response: ContentSourceSuccessResponse) => response.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) { + this.notificationsService.error(this.translate.instant(this.errorTitle), this.translate.instant(this.contentSourceError), new NotificationOptions(-1)); + } else { + this.notificationsService.error(this.translate.instant(this.errorTitle), (response as any).errorMessage, new NotificationOptions(-1)); + } + } + } else { + return response; + } + }), + isNotEmptyOperator(), + map((response: ContentSourceSuccessResponse) => { + if (isNotEmpty(response.contentsource)) { + return response.contentsource; + } + }) + ); + } } diff --git a/src/app/core/data/request.models.ts b/src/app/core/data/request.models.ts index eab7af5751..f25b084af7 100644 --- a/src/app/core/data/request.models.ts +++ b/src/app/core/data/request.models.ts @@ -369,6 +369,16 @@ export class ContentSourceRequest extends GetRequest { } } +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 index 68db549adb..76df1f10ce 100644 --- a/src/app/core/shared/content-source.model.ts +++ b/src/app/core/shared/content-source.model.ts @@ -1,4 +1,4 @@ -import { autoserialize, autoserializeAs } from 'cerialize'; +import { autoserialize, autoserializeAs, deserializeAs, deserialize } from 'cerialize'; export enum ContentSourceHarvestType { None = 'NONE', @@ -15,7 +15,7 @@ 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 */ - @autoserializeAs('self') + @deserializeAs('self') uuid: string; /** @@ -46,6 +46,6 @@ export class ContentSource { /** * The REST link to itself */ - @autoserialize + @deserialize self: string; } From 8103321245b7c0345d75ffa41b351698af5530fb Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Tue, 20 Aug 2019 17:54:58 +0200 Subject: [PATCH 12/70] 64503: MetadataConfig in ContentSource + JSDocs --- .../collection-source.component.ts | 7 ++++++- src/app/core/data/collection-data.service.ts | 13 +++++++++++++ ...content-source-response-parsing.service.ts | 12 ++++++++++++ src/app/core/shared/content-source.model.ts | 9 +++++++++ src/app/core/shared/metadata-config.model.ts | 19 +++++++++++++++++++ 5 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 src/app/core/shared/metadata-config.model.ts 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 92dba10ce8..78c7badf51 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 @@ -213,6 +213,9 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem */ updateSub: Subscription; + /** + * The content harvesting type used when harvesting is disabled + */ harvestTypeNone = ContentSourceHarvestType.None; public constructor(public objectUpdatesService: ObjectUpdatesService, @@ -371,6 +374,7 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem /** * 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( @@ -383,7 +387,8 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem /** * Update the Content Source with the value from a DynamicInputModel - * @param fieldModel + * @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)) { diff --git a/src/app/core/data/collection-data.service.ts b/src/app/core/data/collection-data.service.ts index a858469466..fedb666052 100644 --- a/src/app/core/data/collection-data.service.ts +++ b/src/app/core/data/collection-data.service.ts @@ -74,12 +74,20 @@ 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( map((href: string) => `${href}/${collectionId}/harvester`) ); } + /** + * 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)), @@ -91,6 +99,11 @@ export class CollectionDataService extends ComColDataService { ); } + /** + * 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); diff --git a/src/app/core/data/content-source-response-parsing.service.ts b/src/app/core/data/content-source-response-parsing.service.ts index 34e9360dd7..649a8f9b96 100644 --- a/src/app/core/data/content-source-response-parsing.service.ts +++ b/src/app/core/data/content-source-response-parsing.service.ts @@ -5,14 +5,26 @@ import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response. 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.metadata_configs && payload._embedded.metadata_configs.configs) { + metadataConfigs = new DSpaceRESTv2Serializer(MetadataConfig).serializeArray(payload._embedded.metadata_configs.configs); + } + deserialized.metadataConfigs = metadataConfigs; + return new ContentSourceSuccessResponse(deserialized, data.statusCode, data.statusText); } diff --git a/src/app/core/shared/content-source.model.ts b/src/app/core/shared/content-source.model.ts index 76df1f10ce..a2df90027f 100644 --- a/src/app/core/shared/content-source.model.ts +++ b/src/app/core/shared/content-source.model.ts @@ -1,5 +1,9 @@ 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', @@ -43,6 +47,11 @@ export class ContentSource { @autoserializeAs('harvest_type') harvestType = ContentSourceHarvestType.None; + /** + * The available metadata configurations + */ + metadataConfigs: MetadataConfig[]; + /** * The REST link to itself */ 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; +} From bf8e2078bcaeb5592debcbf9de6c1428429af597 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Wed, 21 Aug 2019 10:40:58 +0200 Subject: [PATCH 13/70] 64503: Metadata Format dropdown filled in by metadataConfig from REST response --- .../collection-source.component.ts | 42 ++++++++++--------- 1 file changed, 22 insertions(+), 20 deletions(-) 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 78c7badf51..5a1f40a1e2 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 @@ -16,7 +16,7 @@ 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 { hasValue, isNotEmpty } from '../../../shared/empty.util'; +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'; @@ -29,6 +29,7 @@ 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'; /** * Component for managing the content source of the collection @@ -96,19 +97,7 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem */ metadataConfigIdModel = new DynamicSelectModel({ id: 'metadataConfigId', - name: 'metadataConfigId', - options: [ - { - value: 'dc' - }, - { - value: 'qdc' - }, - { - value: 'dim' - } - ], - value: 'dc' + name: 'metadataConfigId' }); /** @@ -249,8 +238,7 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem switchMap((uuid) => this.collectionService.getContentSource(uuid)), take(1) ).subscribe((contentSource: ContentSource) => { - this.contentSource = contentSource; - this.initializeOriginalContentSource(); + this.initializeOriginalContentSource(contentSource); }); this.updateFieldTranslations(); @@ -263,7 +251,9 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem /** * Initialize the Field Update and subscribe on it to fire updates to the form whenever it changes */ - initializeOriginalContentSource() { + initializeOriginalContentSource(contentSource: ContentSource) { + this.contentSource = contentSource; + this.initializeMetadataConfigs(); const initialContentSource = cloneDeep(this.contentSource); this.objectUpdatesService.initialize(this.url, [initialContentSource], new Date()); this.update$ = this.objectUpdatesService.getFieldUpdates(this.url, [initialContentSource]).pipe( @@ -289,6 +279,17 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem }); } + /** + * 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.metadataConfigIdModel.value = this.metadataConfigIdModel.options[0].value; + } + } + /** * Used the update translations of errors and labels on init and on language change */ @@ -315,7 +316,9 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem if (fieldModel instanceof DynamicOptionControlModel) { if (isNotEmpty(fieldModel.options)) { fieldModel.options.forEach((option) => { - option.label = this.translate.instant(this.OPTIONS_KEY_PREFIX + fieldModel.id + '.' + option.value); + if (hasNoValue(option.label)) { + option.label = this.translate.instant(this.OPTIONS_KEY_PREFIX + fieldModel.id + '.' + option.value); + } }); } } @@ -340,8 +343,7 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem switchMap((uuid) => this.collectionService.updateContentSource(uuid, this.contentSource)), take(1) ).subscribe((contentSource: ContentSource) => { - this.contentSource = contentSource; - this.initializeOriginalContentSource(); + this.initializeOriginalContentSource(contentSource); this.notificationsService.success(this.getNotificationTitle('saved'), this.getNotificationContent('saved')); }); } From a3b6917abac248969f385b0ddf15d9db44ce7133 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Wed, 21 Aug 2019 11:24:52 +0200 Subject: [PATCH 14/70] 64503: Remembering previously selected harvest type + small dropdown fix --- .../collection-source.component.ts | 16 ++++++++++++++-- src/app/core/shared/content-source.model.ts | 2 +- 2 files changed, 15 insertions(+), 3 deletions(-) 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 5a1f40a1e2..d4d551d61d 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 @@ -207,6 +207,13 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem */ 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; + public constructor(public objectUpdatesService: ObjectUpdatesService, public notificationsService: NotificationsService, protected location: Location, @@ -286,7 +293,11 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem this.metadataConfigIdModel.options = this.contentSource.metadataConfigs .map((metadataConfig: MetadataConfig) => Object.assign({ value: metadataConfig.id, label: metadataConfig.label })); if (this.metadataConfigIdModel.options.length > 0) { - this.metadataConfigIdModel.value = this.metadataConfigIdModel.options[0].value; + this.formGroup.patchValue({ + oaiSetContainer: { + metadataConfigId: this.metadataConfigIdModel.options[0].value + } + }); } } @@ -367,8 +378,9 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem */ changeExternalSource() { if (this.contentSource.harvestType === ContentSourceHarvestType.None) { - this.contentSource.harvestType = ContentSourceHarvestType.Metadata; + this.contentSource.harvestType = this.previouslySelectedHarvestType; } else { + this.previouslySelectedHarvestType = this.contentSource.harvestType; this.contentSource.harvestType = ContentSourceHarvestType.None; } this.updateContentSource(false); diff --git a/src/app/core/shared/content-source.model.ts b/src/app/core/shared/content-source.model.ts index a2df90027f..cd53c2d81e 100644 --- a/src/app/core/shared/content-source.model.ts +++ b/src/app/core/shared/content-source.model.ts @@ -38,7 +38,7 @@ export class ContentSource { * The ID of the metadata format used */ @autoserializeAs('metadata_config_id') - metadataConfigId = 'dc'; + metadataConfigId: string; /** * Type of content being harvested From 9fab40d7e9e2c0afb3702602c8692b6e3ecd9abf Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Wed, 21 Aug 2019 14:14:01 +0200 Subject: [PATCH 15/70] 64503: CollectionSourceComponent and CollectionDataService tests --- .../collection-source.component.spec.ts | 51 ++++++-- .../core/data/collection-data.service.spec.ts | 109 ++++++++++++++++++ 2 files changed, 150 insertions(+), 10 deletions(-) create mode 100644 src/app/core/data/collection-data.service.spec.ts 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 index 89d7445b65..8e93cd7bdd 100644 --- 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 @@ -5,7 +5,7 @@ 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 } from '../../../core/shared/content-source.model'; +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'; @@ -17,6 +17,9 @@ 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'; const infoNotification: INotification = new Notification('id', NotificationType.Info, 'info'); const warningNotification: INotification = new Notification('id', NotificationType.Warning, 'warning'); @@ -31,6 +34,8 @@ let notificationsService: NotificationsService; let location: Location; let formService: DynamicFormService; let router: Router; +let collection: Collection; +let collectionService: CollectionDataService; describe('CollectionSourceComponent', () => { let comp: CollectionSourceComponent; @@ -39,7 +44,24 @@ describe('CollectionSourceComponent', () => { beforeEach(async(() => { date = new Date(); contentSource = Object.assign(new ContentSource(), { - uuid: uuid + 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, @@ -84,6 +106,13 @@ describe('CollectionSourceComponent', () => { 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) + }); TestBed.configureTestingModule({ imports: [TranslateModule.forRoot(), RouterTestingModule], @@ -93,9 +122,10 @@ describe('CollectionSourceComponent', () => { { provide: NotificationsService, useValue: notificationsService }, { provide: Location, useValue: location }, { provide: DynamicFormService, useValue: formService }, - { provide: ActivatedRoute, useValue: { parent: { data: observableOf({ dso: { payload: {} } }) } } }, + { 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 } ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); @@ -104,7 +134,6 @@ describe('CollectionSourceComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(CollectionSourceComponent); comp = fixture.componentInstance; - comp.contentSource = contentSource; fixture.detectChanges(); }); @@ -116,7 +145,7 @@ describe('CollectionSourceComponent', () => { }); it('ContentSource should be disabled', () => { - expect(comp.contentSource.enabled).toBe(false); + expect(comp.contentSource.harvestType).toEqual(ContentSourceHarvestType.None); }); it('the input-form should be hidden', () => { @@ -136,7 +165,7 @@ describe('CollectionSourceComponent', () => { }); it('should enable ContentSource', () => { - expect(comp.contentSource.enabled).toBe(true); + expect(comp.contentSource.harvestType).not.toEqual(ContentSourceHarvestType.None); }); it('should send a field update', () => { @@ -151,19 +180,19 @@ describe('CollectionSourceComponent', () => { describe('isValid', () => { it('should return true when ContentSource is disabled but the form invalid', () => { spyOnProperty(comp.formGroup, 'valid').and.returnValue(false); - comp.contentSource.enabled = 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.enabled = true; + 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.enabled = true; + comp.contentSource.harvestType = ContentSourceHarvestType.Metadata; expect(comp.isValid()).toBe(true); }); }); @@ -181,6 +210,8 @@ describe('CollectionSourceComponent', () => { expect(notificationsService.success).toHaveBeenCalled(); }); - // TODO: Write test for sending data to REST API on submit + it('should call updateContentSource on the collectionService', () => { + expect(collectionService.updateContentSource).toHaveBeenCalled(); + }); }); }); diff --git a/src/app/core/data/collection-data.service.spec.ts b/src/app/core/data/collection-data.service.spec.ts new file mode 100644 index 0000000000..f0d3f63910 --- /dev/null +++ b/src/app/core/data/collection-data.service.spec.ts @@ -0,0 +1,109 @@ +import { CollectionDataService } from './collection-data.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, 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'; + +const url = 'fake-url'; +const collectionId = 'fake-collection-id'; + +describe('CollectionDataService', () => { + let service: CollectionDataService; + + let requestService: RequestService; + let translate: TranslateService; + let notificationsService: any; + let halService: any; + + describe('when the requests are successful', () => { + beforeEach(() => { + createService(); + }); + + 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), undefined); + })); + }); + + 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), undefined); + })); + }); + }); + + 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), undefined); + })); + + 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$); + halService = new HALEndpointServiceStub(url); + notificationsService = new NotificationsServiceStub(); + translate = getMockTranslateService(); + + service = new CollectionDataService(requestService, null, null, null, null, null, halService, notificationsService, null, null, translate); + } + +}); From 86555b936db49a03d2b93654acc1abe6818a3c44 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Wed, 21 Aug 2019 14:22:54 +0200 Subject: [PATCH 16/70] 64503: Small bugfix on login --- .../collection-source.component.html | 108 +++++++++--------- 1 file changed, 53 insertions(+), 55 deletions(-) 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 42f58b4692..d02637ed80 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 @@ -1,57 +1,55 @@ - -
-
- - - -
-

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

-
- - -
-

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

+
+
+ + +
- -
-
- - - -
+

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

+
+ +
- +

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

+
+ +
+
+ + + +
+
From a3116a3c5ad962e28011d1233e9673e2871a9475 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Wed, 21 Aug 2019 14:37:14 +0200 Subject: [PATCH 17/70] 64503: AoT build error fixes --- .../collection-source/collection-source.component.spec.ts | 2 +- .../edit-collection-page/edit-collection-page.component.ts | 2 +- .../edit-community-page/edit-community-page.component.ts | 2 +- .../edit-comcol-page/edit-comcol-page.component.ts | 6 ++---- 4 files changed, 5 insertions(+), 7 deletions(-) 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 index 8e93cd7bdd..a44311a1c7 100644 --- 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 @@ -33,7 +33,7 @@ let objectUpdatesService: ObjectUpdatesService; let notificationsService: NotificationsService; let location: Location; let formService: DynamicFormService; -let router: Router; +let router: any; let collection: Collection; let collectionService: CollectionDataService; diff --git a/src/app/+collection-page/edit-collection-page/edit-collection-page.component.ts b/src/app/+collection-page/edit-collection-page/edit-collection-page.component.ts index 21671fe112..dc6df135b4 100644 --- a/src/app/+collection-page/edit-collection-page/edit-collection-page.component.ts +++ b/src/app/+collection-page/edit-collection-page/edit-collection-page.component.ts @@ -12,7 +12,7 @@ import { getCollectionPageRoute } from '../collection-page-routing.module'; templateUrl: '../../shared/comcol-forms/edit-comcol-page/edit-comcol-page.component.html' }) export class EditCollectionPageComponent extends EditComColPageComponent { - protected type = 'collection'; + public type = 'collection'; public constructor( protected router: Router, diff --git a/src/app/+community-page/edit-community-page/edit-community-page.component.ts b/src/app/+community-page/edit-community-page/edit-community-page.component.ts index a8d4d32b7d..09fc3510a3 100644 --- a/src/app/+community-page/edit-community-page/edit-community-page.component.ts +++ b/src/app/+community-page/edit-community-page/edit-community-page.component.ts @@ -12,7 +12,7 @@ import { getCommunityPageRoute } from '../community-page-routing.module'; templateUrl: '../../shared/comcol-forms/edit-comcol-page/edit-comcol-page.component.html' }) export class EditCommunityPageComponent extends EditComColPageComponent { - protected type = 'community'; + public type = 'community'; public constructor( protected router: Router, 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 9face94abd..a750e76c87 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'; /** @@ -19,7 +17,7 @@ export class EditComColPageComponent implements On /** * The type of DSpaceObject (used to create i18n messages) */ - protected type: string; + public type: string; /** * The current page outlet string From ff2083941a3cc4598a73098bd5e02042aa15e13b Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Wed, 21 Aug 2019 14:42:35 +0200 Subject: [PATCH 18/70] 64503: Removal of unnecessary messages --- resources/i18n/en.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/resources/i18n/en.json b/resources/i18n/en.json index 0fb6fd4229..88acc61d3c 100644 --- a/resources/i18n/en.json +++ b/resources/i18n/en.json @@ -109,9 +109,6 @@ "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.form.options.metadataConfigId.dc": "Simple Dublin Core", - "collection.edit.tabs.source.form.options.metadataConfigId.dim": "DSpace Intermediate Metadata", - "collection.edit.tabs.source.form.options.metadataConfigId.qdc": "Qualified Dublin Core", "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", From 7432297dce35c20f8ea058e59cdba503a30bab80 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Tue, 3 Sep 2019 11:19:23 +0200 Subject: [PATCH 19/70] 64503: Fetch harvester endpoint using halService + small unsubscribe fix --- .../collection-source/collection-source.component.ts | 4 +++- src/app/core/data/collection-data.service.ts | 4 ++-- src/app/core/shared/hal-endpoint.service.ts | 4 ++-- 3 files changed, 7 insertions(+), 5 deletions(-) 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 d4d551d61d..3a87f11910 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 @@ -421,6 +421,8 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem * Make sure open subscriptions are closed */ ngOnDestroy(): void { - this.updateSub.unsubscribe(); + if (this.updateSub) { + this.updateSub.unsubscribe(); + } } } diff --git a/src/app/core/data/collection-data.service.ts b/src/app/core/data/collection-data.service.ts index fedb666052..435bee21b0 100644 --- a/src/app/core/data/collection-data.service.ts +++ b/src/app/core/data/collection-data.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core'; -import { filter, map, take, tap } from 'rxjs/operators'; +import { filter, map, switchMap, take, tap } from 'rxjs/operators'; import { Store } from '@ngrx/store'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; @@ -80,7 +80,7 @@ export class CollectionDataService extends ComColDataService { */ getHarvesterEndpoint(collectionId: string): Observable { return this.halService.getEndpoint(this.linkPath).pipe( - map((href: string) => `${href}/${collectionId}/harvester`) + switchMap((href: string) => this.halService.getEndpoint('harvester', `${href}/${collectionId}`)) ); } diff --git a/src/app/core/shared/hal-endpoint.service.ts b/src/app/core/shared/hal-endpoint.service.ts index a93d54db64..117cc074ca 100644 --- a/src/app/core/shared/hal-endpoint.service.ts +++ b/src/app/core/shared/hal-endpoint.service.ts @@ -43,8 +43,8 @@ export class HALEndpointService { ); } - public getEndpoint(linkPath: string): Observable { - return this.getEndpointAt(this.getRootHref(), ...linkPath.split('/')); + public getEndpoint(linkPath: string, startHref?: string): Observable { + return this.getEndpointAt(startHref || this.getRootHref(), ...linkPath.split('/')); } /** From e59b0929eded9fe343b1e31b3cc3b8d81cb0101b Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Tue, 3 Sep 2019 16:11:31 +0200 Subject: [PATCH 20/70] 64503: Default metadataConfigID --- .../collection-source/collection-source.component.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 3a87f11910..a050443353 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 @@ -269,19 +269,21 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem this.updateSub = this.update$.subscribe((update: FieldUpdate) => { if (update) { const field = update.field as ContentSource; + const defaultConfigId = this.contentSource.metadataConfigs[0].id; this.formGroup.patchValue({ oaiSourceContainer: { oaiSource: field.oaiSource }, oaiSetContainer: { oaiSetId: field.oaiSetId, - metadataConfigId: field.metadataConfigId + metadataConfigId: field.metadataConfigId || defaultConfigId }, harvestTypeContainer: { harvestType: field.harvestType } }); this.contentSource = cloneDeep(field); + this.contentSource.metadataConfigId = defaultConfigId; } }); } From 404729faa3aea88c7e5eed526d7d8cdbaf1ef58a Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Thu, 31 Oct 2019 12:41:03 +0100 Subject: [PATCH 21/70] 64503: Feedback 2019-10-24 ; loading, message fix, notification clearing --- resources/i18n/en.json5 | 3 +- .../collection-source.component.html | 5 +-- .../collection-source.component.ts | 34 ++++++++++++++++--- src/app/core/data/collection-data.service.ts | 14 ++++---- ...content-source-response-parsing.service.ts | 4 +-- 5 files changed, 45 insertions(+), 15 deletions(-) diff --git a/resources/i18n/en.json5 b/resources/i18n/en.json5 index a9078373ae..f83ed568da 100644 --- a/resources/i18n/en.json5 +++ b/resources/i18n/en.json5 @@ -159,7 +159,7 @@ "collection.edit.tabs.roles.head": "Assign Roles", "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.provider.required": "You must provide a set id of the target collection.", + "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", @@ -478,6 +478,7 @@ "loading.browse-by-page": "Loading page...", "loading.collection": "Loading collection...", "loading.collections": "Loading collections...", + "loading.content-source": "Loading content source...", "loading.community": "Loading community...", "loading.default": "Loading...", "loading.item": "Loading item...", 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 d02637ed80..dfcd6e3166 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 @@ -18,11 +18,12 @@

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

-
+
-

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

+ +

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

{ if (update) { const field = update.field as ContentSource; - const defaultConfigId = this.contentSource.metadataConfigs[0].id; + let defaultConfigId; + if (hasValue(this.contentSource) && isNotEmpty(this.contentSource.metadataConfigs)) { + defaultConfigId = this.contentSource.metadataConfigs[0].id; + } this.formGroup.patchValue({ oaiSourceContainer: { oaiSource: field.oaiSource @@ -355,9 +366,14 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem map((col) => col.payload.uuid), switchMap((uuid) => this.collectionService.updateContentSource(uuid, this.contentSource)), take(1) - ).subscribe((contentSource: ContentSource) => { - this.initializeOriginalContentSource(contentSource); - this.notificationsService.success(this.getNotificationTitle('saved'), this.getNotificationContent('saved')); + ).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); + } }); } @@ -419,6 +435,16 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem 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 */ diff --git a/src/app/core/data/collection-data.service.ts b/src/app/core/data/collection-data.service.ts index 94860e5df7..e53b684539 100644 --- a/src/app/core/data/collection-data.service.ts +++ b/src/app/core/data/collection-data.service.ts @@ -38,6 +38,7 @@ import { ResponseParsingService } from './parsing.service'; import { GenericConstructor } from '../shared/generic-constructor'; import { DSpaceObject } from '../shared/dspace-object.model'; import { PaginatedSearchOptions } from '../../+search-page/paginated-search-options.model'; +import { INotification } from '../../shared/notifications/models/notification.model'; @Injectable() export class CollectionDataService extends ComColDataService { @@ -140,7 +141,7 @@ export class CollectionDataService extends ComColDataService { * @param collectionId * @param contentSource */ - updateContentSource(collectionId: string, contentSource: ContentSource): Observable { + updateContentSource(collectionId: string, contentSource: ContentSource): Observable { const requestId = this.requestService.generateRequestId(); const serializedContentSource = new DSpaceRESTv2Serializer(ContentSource).serialize(contentSource); const request$ = this.getHarvesterEndpoint(collectionId).pipe( @@ -166,9 +167,9 @@ export class CollectionDataService extends ComColDataService { if (!response.isSuccessful) { if (hasValue((response as any).errorMessage)) { if (response.statusCode === 422) { - this.notificationsService.error(this.translate.instant(this.errorTitle), this.translate.instant(this.contentSourceError), new NotificationOptions(-1)); + return this.notificationsService.error(this.translate.instant(this.errorTitle), this.translate.instant(this.contentSourceError), new NotificationOptions(-1)); } else { - this.notificationsService.error(this.translate.instant(this.errorTitle), (response as any).errorMessage, new NotificationOptions(-1)); + return this.notificationsService.error(this.translate.instant(this.errorTitle), (response as any).errorMessage, new NotificationOptions(-1)); } } } else { @@ -176,10 +177,11 @@ export class CollectionDataService extends ComColDataService { } }), isNotEmptyOperator(), - map((response: ContentSourceSuccessResponse) => { - if (isNotEmpty(response.contentsource)) { - return response.contentsource; + map((response: ContentSourceSuccessResponse | INotification) => { + if (isNotEmpty((response as any).contentsource)) { + return (response as ContentSourceSuccessResponse).contentsource; } + return response as INotification; }) ); } diff --git a/src/app/core/data/content-source-response-parsing.service.ts b/src/app/core/data/content-source-response-parsing.service.ts index 649a8f9b96..4e0490148b 100644 --- a/src/app/core/data/content-source-response-parsing.service.ts +++ b/src/app/core/data/content-source-response-parsing.service.ts @@ -20,8 +20,8 @@ export class ContentSourceResponseParsingService implements ResponseParsingServi const deserialized = new DSpaceRESTv2Serializer(ContentSource).deserialize(payload); let metadataConfigs = []; - if (payload._embedded && payload._embedded.metadata_configs && payload._embedded.metadata_configs.configs) { - metadataConfigs = new DSpaceRESTv2Serializer(MetadataConfig).serializeArray(payload._embedded.metadata_configs.configs); + if (payload._embedded && payload._embedded.harvestermetadata && payload._embedded.harvestermetadata.configs) { + metadataConfigs = new DSpaceRESTv2Serializer(MetadataConfig).serializeArray(payload._embedded.harvestermetadata.configs); } deserialized.metadataConfigs = metadataConfigs; From 2d3d7ae71ee9d1bc1d6be973ab9fa556ddef8a38 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Tue, 12 Nov 2019 11:59:44 +0100 Subject: [PATCH 22/70] 64503: Prevent submit when harvest type is NONE and not changed --- .../collection-source.component.html | 4 +-- .../collection-source.component.ts | 35 ++++++++++++------- 2 files changed, 24 insertions(+), 15 deletions(-) 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 dfcd6e3166..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 @@ -11,7 +11,7 @@ class="fas fa-undo-alt">  {{"item.edit.metadata.reinstate-button" | translate}} - -
diff --git a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.ts b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.ts index 3a145c99e0..e7d1a14200 100644 --- a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.ts +++ b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.ts @@ -4,10 +4,9 @@ import { Observable } from 'rxjs/internal/Observable'; import { FieldUpdate, FieldUpdates } from '../../../../core/data/object-updates/object-updates.reducer'; import { RelationshipService } from '../../../../core/data/relationship.service'; import { Item } from '../../../../core/shared/item.model'; -import { map, switchMap } from 'rxjs/operators'; +import { map, switchMap} from 'rxjs/operators'; import { hasValue } from '../../../../shared/empty.util'; -import { RemoteData } from '../../../../core/data/remote-data'; -import { PaginatedList } from '../../../../core/data/paginated-list'; +import {Relationship} from "../../../../core/shared/item-relationships/relationship.model"; @Component({ selector: 'ds-edit-relationship-list', @@ -61,22 +60,17 @@ export class EditRelationshipListComponent implements OnInit, OnChanges { this.updates$ = this.getUpdatesByLabel(this.relationshipLabel); } - /** - * Transform the item's relationships of a specific type into related items - * @param label The relationship type's label - */ - public getRelatedItemsByLabel(label: string): Observable>> { - return this.relationshipService.getRelatedItemsByLabel(this.item, label); - } - /** * Get FieldUpdates for the relationships of a specific type * @param label The relationship type's label */ public getUpdatesByLabel(label: string): Observable { - return this.getRelatedItemsByLabel(label).pipe( - switchMap((itemsRD) => this.objectUpdatesService.getFieldUpdatesExclusive(this.url, itemsRD.payload.page)) - ) + return this.relationshipService.getItemRelationshipsByLabel(this.item, label).pipe( + map(relationsRD => relationsRD.payload.page.map(relationship => + Object.assign(new Relationship(), relationship, {uuid: relationship.id}) + )), + switchMap((initialFields) => this.objectUpdatesService.getFieldUpdatesExclusive(this.url, initialFields)), + ); } /** @@ -97,5 +91,4 @@ export class EditRelationshipListComponent implements OnInit, OnChanges { trackUpdate(index, update: FieldUpdate) { return update && update.field ? update.field.uuid : undefined; } - } diff --git a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.html b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.html index 03040ce8e0..245bf04051 100644 --- a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.html +++ b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.html @@ -1,10 +1,10 @@ -
+
- +
-
+ + + + diff --git a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.spec.ts b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.spec.ts index 54fce0a68e..00c2a33006 100644 --- a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.spec.ts +++ b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.spec.ts @@ -112,7 +112,7 @@ describe('EditRelationshipComponent', () => { comp.url = url; comp.fieldUpdate = fieldUpdate1; - comp.item = item; + comp.editItem = item; fixture.detectChanges(); }); diff --git a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.ts b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.ts index 302ebf68a7..7c1780da58 100644 --- a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.ts +++ b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.ts @@ -1,10 +1,15 @@ -import { Component, Input, OnChanges } from '@angular/core'; -import { FieldUpdate } from '../../../../core/data/object-updates/object-updates.reducer'; -import { cloneDeep } from 'lodash'; -import { Item } from '../../../../core/shared/item.model'; -import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service'; -import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions'; -import { ViewMode } from '../../../../core/shared/view-mode.model'; +import {Component, Input, OnChanges, OnInit} from '@angular/core'; +import {combineLatest as observableCombineLatest, Observable} from 'rxjs'; +import {filter, map, switchMap, take, tap} from 'rxjs/operators'; +import {FieldChangeType} from '../../../../core/data/object-updates/object-updates.actions'; +import {DeleteRelationship, FieldUpdate} from '../../../../core/data/object-updates/object-updates.reducer'; +import {ObjectUpdatesService} from '../../../../core/data/object-updates/object-updates.service'; +import {Relationship} from '../../../../core/shared/item-relationships/relationship.model'; +import {Item} from '../../../../core/shared/item.model'; +import {getRemoteDataPayload, getSucceededRemoteData} from '../../../../core/shared/operators'; +import {ViewMode} from '../../../../core/shared/view-mode.model'; +import {hasValue, isNotEmpty} from '../../../../shared/empty.util'; +import {NgbModal, NgbModalRef} from "@ng-bootstrap/ng-bootstrap"; @Component({ // tslint:disable-next-line:component-selector @@ -23,38 +28,109 @@ export class EditRelationshipComponent implements OnChanges { */ @Input() url: string; + /** + * The item being edited + */ + @Input() editItem: Item; + + /** + * The relationship being edited + */ + get relationship(): Relationship { + return this.fieldUpdate.field as Relationship; + } + + private leftItem$: Observable; + private rightItem$: Observable; + /** * The related item of this relationship */ - item: Item; + relatedItem$: Observable; /** * The view-mode we're currently on */ viewMode = ViewMode.ListElement; - constructor(private objectUpdatesService: ObjectUpdatesService) { + constructor( + private objectUpdatesService: ObjectUpdatesService, + private modalService: NgbModal, + ) { } /** * Sets the current relationship based on the fieldUpdate input field */ ngOnChanges(): void { - this.item = cloneDeep(this.fieldUpdate.field) as Item; + this.leftItem$ = this.relationship.leftItem.pipe( + getSucceededRemoteData(), + getRemoteDataPayload(), + filter((item: Item) => hasValue(item) && isNotEmpty(item.uuid)) + ); + this.rightItem$ = this.relationship.rightItem.pipe( + getSucceededRemoteData(), + getRemoteDataPayload(), + filter((item: Item) => hasValue(item) && isNotEmpty(item.uuid)) + ); + this.relatedItem$ = observableCombineLatest( + this.leftItem$, + this.rightItem$, + ).pipe( + map((items: Item[]) => + items.find((item) => item.uuid !== this.editItem.uuid) + ) + ); } /** * Sends a new remove update for this field to the object updates service */ remove(): void { - this.objectUpdatesService.saveRemoveFieldUpdate(this.url, this.item); + this.closeVirtualMetadataModal(); + observableCombineLatest( + this.leftItem$, + this.rightItem$, + ).pipe( + map((items: Item[]) => + items.map(item => this.objectUpdatesService + .isSelectedVirtualMetadataItem(this.url, this.relationship.id, item.uuid)) + ), + switchMap((selection$: Observable[]) => observableCombineLatest(selection$)), + map((selection: boolean[]) => { + return Object.assign({}, + this.fieldUpdate.field, + { + uuid: this.relationship.id, + keepLeftVirtualMetadata: selection[0] == true, + keepRightVirtualMetadata: selection[1] == true, + } + ) as DeleteRelationship + }), + take(1), + ).subscribe((deleteRelationship: DeleteRelationship) => + this.objectUpdatesService.saveRemoveFieldUpdate(this.url, deleteRelationship) + ); + } + + /** + * Reference to NgbModal + */ + public modalRef: NgbModalRef; + + openVirtualMetadataModal(content: any) { + this.modalRef = this.modalService.open(content); + } + + closeVirtualMetadataModal() { + this.modalRef.close(); } /** * Cancels the current update for this field in the object updates service */ undo(): void { - this.objectUpdatesService.removeSingleFieldUpdate(this.url, this.item.uuid); + this.objectUpdatesService.removeSingleFieldUpdate(this.url, this.fieldUpdate.field.uuid); } /** @@ -70,5 +146,4 @@ export class EditRelationshipComponent implements OnChanges { canUndo(): boolean { return this.fieldUpdate.changeType >= 0; } - } 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 e8f34bc70e..92b138e962 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 @@ -1,8 +1,12 @@ import { ChangeDetectorRef, Component, Inject, OnDestroy } from '@angular/core'; import { Item } from '../../../core/shared/item.model'; -import { FieldUpdate, FieldUpdates } from '../../../core/data/object-updates/object-updates.reducer'; +import { + DeleteRelationship, + FieldUpdate, + FieldUpdates +} from '../../../core/data/object-updates/object-updates.reducer'; import { Observable } from 'rxjs/internal/Observable'; -import { filter, map, switchMap, take, tap } from 'rxjs/operators'; +import {filter, map, switchMap, take, tap} from 'rxjs/operators'; import { combineLatest as observableCombineLatest, zip as observableZip } from 'rxjs'; import { AbstractItemUpdateComponent } from '../abstract-item-update/abstract-item-update.component'; import { ItemDataService } from '../../../core/data/item-data.service'; @@ -18,7 +22,7 @@ import { ErrorResponse, RestResponse } from '../../../core/cache/response.models import { isNotEmptyOperator } from '../../../shared/empty.util'; import { RemoteData } from '../../../core/data/remote-data'; import { ObjectCacheService } from '../../../core/cache/object-cache.service'; -import { getSucceededRemoteData } from '../../../core/shared/operators'; +import { getSucceededRemoteData} from '../../../core/shared/operators'; import { RequestService } from '../../../core/data/request.service'; import { Subscription } from 'rxjs/internal/Subscription'; import { getRelationsByRelatedItemIds } from '../../simple/item-types/shared/item-relationships-utils'; @@ -104,22 +108,36 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent impl * Make sure the lists are refreshed afterwards and notifications are sent for success and errors */ public submit(): void { - // Get all IDs of related items of which their relationship with the current item is about to be removed - const removedItemIds$ = this.relationshipService.getRelatedItems(this.item).pipe( - switchMap((items: Item[]) => this.objectUpdatesService.getFieldUpdatesExclusive(this.url, items) as Observable), - map((fieldUpdates: FieldUpdates) => Object.values(fieldUpdates).filter((fieldUpdate: FieldUpdate) => fieldUpdate.changeType === FieldChangeType.REMOVE)), - map((fieldUpdates: FieldUpdate[]) => fieldUpdates.map((fieldUpdate: FieldUpdate) => fieldUpdate.field.uuid) as string[]), - isNotEmptyOperator() - ); // Get all the relationships that should be removed - const removedRelationships$ = removedItemIds$.pipe( - getRelationsByRelatedItemIds(this.item, this.relationshipService) + const removedRelationshipIDs$ = this.relationshipService.getItemRelationshipsArray(this.item).pipe( + map((relationships: Relationship[]) => relationships.map(relationship => + Object.assign(new Relationship(), relationship, {uuid: relationship.id}) + )), + switchMap((relationships: Relationship[]) => { + return this.objectUpdatesService.getFieldUpdatesExclusive(this.url, relationships) as Observable + }), + map((fieldUpdates: FieldUpdates) => Object.values(fieldUpdates).filter((fieldUpdate: FieldUpdate) => fieldUpdate.changeType === FieldChangeType.REMOVE)), + map((fieldUpdates: FieldUpdate[]) => fieldUpdates.map((fieldUpdate: FieldUpdate) => fieldUpdate.field) as DeleteRelationship[]), + isNotEmptyOperator(), ); - // Request a delete for every relationship found in the observable created above - removedRelationships$.pipe( + removedRelationshipIDs$.pipe( take(1), - map((removedRelationships: Relationship[]) => removedRelationships.map((rel: Relationship) => rel.id)), - switchMap((removedIds: string[]) => observableZip(...removedIds.map((uuid: string) => this.relationshipService.deleteRelationship(uuid)))) + switchMap((deleteRelationships: DeleteRelationship[]) => + observableZip(...deleteRelationships.map((deleteRelationship) => { + let copyVirtualMetadata : string; + if (deleteRelationship.keepLeftVirtualMetadata && deleteRelationship.keepRightVirtualMetadata) { + copyVirtualMetadata = 'all'; + } else if (deleteRelationship.keepLeftVirtualMetadata) { + copyVirtualMetadata = 'left'; + } else if (deleteRelationship.keepRightVirtualMetadata) { + copyVirtualMetadata = 'right'; + } else { + copyVirtualMetadata = 'none'; + } + return this.relationshipService.deleteRelationship(deleteRelationship.uuid, copyVirtualMetadata); + } + )) + ), ).subscribe((responses: RestResponse[]) => { this.displayNotifications(responses); this.reset(); diff --git a/src/app/+item-page/edit-item-page/virtual-metadata/virtual-metadata.component.html b/src/app/+item-page/edit-item-page/virtual-metadata/virtual-metadata.component.html new file mode 100644 index 0000000000..aafdb00b47 --- /dev/null +++ b/src/app/+item-page/edit-item-page/virtual-metadata/virtual-metadata.component.html @@ -0,0 +1,42 @@ +
+ + +
diff --git a/src/app/+item-page/edit-item-page/virtual-metadata/virtual-metadata.component.spec.ts b/src/app/+item-page/edit-item-page/virtual-metadata/virtual-metadata.component.spec.ts new file mode 100644 index 0000000000..7b37b238e8 --- /dev/null +++ b/src/app/+item-page/edit-item-page/virtual-metadata/virtual-metadata.component.spec.ts @@ -0,0 +1,172 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { Item } from '../../../core/shared/item.model'; +import { RouterStub } from '../../../shared/testing/router-stub'; +import { CommonModule } from '@angular/common'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { ActivatedRoute, Router } from '@angular/router'; +import { VirtualMetadataComponent } from './virtual-metadata.component'; +import { NotificationsServiceStub } from '../../../shared/testing/notifications-service-stub'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { SearchService } from '../../../+search-page/search-service/search.service'; +import { of as observableOf } from 'rxjs'; +import { FormsModule } from '@angular/forms'; +import { ItemDataService } from '../../../core/data/item-data.service'; +import { RemoteData } from '../../../core/data/remote-data'; +import { PaginatedList } from '../../../core/data/paginated-list'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { RestResponse } from '../../../core/cache/response.models'; +import { Collection } from '../../../core/shared/collection.model'; + +// describe('ItemMoveComponent', () => { +// let comp: VirtualMetadataComponent; +// let fixture: ComponentFixture; +// +// const mockItem = Object.assign(new Item(), { +// id: 'fake-id', +// handle: 'fake/handle', +// lastModified: '2018' +// }); +// +// const itemPageUrl = `fake-url/${mockItem.id}`; +// const routerStub = Object.assign(new RouterStub(), { +// url: `${itemPageUrl}/edit` +// }); +// +// const mockItemDataService = jasmine.createSpyObj({ +// moveToCollection: observableOf(new RestResponse(true, 200, 'Success')) +// }); +// +// const mockItemDataServiceFail = jasmine.createSpyObj({ +// moveToCollection: observableOf(new RestResponse(false, 500, 'Internal server error')) +// }); +// +// const routeStub = { +// data: observableOf({ +// item: new RemoteData(false, false, true, null, { +// id: 'item1' +// }) +// }) +// }; +// +// const collection1 = Object.assign(new Collection(),{ +// uuid: 'collection-uuid-1', +// name: 'Test collection 1', +// self: 'self-link-1', +// }); +// +// const collection2 = Object.assign(new Collection(),{ +// uuid: 'collection-uuid-2', +// name: 'Test collection 2', +// self: 'self-link-2', +// }); +// +// const mockSearchService = { +// search: () => { +// return observableOf(new RemoteData(false, false, true, null, +// new PaginatedList(null, [ +// { +// indexableObject: collection1, +// hitHighlights: {} +// }, { +// indexableObject: collection2, +// hitHighlights: {} +// } +// ]))); +// } +// }; +// +// const notificationsServiceStub = new NotificationsServiceStub(); +// +// describe('ItemMoveComponent success', () => { +// beforeEach(async(() => { +// TestBed.configureTestingModule({ +// imports: [CommonModule, FormsModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()], +// declarations: [VirtualMetadataComponent], +// providers: [ +// {provide: ActivatedRoute, useValue: routeStub}, +// {provide: Router, useValue: routerStub}, +// {provide: ItemDataService, useValue: mockItemDataService}, +// {provide: NotificationsService, useValue: notificationsServiceStub}, +// {provide: SearchService, useValue: mockSearchService}, +// ], schemas: [ +// CUSTOM_ELEMENTS_SCHEMA +// ] +// }).compileComponents(); +// })); +// +// beforeEach(() => { +// fixture = TestBed.createComponent(VirtualMetadataComponent); +// comp = fixture.componentInstance; +// fixture.detectChanges(); +// }); +// it('should load suggestions', () => { +// const expected = [ +// collection1, +// collection2 +// ]; +// +// comp.collectionSearchResults.subscribe((value) => { +// expect(value).toEqual(expected); +// } +// ); +// }); +// it('should get current url ', () => { +// expect(comp.getCurrentUrl()).toEqual('fake-url/fake-id/edit'); +// }); +// it('should on click select the correct collection name and id', () => { +// const data = collection1; +// +// comp.onClick(data); +// +// expect(comp.selectedCollectionName).toEqual('Test collection 1'); +// expect(comp.selectedCollection).toEqual(collection1); +// }); +// describe('moveCollection', () => { +// it('should call itemDataService.moveToCollection', () => { +// comp.itemId = 'item-id'; +// comp.selectedCollectionName = 'selected-collection-id'; +// comp.selectedCollection = collection1; +// comp.moveCollection(); +// +// expect(mockItemDataService.moveToCollection).toHaveBeenCalledWith('item-id', collection1); +// }); +// it('should call notificationsService success message on success', () => { +// comp.moveCollection(); +// +// expect(notificationsServiceStub.success).toHaveBeenCalled(); +// }); +// }); +// }); +// +// describe('ItemMoveComponent fail', () => { +// beforeEach(async(() => { +// TestBed.configureTestingModule({ +// imports: [CommonModule, FormsModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()], +// declarations: [VirtualMetadataComponent], +// providers: [ +// {provide: ActivatedRoute, useValue: routeStub}, +// {provide: Router, useValue: routerStub}, +// {provide: ItemDataService, useValue: mockItemDataServiceFail}, +// {provide: NotificationsService, useValue: notificationsServiceStub}, +// {provide: SearchService, useValue: mockSearchService}, +// ], schemas: [ +// CUSTOM_ELEMENTS_SCHEMA +// ] +// }).compileComponents(); +// })); +// +// beforeEach(() => { +// fixture = TestBed.createComponent(VirtualMetadataComponent); +// comp = fixture.componentInstance; +// fixture.detectChanges(); +// }); +// +// it('should call notificationsService error message on fail', () => { +// comp.moveCollection(); +// +// expect(notificationsServiceStub.error).toHaveBeenCalled(); +// }); +// }); +// }); diff --git a/src/app/+item-page/edit-item-page/virtual-metadata/virtual-metadata.component.ts b/src/app/+item-page/edit-item-page/virtual-metadata/virtual-metadata.component.ts new file mode 100644 index 0000000000..c3379750a2 --- /dev/null +++ b/src/app/+item-page/edit-item-page/virtual-metadata/virtual-metadata.component.ts @@ -0,0 +1,66 @@ +import {Component, EventEmitter, Input, OnChanges, Output} from '@angular/core'; +import {ActivatedRoute} from '@angular/router'; +import {Observable} from 'rxjs'; +import {Item} from "../../../core/shared/item.model"; +import {Relationship} from "../../../core/shared/item-relationships/relationship.model"; +import {MetadataValue} from "../../../core/shared/metadata.models"; +import {getRemoteDataPayload, getSucceededRemoteData} from "../../../core/shared/operators"; +import {ObjectUpdatesService} from "../../../core/data/object-updates/object-updates.service"; + +@Component({ + selector: 'ds-virtual-metadata', + templateUrl: './virtual-metadata.component.html' +}) +/** + * Component that handles the moving of an item to a different collection + */ +export class VirtualMetadataComponent implements OnChanges { + + /** + * The current url of this page + */ + @Input() url: string; + + @Input() relationship: Relationship; + + @Output() close = new EventEmitter(); + @Output() save = new EventEmitter(); + + constructor( + protected route: ActivatedRoute, + protected objectUpdatesService: ObjectUpdatesService, + ) { + } + + leftItem$: Observable; + rightItem$: Observable; + + ngOnChanges(): void { + this.leftItem$ = this.relationship.leftItem.pipe( + getSucceededRemoteData(), + getRemoteDataPayload(), + ); + this.rightItem$ = this.relationship.rightItem.pipe( + getSucceededRemoteData(), + getRemoteDataPayload(), + ); + } + + getVirtualMetadata(relationship: Relationship, relatedItem: Item): VirtualMetadata[] { + + return this.objectUpdatesService.getVirtualMetadataList(relationship, relatedItem); + } + + setSelectedVirtualMetadataItem(item: Item, selected: boolean) { + this.objectUpdatesService.setSelectedVirtualMetadataItem(this.url, this.relationship.id, item.uuid, selected); + } + + isSelectedVirtualMetadataItem(item: Item): Observable { + return this.objectUpdatesService.isSelectedVirtualMetadataItem(this.url, this.relationship.id, item.uuid); + } +} + +export interface VirtualMetadata { + metadataField: string, + metadataValue: MetadataValue, +} 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 6cd74b2626..17ad145eb6 100644 --- a/src/app/core/data/object-updates/object-updates.actions.ts +++ b/src/app/core/data/object-updates/object-updates.actions.ts @@ -1,7 +1,7 @@ -import { type } from '../../../shared/ngrx/type'; -import { Action } from '@ngrx/store'; -import { Identifiable } from './object-updates.reducer'; -import { INotification } from '../../../shared/notifications/models/notification.model'; +import {type} from '../../../shared/ngrx/type'; +import {Action} from '@ngrx/store'; +import {Identifiable} from './object-updates.reducer'; +import {INotification} from '../../../shared/notifications/models/notification.model'; /** * The list of ObjectUpdatesAction type definitions @@ -11,6 +11,7 @@ export const ObjectUpdatesActionTypes = { 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'), + SELECT_VIRTUAL_METADATA: type('dspace/core/cache/object-updates/SELECT_VIRTUAL_METADATA'), DISCARD: type('dspace/core/cache/object-updates/DISCARD'), REINSTATE: type('dspace/core/cache/object-updates/REINSTATE'), REMOVE: type('dspace/core/cache/object-updates/REMOVE'), @@ -83,6 +84,34 @@ export class AddFieldUpdateAction implements Action { } } +export class SelectVirtualMetadataAction implements Action { + + type = ObjectUpdatesActionTypes.SELECT_VIRTUAL_METADATA; + payload: { + url: string, + source: string, + uuid: string, + select: boolean; + }; + + /** + * Create a new AddFieldUpdateAction + * + * @param url + * the unique url of the page for which a field update is added + * @param field The identifiable field of which a new update is added + * @param changeType The update's change type + */ + constructor( + url: string, + source: string, + uuid: string, + select: boolean, + ) { + this.payload = { url, source, uuid, select: select}; + } +} + /** * An ngrx action to set the editable state of an existing field in the ObjectUpdates state for a certain page url */ @@ -242,4 +271,5 @@ export type ObjectUpdatesAction | DiscardObjectUpdatesAction | ReinstateObjectUpdatesAction | RemoveObjectUpdatesAction - | RemoveFieldUpdateAction; + | RemoveFieldUpdateAction + | SelectVirtualMetadataAction; 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 f5698b9b78..8d821c9926 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 @@ -79,7 +79,8 @@ describe('objectUpdatesReducer', () => { changeType: FieldChangeType.ADD } }, - lastModified: modDate + lastModified: modDate, + virtualMetadataSources: {}, } }; @@ -213,6 +214,7 @@ describe('objectUpdatesReducer', () => { }, }, fieldUpdates: {}, + virtualMetadataSources: {}, lastModified: modDate } }; 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 c0f10ff92a..41d1704797 100644 --- a/src/app/core/data/object-updates/object-updates.reducer.ts +++ b/src/app/core/data/object-updates/object-updates.reducer.ts @@ -7,9 +7,13 @@ import { ObjectUpdatesActionTypes, ReinstateObjectUpdatesAction, RemoveFieldUpdateAction, - RemoveObjectUpdatesAction, SetEditableFieldUpdateAction, SetValidFieldUpdateAction + RemoveObjectUpdatesAction, + SetEditableFieldUpdateAction, + SetValidFieldUpdateAction, + SelectVirtualMetadataAction, } from './object-updates.actions'; import { hasNoValue, hasValue } from '../../../shared/empty.util'; +import {Relationship} from "../../shared/item-relationships/relationship.model"; /** * Path where discarded objects are saved @@ -42,7 +46,7 @@ export interface Identifiable { /** * The state of a single field update */ -export interface FieldUpdate { +export interface FieldUpdate { field: Identifiable, changeType: FieldChangeType } @@ -54,12 +58,26 @@ export interface FieldUpdates { [uuid: string]: FieldUpdate; } +export interface VirtualMetadataSources { + [source: string]: VirtualMetadataSource +} + +export interface VirtualMetadataSource { + [uuid: string]: boolean, +} + +export interface DeleteRelationship extends Relationship { + keepLeftVirtualMetadata: boolean, + keepRightVirtualMetadata: boolean, +} + /** * The updated state of a single page */ export interface ObjectUpdatesEntry { fieldStates: FieldStates; - fieldUpdates: FieldUpdates + fieldUpdates: FieldUpdates; + virtualMetadataSources: VirtualMetadataSources; lastModified: Date; } @@ -96,6 +114,9 @@ export function objectUpdatesReducer(state = initialState, action: ObjectUpdates case ObjectUpdatesActionTypes.ADD_FIELD: { return addFieldUpdate(state, action as AddFieldUpdateAction); } + case ObjectUpdatesActionTypes.SELECT_VIRTUAL_METADATA: { + return selectVirtualMetadata(state, action as SelectVirtualMetadataAction); + } case ObjectUpdatesActionTypes.DISCARD: { return discardObjectUpdates(state, action as DiscardObjectUpdatesAction); } @@ -135,6 +156,7 @@ function initializeFieldsUpdate(state: any, action: InitializeFieldsAction) { state[url], { fieldStates: fieldStates }, { fieldUpdates: {} }, + { virtualMetadataSources: {} }, { lastModified: lastModifiedServer } ); return Object.assign({}, state, { [url]: newPageState }); @@ -169,6 +191,51 @@ function addFieldUpdate(state: any, action: AddFieldUpdateAction) { return Object.assign({}, state, { [url]: newPageState }); } +/** + * Add a new update for a specific field to the store + * @param state The current state + * @param action The action to perform on the current state + */ +function selectVirtualMetadata(state: any, action: SelectVirtualMetadataAction) { + + const url: string = action.payload.url; + const source: string = action.payload.source; + const uuid: string = action.payload.uuid; + const select: boolean = action.payload.select; + + const pageState: ObjectUpdatesEntry = state[url] || {}; + + const virtualMetadataSource = Object.assign( + {}, + pageState.virtualMetadataSources[source], + { + [uuid]: select, + }, + ); + + const virtualMetadataSources = Object.assign( + {}, + pageState.virtualMetadataSources, + { + [source]: virtualMetadataSource, + }, + ); + + const newPageState = Object.assign( + {}, + pageState, + {virtualMetadataSources: virtualMetadataSources}, + ); + + return Object.assign( + {}, + state, + { + [url]: newPageState, + } + ); +} + /** * Discard all updates for a specific action's url in the store * @param state The current state 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..0e8b1c8d07 100644 --- a/src/app/core/data/object-updates/object-updates.service.ts +++ b/src/app/core/data/object-updates/object-updates.service.ts @@ -1,16 +1,17 @@ -import { Injectable } from '@angular/core'; -import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store'; -import { CoreState } from '../../core.reducers'; -import { coreSelector } from '../../core.selectors'; +import {Injectable} from '@angular/core'; +import {createSelector, MemoizedSelector, select, Store} from '@ngrx/store'; +import {CoreState} from '../../core.reducers'; +import {coreSelector} from '../../core.selectors'; import { FieldState, FieldUpdates, Identifiable, OBJECT_UPDATES_TRASH_PATH, ObjectUpdatesEntry, - ObjectUpdatesState + ObjectUpdatesState, + VirtualMetadataSource } from './object-updates.reducer'; -import { Observable } from 'rxjs'; +import {Observable} from 'rxjs'; import { AddFieldUpdateAction, DiscardObjectUpdatesAction, @@ -18,12 +19,17 @@ import { InitializeFieldsAction, ReinstateObjectUpdatesAction, RemoveFieldUpdateAction, + SelectVirtualMetadataAction, SetEditableFieldUpdateAction, SetValidFieldUpdateAction } from './object-updates.actions'; -import { distinctUntilChanged, filter, map } from 'rxjs/operators'; -import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../../shared/empty.util'; -import { INotification } from '../../../shared/notifications/models/notification.model'; +import {distinctUntilChanged, filter, map} from 'rxjs/operators'; +import {hasNoValue, hasValue, isEmpty, isNotEmpty} from '../../../shared/empty.util'; +import {INotification} from '../../../shared/notifications/models/notification.model'; +import {Item} from "../../shared/item.model"; +import {Relationship} from "../../shared/item-relationships/relationship.model"; +import {MetadataValue} from "../../shared/metadata.models"; +import {VirtualMetadata} from "../../../+item-page/edit-item-page/virtual-metadata/virtual-metadata.component"; function objectUpdatesStateSelector(): MemoizedSelector { return createSelector(coreSelector, (state: CoreState) => state['cache/object-updates']); @@ -37,6 +43,10 @@ function filterByUrlAndUUIDFieldStateSelector(url: string, uuid: string): Memoiz return createSelector(filterByUrlObjectUpdatesStateSelector(url), (state: ObjectUpdatesEntry) => state.fieldStates[uuid]); } +function virtualMetadataSourceSelector(url: string, source: string): MemoizedSelector { + return createSelector(filterByUrlObjectUpdatesStateSelector(url), (state: ObjectUpdatesEntry) => state.virtualMetadataSources[source]); +} + /** * Service that dispatches and reads from the ObjectUpdates' state in the store */ @@ -195,6 +205,41 @@ export class ObjectUpdatesService { this.saveFieldUpdate(url, field, FieldChangeType.UPDATE); } + getVirtualMetadataList(relationship: Relationship, item: Item): VirtualMetadata[] { + return Object.entries(item.metadata) + .map(([key, value]) => + value + .filter((metadata: MetadataValue) => + metadata.authority && metadata.authority.endsWith(relationship.id)) + .map((metadata: MetadataValue) => { + return { + metadataField: key, + metadataValue: metadata, + } + }) + ) + .reduce((previous, current) => previous.concat(current)); + } + + isSelectedVirtualMetadataItem(url: string, relationship: string, item: string): Observable { + + return this.store + .pipe( + select(virtualMetadataSourceSelector(url, relationship)), + map(virtualMetadataSource => virtualMetadataSource && virtualMetadataSource[item]), + ); + } + + /** + * Method to dispatch an AddFieldUpdateAction to the store + * @param url The page's URL for which the changes are saved + * @param field An updated field for the page's object + * @param changeType The last type of change applied to this field + */ + setSelectedVirtualMetadataItem(url: string, relationship: string, item: string, selected: boolean) { + this.store.dispatch(new SelectVirtualMetadataAction(url, relationship, item, selected)); + } + /** * Dispatches a SetEditableFieldUpdateAction to the store to set a field's editable state * @param url The URL of the page on which the field resides diff --git a/src/app/core/data/relationship.service.spec.ts b/src/app/core/data/relationship.service.spec.ts index 4091759386..7d51f167d3 100644 --- a/src/app/core/data/relationship.service.spec.ts +++ b/src/app/core/data/relationship.service.spec.ts @@ -109,7 +109,7 @@ describe('RelationshipService', () => { beforeEach(() => { spyOn(service, 'findById').and.returnValue(getRemotedataObservable(relationship1)); spyOn(objectCache, 'remove'); - service.deleteRelationship(relationships[0].uuid).subscribe(); + service.deleteRelationship(relationships[0].uuid, 'none').subscribe(); }); it('should send a DeleteRequest', () => { diff --git a/src/app/core/data/relationship.service.ts b/src/app/core/data/relationship.service.ts index c466bd15af..a4a24e8aaa 100644 --- a/src/app/core/data/relationship.service.ts +++ b/src/app/core/data/relationship.service.ts @@ -31,7 +31,7 @@ import { NormalizedObjectBuildService } from '../cache/builders/normalized-objec import { Store } from '@ngrx/store'; import { CoreState } from '../core.reducers'; import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; +import {HttpClient} from '@angular/common/http'; import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; import { SearchParam } from '../cache/models/search-param.model'; @@ -83,11 +83,13 @@ export class RelationshipService extends DataService { * Send a delete request for a relationship by ID * @param uuid */ - deleteRelationship(uuid: string): Observable { + deleteRelationship(uuid: string, copyVirtualMetadata: string): Observable { return this.getRelationshipEndpoint(uuid).pipe( isNotEmptyOperator(), distinctUntilChanged(), - map((endpointURL: string) => new DeleteRequest(this.requestService.generateRequestId(), endpointURL)), + map((endpointURL: string) => + new DeleteRequest(this.requestService.generateRequestId(), endpointURL + "?copyVirtualMetadata=" + copyVirtualMetadata) + ), configureRequest(this.requestService), switchMap((restRequest: RestRequest) => this.requestService.getByUUID(restRequest.uuid)), getResponseFromEntry(), @@ -269,5 +271,4 @@ export class RelationshipService extends DataService { this.requestService.removeByHrefSubstring(rightItem.payload.self); }); } - } From b1f4a90a582f54cb0bd106e77c9cdf6c6c8c283f Mon Sep 17 00:00:00 2001 From: Samuel Date: Tue, 26 Nov 2019 17:31:47 +0100 Subject: [PATCH 25/70] taskid 66074 Keep virtual metadata on relationship delete - fix lint issues --- .../edit-item-page/edit-item-page.module.ts | 2 +- .../edit-relationship-list.component.ts | 4 ++-- .../edit-relationship.component.ts | 21 +++++++++---------- .../item-relationships.component.ts | 4 ++-- .../virtual-metadata.component.ts | 16 +++++++------- .../object-updates/object-updates.reducer.ts | 2 +- .../object-updates/object-updates.service.ts | 10 ++++----- src/app/core/data/relationship.service.ts | 2 +- 8 files changed, 30 insertions(+), 31 deletions(-) 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 6d86e7f35c..5eda9aef10 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 @@ -21,7 +21,7 @@ import { ItemRelationshipsComponent } from './item-relationships/item-relationsh import { EditRelationshipComponent } from './item-relationships/edit-relationship/edit-relationship.component'; import { EditRelationshipListComponent } from './item-relationships/edit-relationship-list/edit-relationship-list.component'; import { ItemMoveComponent } from './item-move/item-move.component'; -import {VirtualMetadataComponent} from "./virtual-metadata/virtual-metadata.component"; +import {VirtualMetadataComponent} from './virtual-metadata/virtual-metadata.component'; /** * Module that contains all components related to the Edit Item page administrator functionality diff --git a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.ts b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.ts index e7d1a14200..7645b2b4b7 100644 --- a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.ts +++ b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.ts @@ -6,7 +6,7 @@ import { RelationshipService } from '../../../../core/data/relationship.service' import { Item } from '../../../../core/shared/item.model'; import { map, switchMap} from 'rxjs/operators'; import { hasValue } from '../../../../shared/empty.util'; -import {Relationship} from "../../../../core/shared/item-relationships/relationship.model"; +import {Relationship} from '../../../../core/shared/item-relationships/relationship.model'; @Component({ selector: 'ds-edit-relationship-list', @@ -66,7 +66,7 @@ export class EditRelationshipListComponent implements OnInit, OnChanges { */ public getUpdatesByLabel(label: string): Observable { return this.relationshipService.getItemRelationshipsByLabel(this.item, label).pipe( - map(relationsRD => relationsRD.payload.page.map(relationship => + map((relationsRD) => relationsRD.payload.page.map((relationship) => Object.assign(new Relationship(), relationship, {uuid: relationship.id}) )), switchMap((initialFields) => this.objectUpdatesService.getFieldUpdatesExclusive(this.url, initialFields)), diff --git a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.ts b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.ts index 7c1780da58..220582a5fd 100644 --- a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.ts +++ b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.ts @@ -9,7 +9,7 @@ import {Item} from '../../../../core/shared/item.model'; import {getRemoteDataPayload, getSucceededRemoteData} from '../../../../core/shared/operators'; import {ViewMode} from '../../../../core/shared/view-mode.model'; import {hasValue, isNotEmpty} from '../../../../shared/empty.util'; -import {NgbModal, NgbModalRef} from "@ng-bootstrap/ng-bootstrap"; +import {NgbModal, NgbModalRef} from '@ng-bootstrap/ng-bootstrap'; @Component({ // tslint:disable-next-line:component-selector @@ -53,6 +53,11 @@ export class EditRelationshipComponent implements OnChanges { */ viewMode = ViewMode.ListElement; + /** + * Reference to NgbModal + */ + public modalRef: NgbModalRef; + constructor( private objectUpdatesService: ObjectUpdatesService, private modalService: NgbModal, @@ -93,17 +98,16 @@ export class EditRelationshipComponent implements OnChanges { this.rightItem$, ).pipe( map((items: Item[]) => - items.map(item => this.objectUpdatesService + items.map((item) => this.objectUpdatesService .isSelectedVirtualMetadataItem(this.url, this.relationship.id, item.uuid)) ), - switchMap((selection$: Observable[]) => observableCombineLatest(selection$)), + switchMap((selection$) => observableCombineLatest(selection$)), map((selection: boolean[]) => { return Object.assign({}, this.fieldUpdate.field, { - uuid: this.relationship.id, - keepLeftVirtualMetadata: selection[0] == true, - keepRightVirtualMetadata: selection[1] == true, + keepLeftVirtualMetadata: selection[0] === true, + keepRightVirtualMetadata: selection[1] === true, } ) as DeleteRelationship }), @@ -113,11 +117,6 @@ export class EditRelationshipComponent implements OnChanges { ); } - /** - * Reference to NgbModal - */ - public modalRef: NgbModalRef; - openVirtualMetadataModal(content: any) { this.modalRef = this.modalService.open(content); } 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 92b138e962..fadba3b981 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 @@ -110,7 +110,7 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent impl public submit(): void { // Get all the relationships that should be removed const removedRelationshipIDs$ = this.relationshipService.getItemRelationshipsArray(this.item).pipe( - map((relationships: Relationship[]) => relationships.map(relationship => + map((relationships: Relationship[]) => relationships.map((relationship) => Object.assign(new Relationship(), relationship, {uuid: relationship.id}) )), switchMap((relationships: Relationship[]) => { @@ -124,7 +124,7 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent impl take(1), switchMap((deleteRelationships: DeleteRelationship[]) => observableZip(...deleteRelationships.map((deleteRelationship) => { - let copyVirtualMetadata : string; + let copyVirtualMetadata: string; if (deleteRelationship.keepLeftVirtualMetadata && deleteRelationship.keepRightVirtualMetadata) { copyVirtualMetadata = 'all'; } else if (deleteRelationship.keepLeftVirtualMetadata) { diff --git a/src/app/+item-page/edit-item-page/virtual-metadata/virtual-metadata.component.ts b/src/app/+item-page/edit-item-page/virtual-metadata/virtual-metadata.component.ts index c3379750a2..170c3aec0c 100644 --- a/src/app/+item-page/edit-item-page/virtual-metadata/virtual-metadata.component.ts +++ b/src/app/+item-page/edit-item-page/virtual-metadata/virtual-metadata.component.ts @@ -1,11 +1,11 @@ import {Component, EventEmitter, Input, OnChanges, Output} from '@angular/core'; import {ActivatedRoute} from '@angular/router'; import {Observable} from 'rxjs'; -import {Item} from "../../../core/shared/item.model"; -import {Relationship} from "../../../core/shared/item-relationships/relationship.model"; -import {MetadataValue} from "../../../core/shared/metadata.models"; -import {getRemoteDataPayload, getSucceededRemoteData} from "../../../core/shared/operators"; -import {ObjectUpdatesService} from "../../../core/data/object-updates/object-updates.service"; +import {Item} from '../../../core/shared/item.model'; +import {Relationship} from '../../../core/shared/item-relationships/relationship.model'; +import {MetadataValue} from '../../../core/shared/metadata.models'; +import {getRemoteDataPayload, getSucceededRemoteData} from '../../../core/shared/operators'; +import {ObjectUpdatesService} from '../../../core/data/object-updates/object-updates.service'; @Component({ selector: 'ds-virtual-metadata', @@ -26,15 +26,15 @@ export class VirtualMetadataComponent implements OnChanges { @Output() close = new EventEmitter(); @Output() save = new EventEmitter(); + leftItem$: Observable; + rightItem$: Observable; + constructor( protected route: ActivatedRoute, protected objectUpdatesService: ObjectUpdatesService, ) { } - leftItem$: Observable; - rightItem$: Observable; - ngOnChanges(): void { this.leftItem$ = this.relationship.leftItem.pipe( getSucceededRemoteData(), 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 41d1704797..daf7555692 100644 --- a/src/app/core/data/object-updates/object-updates.reducer.ts +++ b/src/app/core/data/object-updates/object-updates.reducer.ts @@ -13,7 +13,7 @@ import { SelectVirtualMetadataAction, } from './object-updates.actions'; import { hasNoValue, hasValue } from '../../../shared/empty.util'; -import {Relationship} from "../../shared/item-relationships/relationship.model"; +import {Relationship} from '../../shared/item-relationships/relationship.model'; /** * Path where discarded objects are saved 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 0e8b1c8d07..5063a8a005 100644 --- a/src/app/core/data/object-updates/object-updates.service.ts +++ b/src/app/core/data/object-updates/object-updates.service.ts @@ -26,10 +26,10 @@ import { import {distinctUntilChanged, filter, map} from 'rxjs/operators'; import {hasNoValue, hasValue, isEmpty, isNotEmpty} from '../../../shared/empty.util'; import {INotification} from '../../../shared/notifications/models/notification.model'; -import {Item} from "../../shared/item.model"; -import {Relationship} from "../../shared/item-relationships/relationship.model"; -import {MetadataValue} from "../../shared/metadata.models"; -import {VirtualMetadata} from "../../../+item-page/edit-item-page/virtual-metadata/virtual-metadata.component"; +import {Item} from '../../shared/item.model'; +import {Relationship} from '../../shared/item-relationships/relationship.model'; +import {MetadataValue} from '../../shared/metadata.models'; +import {VirtualMetadata} from '../../../+item-page/edit-item-page/virtual-metadata/virtual-metadata.component'; function objectUpdatesStateSelector(): MemoizedSelector { return createSelector(coreSelector, (state: CoreState) => state['cache/object-updates']); @@ -226,7 +226,7 @@ export class ObjectUpdatesService { return this.store .pipe( select(virtualMetadataSourceSelector(url, relationship)), - map(virtualMetadataSource => virtualMetadataSource && virtualMetadataSource[item]), + map((virtualMetadataSource) => virtualMetadataSource && virtualMetadataSource[item]), ); } diff --git a/src/app/core/data/relationship.service.ts b/src/app/core/data/relationship.service.ts index a4a24e8aaa..895cb46ed1 100644 --- a/src/app/core/data/relationship.service.ts +++ b/src/app/core/data/relationship.service.ts @@ -88,7 +88,7 @@ export class RelationshipService extends DataService { isNotEmptyOperator(), distinctUntilChanged(), map((endpointURL: string) => - new DeleteRequest(this.requestService.generateRequestId(), endpointURL + "?copyVirtualMetadata=" + copyVirtualMetadata) + new DeleteRequest(this.requestService.generateRequestId(), endpointURL + '?copyVirtualMetadata=' + copyVirtualMetadata) ), configureRequest(this.requestService), switchMap((restRequest: RestRequest) => this.requestService.getByUUID(restRequest.uuid)), From 891415daae78bb1c19ebef10c2d4dbd914504c86 Mon Sep 17 00:00:00 2001 From: Samuel Date: Tue, 3 Dec 2019 16:52:41 +0100 Subject: [PATCH 26/70] taskid 66076 Keep virtual metadata on item delete - tests & docs & small fixes --- resources/i18n/en.json5 | 2 +- .../edit-relationship.component.ts | 2 +- .../item-relationships.component.spec.ts | 17 ++++++----- .../item-relationships.component.ts | 7 +++-- .../virtual-metadata.component.html | 2 +- .../virtual-metadata.component.ts | 18 +++++++++-- .../object-updates.reducer.spec.ts | 24 ++++++++++++--- .../object-updates/object-updates.reducer.ts | 12 +++++++- .../object-updates.service.spec.ts | 12 ++++++-- .../object-updates/object-updates.service.ts | 30 +++++-------------- 10 files changed, 81 insertions(+), 45 deletions(-) diff --git a/resources/i18n/en.json5 b/resources/i18n/en.json5 index 0dc5b51c05..0c34aa88b2 100644 --- a/resources/i18n/en.json5 +++ b/resources/i18n/en.json5 @@ -1686,6 +1686,6 @@ "uploader.queue-length": "Queue length", - "virtual-metadata-modal.head": "Select the items for which you want to save the virtual metadata as real metadata", + "virtual-metadata.delete-relationship.modal-head": "Select the items for which you want to save the virtual metadata as real metadata", } diff --git a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.ts b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.ts index 220582a5fd..3e24801992 100644 --- a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.ts +++ b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.ts @@ -99,7 +99,7 @@ export class EditRelationshipComponent implements OnChanges { ).pipe( map((items: Item[]) => items.map((item) => this.objectUpdatesService - .isSelectedVirtualMetadataItem(this.url, this.relationship.id, item.uuid)) + .isSelectedVirtualMetadata(this.url, this.relationship.id, item.uuid)) ), switchMap((selection$) => observableCombineLatest(selection$)), map((selection: boolean[]) => { diff --git a/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.spec.ts b/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.spec.ts index 48bc28a1b9..4ac3f05a40 100644 --- a/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.spec.ts +++ b/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.spec.ts @@ -110,11 +110,14 @@ describe('ItemRelationshipsComponent', () => { relationships[1].rightItem = observableOf(new RemoteData(false, false, true, undefined, item)); fieldUpdate1 = { - field: author1, + field: relationships[0], changeType: undefined }; fieldUpdate2 = { - field: author2, + field: Object.assign( + relationships[1], + {keepLeftVirtualMetadata: true, keepRightVirtualMetadata: false} + ), changeType: FieldChangeType.REMOVE }; @@ -130,12 +133,12 @@ describe('ItemRelationshipsComponent', () => { objectUpdatesService = jasmine.createSpyObj('objectUpdatesService', { getFieldUpdates: observableOf({ - [author1.uuid]: fieldUpdate1, - [author2.uuid]: fieldUpdate2 + [relationships[0].uuid]: fieldUpdate1, + [relationships[1].uuid]: fieldUpdate2 }), getFieldUpdatesExclusive: observableOf({ - [author1.uuid]: fieldUpdate1, - [author2.uuid]: fieldUpdate2 + [relationships[0].uuid]: fieldUpdate1, + [relationships[1].uuid]: fieldUpdate2 }), saveAddFieldUpdate: {}, discardFieldUpdates: {}, @@ -227,7 +230,7 @@ describe('ItemRelationshipsComponent', () => { }); it('it should delete the correct relationship', () => { - expect(relationshipService.deleteRelationship).toHaveBeenCalledWith(relationships[1].uuid); + expect(relationshipService.deleteRelationship).toHaveBeenCalledWith(relationships[1].uuid, 'left'); }); }); }); 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 fadba3b981..1e678884b1 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 @@ -116,8 +116,11 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent impl switchMap((relationships: Relationship[]) => { return this.objectUpdatesService.getFieldUpdatesExclusive(this.url, relationships) as Observable }), - map((fieldUpdates: FieldUpdates) => Object.values(fieldUpdates).filter((fieldUpdate: FieldUpdate) => fieldUpdate.changeType === FieldChangeType.REMOVE)), - map((fieldUpdates: FieldUpdate[]) => fieldUpdates.map((fieldUpdate: FieldUpdate) => fieldUpdate.field) as DeleteRelationship[]), + map((fieldUpdates: FieldUpdates) => + Object.values(fieldUpdates) + .filter((fieldUpdate: FieldUpdate) => fieldUpdate.changeType === FieldChangeType.REMOVE) + .map((fieldUpdate: FieldUpdate) => fieldUpdate.field as DeleteRelationship) + ), isNotEmptyOperator(), ); removedRelationshipIDs$.pipe( diff --git a/src/app/+item-page/edit-item-page/virtual-metadata/virtual-metadata.component.html b/src/app/+item-page/edit-item-page/virtual-metadata/virtual-metadata.component.html index aafdb00b47..8a0269d6b7 100644 --- a/src/app/+item-page/edit-item-page/virtual-metadata/virtual-metadata.component.html +++ b/src/app/+item-page/edit-item-page/virtual-metadata/virtual-metadata.component.html @@ -1,5 +1,5 @@
-