diff --git a/.travis.yml b/.travis.yml index c42923886d..5e4bae7892 100644 --- a/.travis.yml +++ b/.travis.yml @@ -22,6 +22,11 @@ before_install: - sudo mv docker-compose /usr/local/bin install: + # update chrome + - wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | sudo apt-key add - + - sudo sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google-chrome.list' + - sudo apt-get update + - sudo apt-get install google-chrome-stable # Start up DSpace 7 using the entities database dump - docker-compose -f ./docker/docker-compose-travis.yml up -d # Use the dspace-cli image to populate the assetstore. Trigger a discovery and oai update diff --git a/config/environment.default.js b/config/environment.default.js index 24386d6cf7..58193d31bc 100644 --- a/config/environment.default.js +++ b/config/environment.default.js @@ -185,6 +185,11 @@ module.exports = { undoTimeout: 10000 // 10 seconds } }, + collection: { + edit: { + undoTimeout: 10000 // 10 seconds + } + }, theme: { name: 'default', } diff --git a/e2e/app.e2e-spec.ts b/e2e/app.e2e-spec.ts index 6856a6f01b..995340941f 100644 --- a/e2e/app.e2e-spec.ts +++ b/e2e/app.e2e-spec.ts @@ -13,7 +13,7 @@ describe('protractor App', () => { }); it('should contain a news section', () => { - page.navigateTo(); - expect(page.getHomePageNewsText()).toBeDefined(); + page.navigateTo() + .then(() => expect(page.getHomePageNewsText()).toBeDefined()); }); }); diff --git a/e2e/app.po.ts b/e2e/app.po.ts index c76bef118f..2ee9a86201 100644 --- a/e2e/app.po.ts +++ b/e2e/app.po.ts @@ -11,6 +11,6 @@ export class ProtractorPage { } getHomePageNewsText() { - return element(by.xpath('//ds-home-news')).getText(); + return element(by.css('ds-home-news')).getText(); } } diff --git a/e2e/search-page/search-page.e2e-spec.ts b/e2e/search-page/search-page.e2e-spec.ts index cb9c92a87b..e2ab6de824 100644 --- a/e2e/search-page/search-page.e2e-spec.ts +++ b/e2e/search-page/search-page.e2e-spec.ts @@ -11,33 +11,36 @@ describe('protractor SearchPage', () => { it('should contain query value when navigating to page with query parameter', () => { const queryString = 'Interesting query string'; - page.navigateToSearchWithQueryParameter(queryString); - page.getCurrentQuery().then((query: string) => { - expect(query).toEqual(queryString); - }); + page.navigateToSearchWithQueryParameter(queryString) + .then(() => page.getCurrentQuery()) + .then((query: string) => { + expect(query).toEqual(queryString); + }); }); it('should have right scope selected when navigating to page with scope parameter', () => { - const scope: promise.Promise = page.getRandomScopeOption(); - scope.then((scopeString: string) => { - page.navigateToSearchWithScopeParameter(scopeString); - page.getCurrentScope().then((s: string) => { - expect(s).toEqual(scopeString); + page.navigateToSearch() + .then(() => page.getRandomScopeOption()) + .then((scopeString: string) => { + page.navigateToSearchWithScopeParameter(scopeString); + page.getCurrentScope().then((s: string) => { + expect(s).toEqual(scopeString); + }); }); - }); }); it('should redirect to the correct url when scope was set and submit button was triggered', () => { - const scope: promise.Promise = page.getRandomScopeOption(); - scope.then((scopeString: string) => { - page.setCurrentScope(scopeString); - page.submitSearchForm(); - browser.wait(() => { - return browser.getCurrentUrl().then((url: string) => { - return url.indexOf('scope=' + encodeURI(scopeString)) !== -1; + page.navigateToSearch() + .then(() => page.getRandomScopeOption()) + .then((scopeString: string) => { + page.setCurrentScope(scopeString); + page.submitSearchForm(); + browser.wait(() => { + return browser.getCurrentUrl().then((url: string) => { + return url.indexOf('scope=' + encodeURI(scopeString)) !== -1; + }); }); }); - }); }); it('should redirect to the correct url when query was set and submit button was triggered', () => { diff --git a/resources/i18n/en.json5 b/resources/i18n/en.json5 index 91f249aba6..27e8388d86 100644 --- a/resources/i18n/en.json5 +++ b/resources/i18n/en.json5 @@ -338,8 +338,40 @@ "collection.edit.tabs.roles.title": "Collection Edit - Roles", + "collection.edit.tabs.source.external": "This collection harvests its content from an external source", + + "collection.edit.tabs.source.form.errors.oaiSource.required": "You must provide a set id of the target collection.", + + "collection.edit.tabs.source.form.harvestType": "Content being harvested", + + "collection.edit.tabs.source.form.head": "Configure an external source", + + "collection.edit.tabs.source.form.metadataConfigId": "Metadata Format", + + "collection.edit.tabs.source.form.oaiSetId": "OAI specific set id", + + "collection.edit.tabs.source.form.oaiSource": "OAI Provider", + + "collection.edit.tabs.source.form.options.harvestType.METADATA_AND_BITSTREAMS": "Harvest metadata and bitstreams (requires ORE support)", + + "collection.edit.tabs.source.form.options.harvestType.METADATA_AND_REF": "Harvest metadata and references to bitstreams (requires ORE support)", + + "collection.edit.tabs.source.form.options.harvestType.METADATA_ONLY": "Harvest metadata only", + "collection.edit.tabs.source.head": "Content Source", + "collection.edit.tabs.source.notifications.discarded.content": "Your changes were discarded. To reinstate your changes click the 'Undo' button", + + "collection.edit.tabs.source.notifications.discarded.title": "Changed discarded", + + "collection.edit.tabs.source.notifications.invalid.content": "Your changes were not saved. Please make sure all fields are valid before you save.", + + "collection.edit.tabs.source.notifications.invalid.title": "Metadata invalid", + + "collection.edit.tabs.source.notifications.saved.content": "Your changes to this collection's content source were saved.", + + "collection.edit.tabs.source.notifications.saved.title": "Content Source saved", + "collection.edit.tabs.source.title": "Collection Edit - Content Source", @@ -382,6 +414,12 @@ + "collection.source.update.notifications.error.content": "The provided settings have been tested and didn't work.", + + "collection.source.update.notifications.error.title": "Server Error", + + + "communityList.tabTitle": "DSpace - Community List", "communityList.title": "List of Communities", @@ -532,6 +570,8 @@ "error.validation.pattern": "This input is restricted by the current pattern: {{ pattern }}.", + "error.validation.filerequired": "The file upload is mandatory", + "footer.copyright": "copyright © 2002-{{ year }}", @@ -813,7 +853,7 @@ "item.edit.tabs.relationships.head": "Item Relationships", - "item.edit.tabs.relationships.title": "Item Edit - Relationships", + "item.edit.tabs.relationships.title": "Item Edit - Relationships", "item.edit.tabs.status.buttons.authorizations.button": "Authorizations...", @@ -997,6 +1037,8 @@ "loading.collections": "Loading collections...", + "loading.content-source": "Loading content source...", + "loading.community": "Loading community...", "loading.default": "Loading...", @@ -1424,6 +1466,8 @@ "relationships.isSingleVolumeOf": "Journal Volume", "relationships.isVolumeOf": "Journal Volumes", + + "relationships.isContributorOf": "Contributors", @@ -1627,8 +1671,67 @@ "submission.general.save-later": "Save for later", + "submission.sections.describe.relationship-lookup.close": "Close", + "submission.sections.describe.relationship-lookup.external-source.added": "Successfully added local entry to the selection", + + "submission.sections.describe.relationship-lookup.external-source.import-button-title.Author": "Import remote author", + + "submission.sections.describe.relationship-lookup.external-source.import-button-title.Journal": "Import remote journal", + + "submission.sections.describe.relationship-lookup.external-source.import-button-title.Journal Issue": "Import remote journal issue", + + "submission.sections.describe.relationship-lookup.external-source.import-button-title.Journal Volume": "Import remote journal volume", + + "submission.sections.describe.relationship-lookup.external-source.import-modal.Author.title": "Import Remote Author", + + "submission.sections.describe.relationship-lookup.external-source.import-modal.Author.added.local-entity": "Successfully added local author to the selection", + + "submission.sections.describe.relationship-lookup.external-source.import-modal.Author.added.new-entity": "Successfully imported and added external author to the selection", + + "submission.sections.describe.relationship-lookup.external-source.import-modal.authority": "Authority", + + "submission.sections.describe.relationship-lookup.external-source.import-modal.authority.new": "Import as a new local authority entry", + + "submission.sections.describe.relationship-lookup.external-source.import-modal.cancel": "Cancel", + + "submission.sections.describe.relationship-lookup.external-source.import-modal.collection": "Select a collection to import new entries to", + + "submission.sections.describe.relationship-lookup.external-source.import-modal.entities": "Entities", + + "submission.sections.describe.relationship-lookup.external-source.import-modal.entities.new": "Import as a new local entity", + + "submission.sections.describe.relationship-lookup.external-source.import-modal.head.lcname": "Importing from LC Name", + + "submission.sections.describe.relationship-lookup.external-source.import-modal.head.orcidV2": "Importing from ORCID", + + "submission.sections.describe.relationship-lookup.external-source.import-modal.head.sherpaJournal": "Importing from Sherpa Journal", + + "submission.sections.describe.relationship-lookup.external-source.import-modal.head.sherpaPublisher": "Importing from Sherpa Publisher", + + "submission.sections.describe.relationship-lookup.external-source.import-modal.import": "Import", + + "submission.sections.describe.relationship-lookup.external-source.import-modal.Journal.title": "Import Remote Journal", + + "submission.sections.describe.relationship-lookup.external-source.import-modal.Journal.added.local-entity": "Successfully added local journal to the selection", + + "submission.sections.describe.relationship-lookup.external-source.import-modal.Journal.added.new-entity": "Successfully imported and added external journal to the selection", + + "submission.sections.describe.relationship-lookup.external-source.import-modal.Journal Issue.title": "Import Remote Journal Issue", + + "submission.sections.describe.relationship-lookup.external-source.import-modal.Journal Issue.added.local-entity": "Successfully added local journal issue to the selection", + + "submission.sections.describe.relationship-lookup.external-source.import-modal.Journal Issue.added.new-entity": "Successfully imported and added external journal issue to the selection", + + "submission.sections.describe.relationship-lookup.external-source.import-modal.Journal Volume.title": "Import Remote Journal Volume", + + "submission.sections.describe.relationship-lookup.external-source.import-modal.Journal Volume.added.local-entity": "Successfully added local journal volume to the selection", + + "submission.sections.describe.relationship-lookup.external-source.import-modal.Journal Volume.added.new-entity": "Successfully imported and added external journal volume to the selection", + + "submission.sections.describe.relationship-lookup.external-source.import-modal.select": "Select a local match:", + "submission.sections.describe.relationship-lookup.search-tab.deselect-all": "Deselect all", "submission.sections.describe.relationship-lookup.search-tab.deselect-page": "Deselect page", @@ -1875,11 +1978,16 @@ "uploader.drag-message": "Drag & Drop your files here", - "uploader.or": ", or ", + "uploader.or": ", or", "uploader.processing": "Processing", "uploader.queue-length": "Queue length", + "virtual-metadata.delete-item.info": "Select the types for which you want to save the virtual metadata as real metadata", + + "virtual-metadata.delete-item.modal-head": "The virtual metadata of this relation", + + "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/+collection-page/edit-collection-page/collection-source/collection-source.component.html b/src/app/+collection-page/edit-collection-page/collection-source/collection-source.component.html index e69de29bb2..4192922c7e 100644 --- a/src/app/+collection-page/edit-collection-page/collection-source/collection-source.component.html +++ b/src/app/+collection-page/edit-collection-page/collection-source/collection-source.component.html @@ -0,0 +1,56 @@ +
+
+ + + +
+

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

+
+ + +
+ +

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

+
+ +
+
+ + + +
+
diff --git a/src/app/+collection-page/edit-collection-page/collection-source/collection-source.component.spec.ts b/src/app/+collection-page/edit-collection-page/collection-source/collection-source.component.spec.ts new file mode 100644 index 0000000000..11ec9b1f6a --- /dev/null +++ b/src/app/+collection-page/edit-collection-page/collection-source/collection-source.component.spec.ts @@ -0,0 +1,222 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { RouterTestingModule } from '@angular/router/testing'; +import { ActivatedRoute, Router } from '@angular/router'; +import { of as observableOf } from 'rxjs/internal/observable/of'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { CollectionSourceComponent } from './collection-source.component'; +import { ContentSource, ContentSourceHarvestType } from '../../../core/shared/content-source.model'; +import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service'; +import { INotification, Notification } from '../../../shared/notifications/models/notification.model'; +import { NotificationType } from '../../../shared/notifications/models/notification-type'; +import { FieldUpdate } from '../../../core/data/object-updates/object-updates.reducer'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { DynamicFormControlModel, DynamicFormService } from '@ng-dynamic-forms/core'; +import { hasValue } from '../../../shared/empty.util'; +import { FormControl, FormGroup } from '@angular/forms'; +import { RouterStub } from '../../../shared/testing/router-stub'; +import { GLOBAL_CONFIG } from '../../../../config'; +import { By } from '@angular/platform-browser'; +import { Collection } from '../../../core/shared/collection.model'; +import { RemoteData } from '../../../core/data/remote-data'; +import { CollectionDataService } from '../../../core/data/collection-data.service'; +import { RequestService } from '../../../core/data/request.service'; + +const infoNotification: INotification = new Notification('id', NotificationType.Info, 'info'); +const warningNotification: INotification = new Notification('id', NotificationType.Warning, 'warning'); +const successNotification: INotification = new Notification('id', NotificationType.Success, 'success'); + +const uuid = '29481ed7-ae6b-409a-8c51-34dd347a0ce4'; +let date: Date; +let contentSource: ContentSource; +let fieldUpdate: FieldUpdate; +let objectUpdatesService: ObjectUpdatesService; +let notificationsService: NotificationsService; +let location: Location; +let formService: DynamicFormService; +let router: any; +let collection: Collection; +let collectionService: CollectionDataService; +let requestService: RequestService; + +describe('CollectionSourceComponent', () => { + let comp: CollectionSourceComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + date = new Date(); + contentSource = Object.assign(new ContentSource(), { + uuid: uuid, + metadataConfigs: [ + { + id: 'dc', + label: 'Simple Dublin Core', + nameSpace: 'http://www.openarchives.org/OAI/2.0/oai_dc/' + }, + { + id: 'qdc', + label: 'Qualified Dublin Core', + nameSpace: 'http://purl.org/dc/terms/' + }, + { + id: 'dim', + label: 'DSpace Intermediate Metadata', + nameSpace: 'http://www.dspace.org/xmlns/dspace/dim' + } + ] + }); + fieldUpdate = { + field: contentSource, + changeType: undefined + }; + objectUpdatesService = jasmine.createSpyObj('objectUpdatesService', + { + getFieldUpdates: observableOf({ + [contentSource.uuid]: fieldUpdate + }), + saveAddFieldUpdate: {}, + discardFieldUpdates: {}, + reinstateFieldUpdates: observableOf(true), + initialize: {}, + getUpdatedFields: observableOf([contentSource]), + getLastModified: observableOf(date), + hasUpdates: observableOf(true), + isReinstatable: observableOf(false), + isValidPage: observableOf(true) + } + ); + notificationsService = jasmine.createSpyObj('notificationsService', + { + info: infoNotification, + warning: warningNotification, + success: successNotification + } + ); + location = jasmine.createSpyObj('location', ['back']); + formService = Object.assign({ + createFormGroup: (fModel: DynamicFormControlModel[]) => { + const controls = {}; + if (hasValue(fModel)) { + fModel.forEach((controlModel) => { + controls[controlModel.id] = new FormControl((controlModel as any).value); + }); + return new FormGroup(controls); + } + return undefined; + } + }); + router = Object.assign(new RouterStub(), { + url: 'http://test-url.com/test-url' + }); + collection = Object.assign(new Collection(), { + uuid: 'fake-collection-id' + }); + collectionService = jasmine.createSpyObj('collectionService', { + getContentSource: observableOf(contentSource), + updateContentSource: observableOf(contentSource), + getHarvesterEndpoint: observableOf('harvester-endpoint') + }); + requestService = jasmine.createSpyObj('requestService', ['removeByHrefSubstring']); + + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), RouterTestingModule], + declarations: [CollectionSourceComponent], + providers: [ + { provide: ObjectUpdatesService, useValue: objectUpdatesService }, + { provide: NotificationsService, useValue: notificationsService }, + { provide: Location, useValue: location }, + { provide: DynamicFormService, useValue: formService }, + { provide: ActivatedRoute, useValue: { parent: { data: observableOf({ dso: new RemoteData(false, false, true, null, collection) }) } } }, + { provide: Router, useValue: router }, + { provide: GLOBAL_CONFIG, useValue: { collection: { edit: { undoTimeout: 10 } } } as any }, + { provide: CollectionDataService, useValue: collectionService }, + { provide: RequestService, useValue: requestService } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(CollectionSourceComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + }); + + describe('on startup', () => { + let form; + + beforeEach(() => { + form = fixture.debugElement.query(By.css('ds-form')); + }); + + it('ContentSource should be disabled', () => { + expect(comp.contentSource.harvestType).toEqual(ContentSourceHarvestType.None); + }); + + it('the input-form should be hidden', () => { + expect(form).toBeNull(); + }); + }); + + describe('when selecting the checkbox', () => { + let input; + let form; + + beforeEach(() => { + input = fixture.debugElement.query(By.css('#externalSourceCheck')).nativeElement; + input.click(); + fixture.detectChanges(); + form = fixture.debugElement.query(By.css('ds-form')); + }); + + it('should enable ContentSource', () => { + expect(comp.contentSource.harvestType).not.toEqual(ContentSourceHarvestType.None); + }); + + it('should send a field update', () => { + expect(objectUpdatesService.saveAddFieldUpdate).toHaveBeenCalledWith(router.url, comp.contentSource) + }); + + it('should display the form', () => { + expect(form).not.toBeNull(); + }); + }); + + describe('isValid', () => { + it('should return true when ContentSource is disabled but the form invalid', () => { + spyOnProperty(comp.formGroup, 'valid').and.returnValue(false); + comp.contentSource.harvestType = ContentSourceHarvestType.None; + expect(comp.isValid()).toBe(true); + }); + + it('should return false when ContentSource is enabled but the form is invalid', () => { + spyOnProperty(comp.formGroup, 'valid').and.returnValue(false); + comp.contentSource.harvestType = ContentSourceHarvestType.Metadata; + expect(comp.isValid()).toBe(false); + }); + + it('should return true when ContentSource is enabled and the form is valid', () => { + spyOnProperty(comp.formGroup, 'valid').and.returnValue(true); + comp.contentSource.harvestType = ContentSourceHarvestType.Metadata; + expect(comp.isValid()).toBe(true); + }); + }); + + describe('onSubmit', () => { + beforeEach(() => { + comp.onSubmit(); + }); + + it('should re-initialize the field updates', () => { + expect(objectUpdatesService.initialize).toHaveBeenCalled(); + }); + + it('should display a success notification', () => { + expect(notificationsService.success).toHaveBeenCalled(); + }); + + it('should call updateContentSource on the collectionService', () => { + expect(collectionService.updateContentSource).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/app/+collection-page/edit-collection-page/collection-source/collection-source.component.ts b/src/app/+collection-page/edit-collection-page/collection-source/collection-source.component.ts index 6ec5be884d..5fcc740663 100644 --- a/src/app/+collection-page/edit-collection-page/collection-source/collection-source.component.ts +++ b/src/app/+collection-page/edit-collection-page/collection-source/collection-source.component.ts @@ -1,4 +1,37 @@ -import { Component } from '@angular/core'; +import { Component, Inject, OnDestroy, OnInit } from '@angular/core'; +import { AbstractTrackableComponent } from '../../../shared/trackable/abstract-trackable.component'; +import { + DynamicFormControlModel, + DynamicFormGroupModel, + DynamicFormLayout, + DynamicFormService, + DynamicInputModel, + DynamicOptionControlModel, + DynamicRadioGroupModel, + DynamicSelectModel, + DynamicTextAreaModel +} from '@ng-dynamic-forms/core'; +import { Location } from '@angular/common'; +import { TranslateService } from '@ngx-translate/core'; +import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { FormGroup } from '@angular/forms'; +import { hasNoValue, hasValue, isNotEmpty } from '../../../shared/empty.util'; +import { ContentSource, ContentSourceHarvestType } from '../../../core/shared/content-source.model'; +import { Observable } from 'rxjs/internal/Observable'; +import { RemoteData } from '../../../core/data/remote-data'; +import { Collection } from '../../../core/shared/collection.model'; +import { first, map, switchMap, take } from 'rxjs/operators'; +import { ActivatedRoute, Router } from '@angular/router'; +import { FieldUpdate, FieldUpdates } from '../../../core/data/object-updates/object-updates.reducer'; +import { Subscription } from 'rxjs/internal/Subscription'; +import { cloneDeep } from 'lodash'; +import { GLOBAL_CONFIG, GlobalConfig } from '../../../../config'; +import { CollectionDataService } from '../../../core/data/collection-data.service'; +import { getSucceededRemoteData } from '../../../core/shared/operators'; +import { MetadataConfig } from '../../../core/shared/metadata-config.model'; +import { INotification } from '../../../shared/notifications/models/notification.model'; +import { RequestService } from '../../../core/data/request.service'; /** * Component for managing the content source of the collection @@ -7,6 +40,440 @@ import { Component } from '@angular/core'; selector: 'ds-collection-source', templateUrl: './collection-source.component.html', }) -export class CollectionSourceComponent { - /* TODO: Implement Collection Edit - Content Source */ +export class CollectionSourceComponent extends AbstractTrackableComponent implements OnInit, OnDestroy { + /** + * The current collection's remote data + */ + collectionRD$: Observable>; + + /** + * The collection's content source + */ + contentSource: ContentSource; + + /** + * The current update to the content source + */ + update$: Observable; + + /** + * The initial harvest type we started off with + * Used to compare changes + */ + initialHarvestType: ContentSourceHarvestType; + + /** + * @type {string} Key prefix used to generate form labels + */ + LABEL_KEY_PREFIX = 'collection.edit.tabs.source.form.'; + + /** + * @type {string} Key prefix used to generate form error messages + */ + ERROR_KEY_PREFIX = 'collection.edit.tabs.source.form.errors.'; + + /** + * @type {string} Key prefix used to generate form option labels + */ + OPTIONS_KEY_PREFIX = 'collection.edit.tabs.source.form.options.'; + + /** + * The Dynamic Input Model for the OAI Provider + */ + oaiSourceModel = new DynamicInputModel({ + id: 'oaiSource', + name: 'oaiSource', + required: true, + validators: { + required: null + }, + errorMessages: { + required: 'You must provide a set id of the target collection.' + } + }); + + /** + * The Dynamic Input Model for the OAI Set + */ + oaiSetIdModel = new DynamicInputModel({ + id: 'oaiSetId', + name: 'oaiSetId' + }); + + /** + * The Dynamic Input Model for the Metadata Format used + */ + metadataConfigIdModel = new DynamicSelectModel({ + id: 'metadataConfigId', + name: 'metadataConfigId' + }); + + /** + * The Dynamic Input Model for the type of harvesting + */ + harvestTypeModel = new DynamicRadioGroupModel({ + id: 'harvestType', + name: 'harvestType', + options: [ + { + value: ContentSourceHarvestType.Metadata + }, + { + value: ContentSourceHarvestType.MetadataAndRef + }, + { + value: ContentSourceHarvestType.MetadataAndBitstreams + } + ] + }); + + /** + * All input models in a simple array for easier iterations + */ + inputModels = [this.oaiSourceModel, this.oaiSetIdModel, this.metadataConfigIdModel, this.harvestTypeModel]; + + /** + * The dynamic form fields used for editing the content source of a collection + * @type {(DynamicInputModel | DynamicTextAreaModel)[]} + */ + formModel: DynamicFormControlModel[] = [ + new DynamicFormGroupModel({ + id: 'oaiSourceContainer', + group: [ + this.oaiSourceModel + ] + }), + new DynamicFormGroupModel({ + id: 'oaiSetContainer', + group: [ + this.oaiSetIdModel, + this.metadataConfigIdModel + ] + }), + new DynamicFormGroupModel({ + id: 'harvestTypeContainer', + group: [ + this.harvestTypeModel + ] + }) + ]; + + /** + * Layout used for structuring the form inputs + */ + formLayout: DynamicFormLayout = { + oaiSource: { + grid: { + host: 'col-12 d-inline-block' + } + }, + oaiSetId: { + grid: { + host: 'col col-sm-6 d-inline-block' + } + }, + metadataConfigId: { + grid: { + host: 'col col-sm-6 d-inline-block' + } + }, + harvestType: { + grid: { + host: 'col-12', + option: 'btn-outline-secondary' + } + }, + oaiSetContainer: { + grid: { + host: 'row' + } + }, + oaiSourceContainer: { + grid: { + host: 'row' + } + }, + harvestTypeContainer: { + grid: { + host: 'row' + } + } + }; + + /** + * The form group of this form + */ + formGroup: FormGroup; + + /** + * Subscription to update the current form + */ + updateSub: Subscription; + + /** + * The content harvesting type used when harvesting is disabled + */ + harvestTypeNone = ContentSourceHarvestType.None; + + /** + * The previously selected harvesting type + * Used for switching between ContentSourceHarvestType.None and the previously selected value when enabling / disabling harvesting + * Defaults to ContentSourceHarvestType.Metadata + */ + previouslySelectedHarvestType = ContentSourceHarvestType.Metadata; + + /** + * Notifications displayed after clicking submit + * These are cleaned up every time a user submits the form to prevent error or other notifications from staying active + * while they shouldn't be. + */ + displayedNotifications: INotification[] = []; + + public constructor(public objectUpdatesService: ObjectUpdatesService, + public notificationsService: NotificationsService, + protected location: Location, + protected formService: DynamicFormService, + protected translate: TranslateService, + protected route: ActivatedRoute, + protected router: Router, + @Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, + protected collectionService: CollectionDataService, + protected requestService: RequestService) { + super(objectUpdatesService, notificationsService, translate); + } + + /** + * Initialize properties to setup the Field Update and Form + */ + ngOnInit(): void { + this.notificationsPrefix = 'collection.edit.tabs.source.notifications.'; + this.discardTimeOut = this.EnvConfig.collection.edit.undoTimeout; + this.url = this.router.url; + if (this.url.indexOf('?') > 0) { + this.url = this.url.substr(0, this.url.indexOf('?')); + } + this.formGroup = this.formService.createFormGroup(this.formModel); + this.collectionRD$ = this.route.parent.data.pipe(first(), map((data) => data.dso)); + + this.collectionRD$.pipe( + getSucceededRemoteData(), + map((col) => col.payload.uuid), + switchMap((uuid) => this.collectionService.getContentSource(uuid)), + take(1) + ).subscribe((contentSource: ContentSource) => { + this.initializeOriginalContentSource(contentSource); + }); + + this.updateFieldTranslations(); + this.translate.onLangChange + .subscribe(() => { + this.updateFieldTranslations(); + }); + } + + /** + * Initialize the Field Update and subscribe on it to fire updates to the form whenever it changes + */ + initializeOriginalContentSource(contentSource: ContentSource) { + this.contentSource = contentSource; + this.initialHarvestType = contentSource.harvestType; + this.initializeMetadataConfigs(); + const initialContentSource = cloneDeep(this.contentSource); + this.objectUpdatesService.initialize(this.url, [initialContentSource], new Date()); + this.update$ = this.objectUpdatesService.getFieldUpdates(this.url, [initialContentSource]).pipe( + map((updates: FieldUpdates) => updates[initialContentSource.uuid]) + ); + this.updateSub = this.update$.subscribe((update: FieldUpdate) => { + if (update) { + const field = update.field as ContentSource; + let configId; + if (hasValue(this.contentSource) && isNotEmpty(this.contentSource.metadataConfigs)) { + configId = this.contentSource.metadataConfigs[0].id; + } + if (hasValue(field) && hasValue(field.metadataConfigId)) { + configId = field.metadataConfigId; + } + if (hasValue(field)) { + this.formGroup.patchValue({ + oaiSourceContainer: { + oaiSource: field.oaiSource + }, + oaiSetContainer: { + oaiSetId: field.oaiSetId, + metadataConfigId: configId + }, + harvestTypeContainer: { + harvestType: field.harvestType + } + }); + this.contentSource = cloneDeep(field); + } + this.contentSource.metadataConfigId = configId; + } + }); + } + + /** + * Fill the metadataConfigIdModel's options using the contentSource's metadataConfigs property + */ + initializeMetadataConfigs() { + this.metadataConfigIdModel.options = this.contentSource.metadataConfigs + .map((metadataConfig: MetadataConfig) => Object.assign({ value: metadataConfig.id, label: metadataConfig.label })); + if (this.metadataConfigIdModel.options.length > 0) { + this.formGroup.patchValue({ + oaiSetContainer: { + metadataConfigId: this.metadataConfigIdModel.options[0].value + } + }); + } + } + + /** + * Used the update translations of errors and labels on init and on language change + */ + private updateFieldTranslations() { + this.inputModels.forEach( + (fieldModel: DynamicFormControlModel) => { + this.updateFieldTranslation(fieldModel); + } + ); + } + + /** + * Update the translations of a DynamicInputModel + * @param fieldModel + */ + private updateFieldTranslation(fieldModel: DynamicFormControlModel) { + fieldModel.label = this.translate.instant(this.LABEL_KEY_PREFIX + fieldModel.id); + if (isNotEmpty(fieldModel.validators)) { + fieldModel.errorMessages = {}; + Object.keys(fieldModel.validators).forEach((key) => { + fieldModel.errorMessages[key] = this.translate.instant(this.ERROR_KEY_PREFIX + fieldModel.id + '.' + key); + }); + } + if (fieldModel instanceof DynamicOptionControlModel) { + if (isNotEmpty(fieldModel.options)) { + fieldModel.options.forEach((option) => { + if (hasNoValue(option.label)) { + option.label = this.translate.instant(this.OPTIONS_KEY_PREFIX + fieldModel.id + '.' + option.value); + } + }); + } + } + } + + /** + * Fired whenever the form receives an update and makes sure the Content Source and field update is up-to-date with the changes + * @param event + */ + onChange(event) { + this.updateContentSourceField(event.model, true); + this.saveFieldUpdate(); + } + + /** + * Submit the edited Content Source to the REST API, re-initialize the field update and display a notification + */ + onSubmit() { + // Remove cached harvester request to allow for latest harvester to be displayed when switching tabs + this.collectionRD$.pipe( + getSucceededRemoteData(), + map((col) => col.payload.uuid), + switchMap((uuid) => this.collectionService.getHarvesterEndpoint(uuid)), + take(1) + ).subscribe((endpoint) => this.requestService.removeByHrefSubstring(endpoint)); + + // Update harvester + this.collectionRD$.pipe( + getSucceededRemoteData(), + map((col) => col.payload.uuid), + switchMap((uuid) => this.collectionService.updateContentSource(uuid, this.contentSource)), + take(1) + ).subscribe((result: ContentSource | INotification) => { + if (hasValue((result as any).harvestType)) { + this.clearNotifications(); + this.initializeOriginalContentSource(result as ContentSource); + this.displayedNotifications.push(this.notificationsService.success(this.getNotificationTitle('saved'), this.getNotificationContent('saved'))); + } else { + this.displayedNotifications.push(result as INotification); + } + }); + } + + /** + * Cancel the edit and return to the previous page + */ + onCancel() { + this.location.back(); + } + + /** + * Is the current form valid to be submitted ? + */ + isValid(): boolean { + return (this.contentSource.harvestType === ContentSourceHarvestType.None) || this.formGroup.valid; + } + + /** + * Switch the external source on or off and fire a field update + */ + changeExternalSource() { + if (this.contentSource.harvestType === ContentSourceHarvestType.None) { + this.contentSource.harvestType = this.previouslySelectedHarvestType; + } else { + this.previouslySelectedHarvestType = this.contentSource.harvestType; + this.contentSource.harvestType = ContentSourceHarvestType.None; + } + this.updateContentSource(false); + } + + /** + * Loop over all inputs and update the Content Source with their value + * @param updateHarvestType When set to false, the harvestType of the contentSource will be ignored in the update + */ + updateContentSource(updateHarvestType: boolean) { + this.inputModels.forEach( + (fieldModel: DynamicInputModel) => { + this.updateContentSourceField(fieldModel, updateHarvestType) + } + ); + this.saveFieldUpdate(); + } + + /** + * Update the Content Source with the value from a DynamicInputModel + * @param fieldModel The fieldModel to fetch the value from and update the contentSource with + * @param updateHarvestType When set to false, the harvestType of the contentSource will be ignored in the update + */ + updateContentSourceField(fieldModel: DynamicInputModel, updateHarvestType: boolean) { + if (hasValue(fieldModel.value) && !(fieldModel.id === this.harvestTypeModel.id && !updateHarvestType)) { + this.contentSource[fieldModel.id] = fieldModel.value; + } + } + + /** + * Save the current Content Source to the Object Updates cache + */ + saveFieldUpdate() { + this.objectUpdatesService.saveAddFieldUpdate(this.url, cloneDeep(this.contentSource)); + } + + /** + * Clear possible active notifications + */ + clearNotifications() { + this.displayedNotifications.forEach((notification: INotification) => { + this.notificationsService.remove(notification); + }); + this.displayedNotifications = []; + } + + /** + * Make sure open subscriptions are closed + */ + ngOnDestroy(): void { + if (this.updateSub) { + this.updateSub.unsubscribe(); + } + } } diff --git a/src/app/+item-page/edit-item-page/edit-item-page.module.ts b/src/app/+item-page/edit-item-page/edit-item-page.module.ts index 77740f0c6c..71924cf6c8 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,6 +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'; /** * Module that contains all components related to the Edit Item page administrator functionality @@ -51,6 +52,7 @@ import { ItemMoveComponent } from './item-move/item-move.component'; EditRelationshipListComponent, ItemCollectionMapperComponent, ItemMoveComponent, + VirtualMetadataComponent, ] }) export class EditItemPageModule { diff --git a/src/app/+item-page/edit-item-page/item-delete/item-delete.component.html b/src/app/+item-page/edit-item-page/item-delete/item-delete.component.html new file mode 100644 index 0000000000..5e7297409b --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-delete/item-delete.component.html @@ -0,0 +1,98 @@ +
+
+
+ +

