diff --git a/resources/i18n/en.json b/resources/i18n/en.json index f7f89a4471..41a4bc3f29 100644 --- a/resources/i18n/en.json +++ b/resources/i18n/en.json @@ -101,17 +101,17 @@ "collection.edit.tabs.roles.title": "Collection Edit - Roles", "collection.edit.tabs.source.external": "This collection harvests its content from an external source", "collection.edit.tabs.source.form.errors.provider.required": "You must provide a set id of the target collection.", - "collection.edit.tabs.source.form.format": "Metadata Format", - "collection.edit.tabs.source.form.harvest": "Content being harvested", + "collection.edit.tabs.source.form.harvestType": "Content being harvested", "collection.edit.tabs.source.form.head": "Configure an external source", - "collection.edit.tabs.source.form.options.format.dc": "Simple Dublin Core", - "collection.edit.tabs.source.form.options.format.dim": "DSpace Intermediate Metadata", - "collection.edit.tabs.source.form.options.format.qdc": "Qualified Dublin Core", - "collection.edit.tabs.source.form.options.harvest.1": "Harvest metadata only", - "collection.edit.tabs.source.form.options.harvest.2": "Harvest metadata and references to bitstreams (requires ORE support)", - "collection.edit.tabs.source.form.options.harvest.3": "Harvest metadata and bitstreams (requires ORE support)", - "collection.edit.tabs.source.form.provider": "OAI Provider", - "collection.edit.tabs.source.form.set": "OAI specific set id", + "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.form.options.metadataConfigId.dc": "Simple Dublin Core", + "collection.edit.tabs.source.form.options.metadataConfigId.dim": "DSpace Intermediate Metadata", + "collection.edit.tabs.source.form.options.metadataConfigId.qdc": "Qualified Dublin Core", "collection.edit.tabs.source.head": "Content Source", "collection.edit.tabs.source.notifications.discarded.content": "Your changes were discarded. To reinstate your changes click the 'Undo' button", "collection.edit.tabs.source.notifications.discarded.title": "Changed discarded", 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 4e814e89f3..42f58b4692 100644 --- a/src/app/+collection-page/edit-collection-page/collection-source/collection-source.component.html +++ b/src/app/+collection-page/edit-collection-page/collection-source/collection-source.component.html @@ -1,55 +1,57 @@ -
-
- - - + +
+
+ + + +
+

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

+
+ + +
+

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

-

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

-
- - + +
+
+ + + +
-

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