{{headerMessage | translate: {id: item.handle} }}

+

{{descriptionMessage | translate}}

+ + + + +
+ + {{'virtual-metadata.delete-item.info' | translate}} + +
+ +
+ +
+ +
+ +
+
+ {{getRelationshipMessageKey(getLabel(type) | async) | translate}} +
+
+ + + + +
+
+ +
+
+ + +
+ + +
+
+ +
+
+
+ +
+ +
+ +
+ +
+ + + + +
+
+
diff --git a/src/app/+item-page/edit-item-page/item-delete/item-delete.component.spec.ts b/src/app/+item-page/edit-item-page/item-delete/item-delete.component.spec.ts index 82d03f1f1b..00ae038dae 100644 --- a/src/app/+item-page/edit-item-page/item-delete/item-delete.component.spec.ts +++ b/src/app/+item-page/edit-item-page/item-delete/item-delete.component.spec.ts @@ -1,44 +1,132 @@ -import { async, ComponentFixture, TestBed } from '@angular/core/testing'; -import { Item } from '../../../core/shared/item.model'; -import { RouterStub } from '../../../shared/testing/router-stub'; -import { of as observableOf } from 'rxjs'; -import { RemoteData } from '../../../core/data/remote-data'; -import { NotificationsServiceStub } from '../../../shared/testing/notifications-service-stub'; -import { CommonModule } from '@angular/common'; -import { FormsModule } from '@angular/forms'; -import { RouterTestingModule } from '@angular/router/testing'; -import { TranslateModule } from '@ngx-translate/core'; -import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; -import { ActivatedRoute, Router } from '@angular/router'; -import { ItemDataService } from '../../../core/data/item-data.service'; -import { NotificationsService } from '../../../shared/notifications/notifications.service'; -import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; -import { By } from '@angular/platform-browser'; -import { ItemDeleteComponent } from './item-delete.component'; -import { getItemEditPath } from '../../item-page-routing.module'; -import { RestResponse } from '../../../core/cache/response.models'; -import { createSuccessfulRemoteDataObject } from '../../../shared/testing/utils'; +import {async, ComponentFixture, TestBed} from '@angular/core/testing'; +import { ItemType } from '../../../core/shared/item-relationships/item-type.model'; +import { Relationship } from '../../../core/shared/item-relationships/relationship.model'; +import {Item} from '../../../core/shared/item.model'; +import {RouterStub} from '../../../shared/testing/router-stub'; +import {of as observableOf} from 'rxjs'; +import {NotificationsServiceStub} from '../../../shared/testing/notifications-service-stub'; +import {CommonModule} from '@angular/common'; +import {FormsModule} from '@angular/forms'; +import {RouterTestingModule} from '@angular/router/testing'; +import {TranslateModule} from '@ngx-translate/core'; +import {NgbModule} from '@ng-bootstrap/ng-bootstrap'; +import {ActivatedRoute, Router} from '@angular/router'; +import {ItemDataService} from '../../../core/data/item-data.service'; +import {NotificationsService} from '../../../shared/notifications/notifications.service'; +import {CUSTOM_ELEMENTS_SCHEMA} from '@angular/core'; +import {By} from '@angular/platform-browser'; +import {ItemDeleteComponent} from './item-delete.component'; +import {getItemEditPath} from '../../item-page-routing.module'; +import {createSuccessfulRemoteDataObject} from '../../../shared/testing/utils'; +import {VarDirective} from '../../../shared/utils/var.directive'; +import {ObjectUpdatesService} from '../../../core/data/object-updates/object-updates.service'; +import {RelationshipService} from '../../../core/data/relationship.service'; +import {RelationshipType} from '../../../core/shared/item-relationships/relationship-type.model'; +import {RemoteData} from '../../../core/data/remote-data'; +import {PaginatedList} from '../../../core/data/paginated-list'; +import {PageInfo} from '../../../core/shared/page-info.model'; +import {EntityTypeService} from '../../../core/data/entity-type.service'; let comp: ItemDeleteComponent; let fixture: ComponentFixture; let mockItem; +let itemType; +let type1; +let type2; +let types; +let relationships; let itemPageUrl; let routerStub; let mockItemDataService: ItemDataService; let routeStub; +let objectUpdatesServiceStub; +let relationshipService; +let entityTypeService; let notificationsServiceStub; +let typesSelection; describe('ItemDeleteComponent', () => { beforeEach(async(() => { mockItem = Object.assign(new Item(), { id: 'fake-id', + uuid: 'fake-uuid', handle: 'fake/handle', lastModified: '2018', isWithdrawn: true }); + itemType = Object.assign(new ItemType(), { + id: 'itemType', + uuid: 'itemType', + }); + + type1 = Object.assign(new RelationshipType(), { + id: '1', + uuid: 'type-1', + }); + + type2 = Object.assign(new RelationshipType(), { + id: '2', + uuid: 'type-2', + }); + + types = [type1, type2]; + + relationships = [ + Object.assign(new Relationship(), { + id: '1', + uuid: 'relationship-1', + relationshipType: observableOf(new RemoteData( + false, + false, + true, + null, + type1 + )), + leftItem: observableOf(new RemoteData( + false, + false, + true, + null, + mockItem, + )), + rightItem: observableOf(new RemoteData( + false, + false, + true, + null, + Object.assign(new Item(), {}) + )), + }), + Object.assign(new Relationship(), { + id: '2', + uuid: 'relationship-2', + relationshipType: observableOf(new RemoteData( + false, + false, + true, + null, + type2 + )), + leftItem: observableOf(new RemoteData( + false, + false, + true, + null, + mockItem, + )), + rightItem: observableOf(new RemoteData( + false, + false, + true, + null, + Object.assign(new Item(), {}) + )), + }), + ]; + itemPageUrl = `fake-url/${mockItem.id}`; routerStub = Object.assign(new RouterStub(), { url: `${itemPageUrl}/edit` @@ -54,16 +142,56 @@ describe('ItemDeleteComponent', () => { }) }; + typesSelection = { + type1: false, + type2: true, + }; + + entityTypeService = jasmine.createSpyObj('entityTypeService', + { + getEntityTypeByLabel: observableOf(new RemoteData( + false, + false, + true, + null, + itemType, + )), + getEntityTypeRelationships: observableOf(new RemoteData( + false, + false, + true, + null, + new PaginatedList(new PageInfo(), types), + )), + } + ); + + objectUpdatesServiceStub = { + initialize: () => { + // do nothing + }, + isSelectedVirtualMetadata: (type) => observableOf(typesSelection[type]), + }; + + relationshipService = jasmine.createSpyObj('relationshipService', + { + getItemRelationshipsArray: observableOf(relationships), + } + ); + notificationsServiceStub = new NotificationsServiceStub(); TestBed.configureTestingModule({ imports: [CommonModule, FormsModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()], - declarations: [ItemDeleteComponent], + declarations: [ItemDeleteComponent, VarDirective], providers: [ { provide: ActivatedRoute, useValue: routeStub }, { provide: Router, useValue: routerStub }, { provide: ItemDataService, useValue: mockItemDataService }, { provide: NotificationsService, useValue: notificationsServiceStub }, + { provide: ObjectUpdatesService, useValue: objectUpdatesServiceStub }, + { provide: RelationshipService, useValue: relationshipService }, + { provide: EntityTypeService, useValue: entityTypeService }, ], schemas: [ CUSTOM_ELEMENTS_SCHEMA ] @@ -91,7 +219,8 @@ describe('ItemDeleteComponent', () => { it('should call delete function from the ItemDataService', () => { spyOn(comp, 'notify'); comp.performAction(); - expect(mockItemDataService.delete).toHaveBeenCalledWith(mockItem); + expect(mockItemDataService.delete) + .toHaveBeenCalledWith(mockItem, types.filter((type) => typesSelection[type]).map((type) => type.id)); expect(comp.notify).toHaveBeenCalled(); }); }); diff --git a/src/app/+item-page/edit-item-page/item-delete/item-delete.component.ts b/src/app/+item-page/edit-item-page/item-delete/item-delete.component.ts index 2700b45475..6fe44c109b 100644 --- a/src/app/+item-page/edit-item-page/item-delete/item-delete.component.ts +++ b/src/app/+item-page/edit-item-page/item-delete/item-delete.component.ts @@ -1,29 +1,323 @@ -import { Component } from '@angular/core'; -import { first } from 'rxjs/operators'; -import { AbstractSimpleItemActionComponent } from '../simple-item-action/abstract-simple-item-action.component'; -import { getItemEditPath } from '../../item-page-routing.module'; -import { RestResponse } from '../../../core/cache/response.models'; +import {Component, Input, OnInit} from '@angular/core'; +import {filter, first, map, switchMap, take} from 'rxjs/operators'; +import {AbstractSimpleItemActionComponent} from '../simple-item-action/abstract-simple-item-action.component'; +import {getItemEditPath} from '../../item-page-routing.module'; +import {NgbModal, NgbModalRef} from '@ng-bootstrap/ng-bootstrap'; +import {combineLatest as observableCombineLatest, combineLatest, Observable} from 'rxjs'; +import {RelationshipType} from '../../../core/shared/item-relationships/relationship-type.model'; +import {VirtualMetadata} from '../virtual-metadata/virtual-metadata.component'; +import {Relationship} from '../../../core/shared/item-relationships/relationship.model'; +import {getRemoteDataPayload, getSucceededRemoteData} from '../../../core/shared/operators'; +import {hasValue, isNotEmpty} from '../../../shared/empty.util'; +import {Item} from '../../../core/shared/item.model'; +import {MetadataValue} from '../../../core/shared/metadata.models'; +import {ViewMode} from '../../../core/shared/view-mode.model'; +import {ActivatedRoute, Router} from '@angular/router'; +import {NotificationsService} from '../../../shared/notifications/notifications.service'; +import {ItemDataService} from '../../../core/data/item-data.service'; +import {TranslateService} from '@ngx-translate/core'; +import {ObjectUpdatesService} from '../../../core/data/object-updates/object-updates.service'; +import {RelationshipService} from '../../../core/data/relationship.service'; +import {EntityTypeService} from '../../../core/data/entity-type.service'; @Component({ selector: 'ds-item-delete', - templateUrl: '../simple-item-action/abstract-simple-item-action.component.html' + templateUrl: '../item-delete/item-delete.component.html' }) /** * Component responsible for rendering the item delete page */ -export class ItemDeleteComponent extends AbstractSimpleItemActionComponent { +export class ItemDeleteComponent + extends AbstractSimpleItemActionComponent + implements OnInit { + + /** + * The current url of this page + */ + @Input() url: string; protected messageKey = 'delete'; /** - * Perform the delete action to the item + * The view-mode we're currently on + */ + viewMode = ViewMode.ListElement; + + /** + * A list of the relationship types for which this item has relations as an observable. + * The list doesn't contain duplicates. + */ + types$: Observable; + + /** + * A map which stores the relationships of this item for each type as observable lists + */ + relationships$: Map> + = new Map>(); + + /** + * A map which stores the related item of each relationship of this item as an observable + */ + relatedItems$: Map> = new Map>(); + + /** + * A map which stores the virtual metadata (of the related) item corresponding to each relationship of this item + * as an observable list + */ + virtualMetadata$: Map> = new Map>(); + + /** + * Reference to NgbModal + */ + public modalRef: NgbModalRef; + + constructor(protected route: ActivatedRoute, + protected router: Router, + protected notificationsService: NotificationsService, + protected itemDataService: ItemDataService, + protected translateService: TranslateService, + protected modalService: NgbModal, + protected objectUpdatesService: ObjectUpdatesService, + protected relationshipService: RelationshipService, + protected entityTypeService: EntityTypeService, + ) { + super( + route, + router, + notificationsService, + itemDataService, + translateService, + ); + } + + /** + * Set up and initialize all fields + */ + ngOnInit() { + + super.ngOnInit(); + this.url = this.router.url; + + this.types$ = this.entityTypeService.getEntityTypeByLabel( + this.item.firstMetadataValue('relationship.type') + ).pipe( + getSucceededRemoteData(), + getRemoteDataPayload(), + switchMap((entityType) => this.entityTypeService.getEntityTypeRelationships(entityType.id)), + getSucceededRemoteData(), + getRemoteDataPayload(), + map((relationshipTypes) => relationshipTypes.page), + switchMap((types) => + combineLatest(types.map((type) => this.getRelationships(type))).pipe( + map((relationships) => + types.reduce((includedTypes, type, index) => { + if (!includedTypes.some((includedType) => includedType.id === type.id) + && !(relationships[index].length === 0)) { + return [...includedTypes, type]; + } else { + return includedTypes; + } + }, []) + ), + ) + ), + ); + + this.types$.pipe( + take(1), + ).subscribe((types) => + this.objectUpdatesService.initialize(this.url, types, this.item.lastModified) + ); + } + + /** + * Open the modal which lists the virtual metadata of a relation + * @param content the html content of the modal + */ + openVirtualMetadataModal(content: any) { + this.modalRef = this.modalService.open(content); + } + + /** + * Close the modal which lists the virtual metadata of a relation + */ + closeVirtualMetadataModal() { + this.modalRef.close(); + } + + /** + * Get the i18n message key for a relationship + * @param label The relationship type's label + */ + getRelationshipMessageKey(label: string): string { + if (hasValue(label) && label.indexOf('Of') > -1) { + return `relationships.${label.substring(0, label.indexOf('Of') + 2)}` + } else { + return label; + } + } + + /** + * Get the relationship type label relevant for this item as an observable + * @param relationshipType the relationship type to get the label for + */ + getLabel(relationshipType: RelationshipType): Observable { + + return this.getRelationships(relationshipType).pipe( + switchMap((relationships) => + this.isLeftItem(relationships[0]).pipe( + map((isLeftItem) => isLeftItem ? relationshipType.leftwardType : relationshipType.rightwardType), + ) + ), + ) + } + + /** + * Get the relationships of this item with a given type as an observable + * @param relationshipType the relationship type to filter the item's relationships on + */ + getRelationships(relationshipType: RelationshipType): Observable { + + if (!this.relationships$.has(relationshipType)) { + this.relationships$.set( + relationshipType, + this.relationshipService.getItemRelationshipsArray(this.item).pipe( + // filter on type + switchMap((relationships) => + observableCombineLatest( + relationships.map((relationship) => this.getRelationshipType(relationship)) + ).pipe( + map((types) => relationships.filter( + (relationship, index) => relationshipType.id === types[index].id + )), + ) + ), + ) + ); + } + + return this.relationships$.get(relationshipType); + } + + /** + * Get the type of a given relationship as an observable + * @param relationship the relationship to get the type for + */ + private getRelationshipType(relationship: Relationship): Observable { + + return relationship.relationshipType.pipe( + getSucceededRemoteData(), + getRemoteDataPayload(), + filter((relationshipType: RelationshipType) => hasValue(relationshipType) && isNotEmpty(relationshipType.uuid)) + ); + } + + /** + * Get the item this item is related to through a given relationship as an observable + * @param relationship the relationship to get the other item for + */ + getRelatedItem(relationship: Relationship): Observable { + + if (!this.relatedItems$.has(relationship)) { + + this.relatedItems$.set( + relationship, + this.isLeftItem(relationship).pipe( + switchMap((isLeftItem) => isLeftItem ? relationship.rightItem : relationship.leftItem), + getSucceededRemoteData(), + getRemoteDataPayload(), + ), + ); + } + + return this.relatedItems$.get(relationship); + } + + /** + * Get the virtual metadata for a given relationship of the related item. + * @param relationship the relationship to get the virtual metadata for + */ + getVirtualMetadata(relationship: Relationship): Observable { + + if (!this.virtualMetadata$.has(relationship)) { + + this.virtualMetadata$.set( + relationship, + this.getRelatedItem(relationship).pipe( + map((relatedItem) => + Object.entries(relatedItem.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)) + ), + ) + ); + } + + return this.virtualMetadata$.get(relationship); + } + + /** + * Check whether this item is the left item of a given relationship, as an observable boolean + * @param relationship the relationship for which to check whether this item is the left item + */ + private isLeftItem(relationship: Relationship): Observable { + + return relationship.leftItem.pipe( + getSucceededRemoteData(), + getRemoteDataPayload(), + filter((item: Item) => hasValue(item) && isNotEmpty(item.uuid)), + map((leftItem) => leftItem.uuid === this.item.uuid) + ); + } + + /** + * Check whether a given relationship type is selected to save the corresponding virtual metadata + * @param type the relationship type for which to check whether it is selected + */ + isSelected(type: RelationshipType): Observable { + return this.objectUpdatesService.isSelectedVirtualMetadata(this.url, this.item.uuid, type.uuid); + } + + /** + * Select/deselect a given relationship type to save the corresponding virtual metadata + * @param type the relationship type to select/deselect + * @param selected whether the type should be selected + */ + setSelected(type: RelationshipType, selected: boolean): void { + this.objectUpdatesService.setSelectedVirtualMetadata(this.url, this.item.uuid, type.uuid, selected); + } + + /** + * Perform the delete operation */ performAction() { - this.itemDataService.delete(this.item).pipe(first()).subscribe( - (succeeded: boolean) => { - this.notify(succeeded); - } - ); + + this.types$.pipe( + switchMap((types) => + combineLatest( + types.map((type) => this.isSelected(type)) + ).pipe( + map((selection) => types.filter( + (type, index) => selection[index] + )), + map((selectedTypes) => selectedTypes.map((type) => type.id)), + ) + ), + ).subscribe((types) => { + this.itemDataService.delete(this.item, types).pipe(first()).subscribe( + (succeeded: boolean) => { + this.notify(succeeded); + } + ); + }); } /** diff --git a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.html b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.html index ba5164e81a..1a7cc2e2df 100644 --- a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.html +++ b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.html @@ -1,15 +1,15 @@ - -
-
{{getRelationshipMessageKey(relationshipLabel) | translate}}
- -
-
- +
{{getRelationshipMessageKey() | async | translate}}
+ + + + + + -
+
no relationships
diff --git a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.spec.ts b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.spec.ts index 54cb2837a2..cede48e6ee 100644 --- a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.spec.ts +++ b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.spec.ts @@ -1,27 +1,26 @@ -import { EditRelationshipListComponent } from './edit-relationship-list.component'; -import { async, ComponentFixture, TestBed } from '@angular/core/testing'; -import { RelationshipType } from '../../../../core/shared/item-relationships/relationship-type.model'; -import { ResourceType } from '../../../../core/shared/resource-type'; -import { Relationship } from '../../../../core/shared/item-relationships/relationship.model'; -import { of as observableOf } from 'rxjs/internal/observable/of'; -import { RemoteData } from '../../../../core/data/remote-data'; -import { Item } from '../../../../core/shared/item.model'; -import { PaginatedList } from '../../../../core/data/paginated-list'; -import { PageInfo } from '../../../../core/shared/page-info.model'; -import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions'; -import { SharedModule } from '../../../../shared/shared.module'; -import { TranslateModule } from '@ngx-translate/core'; -import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service'; -import { RelationshipService } from '../../../../core/data/relationship.service'; -import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; -import { By } from '@angular/platform-browser'; +import {EditRelationshipListComponent} from './edit-relationship-list.component'; +import {async, ComponentFixture, TestBed} from '@angular/core/testing'; +import {RelationshipType} from '../../../../core/shared/item-relationships/relationship-type.model'; +import {Relationship} from '../../../../core/shared/item-relationships/relationship.model'; +import {of as observableOf} from 'rxjs/internal/observable/of'; +import {RemoteData} from '../../../../core/data/remote-data'; +import {Item} from '../../../../core/shared/item.model'; +import {PaginatedList} from '../../../../core/data/paginated-list'; +import {PageInfo} from '../../../../core/shared/page-info.model'; +import {FieldChangeType} from '../../../../core/data/object-updates/object-updates.actions'; +import {SharedModule} from '../../../../shared/shared.module'; +import {TranslateModule} from '@ngx-translate/core'; +import {ObjectUpdatesService} from '../../../../core/data/object-updates/object-updates.service'; +import {DebugElement, NO_ERRORS_SCHEMA} from '@angular/core'; +import {By} from '@angular/platform-browser'; +import {ItemType} from '../../../../core/shared/item-relationships/item-type.model'; let comp: EditRelationshipListComponent; let fixture: ComponentFixture; let de: DebugElement; let objectUpdatesService; -let relationshipService; +let entityTypeService; const url = 'http://test-url.com/test-url'; @@ -30,42 +29,66 @@ let author1; let author2; let fieldUpdate1; let fieldUpdate2; -let relationships; +let relationship1; +let relationship2; let relationshipType; +let entityType; +let relatedEntityType; describe('EditRelationshipListComponent', () => { - beforeEach(async(() => { + + beforeEach(() => { + + entityType = Object.assign(new ItemType(), { + id: 'entityType', + }); + + relatedEntityType = Object.assign(new ItemType(), { + id: 'relatedEntityType', + }); + relationshipType = Object.assign(new RelationshipType(), { id: '1', uuid: '1', leftwardType: 'isAuthorOfPublication', - rightwardType: 'isPublicationOfAuthor' + rightwardType: 'isPublicationOfAuthor', + leftType: observableOf(new RemoteData(false, false, true, undefined, entityType)), + rightType: observableOf(new RemoteData(false, false, true, undefined, relatedEntityType)), }); - relationships = [ - Object.assign(new Relationship(), { - self: url + '/2', - id: '2', - uuid: '2', - leftId: 'author1', - rightId: 'publication', - relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType)) - }), - Object.assign(new Relationship(), { - self: url + '/3', - id: '3', - uuid: '3', - leftId: 'author2', - rightId: 'publication', - relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType)) - }) - ]; + relationship1 = Object.assign(new Relationship(), { + self: url + '/2', + id: '2', + uuid: '2', + leftId: 'author1', + rightId: 'publication', + leftItem: observableOf(new RemoteData(false, false, true, undefined, item)), + rightItem: observableOf(new RemoteData(false, false, true, undefined, author1)), + relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType)) + }); + + relationship2 = Object.assign(new Relationship(), { + self: url + '/3', + id: '3', + uuid: '3', + leftId: 'author2', + rightId: 'publication', + leftItem: observableOf(new RemoteData(false, false, true, undefined, item)), + rightItem: observableOf(new RemoteData(false, false, true, undefined, author2)), + relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType)) + }); item = Object.assign(new Item(), { self: 'fake-item-url/publication', id: 'publication', uuid: 'publication', - relationships: observableOf(new RemoteData(false, false, true, undefined, new PaginatedList(new PageInfo(), relationships))) + relationships: observableOf(new RemoteData( + false, + false, + true, + undefined, + new PaginatedList(new PageInfo(), [relationship1, relationship2]) + )) }); author1 = Object.assign(new Item(), { @@ -88,16 +111,29 @@ describe('EditRelationshipListComponent', () => { objectUpdatesService = jasmine.createSpyObj('objectUpdatesService', { - getFieldUpdatesExclusive: observableOf({ + getFieldUpdates: observableOf({ [author1.uuid]: fieldUpdate1, [author2.uuid]: fieldUpdate2 }) } ); - relationshipService = jasmine.createSpyObj('relationshipService', + entityTypeService = jasmine.createSpyObj('entityTypeService', { - getRelatedItemsByLabel: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), [author1, author2]))), + getEntityTypeByLabel: observableOf(new RemoteData( + false, + false, + true, + null, + entityType, + )), + getEntityTypeRelationships: observableOf(new RemoteData( + false, + false, + true, + null, + new PaginatedList(new PageInfo(), [relationshipType]), + )), } ); @@ -106,29 +142,27 @@ describe('EditRelationshipListComponent', () => { declarations: [EditRelationshipListComponent], providers: [ { provide: ObjectUpdatesService, useValue: objectUpdatesService }, - { provide: RelationshipService, useValue: relationshipService } ], schemas: [ NO_ERRORS_SCHEMA ] }).compileComponents(); - })); - beforeEach(() => { fixture = TestBed.createComponent(EditRelationshipListComponent); comp = fixture.componentInstance; de = fixture.debugElement; + comp.item = item; + comp.itemType = entityType; comp.url = url; - comp.relationshipLabel = relationshipType.leftwardType; + comp.relationshipType = relationshipType; + fixture.detectChanges(); }); describe('changeType is REMOVE', () => { - beforeEach(() => { - fieldUpdate1.changeType = FieldChangeType.REMOVE; - fixture.detectChanges(); - }); it('the div should have class alert-danger', () => { + + fieldUpdate1.changeType = FieldChangeType.REMOVE; const element = de.queryAll(By.css('.relationship-row'))[1].nativeElement; expect(element.classList).toContain('alert-danger'); }); 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..73e3e1f875 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 @@ -1,13 +1,15 @@ -import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'; +import { Component, Input, OnInit } from '@angular/core'; import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service'; 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 { hasValue } from '../../../../shared/empty.util'; -import { RemoteData } from '../../../../core/data/remote-data'; -import { PaginatedList } from '../../../../core/data/paginated-list'; +import {FieldUpdate, FieldUpdates} from '../../../../core/data/object-updates/object-updates.reducer'; +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 {RelationshipType} from '../../../../core/shared/item-relationships/relationship-type.model'; +import {getRemoteDataPayload, getSucceededRemoteData} from '../../../../core/shared/operators'; +import {combineLatest as observableCombineLatest, combineLatest} from 'rxjs'; +import {ItemType} from '../../../../core/shared/item-relationships/item-type.model'; @Component({ selector: 'ds-edit-relationship-list', @@ -18,12 +20,15 @@ import { PaginatedList } from '../../../../core/data/paginated-list'; * A component creating a list of editable relationships of a certain type * The relationships are rendered as a list of related items */ -export class EditRelationshipListComponent implements OnInit, OnChanges { +export class EditRelationshipListComponent implements OnInit { + /** * The item to display related items for */ @Input() item: Item; + @Input() itemType: ItemType; + /** * The URL to the current page * Used to fetch updates for the current item from the store @@ -33,7 +38,7 @@ export class EditRelationshipListComponent implements OnInit, OnChanges { /** * The label of the relationship-type we're rendering a list for */ - @Input() relationshipLabel: string; + @Input() relationshipType: RelationshipType; /** * The FieldUpdates for the relationships in question @@ -42,53 +47,42 @@ export class EditRelationshipListComponent implements OnInit, OnChanges { constructor( protected objectUpdatesService: ObjectUpdatesService, - protected relationshipService: RelationshipService ) { } - ngOnInit(): void { - this.initUpdates(); - } + /** + * Get the i18n message key for this relationship type + */ + public getRelationshipMessageKey(): Observable { - ngOnChanges(changes: SimpleChanges): void { - this.initUpdates(); + return this.getLabel().pipe( + map((label) => { + if (hasValue(label) && label.indexOf('Of') > -1) { + return `relationships.${label.substring(0, label.indexOf('Of') + 2)}` + } else { + return label; + } + }), + ); } /** - * Initialize the FieldUpdates using the related items + * Get the relevant label for this relationship type */ - initUpdates() { - this.updates$ = this.getUpdatesByLabel(this.relationshipLabel); - } + private getLabel(): Observable { - /** - * 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)) - ) - } - - /** - * Get the i18n message key for a relationship - * @param label The relationship type's label - */ - public getRelationshipMessageKey(label: string): string { - if (hasValue(label) && label.indexOf('Of') > -1) { - return `relationships.${label.substring(0, label.indexOf('Of') + 2)}` - } else { - return label; - } + return combineLatest([ + this.relationshipType.leftType, + this.relationshipType.rightType, + ].map((itemTypeRD) => itemTypeRD.pipe( + getSucceededRemoteData(), + getRemoteDataPayload(), + ))).pipe( + map((itemTypes) => [ + this.relationshipType.leftwardType, + this.relationshipType.rightwardType, + ][itemTypes.findIndex((itemType) => itemType.id === this.itemType.id)]), + ); } /** @@ -98,4 +92,26 @@ export class EditRelationshipListComponent implements OnInit, OnChanges { return update && update.field ? update.field.uuid : undefined; } + ngOnInit(): void { + this.updates$ = this.item.relationships.pipe( + map((relationships) => relationships.payload.page.filter((relationship) => relationship)), + switchMap((itemRelationships) => + observableCombineLatest( + itemRelationships + .map((relationship) => relationship.relationshipType.pipe( + getSucceededRemoteData(), + getRemoteDataPayload(), + )) + ).pipe( + map((relationshipTypes) => itemRelationships.filter( + (relationship, index) => relationshipTypes[index].id === this.relationshipType.id) + ), + map((relationships) => relationships.map((relationship) => + Object.assign(new Relationship(), relationship, {uuid: relationship.id}) + )), + ) + ), + switchMap((initialFields) => this.objectUpdatesService.getFieldUpdates(this.url, initialFields)), + ); + } } 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..7e61e8958f 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..b3c3e773b2 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 @@ -11,11 +11,13 @@ import { Item } from '../../../../core/shared/item.model'; import { PaginatedList } from '../../../../core/data/paginated-list'; import { PageInfo } from '../../../../core/shared/page-info.model'; import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; -let objectUpdatesService: ObjectUpdatesService; +let objectUpdatesService; const url = 'http://test-url.com/test-url'; let item; +let relatedItem; let author1; let author2; let fieldUpdate1; @@ -29,7 +31,9 @@ let de; let el; describe('EditRelationshipComponent', () => { + beforeEach(async(() => { + relationshipType = Object.assign(new RelationshipType(), { id: '1', uuid: '1', @@ -37,6 +41,17 @@ describe('EditRelationshipComponent', () => { rightwardType: 'isPublicationOfAuthor' }); + item = Object.assign(new Item(), { + self: 'fake-item-url/publication', + id: 'publication', + uuid: 'publication', + relationships: observableOf(new RemoteData(false, false, true, undefined, new PaginatedList(new PageInfo(), relationships))) + }); + + relatedItem = Object.assign(new Item(), { + uuid: 'related item id', + }); + relationships = [ Object.assign(new Relationship(), { self: url + '/2', @@ -44,7 +59,9 @@ describe('EditRelationshipComponent', () => { uuid: '2', leftId: 'author1', rightId: 'publication', - relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType)) + relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType)), + leftItem: observableOf(new RemoteData(false, false, true, undefined, relatedItem)), + rightItem: observableOf(new RemoteData(false, false, true, undefined, item)), }), Object.assign(new Relationship(), { self: url + '/3', @@ -56,13 +73,6 @@ describe('EditRelationshipComponent', () => { }) ]; - item = Object.assign(new Item(), { - self: 'fake-item-url/publication', - id: 'publication', - uuid: 'publication', - relationships: observableOf(new RemoteData(false, false, true, undefined, new PaginatedList(new PageInfo(), relationships))) - }); - author1 = Object.assign(new Item(), { id: 'author1', uuid: 'author1' @@ -73,38 +83,44 @@ describe('EditRelationshipComponent', () => { }); fieldUpdate1 = { - field: author1, + field: relationships[0], changeType: undefined }; fieldUpdate2 = { - field: author2, + field: relationships[1], changeType: FieldChangeType.REMOVE }; - objectUpdatesService = jasmine.createSpyObj('objectUpdatesService', - { - saveChangeFieldUpdate: {}, - saveRemoveFieldUpdate: {}, - setEditableFieldUpdate: {}, - setValidFieldUpdate: {}, - removeSingleFieldUpdate: {}, - isEditable: observableOf(false), // should always return something --> its in ngOnInit - isValid: observableOf(true) // should always return something --> its in ngOnInit - } - ); + const itemSelection = {}; + itemSelection[relatedItem.uuid] = false; + itemSelection[item.uuid] = true; + + objectUpdatesService = { + isSelectedVirtualMetadata: () => null, + removeSingleFieldUpdate: jasmine.createSpy('removeSingleFieldUpdate'), + saveRemoveFieldUpdate: jasmine.createSpy('saveRemoveFieldUpdate'), + }; + + spyOn(objectUpdatesService, 'isSelectedVirtualMetadata').and.callFake((a, b, uuid) => observableOf(itemSelection[uuid])); TestBed.configureTestingModule({ imports: [TranslateModule.forRoot()], declarations: [EditRelationshipComponent], providers: [ - { provide: ObjectUpdatesService, useValue: objectUpdatesService } - ], schemas: [ + { provide: ObjectUpdatesService, useValue: objectUpdatesService }, + { provide: NgbModal, useValue: { + open: () => {/*comment*/ + } + }, + }, + ], schemas: [ NO_ERRORS_SCHEMA ] }).compileComponents(); })); beforeEach(() => { + fixture = TestBed.createComponent(EditRelationshipComponent); comp = fixture.componentInstance; de = fixture.debugElement; @@ -112,7 +128,8 @@ describe('EditRelationshipComponent', () => { comp.url = url; comp.fieldUpdate = fieldUpdate1; - comp.item = item; + comp.editItem = item; + comp.relatedItem$ = observableOf(relatedItem); fixture.detectChanges(); }); @@ -156,23 +173,30 @@ describe('EditRelationshipComponent', () => { }); describe('remove', () => { + beforeEach(() => { + spyOn(comp, 'closeVirtualMetadataModal'); + comp.ngOnChanges(); comp.remove(); }); - it('should call saveRemoveFieldUpdate with the correct arguments', () => { - expect(objectUpdatesService.saveRemoveFieldUpdate).toHaveBeenCalledWith(url, item); + it('should close the virtual metadata modal and call saveRemoveFieldUpdate with the correct arguments', () => { + expect(comp.closeVirtualMetadataModal).toHaveBeenCalled(); + expect(objectUpdatesService.saveRemoveFieldUpdate).toHaveBeenCalledWith( + url, + Object.assign({}, fieldUpdate1.field, { + keepLeftVirtualMetadata: false, + keepRightVirtualMetadata: true, + }), + ); }); }); describe('undo', () => { - beforeEach(() => { - comp.undo(); - }); it('should call removeSingleFieldUpdate with the correct arguments', () => { - expect(objectUpdatesService.removeSingleFieldUpdate).toHaveBeenCalledWith(url, item.uuid); + comp.undo(); + expect(objectUpdatesService.removeSingleFieldUpdate).toHaveBeenCalledWith(url, relationships[0].uuid); }); }); - }); 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 ee9d2cda27..d1ee99c3a7 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,14 +1,19 @@ -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 { 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 - selector: '[ds-edit-relationship]', + selector: 'ds-edit-relationship', styleUrls: ['./edit-relationship.component.scss'], templateUrl: './edit-relationship.component.html', }) @@ -23,38 +28,108 @@ 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) { + /** + * Reference to NgbModal + */ + public modalRef: NgbModalRef; + + 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 + .isSelectedVirtualMetadata(this.url, this.relationship.id, item.uuid)) + ), + switchMap((selection$) => observableCombineLatest(selection$)), + map((selection: boolean[]) => { + return Object.assign({}, + this.fieldUpdate.field, + { + keepLeftVirtualMetadata: selection[0] === true, + keepRightVirtualMetadata: selection[1] === true, + } + ) as DeleteRelationship + }), + take(1), + ).subscribe((deleteRelationship: DeleteRelationship) => + this.objectUpdatesService.saveRemoveFieldUpdate(this.url, deleteRelationship) + ); + } + + 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); } /** diff --git a/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.html b/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.html index 4bd0b3df2c..384a469f24 100644 --- a/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.html +++ b/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.html @@ -17,8 +17,13 @@  {{"item.edit.metadata.save-button" | translate}}
-
- +
+
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 37745ec96a..aa812354b6 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 @@ -13,9 +13,8 @@ import { ActivatedRoute, Router } from '@angular/router'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { GLOBAL_CONFIG } from '../../../../config'; import { RelationshipType } from '../../../core/shared/item-relationships/relationship-type.model'; -import { ResourceType } from '../../../core/shared/resource-type'; import { Relationship } from '../../../core/shared/item-relationships/relationship.model'; -import { of as observableOf, combineLatest as observableCombineLatest } from 'rxjs'; +import { combineLatest as observableCombineLatest, of as observableOf } from 'rxjs'; import { RemoteData } from '../../../core/data/remote-data'; import { Item } from '../../../core/shared/item.model'; import { PaginatedList } from '../../../core/data/paginated-list'; @@ -26,6 +25,8 @@ import { ObjectCacheService } from '../../../core/cache/object-cache.service'; import { getTestScheduler } from 'jasmine-marbles'; import { RestResponse } from '../../../core/cache/response.models'; import { RequestService } from '../../../core/data/request.service'; +import { EntityTypeService } from '../../../core/data/entity-type.service'; +import { ItemType } from '../../../core/shared/item-relationships/item-type.model'; let comp: any; let fixture: ComponentFixture; @@ -34,6 +35,7 @@ let el: HTMLElement; let objectUpdatesService; let relationshipService; let requestService; +let entityTypeService; let objectCache; const infoNotification: INotification = new Notification('id', NotificationType.Info, 'info'); const warningNotification: INotification = new Notification('id', NotificationType.Warning, 'warning'); @@ -58,6 +60,7 @@ let author1; let author2; let fieldUpdate1; let fieldUpdate2; +let entityType; let relationships; let relationshipType; @@ -95,6 +98,10 @@ describe('ItemRelationshipsComponent', () => { lastModified: date }); + entityType = Object.assign(new ItemType(), { + id: 'entityType', + }); + author1 = Object.assign(new Item(), { id: 'author1', uuid: 'author1' @@ -110,11 +117,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 +140,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: {}, @@ -173,6 +183,25 @@ describe('ItemRelationshipsComponent', () => { remove: undefined }); + entityTypeService = jasmine.createSpyObj('entityTypeService', + { + getEntityTypeByLabel: observableOf(new RemoteData( + false, + false, + true, + null, + entityType, + )), + getEntityTypeRelationships: observableOf(new RemoteData( + false, + false, + true, + null, + new PaginatedList(new PageInfo(), [relationshipType]), + )), + } + ); + scheduler = getTestScheduler(); TestBed.configureTestingModule({ imports: [SharedModule, TranslateModule.forRoot()], @@ -185,6 +214,7 @@ describe('ItemRelationshipsComponent', () => { { provide: NotificationsService, useValue: notificationsService }, { provide: GLOBAL_CONFIG, useValue: { item: { edit: { undoTimeout: 10 } } } as any }, { provide: RelationshipService, useValue: relationshipService }, + { provide: EntityTypeService, useValue: entityTypeService }, { provide: ObjectCacheService, useValue: objectCache }, { provide: RequestService, useValue: requestService }, ChangeDetectorRef @@ -229,7 +259,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 42ebc5563e..3b0fe5f89b 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,8 @@ 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, flatMap, map, switchMap, take, tap } from 'rxjs/operators'; +import { filter, map, switchMap, take } from 'rxjs/operators'; import { zip as observableZip } from 'rxjs'; import { AbstractItemUpdateComponent } from '../abstract-item-update/abstract-item-update.component'; import { ItemDataService } from '../../../core/data/item-data.service'; @@ -12,15 +12,18 @@ import { NotificationsService } from '../../../shared/notifications/notification import { TranslateService } from '@ngx-translate/core'; import { GLOBAL_CONFIG, GlobalConfig } from '../../../../config'; import { RelationshipService } from '../../../core/data/relationship.service'; -import { FieldChangeType } from '../../../core/data/object-updates/object-updates.actions'; -import { Relationship } from '../../../core/shared/item-relationships/relationship.model'; 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 { getRemoteDataPayload, getSucceededRemoteData } from '../../../core/shared/operators'; import { RequestService } from '../../../core/data/request.service'; import { Subscription } from 'rxjs/internal/Subscription'; +import { RelationshipType } from '../../../core/shared/item-relationships/relationship-type.model'; +import { ItemType } from '../../../core/shared/item-relationships/item-type.model'; +import { EntityTypeService } from '../../../core/data/entity-type.service'; +import { isNotEmptyOperator } from '../../../shared/empty.util'; +import { FieldChangeType } from '../../../core/data/object-updates/object-updates.actions'; +import { Relationship } from '../../../core/shared/item-relationships/relationship.model'; @Component({ selector: 'ds-item-relationships', @@ -35,13 +38,14 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent impl /** * The labels of all different relations within this item */ - relationLabels$: Observable; + relationshipTypes$: Observable; /** * A subscription that checks when the item is deleted in cache and reloads the item by sending a new request * This is used to update the item in cache after relationships are deleted */ itemUpdateSubscription: Subscription; + entityType$: Observable; constructor( protected itemService: ItemDataService, @@ -54,7 +58,8 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent impl protected relationshipService: RelationshipService, protected objectCache: ObjectCacheService, protected requestService: RequestService, - protected cdRef: ChangeDetectorRef + protected entityTypeService: EntityTypeService, + protected cdr: ChangeDetectorRef, ) { super(itemService, objectUpdatesService, router, notificationsService, translateService, EnvConfig, route); } @@ -64,21 +69,14 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent impl */ ngOnInit(): void { super.ngOnInit(); - this.relationLabels$ = this.relationshipService.getRelationshipTypeLabelsByItem(this.item); - this.initializeItemUpdate(); - } - - /** - * Update the item (and view) when it's removed in the request cache - */ - public initializeItemUpdate(): void { this.itemUpdateSubscription = this.requestService.hasByHrefObservable(this.item.self).pipe( filter((exists: boolean) => !exists), switchMap(() => this.itemService.findById(this.item.uuid)), getSucceededRemoteData(), ).subscribe((itemRD: RemoteData) => { this.item = itemRD.payload; - this.cdRef.detectChanges(); + this.cdr.detectChanges(); + this.initializeUpdates(); }); } @@ -86,8 +84,22 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent impl * Initialize the values and updates of the current item's relationship fields */ public initializeUpdates(): void { - this.updates$ = this.relationshipService.getRelatedItems(this.item).pipe( - switchMap((items: Item[]) => this.objectUpdatesService.getFieldUpdates(this.url, items)) + + this.entityType$ = this.entityTypeService.getEntityTypeByLabel( + this.item.firstMetadataValue('relationship.type') + ).pipe( + getSucceededRemoteData(), + getRemoteDataPayload(), + ); + + this.relationshipTypes$ = this.entityType$.pipe( + switchMap((entityType) => + this.entityTypeService.getEntityTypeRelationships(entityType.id).pipe( + getSucceededRemoteData(), + getRemoteDataPayload(), + map((relationshipTypes) => relationshipTypes.page), + ) + ), ); } @@ -103,26 +115,41 @@ 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( - flatMap((uuids) => this.relationshipService.getRelationshipsByRelatedItemIds(this.item, uuids)) - ); - // const removedRelationships$ = removedItemIds$.pipe(flatMap((uuids: string[]) => this.relationshipService.getRelationshipsByRelatedItemIds(this.item, uuids))); - // Request a delete for every relationship found in the observable created above - removedRelationships$.pipe( + 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((fieldUpdate: FieldUpdate) => fieldUpdate.field as DeleteRelationship) + ), + isNotEmptyOperator(), 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(); + this.itemUpdateSubscription.add(() => { + this.displayNotifications(responses); + }); }); } @@ -144,22 +171,12 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent impl } } - /** - * Re-initialize fields and subscriptions - */ - reset() { - this.initializeOriginalFields(); - this.initializeUpdates(); - this.initializeItemUpdate(); - } - /** * Sends all initial values of this item to the object updates service */ public initializeOriginalFields() { - this.relationshipService.getRelatedItems(this.item).pipe(take(1)).subscribe((items: Item[]) => { - this.objectUpdatesService.initialize(this.url, items, this.item.lastModified); - }); + const initialFields = []; + this.objectUpdatesService.initialize(this.url, initialFields, this.item.lastModified); } /** @@ -168,5 +185,4 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent impl ngOnDestroy(): void { this.itemUpdateSubscription.unsubscribe(); } - } 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..c103d83c71 --- /dev/null +++ b/src/app/+item-page/edit-item-page/virtual-metadata/virtual-metadata.component.html @@ -0,0 +1,38 @@ +
+ + +
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..f2732d081a --- /dev/null +++ b/src/app/+item-page/edit-item-page/virtual-metadata/virtual-metadata.component.spec.ts @@ -0,0 +1,102 @@ +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {of as observableOf} from 'rxjs/internal/observable/of'; +import {TranslateModule} from '@ngx-translate/core'; +import {DebugElement, NO_ERRORS_SCHEMA} from '@angular/core'; +import {By} from '@angular/platform-browser'; +import {VirtualMetadataComponent} from './virtual-metadata.component'; +import {Item} from '../../../core/shared/item.model'; +import {ObjectUpdatesService} from '../../../core/data/object-updates/object-updates.service'; +import {VarDirective} from '../../../shared/utils/var.directive'; + +describe('VirtualMetadataComponent', () => { + + let comp: VirtualMetadataComponent; + let fixture: ComponentFixture; + let de: DebugElement; + + let objectUpdatesService; + + const url = 'http://test-url.com/test-url'; + + let item; + let relatedItem; + let relationshipId; + + beforeEach(() => { + + relationshipId = 'relationship id'; + + item = Object.assign(new Item(), { + uuid: 'publication', + metadata: [], + }); + + relatedItem = Object.assign(new Item(), { + uuid: 'relatedItem', + metadata: [], + }); + + objectUpdatesService = jasmine.createSpyObj('objectUpdatesService', { + isSelectedVirtualMetadata: observableOf(false), + setSelectedVirtualMetadata: null, + }); + + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [VirtualMetadataComponent, VarDirective], + providers: [ + {provide: ObjectUpdatesService, useValue: objectUpdatesService}, + ], schemas: [ + NO_ERRORS_SCHEMA + ] + }).compileComponents(); + + fixture = TestBed.createComponent(VirtualMetadataComponent); + comp = fixture.componentInstance; + de = fixture.debugElement; + + comp.url = url; + comp.leftItem = item; + comp.rightItem = relatedItem; + comp.relationshipId = relationshipId; + + fixture.detectChanges(); + }); + + describe('when clicking the save button', () => { + it('should emit a save event', () => { + + spyOn(comp.save, 'emit'); + fixture.debugElement + .query(By.css('button.save')) + .triggerEventHandler('click', null); + expect(comp.save.emit).toHaveBeenCalled(); + }); + }); + + describe('when clicking the close button', () => { + it('should emit a close event', () => { + + spyOn(comp.close, 'emit'); + fixture.debugElement + .query(By.css('button.close')) + .triggerEventHandler('click', null); + expect(comp.close.emit).toHaveBeenCalled(); + }); + }); + + describe('when selecting an item', () => { + it('should call the updates service setSelectedVirtualMetadata method', () => { + + fixture.debugElement + .query(By.css('div.item')) + .triggerEventHandler('click', null); + expect(objectUpdatesService.setSelectedVirtualMetadata).toHaveBeenCalledWith( + url, + relationshipId, + item.uuid, + true + ); + }); + }) +}); 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..cac46724f0 --- /dev/null +++ b/src/app/+item-page/edit-item-page/virtual-metadata/virtual-metadata.component.ts @@ -0,0 +1,120 @@ +import {Component, EventEmitter, Input, OnInit, Output} from '@angular/core'; +import {Observable} from 'rxjs'; +import {Item} from '../../../core/shared/item.model'; +import {MetadataValue} from '../../../core/shared/metadata.models'; +import {ObjectUpdatesService} from '../../../core/data/object-updates/object-updates.service'; + +@Component({ + selector: 'ds-virtual-metadata', + templateUrl: './virtual-metadata.component.html' +}) +/** + * Component that lists both items of a relationship, along with their virtual metadata of the relationship. + * The component is shown when a relationship is marked to be deleted. + * Each item has a checkbox to indicate whether its virtual metadata should be saved as real metadata. + */ +export class VirtualMetadataComponent implements OnInit { + + /** + * The current url of this page + */ + @Input() url: string; + + /** + * The id of the relationship to be deleted. + */ + @Input() relationshipId: string; + + /** + * The left item of the relationship to be deleted. + */ + @Input() leftItem: Item; + + /** + * The right item of the relationship to be deleted. + */ + @Input() rightItem: Item; + + /** + * Emits when the close button is pressed. + */ + @Output() close = new EventEmitter(); + + /** + * Emits when the save button is pressed. + */ + @Output() save = new EventEmitter(); + + /** + * Get an array of the left and the right item of the relationship to be deleted. + */ + get items() { + return [this.leftItem, this.rightItem]; + } + + private virtualMetadata: Map = new Map(); + + constructor( + protected objectUpdatesService: ObjectUpdatesService, + ) { + } + + /** + * Get the virtual metadata of a given item corresponding to this relationship. + * @param item the item to get the virtual metadata for + */ + getVirtualMetadata(item: Item): VirtualMetadata[] { + + return Object.entries(item.metadata) + .map(([key, value]) => + value + .filter((metadata: MetadataValue) => + !key.startsWith('relation') && metadata.authority && metadata.authority.endsWith(this.relationshipId)) + .map((metadata: MetadataValue) => { + return { + metadataField: key, + metadataValue: metadata, + } + }) + ) + .reduce((previous, current) => previous.concat(current), []); + } + + /** + * Select/deselect the virtual metadata of an item to be saved as real metadata. + * @param item the item for which (not) to save the virtual metadata as real metadata + * @param selected whether or not to save the virtual metadata as real metadata + */ + setSelectedVirtualMetadataItem(item: Item, selected: boolean) { + this.objectUpdatesService.setSelectedVirtualMetadata(this.url, this.relationshipId, item.uuid, selected); + } + + /** + * Check whether the virtual metadata of a given item is selected to be saved as real metadata + * @param item the item for which to check whether the virtual metadata is selected to be saved as real metadata + */ + isSelectedVirtualMetadataItem(item: Item): Observable { + return this.objectUpdatesService.isSelectedVirtualMetadata(this.url, this.relationshipId, item.uuid); + } + + /** + * Prevent unnecessary rerendering so fields don't lose focus + */ + trackItem(index, item: Item) { + return item && item.uuid; + } + + ngOnInit(): void { + this.items.forEach((item) => { + this.virtualMetadata.set(item.uuid, this.getVirtualMetadata(item)); + }); + } +} + +/** + * Represents a virtual metadata entry. + */ +export interface VirtualMetadata { + metadataField: string, + metadataValue: MetadataValue, +} diff --git a/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.spec.ts b/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.spec.ts index 4511f16aae..21903e6557 100644 --- a/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.spec.ts +++ b/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.spec.ts @@ -11,6 +11,7 @@ let fixture: ComponentFixture; const mockField = 'dc.identifier.uri'; const mockValue = 'test value'; +const mockLabel = 'test label'; describe('ItemPageUriFieldComponent', () => { beforeEach(async(() => { @@ -32,6 +33,8 @@ describe('ItemPageUriFieldComponent', () => { fixture = TestBed.createComponent(ItemPageUriFieldComponent); comp = fixture.componentInstance; comp.item = mockItemWithMetadataFieldAndValue(mockField, mockValue); + comp.fields = [mockField]; + comp.label = mockLabel; fixture.detectChanges(); })); diff --git a/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.ts b/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.ts index c9cd5f1a00..b70cbbd5e8 100644 --- a/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.ts +++ b/src/app/+item-page/simple/field-components/specific-field/uri/item-page-uri-field.component.ts @@ -8,7 +8,8 @@ import { ItemPageFieldComponent } from '../item-page-field.component'; templateUrl: './item-page-uri-field.component.html' }) /** - * This component is used for displaying the uri (dc.identifier.uri) metadata of an item + * This component can be used to represent any uri on a simple item page. + * It expects 4 parameters: The item, a separator, the metadata keys and an i18n key */ export class ItemPageUriFieldComponent extends ItemPageFieldComponent { @@ -21,19 +22,16 @@ export class ItemPageUriFieldComponent extends ItemPageFieldComponent { * Separator string between multiple values of the metadata fields defined * @type {string} */ - separator: string; + @Input() separator: string; /** * Fields (schema.element.qualifier) used to render their values. - * In this component, we want to display values for metadata 'dc.identifier.uri' */ - fields: string[] = [ - 'dc.identifier.uri' - ]; + @Input() fields: string[]; /** * Label i18n key for the rendered metadata */ - label = 'item.page.uri'; + @Input() label: string; } diff --git a/src/app/+item-page/simple/item-types/publication/publication.component.html b/src/app/+item-page/simple/item-types/publication/publication.component.html index 3cd4d0aa69..c45e85668a 100644 --- a/src/app/+item-page/simple/item-types/publication/publication.component.html +++ b/src/app/+item-page/simple/item-types/publication/publication.component.html @@ -62,8 +62,11 @@ - - + + +
diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 1f3da086c2..2141b06a77 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,4 +1,4 @@ -import { filter, map, take } from 'rxjs/operators'; +import { delay, filter, map, take } from 'rxjs/operators'; import { AfterViewInit, ChangeDetectionStrategy, Component, HostListener, Inject, OnInit, ViewEncapsulation } from '@angular/core'; import { NavigationCancel, NavigationEnd, NavigationStart, Router } from '@angular/router'; @@ -125,8 +125,11 @@ export class AppComponent implements OnInit, AfterViewInit { } ngAfterViewInit() { - this.router.events - .subscribe((event) => { + this.router.events.pipe( + // This fixes an ExpressionChangedAfterItHasBeenCheckedError from being thrown while loading the component + // More information on this bug-fix: https://blog.angular-university.io/angular-debugging/ + delay(0) + ).subscribe((event) => { if (event instanceof NavigationStart) { this.isLoading = true; } else if ( diff --git a/src/app/core/cache/models/normalized-external-source-entry.model.ts b/src/app/core/cache/models/normalized-external-source-entry.model.ts index e8e3c695c3..de262949e7 100644 --- a/src/app/core/cache/models/normalized-external-source-entry.model.ts +++ b/src/app/core/cache/models/normalized-external-source-entry.model.ts @@ -28,6 +28,12 @@ export class NormalizedExternalSourceEntry extends NormalizedObject { let service: CollectionDataService; - let objectCache: ObjectCacheService; + let requestService: RequestService; - let halService: HALEndpointService; + let translate: TranslateService; + let notificationsService: any; let rdbService: RemoteDataBuildService; + let objectCache: ObjectCacheService; + let halService: any; - const url = 'fake-collections-url'; - - beforeEach(() => { - objectCache = jasmine.createSpyObj('objectCache', { - remove: jasmine.createSpy('remove') + describe('when the requests are successful', () => { + beforeEach(() => { + createService(); }); - requestService = getMockRequestService(); - halService = Object.assign(new HALEndpointServiceStub(url)); + + describe('when calling getContentSource', () => { + let contentSource$; + + beforeEach(() => { + contentSource$ = service.getContentSource(collectionId); + }); + + it('should configure a new ContentSourceRequest', fakeAsync(() => { + contentSource$.subscribe(); + tick(); + expect(requestService.configure).toHaveBeenCalledWith(jasmine.any(ContentSourceRequest)); + })); + }); + + describe('when calling updateContentSource', () => { + let returnedContentSource$; + let contentSource; + + beforeEach(() => { + contentSource = new ContentSource(); + returnedContentSource$ = service.updateContentSource(collectionId, contentSource); + }); + + it('should configure a new UpdateContentSourceRequest', fakeAsync(() => { + returnedContentSource$.subscribe(); + tick(); + expect(requestService.configure).toHaveBeenCalledWith(jasmine.any(UpdateContentSourceRequest)); + })); + }); + + describe('getMappedItems', () => { + let result; + + beforeEach(() => { + result = service.getMappedItems('collection-id'); + }); + + it('should configure a GET request', () => { + expect(requestService.configure).toHaveBeenCalledWith(jasmine.any(GetRequest)); + }); + }); + + }); + + describe('when the requests are unsuccessful', () => { + beforeEach(() => { + createService(observableOf(Object.assign(new RequestEntry(), { + response: new ErrorResponse(Object.assign({ + statusCode: 422, + statusText: 'Unprocessable Entity', + message: 'Error message' + })) + }))); + }); + + describe('when calling updateContentSource', () => { + let returnedContentSource$; + let contentSource; + + beforeEach(() => { + contentSource = new ContentSource(); + returnedContentSource$ = service.updateContentSource(collectionId, contentSource); + }); + + it('should configure a new UpdateContentSourceRequest', fakeAsync(() => { + returnedContentSource$.subscribe(); + tick(); + expect(requestService.configure).toHaveBeenCalledWith(jasmine.any(UpdateContentSourceRequest)); + })); + + it('should display an error notification', fakeAsync(() => { + returnedContentSource$.subscribe(); + tick(); + expect(notificationsService.error).toHaveBeenCalled(); + })); + }); + }); + + /** + * Create a CollectionDataService used for testing + * @param requestEntry$ Supply a requestEntry to be returned by the REST API (optional) + */ + function createService(requestEntry$?) { + requestService = getMockRequestService(requestEntry$); rdbService = jasmine.createSpyObj('rdbService', { buildList: jasmine.createSpy('buildList') }); - - service = new CollectionDataService(requestService, rdbService, null, null, null, objectCache, halService, null, null, null); - }); - - describe('getMappedItems', () => { - let result; - - beforeEach(() => { - result = service.getMappedItems('collection-id'); + objectCache = jasmine.createSpyObj('objectCache', { + remove: jasmine.createSpy('remove') }); + halService = new HALEndpointServiceStub(url); + notificationsService = new NotificationsServiceStub(); + translate = getMockTranslateService(); - it('should configure a GET request', () => { - expect(requestService.configure).toHaveBeenCalledWith(jasmine.any(GetRequest)); - }); - }); + service = new CollectionDataService(requestService, rdbService, null, null, null, objectCache, halService, notificationsService, null, null, translate); + } }); diff --git a/src/app/core/data/collection-data.service.ts b/src/app/core/data/collection-data.service.ts index 0c032e6766..ed05c99e27 100644 --- a/src/app/core/data/collection-data.service.ts +++ b/src/app/core/data/collection-data.service.ts @@ -12,25 +12,45 @@ import { CommunityDataService } from './community-data.service'; import { RequestService } from './request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; +import { HttpClient, HttpHeaders } from '@angular/common/http'; import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; import { Observable } from 'rxjs/internal/Observable'; -import {FindListOptions, FindListRequest, GetRequest} from './request.models'; +import { + ContentSourceRequest, + RestRequest, + UpdateContentSourceRequest, + GetRequest, + FindListOptions +} from './request.models'; import { RemoteData } from './remote-data'; import { PaginatedList } from './paginated-list'; -import { configureRequest } from '../shared/operators'; +import { ContentSource } from '../shared/content-source.model'; +import { + configureRequest, + filterSuccessfulResponses, + getRequestFromRequestHref, + getResponseFromEntry +} from '../shared/operators'; +import { ContentSourceSuccessResponse, RestResponse } from '../cache/response.models'; +import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; +import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; +import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; +import { NotificationOptions } from '../../shared/notifications/models/notification-options.model'; +import { TranslateService } from '@ngx-translate/core'; +import { SearchParam } from '../cache/models/search-param.model'; import { DSOResponseParsingService } from './dso-response-parsing.service'; import { ResponseParsingService } from './parsing.service'; import { GenericConstructor } from '../shared/generic-constructor'; -import { hasValue, isNotEmptyOperator } from '../../shared/empty.util'; import { DSpaceObject } from '../shared/dspace-object.model'; -import { SearchParam } from '../cache/models/search-param.model'; +import { INotification } from '../../shared/notifications/models/notification.model'; import { PaginatedSearchOptions } from '../../shared/search/paginated-search-options.model'; @Injectable() export class CollectionDataService extends ComColDataService { protected linkPath = 'collections'; + protected errorTitle = 'collection.source.update.notifications.error.title'; + protected contentSourceError = 'collection.source.update.notifications.error.content'; constructor( protected requestService: RequestService, @@ -42,7 +62,8 @@ export class CollectionDataService extends ComColDataService { protected halService: HALEndpointService, protected notificationsService: NotificationsService, protected http: HttpClient, - protected comparator: DSOChangeAnalyzer + protected comparator: DSOChangeAnalyzer, + protected translate: TranslateService ) { super(); } @@ -97,6 +118,81 @@ export class CollectionDataService extends ComColDataService { ); } + /** + * Get the endpoint for the collection's content harvester + * @param collectionId + */ + getHarvesterEndpoint(collectionId: string): Observable { + return this.halService.getEndpoint(this.linkPath).pipe( + switchMap((href: string) => this.halService.getEndpoint('harvester', `${href}/${collectionId}`)) + ); + } + + /** + * Get the collection's content harvester + * @param collectionId + */ + getContentSource(collectionId: string): Observable { + return this.getHarvesterEndpoint(collectionId).pipe( + map((href: string) => new ContentSourceRequest(this.requestService.generateRequestId(), href)), + configureRequest(this.requestService), + map((request: RestRequest) => request.href), + getRequestFromRequestHref(this.requestService), + filterSuccessfulResponses(), + map((response: ContentSourceSuccessResponse) => response.contentsource) + ); + } + + /** + * Update the settings of the collection's content harvester + * @param collectionId + * @param contentSource + */ + updateContentSource(collectionId: string, contentSource: ContentSource): Observable { + const requestId = this.requestService.generateRequestId(); + const serializedContentSource = new DSpaceRESTv2Serializer(ContentSource).serialize(contentSource); + const request$ = this.getHarvesterEndpoint(collectionId).pipe( + take(1), + map((href: string) => { + const options: HttpOptions = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Content-Type', 'application/json'); + options.headers = headers; + return new UpdateContentSourceRequest(requestId, href, JSON.stringify(serializedContentSource), options); + }) + ); + + // Execute the post/put request + request$.pipe( + configureRequest(this.requestService) + ).subscribe(); + + // Return updated ContentSource + return this.requestService.getByUUID(requestId).pipe( + getResponseFromEntry(), + map((response: RestResponse) => { + if (!response.isSuccessful) { + if (hasValue((response as any).errorMessage)) { + if (response.statusCode === 422) { + return this.notificationsService.error(this.translate.instant(this.errorTitle), this.translate.instant(this.contentSourceError), new NotificationOptions(-1)); + } else { + return this.notificationsService.error(this.translate.instant(this.errorTitle), (response as any).errorMessage, new NotificationOptions(-1)); + } + } + } else { + return response; + } + }), + isNotEmptyOperator(), + map((response: ContentSourceSuccessResponse | INotification) => { + if (isNotEmpty((response as any).contentsource)) { + return (response as ContentSourceSuccessResponse).contentsource; + } + return response as INotification; + }) + ); + } + /** * Fetches the endpoint used for mapping items to a collection * @param collectionId The id of the collection to map items to diff --git a/src/app/core/data/content-source-response-parsing.service.ts b/src/app/core/data/content-source-response-parsing.service.ts new file mode 100644 index 0000000000..4e0490148b --- /dev/null +++ b/src/app/core/data/content-source-response-parsing.service.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@angular/core'; +import { ResponseParsingService } from './parsing.service'; +import { RestRequest } from './request.models'; +import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; +import { ContentSourceSuccessResponse, RestResponse } from '../cache/response.models'; +import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; +import { ContentSource } from '../shared/content-source.model'; +import { MetadataConfig } from '../shared/metadata-config.model'; + +@Injectable() +/** + * A ResponseParsingService used to parse DSpaceRESTV2Response coming from the REST API to a ContentSource object + * wrapped in a ContentSourceSuccessResponse + */ +export class ContentSourceResponseParsingService implements ResponseParsingService { + + parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { + const payload = data.payload; + + const deserialized = new DSpaceRESTv2Serializer(ContentSource).deserialize(payload); + + let metadataConfigs = []; + if (payload._embedded && payload._embedded.harvestermetadata && payload._embedded.harvestermetadata.configs) { + metadataConfigs = new DSpaceRESTv2Serializer(MetadataConfig).serializeArray(payload._embedded.harvestermetadata.configs); + } + deserialized.metadataConfigs = metadataConfigs; + + return new ContentSourceSuccessResponse(deserialized, data.statusCode, data.statusText); + } + +} diff --git a/src/app/core/data/data.service.ts b/src/app/core/data/data.service.ts index d55b7353eb..82fdb82008 100644 --- a/src/app/core/data/data.service.ts +++ b/src/app/core/data/data.service.ts @@ -318,9 +318,11 @@ export abstract class DataService { /** * Delete an existing DSpace Object on the server * @param dso The DSpace Object to be removed - * Return an observable that emits true when the deletion was successful, false when it failed + * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual + * metadata should be saved as real metadata + * @return an observable that emits true when the deletion was successful, false when it failed */ - delete(dso: T): Observable { + delete(dso: T, copyVirtualMetadata?: string[]): Observable { const requestId = this.requestService.generateRequestId(); const hrefObs = this.halService.getEndpoint(this.linkPath).pipe( @@ -329,6 +331,13 @@ export abstract class DataService { hrefObs.pipe( find((href: string) => hasValue(href)), map((href: string) => { + if (copyVirtualMetadata) { + copyVirtualMetadata.forEach((id) => + href += (href.includes('?') ? '&' : '?') + + 'copyVirtualMetadata=' + + id + ); + } const request = new DeleteByIDRequest(requestId, href, dso.uuid); this.requestService.configure(request); }) diff --git a/src/app/core/data/entity-type.service.ts b/src/app/core/data/entity-type.service.ts new file mode 100644 index 0000000000..583601d898 --- /dev/null +++ b/src/app/core/data/entity-type.service.ts @@ -0,0 +1,103 @@ +import { DataService } from './data.service'; +import { RequestService } from './request.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; +import { Store } from '@ngrx/store'; +import { CoreState } from '../core.reducers'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { HttpClient } from '@angular/common/http'; +import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; +import { Injectable } from '@angular/core'; +import { GetRequest } from './request.models'; +import { Observable } from 'rxjs/internal/Observable'; +import {switchMap, take, tap} from 'rxjs/operators'; +import { RemoteData } from './remote-data'; +import {RelationshipType} from '../shared/item-relationships/relationship-type.model'; +import {PaginatedList} from './paginated-list'; +import {ItemType} from '../shared/item-relationships/item-type.model'; + +/** + * Service handling all ItemType requests + */ +@Injectable() +export class EntityTypeService extends DataService { + + protected linkPath = 'entitytypes'; + protected forceBypassCache = false; + + constructor(protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected dataBuildService: NormalizedObjectBuildService, + protected store: Store, + protected halService: HALEndpointService, + protected objectCache: ObjectCacheService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DefaultChangeAnalyzer) { + super(); + } + + getBrowseEndpoint(options, linkPath?: string): Observable { + return this.halService.getEndpoint(this.linkPath); + } + + /** + * Get the endpoint for the item type's allowed relationship types + * @param entityTypeId + */ + getRelationshipTypesEndpoint(entityTypeId: string): Observable { + return this.halService.getEndpoint(this.linkPath).pipe( + switchMap((href) => this.halService.getEndpoint('relationshiptypes', `${href}/${entityTypeId}`)) + ); + } + + /** + * Get the allowed relationship types for an entity type + * @param entityTypeId + */ + getEntityTypeRelationships(entityTypeId: string): Observable>> { + + const href$ = this.getRelationshipTypesEndpoint(entityTypeId); + + href$.pipe(take(1)).subscribe((href) => { + const request = new GetRequest(this.requestService.generateRequestId(), href); + this.requestService.configure(request); + }); + + return this.rdbService.buildList(href$); + } + + /** + * Get an entity type by their label + * @param label + */ + getEntityTypeByLabel(label: string): Observable> { + + // TODO: Remove mock data once REST API supports this + /* + href$.pipe(take(1)).subscribe((href) => { + const request = new GetRequest(this.requestService.generateRequestId(), href); + this.requestService.configure(request); + }); + + return this.rdbService.buildSingle(href$); + */ + + // Mock: + const index = [ + 'Publication', + 'Person', + 'Project', + 'OrgUnit', + 'Journal', + 'JournalVolume', + 'JournalIssue', + 'DataPackage', + 'DataFile', + ].indexOf(label); + + return this.findById((index + 1) + ''); + } +} diff --git a/src/app/core/data/facet-value-response-parsing.service.ts b/src/app/core/data/facet-value-response-parsing.service.ts index 70585bc3d9..7fedc17545 100644 --- a/src/app/core/data/facet-value-response-parsing.service.ts +++ b/src/app/core/data/facet-value-response-parsing.service.ts @@ -4,7 +4,7 @@ import { ResponseParsingService } from './parsing.service'; import { RestRequest } from './request.models'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; -import { FacetValue } from '../../shared/search/facet-value.model'; +import {FacetValue} from '../../shared/search/facet-value.model'; import { BaseResponseParsingService } from './base-response-parsing.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { GLOBAL_CONFIG } from '../../../config'; diff --git a/src/app/core/data/item-data.service.spec.ts b/src/app/core/data/item-data.service.spec.ts index 6f2719f374..8263601e28 100644 --- a/src/app/core/data/item-data.service.spec.ts +++ b/src/app/core/data/item-data.service.spec.ts @@ -15,6 +15,7 @@ import { NormalizedObjectBuildService } from '../cache/builders/normalized-objec import { HttpClient } from '@angular/common/http'; import { RequestEntry } from './request.reducer'; import { getMockRequestService } from '../../shared/mocks/mock-request.service'; +import { ExternalSourceEntry } from '../shared/external-source-entry.model'; describe('ItemDataService', () => { let scheduler: TestScheduler; @@ -194,4 +195,24 @@ describe('ItemDataService', () => { }); }); + describe('importExternalSourceEntry', () => { + let result; + + const externalSourceEntry = Object.assign(new ExternalSourceEntry(), { + display: 'John, Doe', + value: 'John, Doe', + self: 'http://test-rest.com/server/api/integration/externalSources/orcidV2/entryValues/0000-0003-4851-8004' + }); + + beforeEach(() => { + service = initTestService(); + spyOn(requestService, 'configure'); + result = service.importExternalSourceEntry(externalSourceEntry, 'collection-id'); + }); + + it('should configure a POST request', () => { + result.subscribe(() => expect(requestService.configure).toHaveBeenCalledWith(jasmine.any(PostRequest))); + }); + }); + }); diff --git a/src/app/core/data/item-data.service.ts b/src/app/core/data/item-data.service.ts index b729c0fafe..cd7e70dc32 100644 --- a/src/app/core/data/item-data.service.ts +++ b/src/app/core/data/item-data.service.ts @@ -37,6 +37,7 @@ import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; import { Collection } from '../shared/collection.model'; import { RemoteData } from './remote-data'; import { PaginatedList } from './paginated-list'; +import { ExternalSourceEntry } from '../shared/external-source-entry.model'; @Injectable() export class ItemDataService extends DataService { @@ -248,6 +249,40 @@ export class ItemDataService extends DataService { ); } + /** + * Import an external source entry into a collection + * @param externalSourceEntry + * @param collectionId + */ + public importExternalSourceEntry(externalSourceEntry: ExternalSourceEntry, collectionId: string): Observable> { + const options: HttpOptions = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Content-Type', 'text/uri-list'); + options.headers = headers; + + const requestId = this.requestService.generateRequestId(); + const href$ = this.halService.getEndpoint(this.linkPath).pipe(map((href) => `${href}?owningCollection=${collectionId}`)); + + href$.pipe( + find((href: string) => hasValue(href)), + map((href: string) => { + const request = new PostRequest(requestId, href, externalSourceEntry.self, options); + this.requestService.configure(request); + }) + ).subscribe(); + + return this.requestService.getByUUID(requestId).pipe( + find((request: RequestEntry) => request.completed), + getResponseFromEntry(), + map((response: any) => { + if (isNotEmpty(response.resourceSelfLinks)) { + return response.resourceSelfLinks[0]; + } + }), + switchMap((selfLink: string) => this.findByHref(selfLink)) + ); + } + /** * Get the endpoint for an item's bitstreams * @param itemId diff --git a/src/app/core/data/lookup-relation.service.spec.ts b/src/app/core/data/lookup-relation.service.spec.ts index 321fd8d218..c9fc7fc50d 100644 --- a/src/app/core/data/lookup-relation.service.spec.ts +++ b/src/app/core/data/lookup-relation.service.spec.ts @@ -10,11 +10,14 @@ import { SearchResult } from '../../shared/search/search-result.model'; import { Item } from '../shared/item.model'; import { skip, take } from 'rxjs/operators'; import { ExternalSource } from '../shared/external-source.model'; +import { RequestService } from './request.service'; +import { of as observableOf } from 'rxjs'; describe('LookupRelationService', () => { let service: LookupRelationService; let externalSourceService: ExternalSourceService; let searchService: SearchService; + let requestService: RequestService; const totalExternal = 8; const optionsWithQuery = new PaginatedSearchOptions({ query: 'test-query' }); @@ -35,15 +38,18 @@ describe('LookupRelationService', () => { name: 'orcidV2', hierarchical: false }); + const searchServiceEndpoint = 'http://test-rest.com/server/api/core/search'; function init() { externalSourceService = jasmine.createSpyObj('externalSourceService', { getExternalSourceEntries: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo({ elementsPerPage: 1, totalElements: totalExternal, totalPages: totalExternal, currentPage: 1 }), [{}])) }); searchService = jasmine.createSpyObj('searchService', { - search: createSuccessfulRemoteDataObject$(createPaginatedList(localResults)) + search: createSuccessfulRemoteDataObject$(createPaginatedList(localResults)), + getEndpoint: observableOf(searchServiceEndpoint) }); - service = new LookupRelationService(externalSourceService, searchService); + requestService = jasmine.createSpyObj('requestService', ['removeByHrefSubstring']); + service = new LookupRelationService(externalSourceService, searchService, requestService); } beforeEach(() => { @@ -113,4 +119,14 @@ describe('LookupRelationService', () => { }); }); }); + + describe('removeLocalResultsCache', () => { + beforeEach(() => { + service.removeLocalResultsCache(); + }); + + it('should call requestService\'s removeByHrefSubstring with the search endpoint', () => { + expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith(searchServiceEndpoint); + }); + }); }); diff --git a/src/app/core/data/lookup-relation.service.ts b/src/app/core/data/lookup-relation.service.ts index ad977e42dc..395976cbc3 100644 --- a/src/app/core/data/lookup-relation.service.ts +++ b/src/app/core/data/lookup-relation.service.ts @@ -15,6 +15,7 @@ import { getAllSucceededRemoteData, getRemoteDataPayload } from '../shared/opera import { Injectable } from '@angular/core'; import { ExternalSource } from '../shared/external-source.model'; import { ExternalSourceEntry } from '../shared/external-source-entry.model'; +import { RequestService } from './request.service'; /** * A service for retrieving local and external entries information during a relation lookup @@ -35,7 +36,8 @@ export class LookupRelationService { }); constructor(protected externalSourceService: ExternalSourceService, - protected searchService: SearchService) { + protected searchService: SearchService, + protected requestService: RequestService) { } /** @@ -91,4 +93,11 @@ export class LookupRelationService { startWith(0) ); } + + /** + * Remove cached requests from local results + */ + removeLocalResultsCache() { + this.searchService.getEndpoint().subscribe((href) => this.requestService.removeByHrefSubstring(href)); + } } 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..a3a95369fd 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,41 @@ export class AddFieldUpdateAction implements Action { } } +/** + * An ngrx action to select/deselect virtual metadata in the ObjectUpdates state for a certain page url + */ +export class SelectVirtualMetadataAction implements Action { + + type = ObjectUpdatesActionTypes.SELECT_VIRTUAL_METADATA; + payload: { + url: string, + source: string, + uuid: string, + select: boolean; + }; + + /** + * Create a new SelectVirtualMetadataAction + * + * @param url + * the unique url of the page for which a field update is added + * @param source + * the id of the relationship which adds the virtual metadata + * @param uuid + * the id of the item which has the virtual metadata + * @param select + * whether to select or deselect the virtual metadata to be saved as real metadata + */ + 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 +278,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..faae4732bc 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 @@ -5,10 +5,11 @@ import { FieldChangeType, InitializeFieldsAction, ReinstateObjectUpdatesAction, - RemoveFieldUpdateAction, RemoveObjectUpdatesAction, + RemoveFieldUpdateAction, RemoveObjectUpdatesAction, SelectVirtualMetadataAction, SetEditableFieldUpdateAction, SetValidFieldUpdateAction } from './object-updates.actions'; import { OBJECT_UPDATES_TRASH_PATH, objectUpdatesReducer } from './object-updates.reducer'; +import {Relationship} from '../../shared/item-relationships/relationship.model'; class NullAction extends RemoveFieldUpdateAction { type = null; @@ -44,6 +45,7 @@ const identifiable3 = { language: null, value: 'Unchanged value' }; +const relationship: Relationship = Object.assign(new Relationship(), {uuid: 'test relationship uuid'}); const modDate = new Date(2010, 2, 11); const uuid = identifiable1.uuid; @@ -79,7 +81,10 @@ describe('objectUpdatesReducer', () => { changeType: FieldChangeType.ADD } }, - lastModified: modDate + lastModified: modDate, + virtualMetadataSources: { + [relationship.uuid]: {[identifiable1.uuid]: true} + }, } }; @@ -102,7 +107,10 @@ describe('objectUpdatesReducer', () => { isValid: true }, }, - lastModified: modDate + lastModified: modDate, + virtualMetadataSources: { + [relationship.uuid]: {[identifiable1.uuid]: true} + }, }, [url + OBJECT_UPDATES_TRASH_PATH]: { fieldStates: { @@ -133,7 +141,10 @@ describe('objectUpdatesReducer', () => { changeType: FieldChangeType.ADD } }, - lastModified: modDate + lastModified: modDate, + virtualMetadataSources: { + [relationship.uuid]: {[identifiable1.uuid]: true} + }, } }; @@ -195,6 +206,12 @@ describe('objectUpdatesReducer', () => { objectUpdatesReducer(testState, action); }); + it('should perform the SELECT_VIRTUAL_METADATA action without affecting the previous state', () => { + const action = new SelectVirtualMetadataAction(url, relationship.uuid, identifiable1.uuid, true); + // testState has already been frozen above + objectUpdatesReducer(testState, action); + }); + it('should initialize all fields when the INITIALIZE action is dispatched, based on the payload', () => { const action = new InitializeFieldsAction(url, [identifiable1, identifiable3], modDate); @@ -213,6 +230,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..cffd41856d 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,36 @@ export interface FieldUpdates { [uuid: string]: FieldUpdate; } +/** + * The states of all virtual metadata selections available for a single page, mapped by the relationship uuid + */ +export interface VirtualMetadataSources { + [source: string]: VirtualMetadataSource +} + +/** + * The selection of virtual metadata for a relationship, mapped by the uuid of either the item or the relationship type + */ +export interface VirtualMetadataSource { + [uuid: string]: boolean, +} + +/** + * A fieldupdate interface which represents a relationship selected to be deleted, + * along with a selection of the virtual metadata to keep + */ +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 +124,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 +166,7 @@ function initializeFieldsUpdate(state: any, action: InitializeFieldsAction) { state[url], { fieldStates: fieldStates }, { fieldUpdates: {} }, + { virtualMetadataSources: {} }, { lastModified: lastModifiedServer } ); return Object.assign({}, state, { [url]: newPageState }); @@ -169,6 +201,51 @@ function addFieldUpdate(state: any, action: AddFieldUpdateAction) { return Object.assign({}, state, { [url]: newPageState }); } +/** + * Update the selected virtual metadata in 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.spec.ts b/src/app/core/data/object-updates/object-updates.service.spec.ts index e9fc4652b0..730ee5ad43 100644 --- a/src/app/core/data/object-updates/object-updates.service.spec.ts +++ b/src/app/core/data/object-updates/object-updates.service.spec.ts @@ -4,13 +4,14 @@ import { ObjectUpdatesService } from './object-updates.service'; import { DiscardObjectUpdatesAction, FieldChangeType, - InitializeFieldsAction, ReinstateObjectUpdatesAction, RemoveFieldUpdateAction, + InitializeFieldsAction, ReinstateObjectUpdatesAction, RemoveFieldUpdateAction, SelectVirtualMetadataAction, SetEditableFieldUpdateAction } from './object-updates.actions'; import { of as observableOf } from 'rxjs'; import { Notification } from '../../../shared/notifications/models/notification.model'; import { NotificationType } from '../../../shared/notifications/models/notification-type'; import { OBJECT_UPDATES_TRASH_PATH } from './object-updates.reducer'; +import {Relationship} from '../../shared/item-relationships/relationship.model'; describe('ObjectUpdatesService', () => { let service: ObjectUpdatesService; @@ -22,6 +23,7 @@ describe('ObjectUpdatesService', () => { const identifiable2 = { uuid: '26cbb5ce-5786-4e57-a394-b9fcf8eaf241' }; const identifiable3 = { uuid: 'c5d2c2f7-d757-48bf-84cc-8c9229c8407e' }; const identifiables = [identifiable1, identifiable2]; + const relationship: Relationship = Object.assign(new Relationship(), {uuid: 'test relationship uuid'}); const fieldUpdates = { [identifiable1.uuid]: { field: identifiable1Updated, changeType: FieldChangeType.UPDATE }, @@ -38,11 +40,11 @@ describe('ObjectUpdatesService', () => { }; const objectEntry = { - fieldStates, fieldUpdates, lastModified: modDate + fieldStates, fieldUpdates, lastModified: modDate, virtualMetadataSources: {} }; store = new Store(undefined, undefined, undefined); spyOn(store, 'dispatch'); - service = new ObjectUpdatesService(store); + service = (new ObjectUpdatesService(store)); spyOn(service as any, 'getObjectEntry').and.returnValue(observableOf(objectEntry)); spyOn(service as any, 'getFieldState').and.callFake((uuid) => { @@ -251,4 +253,10 @@ describe('ObjectUpdatesService', () => { }); }); + describe('setSelectedVirtualMetadata', () => { + it('should dispatch a SELECT_VIRTUAL_METADATA action with the correct URL, relationship, identifiable and boolean', () => { + service.setSelectedVirtualMetadata(url, relationship.uuid, identifiable1.uuid, true); + expect(store.dispatch).toHaveBeenCalledWith(new SelectVirtualMetadataAction(url, relationship.uuid, identifiable1.uuid, true)); + }); + }); }); 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..367b73ee30 100644 --- a/src/app/core/data/object-updates/object-updates.service.ts +++ b/src/app/core/data/object-updates/object-updates.service.ts @@ -8,7 +8,8 @@ import { Identifiable, OBJECT_UPDATES_TRASH_PATH, ObjectUpdatesEntry, - ObjectUpdatesState + ObjectUpdatesState, + VirtualMetadataSource } from './object-updates.reducer'; import { Observable } from 'rxjs'; import { @@ -18,10 +19,11 @@ import { InitializeFieldsAction, ReinstateObjectUpdatesAction, RemoveFieldUpdateAction, + SelectVirtualMetadataAction, SetEditableFieldUpdateAction, SetValidFieldUpdateAction } from './object-updates.actions'; -import { distinctUntilChanged, filter, map } from 'rxjs/operators'; +import { distinctUntilChanged, filter, map, switchMap } from 'rxjs/operators'; import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../../shared/empty.util'; import { INotification } from '../../../shared/notifications/models/notification.model'; @@ -37,6 +39,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 */ @@ -91,18 +97,24 @@ export class ObjectUpdatesService { */ getFieldUpdates(url: string, initialFields: Identifiable[]): Observable { 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 }; + return objectUpdates.pipe( + switchMap((objectEntry) => { + const fieldUpdates: FieldUpdates = {}; + if (hasValue(objectEntry)) { + Object.keys(objectEntry.fieldStates).forEach((uuid) => { + fieldUpdates[uuid] = objectEntry.fieldUpdates[uuid]; + }); } - fieldUpdates[uuid] = fieldUpdate; - }); - return fieldUpdates; - })) + return this.getFieldUpdatesExclusive(url, initialFields).pipe( + map((fieldUpdatesExclusive) => { + Object.keys(fieldUpdatesExclusive).forEach((uuid) => { + fieldUpdates[uuid] = fieldUpdatesExclusive[uuid]; + }); + return fieldUpdates; + }) + ); + }), + ); } /** @@ -195,6 +207,34 @@ export class ObjectUpdatesService { this.saveFieldUpdate(url, field, FieldChangeType.UPDATE); } + /** + * Check whether the virtual metadata of a given item is selected to be saved as real metadata + * @param url The URL of the page on which the field resides + * @param relationship The id of the relationship for which to check whether the virtual metadata is selected to be + * saved as real metadata + * @param item The id of the item for which to check whether the virtual metadata is selected to be + * saved as real metadata + */ + isSelectedVirtualMetadata(url: string, relationship: string, item: string): Observable { + + return this.store + .pipe( + select(virtualMetadataSourceSelector(url, relationship)), + map((virtualMetadataSource) => virtualMetadataSource && virtualMetadataSource[item]), + ); + } + + /** + * Method to dispatch a SelectVirtualMetadataAction to the store + * @param url The page's URL for which the changes are saved + * @param relationship the relationship for which virtual metadata is selected + * @param uuid the selection identifier, can either be the item uuid or the relationship type uuid + * @param selected whether or not to select the virtual metadata to be saved + */ + setSelectedVirtualMetadata(url: string, relationship: string, uuid: string, selected: boolean) { + this.store.dispatch(new SelectVirtualMetadataAction(url, relationship, uuid, 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 9287935f59..99442da58d 100644 --- a/src/app/core/data/relationship.service.spec.ts +++ b/src/app/core/data/relationship.service.spec.ts @@ -54,10 +54,12 @@ describe('RelationshipService', () => { }); const relatedItem1 = Object.assign(new Item(), { + self: 'fake-item-url/author1', id: 'author1', uuid: 'author1' }); const relatedItem2 = Object.assign(new Item(), { + self: 'fake-item-url/author2', id: 'author2', uuid: 'author2' }); @@ -112,19 +114,19 @@ describe('RelationshipService', () => { beforeEach(() => { spyOn(service, 'findById').and.returnValue(getRemotedataObservable(relationship1)); spyOn(objectCache, 'remove'); - service.deleteRelationship(relationships[0].uuid).subscribe(); + service.deleteRelationship(relationships[0].uuid, 'right').subscribe(); }); it('should send a DeleteRequest', () => { - const expected = new DeleteRequest(requestService.generateRequestId(), relationshipsEndpointURL + '/' + relationship1.uuid); + const expected = new DeleteRequest(requestService.generateRequestId(), relationshipsEndpointURL + '/' + relationship1.uuid + '?copyVirtualMetadata=right'); expect(requestService.configure).toHaveBeenCalledWith(expected); }); - it('should clear the related items their cache', () => { + it('should clear the cache of the related items', () => { expect(objectCache.remove).toHaveBeenCalledWith(relatedItem1.self); expect(objectCache.remove).toHaveBeenCalledWith(item.self); - expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith(relatedItem1.uuid); - expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith(item.uuid); + expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith(relatedItem1.self); + expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith(item.self); }); }); diff --git a/src/app/core/data/relationship.service.ts b/src/app/core/data/relationship.service.ts index d6993ebcee..0448c18ec6 100644 --- a/src/app/core/data/relationship.service.ts +++ b/src/app/core/data/relationship.service.ts @@ -1,35 +1,47 @@ -import { HttpClient, HttpHeaders } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { MemoizedSelector, select, Store } from '@ngrx/store'; -import { combineLatest, combineLatest as observableCombineLatest } from 'rxjs'; -import { distinctUntilChanged, filter, map, mergeMap, startWith, switchMap, take, tap } from 'rxjs/operators'; -import { compareArraysUsingIds, paginatedRelationsToItems, relationsToItems } from '../../+item-page/simple/item-types/shared/item-relationships-utils'; -import { AppState, keySelector } from '../../app.reducer'; -import { hasValue, hasValueOperator, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; import { ReorderableRelationship } from '../../shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component'; -import { RemoveNameVariantAction, SetNameVariantAction } from '../../shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/name-variant.actions'; -import { NameVariantListState } from '../../shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/name-variant.reducer'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { configureRequest, getRemoteDataPayload, getResponseFromEntry, getSucceededRemoteData } from '../shared/operators'; -import { SearchParam } from '../cache/models/search-param.model'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { DeleteRequest, FindListOptions, PostRequest, RestRequest } from './request.models'; -import { RestResponse } from '../cache/response.models'; -import { CoreState } from '../core.reducers'; -import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; +import { RequestService } from './request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { hasValue, hasValueOperator, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; +import { distinctUntilChanged, filter, map, mergeMap, startWith, switchMap, take, tap } from 'rxjs/operators'; +import { + configureRequest, + getRemoteDataPayload, + getResponseFromEntry, + getSucceededRemoteData +} from '../shared/operators'; +import { DeleteRequest, FindListOptions, PostRequest, RestRequest } from './request.models'; +import { Observable } from 'rxjs/internal/Observable'; +import { RestResponse } from '../cache/response.models'; +import { Item } from '../shared/item.model'; +import { Relationship } from '../shared/item-relationships/relationship.model'; import { RelationshipType } from '../shared/item-relationships/relationship-type.model'; import { RemoteData, RemoteDataState } from './remote-data'; +import { combineLatest, combineLatest as observableCombineLatest } from 'rxjs'; import { PaginatedList } from './paginated-list'; import { ItemDataService } from './item-data.service'; -import { Relationship } from '../shared/item-relationships/relationship.model'; -import { Item } from '../shared/item.model'; +import { + compareArraysUsingIds, + paginatedRelationsToItems, + relationsToItems +} from '../../+item-page/simple/item-types/shared/item-relationships-utils'; +import { ObjectCacheService } from '../cache/object-cache.service'; import { DataService } from './data.service'; +import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; +import { MemoizedSelector, select, Store } from '@ngrx/store'; +import { CoreState } from '../core.reducers'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { HttpClient, HttpHeaders } from '@angular/common/http'; import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; -import { RequestService } from './request.service'; -import { Observable } from 'rxjs/internal/Observable'; +import { SearchParam } from '../cache/models/search-param.model'; +import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; +import { AppState, keySelector } from '../../app.reducer'; +import { NameVariantListState } from '../../shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/name-variant.reducer'; +import { + RemoveNameVariantAction, + SetNameVariantAction +} from '../../shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/name-variant.actions'; const relationshipListsStateSelector = (state: AppState) => state.relationshipLists; @@ -81,15 +93,22 @@ export class RelationshipService extends DataService { * Send a delete request for a relationship by ID * @param id */ - deleteRelationship(id: string): Observable { + deleteRelationship(id: string, copyVirtualMetadata: string): Observable { return this.getRelationshipEndpoint(id).pipe( isNotEmptyOperator(), take(1), - map((endpointURL: string) => new DeleteRequest(this.requestService.generateRequestId(), endpointURL)), + distinctUntilChanged(), + map((endpointURL: string) => + new DeleteRequest(this.requestService.generateRequestId(), endpointURL + '?copyVirtualMetadata=' + copyVirtualMetadata) + ), configureRequest(this.requestService), switchMap((restRequest: RestRequest) => this.requestService.getByUUID(restRequest.uuid)), getResponseFromEntry(), - tap(() => this.removeRelationshipItemsFromCacheByRelationship(id)) + switchMap((response) => + this.clearRelatedCache(id).pipe( + map(() => response), + ) + ), ); } @@ -417,4 +436,26 @@ export class RelationshipService extends DataService { return update$; } + /** + * Clear object and request caches of the items related to a relationship (left and right items) + * @param uuid The uuid of the relationship for which to clear the related items from the cache + */ + clearRelatedCache(uuid: string): Observable { + return this.findById(uuid).pipe( + getSucceededRemoteData(), + switchMap((rd: RemoteData) => + observableCombineLatest( + rd.payload.leftItem.pipe(getSucceededRemoteData()), + rd.payload.rightItem.pipe(getSucceededRemoteData()) + ) + ), + take(1), + map(([leftItem, rightItem]) => { + this.objectCache.remove(leftItem.payload.self); + this.objectCache.remove(rightItem.payload.self); + this.requestService.removeByHrefSubstring(leftItem.payload.self); + this.requestService.removeByHrefSubstring(rightItem.payload.self); + }), + ); + } } diff --git a/src/app/core/data/request.models.ts b/src/app/core/data/request.models.ts index 5b3a6cde19..07ea8c9c4b 100644 --- a/src/app/core/data/request.models.ts +++ b/src/app/core/data/request.models.ts @@ -18,6 +18,7 @@ import { MetadataschemaParsingService } from './metadataschema-parsing.service'; import { MetadatafieldParsingService } from './metadatafield-parsing.service'; import { URLCombiner } from '../url-combiner/url-combiner'; import { TaskResponseParsingService } from '../tasks/task-response-parsing.service'; +import { ContentSourceResponseParsingService } from './content-source-response-parsing.service'; import { MappedCollectionsReponseParsingService } from './mapped-collections-reponse-parsing.service'; /* tslint:disable:max-classes-per-file */ @@ -380,6 +381,26 @@ export class CreateRequest extends PostRequest { } } +export class ContentSourceRequest extends GetRequest { + constructor(uuid: string, href: string) { + super(uuid, href); + } + + getResponseParser(): GenericConstructor { + return ContentSourceResponseParsingService; + } +} + +export class UpdateContentSourceRequest extends PutRequest { + constructor(uuid: string, href: string, public body?: any, public options?: HttpOptions) { + super(uuid, href, body, options); + } + + getResponseParser(): GenericConstructor { + return ContentSourceResponseParsingService; + } +} + /** * Request to delete an object based on its identifier */ diff --git a/src/app/core/shared/content-source.model.ts b/src/app/core/shared/content-source.model.ts new file mode 100644 index 0000000000..cd53c2d81e --- /dev/null +++ b/src/app/core/shared/content-source.model.ts @@ -0,0 +1,60 @@ +import { autoserialize, autoserializeAs, deserializeAs, deserialize } from 'cerialize'; +import { MetadataConfig } from './metadata-config.model'; + +/** + * The type of content harvesting used + */ +export enum ContentSourceHarvestType { + None = 'NONE', + Metadata = 'METADATA_ONLY', + MetadataAndRef = 'METADATA_AND_REF', + MetadataAndBitstreams = 'METADATA_AND_BITSTREAMS' +} + +/** + * A model class that holds information about the Content Source of a Collection + */ +export class ContentSource { + /** + * Unique identifier, this is necessary to store the ContentSource in FieldUpdates + * Because the ContentSource coming from the REST API doesn't have a UUID, we're using the selflink + */ + @deserializeAs('self') + uuid: string; + + /** + * OAI Provider / Source + */ + @autoserializeAs('oai_source') + oaiSource: string; + + /** + * OAI Specific set ID + */ + @autoserializeAs('oai_set_id') + oaiSetId: string; + + /** + * The ID of the metadata format used + */ + @autoserializeAs('metadata_config_id') + metadataConfigId: string; + + /** + * Type of content being harvested + * Defaults to 'NONE', meaning the collection doesn't harvest its content from an external source + */ + @autoserializeAs('harvest_type') + harvestType = ContentSourceHarvestType.None; + + /** + * The available metadata configurations + */ + metadataConfigs: MetadataConfig[]; + + /** + * The REST link to itself + */ + @deserialize + self: string; +} diff --git a/src/app/core/shared/external-source-entry.model.ts b/src/app/core/shared/external-source-entry.model.ts index be52f96b07..2451aa4d24 100644 --- a/src/app/core/shared/external-source-entry.model.ts +++ b/src/app/core/shared/external-source-entry.model.ts @@ -24,6 +24,11 @@ export class ExternalSourceEntry extends ListableObject { */ value: string; + /** + * The ID of the external source this entry originates from + */ + externalSource: string; + /** * Metadata of the entry */ diff --git a/src/app/core/shared/metadata-config.model.ts b/src/app/core/shared/metadata-config.model.ts new file mode 100644 index 0000000000..861d04586e --- /dev/null +++ b/src/app/core/shared/metadata-config.model.ts @@ -0,0 +1,19 @@ +/** + * A model class that holds information about a certain metadata configuration + */ +export class MetadataConfig { + /** + * A unique indentifier + */ + id: string; + + /** + * The label used for display + */ + label: string; + + /** + * The namespace of the metadata + */ + nameSpace: string; +} diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/external-source-entry/external-source-entry-list-submission-element.component.html b/src/app/entity-groups/research-entities/submission/item-list-elements/external-source-entry/external-source-entry-list-submission-element.component.html index 55b8f38a5e..9d4a3566ad 100644 --- a/src/app/entity-groups/research-entities/submission/item-list-elements/external-source-entry/external-source-entry-list-submission-element.component.html +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/external-source-entry/external-source-entry-list-submission-element.component.html @@ -1,2 +1,4 @@ -
{{object.display}}
-
+
+
{{object.display}}
+ +
diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/external-source-entry/external-source-entry-list-submission-element.component.ts b/src/app/entity-groups/research-entities/submission/item-list-elements/external-source-entry/external-source-entry-list-submission-element.component.ts index c0512b4995..4612996e91 100644 --- a/src/app/entity-groups/research-entities/submission/item-list-elements/external-source-entry/external-source-entry-list-submission-element.component.ts +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/external-source-entry/external-source-entry-list-submission-element.component.ts @@ -3,7 +3,7 @@ import { ExternalSourceEntry } from '../../../../../core/shared/external-source- import { listableObjectComponent } from '../../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; import { ViewMode } from '../../../../../core/shared/view-mode.model'; import { Context } from '../../../../../core/shared/context.model'; -import { Component, OnInit } from '@angular/core'; +import { Component, Inject, OnInit } from '@angular/core'; import { Metadata } from '../../../../../core/shared/metadata.utils'; import { MetadataValue } from '../../../../../core/shared/metadata.models'; diff --git a/src/app/shared/comcol-forms/edit-comcol-page/edit-comcol-page.component.ts b/src/app/shared/comcol-forms/edit-comcol-page/edit-comcol-page.component.ts index 0f9d4c55b4..2fa05fa28b 100644 --- a/src/app/shared/comcol-forms/edit-comcol-page/edit-comcol-page.component.ts +++ b/src/app/shared/comcol-forms/edit-comcol-page/edit-comcol-page.component.ts @@ -2,10 +2,8 @@ import { Component, OnInit } from '@angular/core'; import { Observable } from 'rxjs'; import { ActivatedRoute, Router } from '@angular/router'; import { RemoteData } from '../../../core/data/remote-data'; -import { isNotEmpty, isNotUndefined } from '../../empty.util'; +import { isNotEmpty } from '../../empty.util'; import { first, map } from 'rxjs/operators'; -import { getSucceededRemoteData } from '../../../core/shared/operators'; -import { DataService } from '../../../core/data/data.service'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; /** diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts index 22376502e7..466ad8ac2a 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts @@ -97,6 +97,7 @@ import { PaginatedList } from '../../../../core/data/paginated-list'; import { ItemSearchResult } from '../../../object-collection/shared/item-search-result.model'; import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop'; import { Relationship } from '../../../../core/shared/item-relationships/relationship.model'; +import { Collection } from '../../../../core/shared/collection.model'; export function dsDynamicFormControlMapFn(model: DynamicFormControlModel): Type | null { switch (model.type) { @@ -185,6 +186,7 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo hasRelationLookup: boolean; modalRef: NgbModalRef; item: Item; + collection: Collection; listId: string; searchConfig: string; @@ -236,19 +238,18 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo if (this.hasRelationLookup) { this.listId = 'list-' + this.model.relationship.relationshipType; - const item$ = this.submissionObjectService + + const submissionObject$ = this.submissionObjectService .findById(this.model.submissionId).pipe( getAllSucceededRemoteData(), - getRemoteDataPayload(), - switchMap((submissionObject: SubmissionObject) => (submissionObject.item as Observable>) - .pipe( - getAllSucceededRemoteData(), - getRemoteDataPayload() - ) - ) + getRemoteDataPayload() ); + const item$ = submissionObject$.pipe(switchMap((submissionObject: SubmissionObject) => (submissionObject.item as Observable>).pipe(getAllSucceededRemoteData(), getRemoteDataPayload()))); + const collection$ = submissionObject$.pipe(switchMap((submissionObject: SubmissionObject) => (submissionObject.collection as Observable>).pipe(getAllSucceededRemoteData(), getRemoteDataPayload()))); + this.subs.push(item$.subscribe((item) => this.item = item)); + this.subs.push(collection$.subscribe((collection) => this.collection = collection)); this.reorderables$ = item$.pipe( switchMap((item) => this.relationService.getItemRelationshipsByLabel(item, this.model.relationship.relationshipType) .pipe( @@ -343,6 +344,7 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo modalComp.label = this.model.label; modalComp.metadataFields = this.model.metadataFields; modalComp.item = this.item; + modalComp.collection = this.collection; } /** diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.html index 46620aa00b..328cdc6763 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.html @@ -25,12 +25,14 @@ [title]="'submission.sections.describe.relationship-lookup.search-tab.tab-title.' + source.id | translate : {count: (totalExternal$ | async)[idx]}"> diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.scss b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.scss index 4fb77a7590..42c94c1f68 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.scss +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.scss @@ -1,3 +1,11 @@ .modal-footer { justify-content: space-between; } + +/* Render child-modals slightly smaller than this modal to avoid complete overlap */ +:host { + ::ng-deep .modal-content { + width: 90%; + margin: 5%; + } +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.ts index bce1f53c4d..edf54bf08b 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.ts @@ -66,6 +66,11 @@ export class DsDynamicLookupRelationModalComponent implements OnInit, OnDestroy */ item; + /** + * The collection we're submitting an item to + */ + collection; + /** * Is the selection repeatable? */ @@ -233,6 +238,15 @@ export class DsDynamicLookupRelationModalComponent implements OnInit, OnDestroy ) } + /** + * Called when an external object has been imported, resets the total values and adds the object to the selected list + * @param object + */ + imported(object) { + this.setTotals(); + this.select(object); + } + /** * Calculate and set the total entries available for each tab */ diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/dynamic-lookup-relation-external-source-tab.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/dynamic-lookup-relation-external-source-tab.component.html index 9536d0a5cb..04737c44e4 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/dynamic-lookup-relation-external-source-tab.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/dynamic-lookup-relation-external-source-tab.component.html @@ -10,13 +10,13 @@ + [importable]="true" + [importConfig]="importConfig" + (importObject)="import($event)"> diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/dynamic-lookup-relation-external-source-tab.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/dynamic-lookup-relation-external-source-tab.component.spec.ts index 62327e236e..00242ad9ce 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/dynamic-lookup-relation-external-source-tab.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/dynamic-lookup-relation-external-source-tab.component.spec.ts @@ -3,7 +3,7 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { VarDirective } from '../../../../../utils/var.directive'; import { TranslateModule } from '@ngx-translate/core'; import { RouterTestingModule } from '@angular/router/testing'; -import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { EventEmitter, NO_ERRORS_SCHEMA } from '@angular/core'; import { PaginatedSearchOptions } from '../../../../../search/paginated-search-options.model'; import { SearchConfigurationService } from '../../../../../../core/shared/search/search-configuration.service'; import { of as observableOf } from 'rxjs/internal/observable/of'; @@ -18,12 +18,20 @@ import { ExternalSource } from '../../../../../../core/shared/external-source.mo import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { By } from '@angular/platform-browser'; import { ExternalSourceEntry } from '../../../../../../core/shared/external-source-entry.model'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { SelectableListService } from '../../../../../object-list/selectable-list/selectable-list.service'; +import { Item } from '../../../../../../core/shared/item.model'; +import { Collection } from '../../../../../../core/shared/collection.model'; +import { RelationshipOptions } from '../../../models/relationship-options.model'; +import { ExternalSourceEntryImportModalComponent } from './external-source-entry-import-modal/external-source-entry-import-modal.component'; describe('DsDynamicLookupRelationExternalSourceTabComponent', () => { let component: DsDynamicLookupRelationExternalSourceTabComponent; let fixture: ComponentFixture; let pSearchOptions; let externalSourceService; + let selectableListService; + let modalService; const externalSource = { id: 'orcidV2', @@ -68,6 +76,10 @@ describe('DsDynamicLookupRelationExternalSourceTabComponent', () => { } }) ] as ExternalSourceEntry[]; + const item = Object.assign(new Item(), { id: 'submission-item' }); + const collection = Object.assign(new Collection(), { id: 'submission-collection' }); + const relationship = Object.assign(new RelationshipOptions(), { relationshipType: 'isAuthorOfPublication' }); + const label = 'Author'; function init() { pSearchOptions = new PaginatedSearchOptions({ @@ -76,20 +88,22 @@ describe('DsDynamicLookupRelationExternalSourceTabComponent', () => { externalSourceService = jasmine.createSpyObj('externalSourceService', { getExternalSourceEntries: createSuccessfulRemoteDataObject$(createPaginatedList(externalEntries)) }); + selectableListService = jasmine.createSpyObj('selectableListService', ['selectSingle']); } beforeEach(async(() => { init(); TestBed.configureTestingModule({ declarations: [DsDynamicLookupRelationExternalSourceTabComponent, VarDirective], - imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), BrowserAnimationsModule], + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NgbModule.forRoot(), BrowserAnimationsModule], providers: [ { provide: SearchConfigurationService, useValue: { paginatedSearchOptions: observableOf(pSearchOptions) } }, - { provide: ExternalSourceService, useValue: externalSourceService } + { provide: ExternalSourceService, useValue: externalSourceService }, + { provide: SelectableListService, useValue: selectableListService } ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); @@ -99,13 +113,18 @@ describe('DsDynamicLookupRelationExternalSourceTabComponent', () => { fixture = TestBed.createComponent(DsDynamicLookupRelationExternalSourceTabComponent); component = fixture.componentInstance; component.externalSource = externalSource; + component.item = item; + component.collection = collection; + component.relationship = relationship; + component.label = label; + modalService = (component as any).modalService; fixture.detectChanges(); }); describe('when the external entries finished loading successfully', () => { it('should display a ds-viewable-collection component', () => { - const collection = fixture.debugElement.query(By.css('ds-viewable-collection')); - expect(collection).toBeDefined(); + const viewableCollection = fixture.debugElement.query(By.css('ds-viewable-collection')); + expect(viewableCollection).toBeDefined(); }); }); @@ -116,8 +135,8 @@ describe('DsDynamicLookupRelationExternalSourceTabComponent', () => { }); it('should not display a ds-viewable-collection component', () => { - const collection = fixture.debugElement.query(By.css('ds-viewable-collection')); - expect(collection).toBeNull(); + const viewableCollection = fixture.debugElement.query(By.css('ds-viewable-collection')); + expect(viewableCollection).toBeNull(); }); it('should display a ds-loading component', () => { @@ -133,8 +152,8 @@ describe('DsDynamicLookupRelationExternalSourceTabComponent', () => { }); it('should not display a ds-viewable-collection component', () => { - const collection = fixture.debugElement.query(By.css('ds-viewable-collection')); - expect(collection).toBeNull(); + const viewableCollection = fixture.debugElement.query(By.css('ds-viewable-collection')); + expect(viewableCollection).toBeNull(); }); it('should display a ds-error component', () => { @@ -150,8 +169,8 @@ describe('DsDynamicLookupRelationExternalSourceTabComponent', () => { }); it('should not display a ds-viewable-collection component', () => { - const collection = fixture.debugElement.query(By.css('ds-viewable-collection')); - expect(collection).toBeNull(); + const viewableCollection = fixture.debugElement.query(By.css('ds-viewable-collection')); + expect(viewableCollection).toBeNull(); }); it('should display a message the list is empty', () => { @@ -159,4 +178,15 @@ describe('DsDynamicLookupRelationExternalSourceTabComponent', () => { expect(empty).not.toBeNull(); }); }); + + describe('import', () => { + beforeEach(() => { + spyOn(modalService, 'open').and.returnValue(Object.assign({ componentInstance: Object.assign({ importedObject: new EventEmitter() }) })); + component.import(externalEntries[0]); + }); + + it('should open a new ExternalSourceEntryImportModalComponent', () => { + expect(modalService.open).toHaveBeenCalledWith(ExternalSourceEntryImportModalComponent, jasmine.any(Object)) + }); + }); }); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/dynamic-lookup-relation-external-source-tab.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/dynamic-lookup-relation-external-source-tab.component.ts index d1fa538de3..c8b3b3d311 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/dynamic-lookup-relation-external-source-tab.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/dynamic-lookup-relation-external-source-tab.component.ts @@ -1,4 +1,4 @@ -import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'; import { SEARCH_CONFIG_SERVICE } from '../../../../../../+my-dspace-page/my-dspace-page.component'; import { SearchConfigurationService } from '../../../../../../core/shared/search/search-configuration.service'; import { Router } from '@angular/router'; @@ -14,6 +14,14 @@ import { Context } from '../../../../../../core/shared/context.model'; import { ListableObject } from '../../../../../object-collection/shared/listable-object.model'; import { fadeIn, fadeInOut } from '../../../../../animations/fade'; import { PaginationComponentOptions } from '../../../../../pagination/pagination-component-options.model'; +import { RelationshipOptions } from '../../../models/relationship-options.model'; +import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; +import { ExternalSourceEntryImportModalComponent } from './external-source-entry-import-modal/external-source-entry-import-modal.component'; +import { Subscription } from 'rxjs/internal/Subscription'; +import { hasValue } from '../../../../../empty.util'; +import { SelectableListService } from '../../../../../object-list/selectable-list/selectable-list.service'; +import { Item } from '../../../../../../core/shared/item.model'; +import { Collection } from '../../../../../../core/shared/collection.model'; @Component({ selector: 'ds-dynamic-lookup-relation-external-source-tab', @@ -31,11 +39,12 @@ import { PaginationComponentOptions } from '../../../../../pagination/pagination ] }) /** - * The tab displaying a list of importable entries for an external source + * Component rendering the tab content of an external source during submission lookup + * Shows a list of entries matching the current search query with the option to import them into the repository */ -export class DsDynamicLookupRelationExternalSourceTabComponent implements OnInit { +export class DsDynamicLookupRelationExternalSourceTabComponent implements OnInit, OnDestroy { /** - * The label to use to display i18n messages (describing the type of relationship) + * The label to use for all messages (added to the end of relevant i18n keys) */ @Input() label: string; @@ -45,27 +54,32 @@ export class DsDynamicLookupRelationExternalSourceTabComponent implements OnInit @Input() listId: string; /** - * Is the selection repeatable? + * The item in submission */ - @Input() repeatable: boolean; + @Input() item: Item; /** - * The context to display lists + * The collection the user is submitting an item into + */ + @Input() collection: Collection; + + /** + * The relationship-options for the current lookup + */ + @Input() relationship: RelationshipOptions; + + /** + * The context to displaying lists for */ @Input() context: Context; /** - * Send an event to deselect an object from the list + * Emit an event when an object has been imported (or selected from similar local entries) */ - @Output() deselectObject: EventEmitter = new EventEmitter(); + @Output() importedObject: EventEmitter = new EventEmitter(); /** - * Send an event to select an object from the list - */ - @Output() selectObject: EventEmitter = new EventEmitter(); - - /** - * The initial pagination to start with + * The initial pagination options */ initialPagination = Object.assign(new PaginationComponentOptions(), { id: 'submission-external-source-relation-list', @@ -82,15 +96,68 @@ export class DsDynamicLookupRelationExternalSourceTabComponent implements OnInit */ entriesRD$: Observable>>; + /** + * Config to use for the import buttons + */ + importConfig; + + /** + * The modal for importing the entry + */ + modalRef: NgbModalRef; + + /** + * Subscription to the modal's importedObject event-emitter + */ + importObjectSub: Subscription; + constructor(private router: Router, public searchConfigService: SearchConfigurationService, - private externalSourceService: ExternalSourceService) { + private externalSourceService: ExternalSourceService, + private modalService: NgbModal, + private selectableListService: SelectableListService) { } + /** + * Get the entries for the selected external source + */ ngOnInit(): void { this.entriesRD$ = this.searchConfigService.paginatedSearchOptions.pipe( switchMap((searchOptions: PaginatedSearchOptions) => this.externalSourceService.getExternalSourceEntries(this.externalSource.id, searchOptions).pipe(startWith(undefined))) - ) + ); + this.importConfig = { + buttonLabel: 'submission.sections.describe.relationship-lookup.external-source.import-button-title.' + this.label + }; + } + + /** + * Start the import of an entry by opening up an import modal window + * @param entry The entry to import + */ + import(entry) { + this.modalRef = this.modalService.open(ExternalSourceEntryImportModalComponent, { + size: 'lg', + container: 'ds-dynamic-lookup-relation-modal' + }); + const modalComp = this.modalRef.componentInstance; + modalComp.externalSourceEntry = entry; + modalComp.item = this.item; + modalComp.collection = this.collection; + modalComp.relationship = this.relationship; + modalComp.label = this.label; + this.importObjectSub = modalComp.importedObject.subscribe((object) => { + this.selectableListService.selectSingle(this.listId, object); + this.importedObject.emit(object); + }); + } + + /** + * Unsubscribe from open subscriptions + */ + ngOnDestroy(): void { + if (hasValue(this.importObjectSub)) { + this.importObjectSub.unsubscribe(); + } } } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/external-source-entry-import-modal/external-source-entry-import-modal.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/external-source-entry-import-modal/external-source-entry-import-modal.component.html new file mode 100644 index 0000000000..a4fc356ef9 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/external-source-entry-import-modal/external-source-entry-import-modal.component.html @@ -0,0 +1,61 @@ + + + diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/external-source-entry-import-modal/external-source-entry-import-modal.component.scss b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/external-source-entry-import-modal/external-source-entry-import-modal.component.scss new file mode 100644 index 0000000000..7db9839e38 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/external-source-entry-import-modal/external-source-entry-import-modal.component.scss @@ -0,0 +1,3 @@ +.modal-footer { + justify-content: space-between; +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/external-source-entry-import-modal/external-source-entry-import-modal.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/external-source-entry-import-modal/external-source-entry-import-modal.component.spec.ts new file mode 100644 index 0000000000..5248f95573 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/external-source-entry-import-modal/external-source-entry-import-modal.component.spec.ts @@ -0,0 +1,194 @@ +import { ExternalSourceEntryImportModalComponent, ImportType } from './external-source-entry-import-modal.component'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { RouterTestingModule } from '@angular/router/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { LookupRelationService } from '../../../../../../../core/data/lookup-relation.service'; +import { ExternalSourceEntry } from '../../../../../../../core/shared/external-source-entry.model'; +import { Item } from '../../../../../../../core/shared/item.model'; +import { ItemSearchResult } from '../../../../../../object-collection/shared/item-search-result.model'; +import { createPaginatedList, createSuccessfulRemoteDataObject$ } from '../../../../../../testing/utils'; +import { Collection } from '../../../../../../../core/shared/collection.model'; +import { RelationshipOptions } from '../../../../models/relationship-options.model'; +import { SelectableListService } from '../../../../../../object-list/selectable-list/selectable-list.service'; +import { ItemDataService } from '../../../../../../../core/data/item-data.service'; +import { NotificationsService } from '../../../../../../notifications/notifications.service'; + +describe('DsDynamicLookupRelationExternalSourceTabComponent', () => { + let component: ExternalSourceEntryImportModalComponent; + let fixture: ComponentFixture; + let lookupRelationService: LookupRelationService; + let selectService: SelectableListService; + let itemService: ItemDataService; + let notificationsService: NotificationsService; + let modalStub: NgbActiveModal; + + const uri = 'https://orcid.org/0001-0001-0001-0001'; + const entry = Object.assign(new ExternalSourceEntry(), { + id: '0001-0001-0001-0001', + display: 'John Doe', + value: 'John, Doe', + metadata: { + 'dc.identifier.uri': [ + { + value: uri + } + ] + } + }); + + const label = 'Author'; + const relationship = Object.assign(new RelationshipOptions(), { relationshipType: 'isAuthorOfPublication' }); + const submissionCollection = Object.assign(new Collection(), { uuid: '9398affe-a977-4992-9a1d-6f00908a259f' }); + const submissionItem = Object.assign(new Item(), { uuid: '26224069-5f99-412a-9e9b-7912a7e35cb1' }); + const item1 = Object.assign(new Item(), { uuid: 'e1c51c69-896d-42dc-8221-1d5f2ad5516e' }); + const item2 = Object.assign(new Item(), { uuid: 'c8279647-1acc-41ae-b036-951d5f65649b' }); + const item3 = Object.assign(new Item(), { uuid: 'c3bcbff5-ec0c-4831-8e4c-94b9c933ccac' }); + const searchResult1 = Object.assign(new ItemSearchResult(), { indexableObject: item1 }); + const searchResult2 = Object.assign(new ItemSearchResult(), { indexableObject: item2 }); + const searchResult3 = Object.assign(new ItemSearchResult(), { indexableObject: item3 }); + const importedItem = Object.assign(new Item(), { uuid: '5d0098fc-344a-4067-a57d-457092b72e82' }); + + function init() { + lookupRelationService = jasmine.createSpyObj('lookupRelationService', { + getLocalResults: createSuccessfulRemoteDataObject$(createPaginatedList([searchResult1, searchResult2, searchResult3])), + removeLocalResultsCache: {} + }); + selectService = jasmine.createSpyObj('selectService', ['deselectAll']); + notificationsService = jasmine.createSpyObj('notificationsService', ['success']); + itemService = jasmine.createSpyObj('itemService', { + importExternalSourceEntry: createSuccessfulRemoteDataObject$(importedItem) + }); + modalStub = jasmine.createSpyObj('modal', ['close']); + } + + beforeEach(async(() => { + init(); + TestBed.configureTestingModule({ + declarations: [ExternalSourceEntryImportModalComponent], + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NgbModule.forRoot()], + providers: [ + { provide: LookupRelationService, useValue: lookupRelationService }, + { provide: SelectableListService, useValue: selectService }, + { provide: NotificationsService, useValue: notificationsService }, + { provide: ItemDataService, useValue: itemService }, + { provide: NgbActiveModal, useValue: modalStub } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ExternalSourceEntryImportModalComponent); + component = fixture.componentInstance; + component.externalSourceEntry = entry; + component.label = label; + component.relationship = relationship; + component.collection = submissionCollection; + component.item = submissionItem; + fixture.detectChanges(); + }); + + describe('close', () => { + beforeEach(() => { + component.close(); + }); + + it('should close the modal', () => { + expect(modalStub.close).toHaveBeenCalled(); + }); + }); + + describe('selectEntity', () => { + const entity = Object.assign(new Item(), { uuid: 'd8698de5-5b05-4ea4-9d02-da73803a50f9' }); + + beforeEach(() => { + component.selectEntity(entity); + }); + + it('should set selected entity', () => { + expect(component.selectedEntity).toBe(entity); + }); + + it('should set the import type to local entity', () => { + expect(component.selectedImportType).toEqual(ImportType.LocalEntity); + }); + }); + + describe('deselectEntity', () => { + const entity = Object.assign(new Item(), { uuid: 'd8698de5-5b05-4ea4-9d02-da73803a50f9' }); + + beforeEach(() => { + component.selectedImportType = ImportType.LocalEntity; + component.selectedEntity = entity; + component.deselectEntity(); + }); + + it('should remove the selected entity', () => { + expect(component.selectedEntity).toBeUndefined(); + }); + + it('should set the import type to none', () => { + expect(component.selectedImportType).toEqual(ImportType.None); + }); + }); + + describe('selectNewEntity', () => { + describe('when current import type is set to new entity', () => { + beforeEach(() => { + component.selectedImportType = ImportType.NewEntity; + component.selectNewEntity(); + }); + + it('should set the import type to none', () => { + expect(component.selectedImportType).toEqual(ImportType.None); + }); + }); + + describe('when current import type is not set to new entity', () => { + beforeEach(() => { + component.selectedImportType = ImportType.None; + component.selectNewEntity(); + }); + + it('should set the import type to new entity', () => { + expect(component.selectedImportType).toEqual(ImportType.NewEntity); + }); + + it('should deselect the entity and authority list', () => { + expect(selectService.deselectAll).toHaveBeenCalledWith(component.entityListId); + expect(selectService.deselectAll).toHaveBeenCalledWith(component.authorityListId); + }); + }); + }); + + describe('selectNewAuthority', () => { + describe('when current import type is set to new authority', () => { + beforeEach(() => { + component.selectedImportType = ImportType.NewAuthority; + component.selectNewAuthority(); + }); + + it('should set the import type to none', () => { + expect(component.selectedImportType).toEqual(ImportType.None); + }); + }); + + describe('when current import type is not set to new authority', () => { + beforeEach(() => { + component.selectedImportType = ImportType.None; + component.selectNewAuthority(); + }); + + it('should set the import type to new authority', () => { + expect(component.selectedImportType).toEqual(ImportType.NewAuthority); + }); + + it('should deselect the entity and authority list', () => { + expect(selectService.deselectAll).toHaveBeenCalledWith(component.entityListId); + expect(selectService.deselectAll).toHaveBeenCalledWith(component.authorityListId); + }); + }); + }); +}); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/external-source-entry-import-modal/external-source-entry-import-modal.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/external-source-entry-import-modal/external-source-entry-import-modal.component.ts new file mode 100644 index 0000000000..7e0fe78717 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/external-source-entry-import-modal/external-source-entry-import-modal.component.ts @@ -0,0 +1,311 @@ +import { Component, EventEmitter, OnInit } from '@angular/core'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { ExternalSourceEntry } from '../../../../../../../core/shared/external-source-entry.model'; +import { MetadataValue } from '../../../../../../../core/shared/metadata.models'; +import { Metadata } from '../../../../../../../core/shared/metadata.utils'; +import { Observable } from 'rxjs/internal/Observable'; +import { RemoteData } from '../../../../../../../core/data/remote-data'; +import { PaginatedList } from '../../../../../../../core/data/paginated-list'; +import { SearchResult } from '../../../../../../search/search-result.model'; +import { Item } from '../../../../../../../core/shared/item.model'; +import { RelationshipOptions } from '../../../../models/relationship-options.model'; +import { LookupRelationService } from '../../../../../../../core/data/lookup-relation.service'; +import { PaginatedSearchOptions } from '../../../../../../search/paginated-search-options.model'; +import { CollectionElementLinkType } from '../../../../../../object-collection/collection-element-link.type'; +import { Context } from '../../../../../../../core/shared/context.model'; +import { SelectableListService } from '../../../../../../object-list/selectable-list/selectable-list.service'; +import { ListableObject } from '../../../../../../object-collection/shared/listable-object.model'; +import { Collection } from '../../../../../../../core/shared/collection.model'; +import { ItemDataService } from '../../../../../../../core/data/item-data.service'; +import { PaginationComponentOptions } from '../../../../../../pagination/pagination-component-options.model'; +import { getRemoteDataPayload, getSucceededRemoteData } from '../../../../../../../core/shared/operators'; +import { take } from 'rxjs/operators'; +import { ItemSearchResult } from '../../../../../../object-collection/shared/item-search-result.model'; +import { NotificationsService } from '../../../../../../notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; + +/** + * The possible types of import for the external entry + */ +export enum ImportType { + None = 'None', + LocalEntity = 'LocalEntity', + LocalAuthority = 'LocalAuthority', + NewEntity = 'NewEntity', + NewAuthority = 'NewAuthority' +} + +@Component({ + selector: 'ds-external-source-entry-import-modal', + styleUrls: ['./external-source-entry-import-modal.component.scss'], + templateUrl: './external-source-entry-import-modal.component.html' +}) +/** + * Component to display a modal window for importing an external source entry + * Shows information about the selected entry and a selectable list of local entities and authorities with similar names + * and the ability to add one of those results to the selection instead of the external entry. + * The other option is to import the external entry as a new entity or authority into the repository. + */ +export class ExternalSourceEntryImportModalComponent implements OnInit { + /** + * The prefix for every i18n key within this modal + */ + labelPrefix = 'submission.sections.describe.relationship-lookup.external-source.import-modal.'; + + /** + * The label to use for all messages (added to the end of relevant i18n keys) + */ + label: string; + + /** + * The external source entry + */ + externalSourceEntry: ExternalSourceEntry; + + /** + * The item in submission + */ + item: Item; + + /** + * The collection the user is submitting in + */ + collection: Collection; + + /** + * The ID of the collection to import entries to + */ + collectionId: string; + + /** + * The current relationship-options used for filtering results + */ + relationship: RelationshipOptions; + + /** + * The metadata value for the entry's uri + */ + uri: MetadataValue; + + /** + * Local entities with a similar name + */ + localEntitiesRD$: Observable>>>; + + /** + * Search options to use for fetching similar results + */ + searchOptions: PaginatedSearchOptions; + + /** + * The type of link to render in listable elements + */ + linkTypes = CollectionElementLinkType; + + /** + * The context we're currently in (submission) + */ + context = Context.SubmissionModal; + + /** + * List ID for selecting local entities + */ + entityListId = 'external-source-import-entity'; + + /** + * List ID for selecting local authorities + */ + authorityListId = 'external-source-import-authority'; + + /** + * ImportType enum + */ + importType = ImportType; + + /** + * The type of import the user currently has selected + */ + selectedImportType = ImportType.None; + + /** + * The selected local entity + */ + selectedEntity: ListableObject; + + /** + * The selected local authority + */ + selectedAuthority: ListableObject; + + /** + * An object has been imported, send it to the parent component + */ + importedObject: EventEmitter = new EventEmitter(); + + /** + * Should it display the ability to import the entry as an authority? + */ + authorityEnabled = false; + + constructor(public modal: NgbActiveModal, + public lookupRelationService: LookupRelationService, + private selectService: SelectableListService, + private itemService: ItemDataService, + private notificationsService: NotificationsService, + private translateService: TranslateService) { + } + + ngOnInit(): void { + this.uri = Metadata.first(this.externalSourceEntry.metadata, 'dc.identifier.uri'); + const pagination = Object.assign(new PaginationComponentOptions(), { id: 'external-entry-import', pageSize: 5 }); + this.searchOptions = Object.assign(new PaginatedSearchOptions({ query: this.externalSourceEntry.value, pagination: pagination })); + this.localEntitiesRD$ = this.lookupRelationService.getLocalResults(this.relationship, this.searchOptions); + this.collectionId = this.collection.id; + } + + /** + * Close the window + */ + close() { + this.modal.close(); + } + + /** + * Perform the import of the external entry + */ + import() { + switch (this.selectedImportType) { + case ImportType.LocalEntity : { + this.importLocalEntity(); + break; + } + case ImportType.NewEntity : { + this.importNewEntity(); + break; + } + case ImportType.LocalAuthority : { + this.importLocalAuthority(); + break; + } + case ImportType.NewAuthority : { + this.importNewAuthority(); + break; + } + } + this.selectedImportType = ImportType.None; + this.deselectAllLists(); + this.close(); + } + + /** + * Import the selected local entity + */ + importLocalEntity() { + if (this.selectedEntity !== undefined) { + this.notificationsService.success(this.translateService.get(this.labelPrefix + this.label + '.added.local-entity')); + this.importedObject.emit(this.selectedEntity); + } + } + + /** + * Create and import a new entity from the external entry + */ + importNewEntity() { + this.itemService.importExternalSourceEntry(this.externalSourceEntry, this.collectionId).pipe( + getSucceededRemoteData(), + getRemoteDataPayload(), + take(1) + ).subscribe((item: Item) => { + this.lookupRelationService.removeLocalResultsCache(); + const searchResult = Object.assign(new ItemSearchResult(), { + indexableObject: item + }); + this.notificationsService.success(this.translateService.get(this.labelPrefix + this.label + '.added.new-entity')); + this.importedObject.emit(searchResult); + }); + } + + /** + * Import the selected local authority + */ + importLocalAuthority() { + // TODO: Implement ability to import local authorities + } + + /** + * Create and import a new authority from the external entry + */ + importNewAuthority() { + // TODO: Implement ability to import new authorities + } + + /** + * Deselected a local entity + */ + deselectEntity() { + this.selectedEntity = undefined; + if (this.selectedImportType === ImportType.LocalEntity) { + this.selectedImportType = ImportType.None; + } + } + + /** + * Selected a local entity + * @param entity + */ + selectEntity(entity) { + this.selectedEntity = entity; + this.selectedImportType = ImportType.LocalEntity; + } + + /** + * Selected/deselected the new entity option + */ + selectNewEntity() { + if (this.selectedImportType === ImportType.NewEntity) { + this.selectedImportType = ImportType.None; + } else { + this.selectedImportType = ImportType.NewEntity; + this.deselectAllLists(); + } + } + + /** + * Deselected a local authority + */ + deselectAuthority() { + this.selectedAuthority = undefined; + if (this.selectedImportType === ImportType.LocalAuthority) { + this.selectedImportType = ImportType.None; + } + } + + /** + * Selected a local authority + * @param authority + */ + selectAuthority(authority) { + this.selectedAuthority = authority; + this.selectedImportType = ImportType.LocalAuthority; + } + + /** + * Selected/deselected the new authority option + */ + selectNewAuthority() { + if (this.selectedImportType === ImportType.NewAuthority) { + this.selectedImportType = ImportType.None; + } else { + this.selectedImportType = ImportType.NewAuthority; + this.deselectAllLists(); + } + } + + /** + * Deselect every element from both entity and authority lists + */ + deselectAllLists() { + this.selectService.deselectAll(this.entityListId); + this.selectService.deselectAll(this.authorityListId); + } +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/relationship.effects.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/relationship.effects.ts index e26abf94c1..201d50e511 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/relationship.effects.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/relationship.effects.ts @@ -128,7 +128,7 @@ export class RelationshipEffects { this.relationshipService.getRelationshipByItemsAndLabel(item1, item2, relationshipType).pipe( take(1), hasValueOperator(), - mergeMap((relationship: Relationship) => this.relationshipService.deleteRelationship(relationship.id)), + mergeMap((relationship: Relationship) => this.relationshipService.deleteRelationship(relationship.id, 'none')), take(1) ).subscribe(); } diff --git a/src/app/shared/mocks/mock-submission.ts b/src/app/shared/mocks/mock-submission.ts index 922e6ad02d..a97d2fb31a 100644 --- a/src/app/shared/mocks/mock-submission.ts +++ b/src/app/shared/mocks/mock-submission.ts @@ -1325,7 +1325,7 @@ export const mockUploadConfigResponse = { }, self: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/bitstream-metadata' }, - required: false, + required: true, maxSize: 536870912, name: 'upload', type: 'submissionupload', @@ -1336,6 +1336,10 @@ export const mockUploadConfigResponse = { self: 'https://rest.api/dspace-spring-rest/api/config/submissionuploads/upload' }; +// Clone the object and change one property +export const mockUploadConfigResponseNotRequired = JSON.parse(JSON.stringify(mockUploadConfigResponse)); +mockUploadConfigResponseNotRequired.required = false; + export const mockAccessConditionOptions = [ { name: 'openaccess', diff --git a/src/app/shared/object-collection/object-collection.component.html b/src/app/shared/object-collection/object-collection.component.html index 57e1ccd81a..e696170a6f 100644 --- a/src/app/shared/object-collection/object-collection.component.html +++ b/src/app/shared/object-collection/object-collection.component.html @@ -5,6 +5,7 @@ [hideGear]="hideGear" [linkType]="linkType" [context]="context" + [hidePaginationDetail]="hidePaginationDetail" (paginationChange)="onPaginationChange($event)" (pageChange)="onPageChange($event)" (pageSizeChange)="onPageSizeChange($event)" @@ -14,6 +15,9 @@ (sortFieldChange)="onSortFieldChange($event)" [selectable]="selectable" [selectionConfig]="selectionConfig" + [importable]="importable" + [importConfig]="importConfig" + (importObject)="importObject.emit($event)" *ngIf="(currentMode$ | async) === viewModeEnum.ListElement"> @@ -23,6 +27,7 @@ [hideGear]="hideGear" [linkType]="linkType" [context]="context" + [hidePaginationDetail]="hidePaginationDetail" (paginationChange)="onPaginationChange($event)" (pageChange)="onPageChange($event)" (pageSizeChange)="onPageSizeChange($event)" @@ -37,6 +42,7 @@ [hideGear]="hideGear" [linkType]="linkType" [context]="context" + [hidePaginationDetail]="hidePaginationDetail" *ngIf="(currentMode$ | async) === viewModeEnum.DetailedListElement"> diff --git a/src/app/shared/object-collection/object-collection.component.ts b/src/app/shared/object-collection/object-collection.component.ts index f09ba3953e..f39bf07123 100644 --- a/src/app/shared/object-collection/object-collection.component.ts +++ b/src/app/shared/object-collection/object-collection.component.ts @@ -53,6 +53,21 @@ export class ObjectCollectionComponent implements OnInit { @Output() deselectObject: EventEmitter = new EventEmitter(); @Output() selectObject: EventEmitter = new EventEmitter(); + /** + * Whether or not to add an import button to the object elements + */ + @Input() importable = false; + + /** + * The config to use for the import button + */ + @Input() importConfig: { buttonLabel: string }; + + /** + * Send an import event to the parent component + */ + @Output() importObject: EventEmitter = new EventEmitter(); + /** * The link type of the rendered list elements */ @@ -63,6 +78,11 @@ export class ObjectCollectionComponent implements OnInit { */ @Input() context: Context; + /** + * Option for hiding the pagination detail + */ + @Input() hidePaginationDetail = false; + /** * the page info of the list */ diff --git a/src/app/shared/object-collection/shared/importable-list-item-control/importable-list-item-control.component.html b/src/app/shared/object-collection/shared/importable-list-item-control/importable-list-item-control.component.html new file mode 100644 index 0000000000..ca3b086653 --- /dev/null +++ b/src/app/shared/object-collection/shared/importable-list-item-control/importable-list-item-control.component.html @@ -0,0 +1,7 @@ +
+ +
diff --git a/src/app/shared/object-collection/shared/importable-list-item-control/importable-list-item-control.component.ts b/src/app/shared/object-collection/shared/importable-list-item-control/importable-list-item-control.component.ts new file mode 100644 index 0000000000..f381a02d86 --- /dev/null +++ b/src/app/shared/object-collection/shared/importable-list-item-control/importable-list-item-control.component.ts @@ -0,0 +1,26 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { ListableObject } from '../listable-object.model'; + +@Component({ + selector: 'ds-importable-list-item-control', + templateUrl: './importable-list-item-control.component.html' +}) +/** + * Component adding an import button to a list item + */ +export class ImportableListItemControlComponent { + /** + * The item or metadata to determine the component for + */ + @Input() object: ListableObject; + + /** + * Extra configuration for the import button + */ + @Input() importConfig: { buttonLabel: string }; + + /** + * Output the object to import + */ + @Output() importObject: EventEmitter = new EventEmitter(); +} diff --git a/src/app/shared/object-detail/object-detail.component.ts b/src/app/shared/object-detail/object-detail.component.ts index fb68316251..45efb4a0b7 100644 --- a/src/app/shared/object-detail/object-detail.component.ts +++ b/src/app/shared/object-detail/object-detail.component.ts @@ -89,7 +89,7 @@ export class ObjectDetailComponent { /** * Option for hiding the pagination detail */ - public hidePaginationDetail = true; + @Input() hidePaginationDetail = true; /** * An event fired when the page is changed. diff --git a/src/app/shared/object-grid/object-grid.component.html b/src/app/shared/object-grid/object-grid.component.html index 348536bfed..0afd623d86 100644 --- a/src/app/shared/object-grid/object-grid.component.html +++ b/src/app/shared/object-grid/object-grid.component.html @@ -5,6 +5,7 @@ [sortOptions]="sortConfig" [hideGear]="hideGear" [hidePagerWhenSinglePage]="hidePagerWhenSinglePage" + [hidePaginationDetail]="hidePaginationDetail" (pageChange)="onPageChange($event)" (pageSizeChange)="onPageSizeChange($event)" (sortDirectionChange)="onSortDirectionChange($event)" diff --git a/src/app/shared/object-grid/object-grid.component.ts b/src/app/shared/object-grid/object-grid.component.ts index 2da4abe13b..c6f8347217 100644 --- a/src/app/shared/object-grid/object-grid.component.ts +++ b/src/app/shared/object-grid/object-grid.component.ts @@ -69,6 +69,11 @@ export class ObjectGridComponent implements OnInit { */ @Input() context: Context; + /** + * Option for hiding the pagination detail + */ + @Input() hidePaginationDetail = false; + /** * Behavior subject to output the current listable objects */ diff --git a/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.html b/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.html index bcd5c3c027..e1478b5206 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.html +++ b/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.html @@ -2,6 +2,7 @@ +

diff --git a/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.spec.ts b/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.spec.ts index eb531d2f93..6366539157 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.spec.ts +++ b/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.spec.ts @@ -47,6 +47,23 @@ const mockItemWithoutAuthorAndDate: Item = Object.assign(new Item(), { ] } }); +const mockItemWithEntityType: Item = Object.assign(new Item(), { + bundles: observableOf({}), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'relationship.type': [ + { + language: null, + value: 'Publication' + } + ] + } +}); describe('ItemListPreviewComponent', () => { beforeEach(async(() => { @@ -128,4 +145,16 @@ describe('ItemListPreviewComponent', () => { expect(dateField).not.toBeNull(); }); }); + + describe('When the item has an entity type', () => { + beforeEach(() => { + component.item = mockItemWithEntityType; + fixture.detectChanges(); + }); + + it('should show the entity type span', () => { + const entityField = fixture.debugElement.query(By.css('ds-item-type-badge')); + expect(entityField).not.toBeNull(); + }); + }); }); diff --git a/src/app/shared/object-list/object-list.component.html b/src/app/shared/object-list/object-list.component.html index 887be96785..5f6b1d1ec8 100644 --- a/src/app/shared/object-list/object-list.component.html +++ b/src/app/shared/object-list/object-list.component.html @@ -5,6 +5,7 @@ [sortOptions]="sortConfig" [hideGear]="hideGear" [hidePagerWhenSinglePage]="hidePagerWhenSinglePage" + [hidePaginationDetail]="hidePaginationDetail" (pageChange)="onPageChange($event)" (pageSizeChange)="onPageSizeChange($event)" (sortDirectionChange)="onSortDirectionChange($event)" @@ -19,6 +20,11 @@ (deselectObject)="deselectObject.emit($event)" (selectObject)="selectObject.emit($event)"> + + + diff --git a/src/app/shared/object-list/object-list.component.ts b/src/app/shared/object-list/object-list.component.ts index 6ca7adb3f9..60544c4ec5 100644 --- a/src/app/shared/object-list/object-list.component.ts +++ b/src/app/shared/object-list/object-list.component.ts @@ -61,6 +61,21 @@ export class ObjectListComponent { */ @Input() context: Context; + /** + * Option for hiding the pagination detail + */ + @Input() hidePaginationDetail = false; + + /** + * Whether or not to add an import button to the object + */ + @Input() importable = false; + + /** + * Config used for the import button + */ + @Input() importConfig: { importLabel: string }; + /** * The current listable objects */ @@ -119,6 +134,12 @@ export class ObjectListComponent { @Output() deselectObject: EventEmitter = new EventEmitter(); @Output() selectObject: EventEmitter = new EventEmitter(); + + /** + * Send an import event to the parent component + */ + @Output() importObject: EventEmitter = new EventEmitter(); + /** * An event fired when the sort field is changed. * Event's payload equals to the newly selected sort field. diff --git a/src/app/shared/pagination/pagination.component.html b/src/app/shared/pagination/pagination.component.html index c16a153026..649fe686ff 100644 --- a/src/app/shared/pagination/pagination.component.html +++ b/src/app/shared/pagination/pagination.component.html @@ -1,5 +1,5 @@
-
+
{{ 'pagination.showing.label' | translate }} diff --git a/src/app/shared/search/search-results/search-results.component.html b/src/app/shared/search/search-results/search-results.component.html index ab1e96c58f..cbc56d1080 100644 --- a/src/app/shared/search/search-results/search-results.component.html +++ b/src/app/shared/search/search-results/search-results.component.html @@ -8,6 +8,7 @@ [selectable]="selectable" [selectionConfig]="selectionConfig" [context]="context" + [hidePaginationDetail]="hidePaginationDetail" (deselectObject)="deselectObject.emit($event)" (selectObject)="selectObject.emit($event)" > diff --git a/src/app/shared/search/search-results/search-results.component.ts b/src/app/shared/search/search-results/search-results.component.ts index f245b5f9ae..b094e69a57 100644 --- a/src/app/shared/search/search-results/search-results.component.ts +++ b/src/app/shared/search/search-results/search-results.component.ts @@ -67,6 +67,11 @@ export class SearchResultsComponent { @Input() context: Context; + /** + * Option for hiding the pagination detail + */ + @Input() hidePaginationDetail = false; + @Input() selectionConfig: {repeatable: boolean, listId: string}; @Output() deselectObject: EventEmitter = new EventEmitter(); diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index 9472ceb568..378387b6dd 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -47,7 +47,10 @@ import { LogOutComponent } from './log-out/log-out.component'; import { FormComponent } from './form/form.component'; import { DsDynamicTypeaheadComponent } from './form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component'; import { DsDynamicScrollableDropdownComponent } from './form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component'; -import { DsDynamicFormControlContainerComponent, dsDynamicFormControlMapFn } from './form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component'; +import { + DsDynamicFormControlContainerComponent, + dsDynamicFormControlMapFn +} from './form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component'; import { DsDynamicFormComponent } from './form/builder/ds-dynamic-form-ui/ds-dynamic-form.component'; import { DYNAMIC_FORM_CONTROL_MAP_FN, DynamicFormsCoreModule } from '@ng-dynamic-forms/core'; import { DynamicFormsNGBootstrapUIModule } from '@ng-dynamic-forms/ui-ng-bootstrap'; @@ -172,6 +175,8 @@ import { SidebarFilterComponent } from './sidebar/filter/sidebar-filter.componen import { SidebarFilterSelectedOptionComponent } from './sidebar/filter/sidebar-filter-selected-option.component'; import { SelectableListItemControlComponent } from './object-collection/shared/selectable-list-item-control/selectable-list-item-control.component'; import { DsDynamicLookupRelationExternalSourceTabComponent } from './form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/dynamic-lookup-relation-external-source-tab.component'; +import { ExternalSourceEntryImportModalComponent } from './form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/external-source-entry-import-modal/external-source-entry-import-modal.component'; +import { ImportableListItemControlComponent } from './object-collection/shared/importable-list-item-control/importable-list-item-control.component'; import { DragDropModule } from '@angular/cdk/drag-drop'; import { ExistingMetadataListElementComponent } from './form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component'; import { LogInContainerComponent } from './log-in/container/log-in-container.component'; @@ -338,11 +343,12 @@ const COMPONENTS = [ CollectionSelectComponent, MetadataRepresentationLoaderComponent, SelectableListItemControlComponent, + ExternalSourceEntryImportModalComponent, + ImportableListItemControlComponent, ExistingMetadataListElementComponent, LogInShibbolethComponent, LogInPasswordComponent, - LogInContainerComponent, - ItemTypeBadgeComponent + LogInContainerComponent ]; const ENTRY_COMPONENTS = [ @@ -405,6 +411,7 @@ const ENTRY_COMPONENTS = [ DsDynamicLookupRelationSearchTabComponent, DsDynamicLookupRelationSelectionTabComponent, DsDynamicLookupRelationExternalSourceTabComponent, + ExternalSourceEntryImportModalComponent, LogInPasswordComponent, LogInShibbolethComponent ]; 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/app/submission/sections/upload/section-upload.component.spec.ts b/src/app/submission/sections/upload/section-upload.component.spec.ts index fd9f88d939..a58de09b8d 100644 --- a/src/app/submission/sections/upload/section-upload.component.spec.ts +++ b/src/app/submission/sections/upload/section-upload.component.spec.ts @@ -5,6 +5,7 @@ import { TranslateModule } from '@ngx-translate/core'; import { of as observableOf } from 'rxjs'; import { createSuccessfulRemoteDataObject$, createTestComponent } from '../../../shared/testing/utils'; +import { SubmissionObjectState } from '../../objects/submission-objects.reducer'; import { SubmissionService } from '../../submission.service'; import { SubmissionServiceStub } from '../../../shared/testing/submission-service-stub'; import { SectionsService } from '../sections.service'; @@ -18,7 +19,7 @@ import { mockSubmissionId, mockSubmissionState, mockUploadConfigResponse, - mockUploadFiles + mockUploadConfigResponseNotRequired, mockUploadFiles, } from '../../../shared/mocks/mock-submission'; import { BrowserModule } from '@angular/platform-browser'; import { CommonModule } from '@angular/common'; @@ -31,7 +32,6 @@ import { cold, hot } from 'jasmine-marbles'; import { Collection } from '../../../core/shared/collection.model'; import { ResourcePolicy } from '../../../core/shared/resource-policy.model'; import { ResourcePolicyService } from '../../../core/data/resource-policy.service'; -import { RemoteData } from '../../../core/data/remote-data'; import { ConfigData } from '../../../core/config/config-data'; import { PageInfo } from '../../../core/shared/page-info.model'; import { Group } from '../../../core/eperson/models/group.model'; @@ -65,17 +65,7 @@ function getMockResourcePolicyService(): ResourcePolicyService { }); } -const sectionObject: SectionDataObject = { - config: 'https://dspace7.4science.it/or2018/api/config/submissionforms/upload', - mandatory: true, - data: { - files: [] - }, - errors: [], - header: 'submit.progressbar.describe.upload', - id: 'upload', - sectionType: SectionsType.Upload -}; +let sectionObject: SectionDataObject; describe('SubmissionSectionUploadComponent test suite', () => { @@ -90,30 +80,48 @@ describe('SubmissionSectionUploadComponent test suite', () => { let uploadsConfigService: any; let bitstreamService: any; - const submissionId = mockSubmissionId; - const collectionId = mockSubmissionCollectionId; - const submissionState = Object.assign({}, mockSubmissionState[mockSubmissionId]); - const mockCollection = Object.assign(new Collection(), { - name: 'Community 1-Collection 1', - id: collectionId, - metadata: [ - { - key: 'dc.title', - language: 'en_US', - value: 'Community 1-Collection 1' - }], - _links: { - defaultAccessConditions: collectionId + '/defaultAccessConditions' - } - }); - const mockDefaultAccessCondition = Object.assign(new ResourcePolicy(), { - name: null, - groupUUID: '11cc35e5-a11d-4b64-b5b9-0052a5d15509', - id: 20, - uuid: 'resource-policy-20' - }); + let submissionId: string; + let collectionId: string; + let submissionState: SubmissionObjectState; + let mockCollection: Collection; + let mockDefaultAccessCondition: ResourcePolicy; beforeEach(async(() => { + sectionObject = { + config: 'https://dspace7.4science.it/or2018/api/config/submissionforms/upload', + mandatory: true, + data: { + files: [] + }, + errors: [], + header: 'submit.progressbar.describe.upload', + id: 'upload', + sectionType: SectionsType.Upload + }; + submissionId = mockSubmissionId; + collectionId = mockSubmissionCollectionId; + submissionState = Object.assign({}, mockSubmissionState[mockSubmissionId]) as any; + mockCollection = Object.assign(new Collection(), { + name: 'Community 1-Collection 1', + id: collectionId, + metadata: [ + { + key: 'dc.title', + language: 'en_US', + value: 'Community 1-Collection 1' + }], + _links: { + defaultAccessConditions: collectionId + '/defaultAccessConditions' + } + }); + + mockDefaultAccessCondition = Object.assign(new ResourcePolicy(), { + name: null, + groupUUID: '11cc35e5-a11d-4b64-b5b9-0052a5d15509', + id: 20, + uuid: 'resource-policy-20' + }); + TestBed.configureTestingModule({ imports: [ BrowserModule, @@ -206,7 +214,7 @@ describe('SubmissionSectionUploadComponent test suite', () => { comp.onSectionInit(); - const expectedGroupsMap = new Map([ + const expectedGroupsMap = new Map([ [mockUploadConfigResponse.accessConditionOptions[1].name, [mockGroup as any]], [mockUploadConfigResponse.accessConditionOptions[2].name, [mockGroup as any]], ]); @@ -215,6 +223,7 @@ describe('SubmissionSectionUploadComponent test suite', () => { expect(comp.collectionName).toBe(mockCollection.name); expect(comp.availableAccessConditionOptions.length).toBe(4); expect(comp.availableAccessConditionOptions).toEqual(mockUploadConfigResponse.accessConditionOptions as any); + expect(comp.required$.getValue()).toBe(true); expect(compAsAny.subs.length).toBe(2); expect(compAsAny.availableGroups.size).toBe(2); expect(compAsAny.availableGroups).toEqual(expectedGroupsMap); @@ -245,7 +254,7 @@ describe('SubmissionSectionUploadComponent test suite', () => { comp.onSectionInit(); - const expectedGroupsMap = new Map([ + const expectedGroupsMap = new Map([ [mockUploadConfigResponse.accessConditionOptions[1].name, [mockGroup as any]], [mockUploadConfigResponse.accessConditionOptions[2].name, [mockGroup as any]], ]); @@ -254,6 +263,7 @@ describe('SubmissionSectionUploadComponent test suite', () => { expect(comp.collectionName).toBe(mockCollection.name); expect(comp.availableAccessConditionOptions.length).toBe(4); expect(comp.availableAccessConditionOptions).toEqual(mockUploadConfigResponse.accessConditionOptions as any); + expect(comp.required$.getValue()).toBe(true); expect(compAsAny.subs.length).toBe(2); expect(compAsAny.availableGroups.size).toBe(2); expect(compAsAny.availableGroups).toEqual(expectedGroupsMap); @@ -263,17 +273,67 @@ describe('SubmissionSectionUploadComponent test suite', () => { }); - it('should the properly section status', () => { - bitstreamService.getUploadedFileList.and.returnValue(hot('-a-b', { + it('should properly read the section status when required is true', () => { + submissionServiceStub.getSubmissionObject.and.returnValue(observableOf(submissionState)); + + collectionDataService.findById.and.returnValue(createSuccessfulRemoteDataObject$(mockCollection)); + + resourcePolicyService.findByHref.and.returnValue(createSuccessfulRemoteDataObject$(mockDefaultAccessCondition)); + + uploadsConfigService.getConfigByHref.and.returnValue(observableOf( + new ConfigData(new PageInfo(), mockUploadConfigResponse as any) + )); + + groupService.findById.and.returnValues( + createSuccessfulRemoteDataObject$(Object.assign(new Group(), mockGroup)), + createSuccessfulRemoteDataObject$(Object.assign(new Group(), mockGroup)) + ); + + bitstreamService.getUploadedFileList.and.returnValue(cold('-a-b', { a: [], b: mockUploadFiles })); + comp.onSectionInit(); + + expect(comp.required$.getValue()).toBe(true); + expect(compAsAny.getSectionStatus()).toBeObservable(cold('-c-d', { c: false, d: true })); }); + + it('should properly read the section status when required is false', () => { + submissionServiceStub.getSubmissionObject.and.returnValue(observableOf(submissionState)); + + collectionDataService.findById.and.returnValue(createSuccessfulRemoteDataObject$(mockCollection)); + + resourcePolicyService.findByHref.and.returnValue(createSuccessfulRemoteDataObject$(mockDefaultAccessCondition)); + + uploadsConfigService.getConfigByHref.and.returnValue(observableOf( + new ConfigData(new PageInfo(), mockUploadConfigResponseNotRequired as any) + )); + + groupService.findById.and.returnValues( + createSuccessfulRemoteDataObject$(Object.assign(new Group(), mockGroup)), + createSuccessfulRemoteDataObject$(Object.assign(new Group(), mockGroup)) + ); + + bitstreamService.getUploadedFileList.and.returnValue(cold('-a-b', { + a: [], + b: mockUploadFiles + })); + + comp.onSectionInit(); + + expect(comp.required$.getValue()).toBe(false); + + expect(compAsAny.getSectionStatus()).toBeObservable(cold('-c-d', { + c: true, + d: true + })); + }); }); }); diff --git a/src/app/submission/sections/upload/section-upload.component.ts b/src/app/submission/sections/upload/section-upload.component.ts index 9dbd1079f4..6c2506b773 100644 --- a/src/app/submission/sections/upload/section-upload.component.ts +++ b/src/app/submission/sections/upload/section-upload.component.ts @@ -1,6 +1,6 @@ import { ChangeDetectorRef, Component, Inject } from '@angular/core'; -import { combineLatest, Observable, Subscription } from 'rxjs'; +import { BehaviorSubject, combineLatest as observableCombineLatest, Observable, Subscription} from 'rxjs'; import { distinctUntilChanged, filter, find, flatMap, map, reduce, take, tap } from 'rxjs/operators'; import { SectionModelComponent } from '../models/section.model'; @@ -104,6 +104,12 @@ export class SubmissionSectionUploadComponent extends SectionModelComponent { */ protected availableGroups: Map; // Groups for any policy + /** + * Is the upload required + * @type {boolean} + */ + public required$ = new BehaviorSubject(true); + /** * Array to track all subscriptions and unsubscribe them onDestroy * @type {Array} @@ -172,6 +178,7 @@ export class SubmissionSectionUploadComponent extends SectionModelComponent { }), flatMap(() => config$), flatMap((config: SubmissionUploadsModel) => { + this.required$.next(config.required); this.availableAccessConditionOptions = isNotEmpty(config.accessConditionOptions) ? config.accessConditionOptions : []; this.collectionPolicyType = this.availableAccessConditionOptions.length > 0 @@ -221,7 +228,7 @@ export class SubmissionSectionUploadComponent extends SectionModelComponent { }), // retrieve submission's bitstreams from state - combineLatest(this.configMetadataForm$, + observableCombineLatest(this.configMetadataForm$, this.bitstreamService.getUploadedFileList(this.submissionId, this.sectionData.id)).pipe( filter(([configMetadataForm, fileList]: [SubmissionFormsModel, any[]]) => { return isNotEmpty(configMetadataForm) && isNotUndefined(fileList) @@ -273,8 +280,13 @@ export class SubmissionSectionUploadComponent extends SectionModelComponent { * the section status */ protected getSectionStatus(): Observable { - return this.bitstreamService.getUploadedFileList(this.submissionId, this.sectionData.id).pipe( - map((fileList: any[]) => (isNotUndefined(fileList) && fileList.length > 0))); + // if not mandatory, always true + // if mandatory, at least one file is required + return observableCombineLatest(this.required$, + this.bitstreamService.getUploadedFileList(this.submissionId, this.sectionData.id), + (required,fileList: any[]) => { + return (!required || (isNotUndefined(fileList) && fileList.length > 0)); + }); } /** 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 938b5be984..f361e6def6 100644 --- a/src/config/global-config.interface.ts +++ b/src/config/global-config.interface.ts @@ -8,6 +8,7 @@ import { FormConfig } from './form-config.interfaces'; import {LangConfig} from './lang-config.interface'; import { BrowseByConfig } from './browse-by-config.interface'; import { ItemPageConfig } from './item-page-config.interface'; +import { CollectionPageConfig } from './collection-page-config.interface'; import { Theme } from './theme.inferface'; import {AuthConfig} from './auth-config.interfaces'; @@ -28,5 +29,6 @@ export interface GlobalConfig extends Config { languages: LangConfig[]; browseBy: BrowseByConfig; item: ItemPageConfig; + collection: CollectionPageConfig; themes: Theme[]; } diff --git a/themes/mantis/app/+item-page/simple/item-types/publication/publication.component.html b/themes/mantis/app/+item-page/simple/item-types/publication/publication.component.html index 35dc903432..50b5fed9d3 100644 --- a/themes/mantis/app/+item-page/simple/item-types/publication/publication.component.html +++ b/themes/mantis/app/+item-page/simple/item-types/publication/publication.component.html @@ -50,7 +50,10 @@ a
[fields]="['dc.identifier.citation']" [label]="'item.page.citation'"> - + < +