-
- -
-
- - - -
-
+
diff --git a/src/app/+collection-page/edit-collection-page/collection-source/collection-source.component.ts b/src/app/+collection-page/edit-collection-page/collection-source/collection-source.component.ts index 70b268d9b7..932092a4a4 100644 --- a/src/app/+collection-page/edit-collection-page/collection-source/collection-source.component.ts +++ b/src/app/+collection-page/edit-collection-page/collection-source/collection-source.component.ts @@ -1,8 +1,13 @@ import { Component, Inject, OnDestroy, OnInit } from '@angular/core'; import { AbstractTrackableComponent } from '../../../shared/trackable/abstract-trackable.component'; import { - DynamicFormControlModel, DynamicFormGroupModel, DynamicFormLayout, DynamicFormService, - DynamicInputModel, DynamicOptionControlModel, DynamicRadioGroupModel, + DynamicFormControlModel, + DynamicFormGroupModel, + DynamicFormLayout, + DynamicFormService, + DynamicInputModel, + DynamicOptionControlModel, + DynamicRadioGroupModel, DynamicSelectModel, DynamicTextAreaModel } from '@ng-dynamic-forms/core'; @@ -12,16 +17,18 @@ import { ObjectUpdatesService } from '../../../core/data/object-updates/object-u import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { FormGroup } from '@angular/forms'; import { hasValue, isNotEmpty } from '../../../shared/empty.util'; -import { ContentSource } from '../../../core/shared/content-source.model'; +import { ContentSource, ContentSourceHarvestType } from '../../../core/shared/content-source.model'; import { Observable } from 'rxjs/internal/Observable'; import { RemoteData } from '../../../core/data/remote-data'; import { Collection } from '../../../core/shared/collection.model'; -import { first, map } from 'rxjs/operators'; +import { first, map, switchMap, take } from 'rxjs/operators'; import { ActivatedRoute, Router } from '@angular/router'; import { FieldUpdate, FieldUpdates } from '../../../core/data/object-updates/object-updates.reducer'; import { Subscription } from 'rxjs/internal/Subscription'; import { cloneDeep } from 'lodash'; import { GLOBAL_CONFIG, GlobalConfig } from '../../../../config'; +import { CollectionDataService } from '../../../core/data/collection-data.service'; +import { getSucceededRemoteData } from '../../../core/shared/operators'; /** * Component for managing the content source of the collection @@ -64,9 +71,9 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem /** * The Dynamic Input Model for the OAI Provider */ - providerModel = new DynamicInputModel({ - id: 'provider', - name: 'provider', + oaiSourceModel = new DynamicInputModel({ + id: 'oaiSource', + name: 'oaiSource', required: true, validators: { required: null @@ -79,17 +86,17 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem /** * The Dynamic Input Model for the OAI Set */ - setModel = new DynamicInputModel({ - id: 'set', - name: 'set' + oaiSetIdModel = new DynamicInputModel({ + id: 'oaiSetId', + name: 'oaiSetId' }); /** * The Dynamic Input Model for the Metadata Format used */ - formatModel = new DynamicSelectModel({ - id: 'format', - name: 'format', + metadataConfigIdModel = new DynamicSelectModel({ + id: 'metadataConfigId', + name: 'metadataConfigId', options: [ { value: 'dc' @@ -100,24 +107,25 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem { value: 'dim' } - ] + ], + value: 'dc' }); /** * The Dynamic Input Model for the type of harvesting */ - harvestModel = new DynamicRadioGroupModel({ - id: 'harvest', - name: 'harvest', + harvestTypeModel = new DynamicRadioGroupModel({ + id: 'harvestType', + name: 'harvestType', options: [ { - value: 1 + value: ContentSourceHarvestType.Metadata }, { - value: 2 + value: ContentSourceHarvestType.MetadataAndRef }, { - value: 3 + value: ContentSourceHarvestType.MetadataAndBitstreams } ] }); @@ -125,7 +133,7 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem /** * All input models in a simple array for easier iterations */ - inputModels = [this.providerModel, this.setModel, this.formatModel, this.harvestModel]; + inputModels = [this.oaiSourceModel, this.oaiSetIdModel, this.metadataConfigIdModel, this.harvestTypeModel]; /** * The dynamic form fields used for editing the content source of a collection @@ -133,22 +141,22 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem */ formModel: DynamicFormControlModel[] = [ new DynamicFormGroupModel({ - id: 'providerContainer', + id: 'oaiSourceContainer', group: [ - this.providerModel + this.oaiSourceModel ] }), new DynamicFormGroupModel({ - id: 'setContainer', + id: 'oaiSetContainer', group: [ - this.setModel, - this.formatModel + this.oaiSetIdModel, + this.metadataConfigIdModel ] }), new DynamicFormGroupModel({ - id: 'harvestContainer', + id: 'harvestTypeContainer', group: [ - this.harvestModel + this.harvestTypeModel ] }) ]; @@ -157,38 +165,38 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem * Layout used for structuring the form inputs */ formLayout: DynamicFormLayout = { - provider: { + oaiSource: { grid: { host: 'col-12 d-inline-block' } }, - set: { + oaiSetId: { grid: { host: 'col col-sm-6 d-inline-block' } }, - format: { + metadataConfigId: { grid: { host: 'col col-sm-6 d-inline-block' } }, - harvest: { + harvestType: { grid: { host: 'col-12', option: 'btn-outline-secondary' } }, - setContainer: { + oaiSetContainer: { grid: { host: 'row' } }, - providerContainer: { + oaiSourceContainer: { grid: { host: 'row' } }, - harvestContainer: { + harvestTypeContainer: { grid: { host: 'row' } @@ -205,6 +213,8 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem */ updateSub: Subscription; + harvestTypeNone = ContentSourceHarvestType.None; + public constructor(public objectUpdatesService: ObjectUpdatesService, public notificationsService: NotificationsService, protected location: Location, @@ -212,7 +222,8 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem protected translate: TranslateService, protected route: ActivatedRoute, protected router: Router, - @Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig,) { + @Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, + protected collectionService: CollectionDataService) { super(objectUpdatesService, notificationsService, translate); } @@ -229,16 +240,21 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem this.formGroup = this.formService.createFormGroup(this.formModel); this.collectionRD$ = this.route.parent.data.pipe(first(), map((data) => data.dso)); - /* Create a new ContentSource object - TODO: Update to be fetched from the collection */ - this.contentSource = new ContentSource(); + this.collectionRD$.pipe( + getSucceededRemoteData(), + map((col) => col.payload.uuid), + switchMap((uuid) => this.collectionService.getContentSource(uuid)), + take(1) + ).subscribe((contentSource: ContentSource) => { + this.contentSource = contentSource; + this.initializeOriginalContentSource(); + }); this.updateFieldTranslations(); this.translate.onLangChange .subscribe(() => { this.updateFieldTranslations(); }); - - this.initializeOriginalContentSource(); } /** @@ -254,15 +270,15 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem if (update) { const field = update.field as ContentSource; this.formGroup.patchValue({ - providerContainer: { - provider: field.provider + oaiSourceContainer: { + oaiSource: field.oaiSource }, - setContainer: { - set: field.set, - format: field.format + oaiSetContainer: { + oaiSetId: field.oaiSetId, + metadataConfigId: field.metadataConfigId }, - harvestContainer: { - harvest: field.harvest + harvestTypeContainer: { + harvestType: field.harvestType } }); this.contentSource = cloneDeep(field); @@ -307,7 +323,7 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem * @param event */ onChange(event) { - this.updateContentSourceField(event.model); + this.updateContentSourceField(event.model, true); this.saveFieldUpdate(); } @@ -331,24 +347,28 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem * Is the current form valid to be submitted ? */ isValid(): boolean { - return !this.contentSource.enabled || this.formGroup.valid; + return (this.contentSource.harvestType === ContentSourceHarvestType.None) || this.formGroup.valid; } /** * Switch the external source on or off and fire a field update */ changeExternalSource() { - this.contentSource.enabled = !this.contentSource.enabled; - this.updateContentSource(); + if (this.contentSource.harvestType === ContentSourceHarvestType.None) { + this.contentSource.harvestType = ContentSourceHarvestType.Metadata; + } else { + this.contentSource.harvestType = ContentSourceHarvestType.None; + } + this.updateContentSource(false); } /** * Loop over all inputs and update the Content Source with their value */ - updateContentSource() { + updateContentSource(updateHarvestType: boolean) { this.inputModels.forEach( (fieldModel: DynamicInputModel) => { - this.updateContentSourceField(fieldModel) + this.updateContentSourceField(fieldModel, updateHarvestType) } ); this.saveFieldUpdate(); @@ -358,8 +378,8 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem * Update the Content Source with the value from a DynamicInputModel * @param fieldModel */ - updateContentSourceField(fieldModel: DynamicInputModel) { - if (hasValue(fieldModel.value)) { + updateContentSourceField(fieldModel: DynamicInputModel, updateHarvestType: boolean) { + if (hasValue(fieldModel.value) && !(fieldModel.id === this.harvestTypeModel.id && !updateHarvestType)) { this.contentSource[fieldModel.id] = fieldModel.value; } } diff --git a/src/app/core/cache/response.models.ts b/src/app/core/cache/response.models.ts index 03233e616b..c9934d6abe 100644 --- a/src/app/core/cache/response.models.ts +++ b/src/app/core/cache/response.models.ts @@ -14,6 +14,7 @@ import { DSpaceObject } from '../shared/dspace-object.model'; import { NormalizedAuthStatus } from '../auth/models/normalized-auth-status.model'; import { MetadataSchema } from '../metadata/metadata-schema.model'; import { MetadataField } from '../metadata/metadata-field.model'; +import { ContentSource } from '../shared/content-source.model'; /* tslint:disable:max-classes-per-file */ export class RestResponse { @@ -288,4 +289,17 @@ export class FilteredDiscoveryQueryResponse extends RestResponse { super(true, statusCode, statusText); } } + +/** + * A successful response containing exactly one MetadataSchema + */ +export class ContentSourceSuccessResponse extends RestResponse { + constructor( + public contentsource: ContentSource, + public statusCode: number, + public statusText: string, + ) { + super(true, statusCode, statusText); + } +} /* tslint:enable:max-classes-per-file */ diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 1d22b5fefe..fe46bf88aa 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -117,6 +117,7 @@ import { MetadatafieldParsingService } from './data/metadatafield-parsing.servic import { NormalizedSubmissionUploadsModel } from './config/models/normalized-config-submission-uploads.model'; import { NormalizedBrowseEntry } from './shared/normalized-browse-entry.model'; import { BrowseDefinition } from './shared/browse-definition.model'; +import { ContentSourceResponseParsingService } from './data/content-source-response-parsing.service'; const IMPORTS = [ CommonModule, @@ -203,6 +204,7 @@ const PROVIDERS = [ TaskResponseParsingService, ClaimedTaskDataService, PoolTaskDataService, + ContentSourceResponseParsingService, // register AuthInterceptor as HttpInterceptor { provide: HTTP_INTERCEPTORS, diff --git a/src/app/core/data/collection-data.service.ts b/src/app/core/data/collection-data.service.ts index 993954a360..1a59498a72 100644 --- a/src/app/core/data/collection-data.service.ts +++ b/src/app/core/data/collection-data.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core'; -import { filter, map, take } from 'rxjs/operators'; +import { filter, map, take, tap } from 'rxjs/operators'; import { Store } from '@ngrx/store'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; @@ -16,9 +16,12 @@ import { HttpClient } from '@angular/common/http'; import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; import { Observable } from 'rxjs/internal/Observable'; -import { FindAllOptions } from './request.models'; +import { ContentSourceRequest, FindAllOptions, RestRequest } from './request.models'; import { RemoteData } from './remote-data'; import { PaginatedList } from './paginated-list'; +import { ContentSource } from '../shared/content-source.model'; +import { configureRequest, filterSuccessfulResponses, getRequestFromRequestHref } from '../shared/operators'; +import { ContentSourceSuccessResponse } from '../cache/response.models'; @Injectable() export class CollectionDataService extends ComColDataService { @@ -57,4 +60,21 @@ export class CollectionDataService extends ComColDataService { map((collections: RemoteData>) => collections.payload.totalElements > 0) ); } + + getHarvesterEndpoint(collectionId: string): Observable { + return this.halService.getEndpoint(this.linkPath).pipe( + map((href: string) => `${href}/${collectionId}/harvester`) + ); + } + + getContentSource(collectionId: string): Observable { + return this.getHarvesterEndpoint(collectionId).pipe( + map((href: string) => new ContentSourceRequest(this.requestService.generateRequestId(), href)), + configureRequest(this.requestService), + map((request: RestRequest) => request.href), + getRequestFromRequestHref(this.requestService), + filterSuccessfulResponses(), + map((response: ContentSourceSuccessResponse) => response.contentsource) + ); + } } diff --git a/src/app/core/data/content-source-response-parsing.service.ts b/src/app/core/data/content-source-response-parsing.service.ts new file mode 100644 index 0000000000..34e9360dd7 --- /dev/null +++ b/src/app/core/data/content-source-response-parsing.service.ts @@ -0,0 +1,19 @@ +import { Injectable } from '@angular/core'; +import { ResponseParsingService } from './parsing.service'; +import { RestRequest } from './request.models'; +import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; +import { ContentSourceSuccessResponse, RestResponse } from '../cache/response.models'; +import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; +import { ContentSource } from '../shared/content-source.model'; + +@Injectable() +export class ContentSourceResponseParsingService implements ResponseParsingService { + + parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { + const payload = data.payload; + + const deserialized = new DSpaceRESTv2Serializer(ContentSource).deserialize(payload); + return new ContentSourceSuccessResponse(deserialized, data.statusCode, data.statusText); + } + +} diff --git a/src/app/core/data/object-updates/object-updates.service.ts b/src/app/core/data/object-updates/object-updates.service.ts index 22d5fd3e77..2b96cb378a 100644 --- a/src/app/core/data/object-updates/object-updates.service.ts +++ b/src/app/core/data/object-updates/object-updates.service.ts @@ -93,14 +93,16 @@ export class ObjectUpdatesService { const objectUpdates = this.getObjectEntry(url); return objectUpdates.pipe(map((objectEntry) => { const fieldUpdates: FieldUpdates = {}; - Object.keys(objectEntry.fieldStates).forEach((uuid) => { - let fieldUpdate = objectEntry.fieldUpdates[uuid]; - if (isEmpty(fieldUpdate)) { - const identifiable = initialFields.find((object: Identifiable) => object.uuid === uuid); - fieldUpdate = { field: identifiable, changeType: undefined }; - } - fieldUpdates[uuid] = fieldUpdate; - }); + if (hasValue(objectEntry)) { + Object.keys(objectEntry.fieldStates).forEach((uuid) => { + let fieldUpdate = objectEntry.fieldUpdates[uuid]; + if (isEmpty(fieldUpdate)) { + const identifiable = initialFields.find((object: Identifiable) => object.uuid === uuid); + fieldUpdate = {field: identifiable, changeType: undefined}; + } + fieldUpdates[uuid] = fieldUpdate; + }); + } return fieldUpdates; })) } diff --git a/src/app/core/data/request.models.ts b/src/app/core/data/request.models.ts index a2b3423960..eab7af5751 100644 --- a/src/app/core/data/request.models.ts +++ b/src/app/core/data/request.models.ts @@ -18,6 +18,7 @@ import { MetadataschemaParsingService } from './metadataschema-parsing.service'; import { MetadatafieldParsingService } from './metadatafield-parsing.service'; import { URLCombiner } from '../url-combiner/url-combiner'; import { TaskResponseParsingService } from '../tasks/task-response-parsing.service'; +import { ContentSourceResponseParsingService } from './content-source-response-parsing.service'; /* tslint:disable:max-classes-per-file */ @@ -358,6 +359,16 @@ export class CreateRequest extends PostRequest { } } +export class ContentSourceRequest extends GetRequest { + constructor(uuid: string, href: string) { + super(uuid, href); + } + + getResponseParser(): GenericConstructor { + return ContentSourceResponseParsingService; + } +} + /** * Request to delete an object based on its identifier */ diff --git a/src/app/core/shared/content-source.model.ts b/src/app/core/shared/content-source.model.ts index c6d6c03fab..68db549adb 100644 --- a/src/app/core/shared/content-source.model.ts +++ b/src/app/core/shared/content-source.model.ts @@ -1,41 +1,51 @@ -import { v4 as uuid } from 'uuid'; +import { autoserialize, autoserializeAs } from 'cerialize'; + +export enum ContentSourceHarvestType { + None = 'NONE', + Metadata = 'METADATA_ONLY', + MetadataAndRef = 'METADATA_AND_REF', + MetadataAndBitstreams = 'METADATA_AND_BITSTREAMS' +} /** * A model class that holds information about the Content Source of a Collection */ export class ContentSource { /** - * Unique identifier + * Unique identifier, this is necessary to store the ContentSource in FieldUpdates + * Because the ContentSource coming from the REST API doesn't have a UUID, we're using the selflink */ + @autoserializeAs('self') uuid: string; /** - * Does this collection harvest its content from an external source ? + * OAI Provider / Source */ - enabled = false; - - /** - * OAI Provider - */ - provider: string; + @autoserializeAs('oai_source') + oaiSource: string; /** * OAI Specific set ID */ - set: string; + @autoserializeAs('oai_set_id') + oaiSetId: string; /** - * Metadata Format + * The ID of the metadata format used */ - format = 'dc'; + @autoserializeAs('metadata_config_id') + metadataConfigId = 'dc'; /** * Type of content being harvested + * Defaults to 'NONE', meaning the collection doesn't harvest its content from an external source */ - harvest = 3; + @autoserializeAs('harvest_type') + harvestType = ContentSourceHarvestType.None; - constructor() { - // TODO: Remove this once the Content Source is fetched from the REST API and a custom generated UUID is not necessary anymore - this.uuid = uuid(); - } + /** + * The REST link to itself + */ + @autoserialize + self: string; }