diff --git a/src/app/bitstream-page/edit-bitstream-page/edit-bitstream-page.component.html b/src/app/bitstream-page/edit-bitstream-page/edit-bitstream-page.component.html index 1ac3300bca3..7683211954d 100644 --- a/src/app/bitstream-page/edit-bitstream-page/edit-bitstream-page.component.html +++ b/src/app/bitstream-page/edit-bitstream-page/edit-bitstream-page.component.html @@ -1,6 +1,6 @@
-
+
@@ -27,7 +27,7 @@

{{dsoNameService.getName(bitstreamRD?.payload)}} -

diff --git a/src/app/bitstream-page/edit-bitstream-page/edit-bitstream-page.component.spec.ts b/src/app/bitstream-page/edit-bitstream-page/edit-bitstream-page.component.spec.ts index c81926d83d9..09ca2f3c9d9 100644 --- a/src/app/bitstream-page/edit-bitstream-page/edit-bitstream-page.component.spec.ts +++ b/src/app/bitstream-page/edit-bitstream-page/edit-bitstream-page.component.spec.ts @@ -102,7 +102,8 @@ describe('EditBitstreamPageComponent', () => { }); bitstreamFormatService = jasmine.createSpyObj('bitstreamFormatService', { - findAll: createSuccessfulRemoteDataObject$(createPaginatedList(allFormats)) + findAll: createSuccessfulRemoteDataObject$(createPaginatedList(allFormats)), + findByHref: createSuccessfulRemoteDataObject$(selectedFormat), }); notificationsService = jasmine.createSpyObj('notificationsService', @@ -138,6 +139,7 @@ describe('EditBitstreamPageComponent', () => { }); describe('EditBitstreamPageComponent no IIIF fields', () => { + const dsoNameServiceReturnValue = 'ORIGINAL'; beforeEach(waitForAsync(() => { bundle = { @@ -153,7 +155,6 @@ describe('EditBitstreamPageComponent', () => { }, })) }; - const bundleName = 'ORIGINAL'; bitstream = Object.assign(new Bitstream(), { uuid: bitstreamID, @@ -172,7 +173,8 @@ describe('EditBitstreamPageComponent', () => { }, format: createSuccessfulRemoteDataObject$(selectedFormat), _links: { - self: 'bitstream-selflink' + self: 'bitstream-selflink', + format: 'format-link', }, bundle: createSuccessfulRemoteDataObject$(bundle) }); @@ -185,10 +187,11 @@ describe('EditBitstreamPageComponent', () => { patch: {} }); bitstreamFormatService = jasmine.createSpyObj('bitstreamFormatService', { - findAll: createSuccessfulRemoteDataObject$(createPaginatedList(allFormats)) + findAll: createSuccessfulRemoteDataObject$(createPaginatedList(allFormats)), + findByHref: createSuccessfulRemoteDataObject$(selectedFormat), }); dsoNameService = jasmine.createSpyObj('dsoNameService', { - getName: bundleName + getName: dsoNameServiceReturnValue, }); TestBed.configureTestingModule({ @@ -231,7 +234,7 @@ describe('EditBitstreamPageComponent', () => { }); it('should fill in the bitstream\'s title', () => { - expect(rawForm.fileNamePrimaryContainer.fileName).toEqual(bitstream.name); + expect(rawForm.fileNamePrimaryContainer.fileName).toEqual(dsoNameServiceReturnValue); }); it('should fill in the bitstream\'s description', () => { @@ -410,7 +413,7 @@ describe('EditBitstreamPageComponent', () => { }); describe('when navigateToItemEditBitstreams is called', () => { it('should redirect to the item edit page on the bitstreams tab with the itemId from the component', () => { - comp.itemId = 'some-uuid1'; + comp.item.uuid = 'some-uuid1'; comp.navigateToItemEditBitstreams(); expect(router.navigate).toHaveBeenCalledWith([getEntityEditRoute(null, 'some-uuid1'), 'bitstreams']); }); @@ -458,7 +461,8 @@ describe('EditBitstreamPageComponent', () => { }, format: createSuccessfulRemoteDataObject$(allFormats[1]), _links: { - self: 'bitstream-selflink' + self: 'bitstream-selflink', + format: 'format-link', }, bundle: createSuccessfulRemoteDataObject$({ _links: { @@ -583,7 +587,8 @@ describe('EditBitstreamPageComponent', () => { }, format: createSuccessfulRemoteDataObject$(allFormats[2]), _links: { - self: 'bitstream-selflink' + self: 'bitstream-selflink', + format: 'format-link', }, bundle: createSuccessfulRemoteDataObject$({ _links: { diff --git a/src/app/bitstream-page/edit-bitstream-page/edit-bitstream-page.component.ts b/src/app/bitstream-page/edit-bitstream-page/edit-bitstream-page.component.ts index 082ffd55a99..c7e1ddf3497 100644 --- a/src/app/bitstream-page/edit-bitstream-page/edit-bitstream-page.component.ts +++ b/src/app/bitstream-page/edit-bitstream-page/edit-bitstream-page.component.ts @@ -1,21 +1,23 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { Bitstream } from '../../core/shared/bitstream.model'; import { ActivatedRoute, Router } from '@angular/router'; -import { filter, map, switchMap, tap } from 'rxjs/operators'; +import { map, switchMap } from 'rxjs/operators'; import { - combineLatest, + BehaviorSubject, combineLatest, combineLatest as observableCombineLatest, Observable, of as observableOf, - Subscription, take + Subscription, } from 'rxjs'; import { DynamicFormControlModel, DynamicFormGroupModel, DynamicFormLayout, DynamicFormService, DynamicInputModel } from '@ng-dynamic-forms/core'; -import { UntypedFormGroup } from '@angular/forms'; +import { UntypedFormGroup, FormGroup } from '@angular/forms'; import { TranslateService } from '@ngx-translate/core'; -import { DynamicCustomSwitchModel } from '../../shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.model'; +import { + DynamicCustomSwitchModel +} from '../../shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.model'; import cloneDeep from 'lodash/cloneDeep'; import { BitstreamDataService } from '../../core/data/bitstream-data.service'; -import { getFirstCompletedRemoteData, getFirstSucceededRemoteData, getFirstSucceededRemoteDataPayload, getRemoteDataPayload } from '../../core/shared/operators'; +import { getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload, } from '../../core/shared/operators'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { BitstreamFormatDataService } from '../../core/data/bitstream-format-data.service'; import { BitstreamFormat } from '../../core/shared/bitstream-format.model'; @@ -33,6 +35,67 @@ import { DsDynamicTextAreaModel } from '../../shared/form/builder/ds-dynamic-for import { PrimaryBitstreamService } from '../../core/data/primary-bitstream.service'; import { DynamicScrollableDropdownModel } from 'src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.model'; import { FindAllDataImpl } from '../../core/data/base/find-all-data'; +import { ObservablesDictionary } from 'src/app/shared/utils/observables-dictionary'; + +/** + * All data that is required before the form can be created and filled. + */ +export interface DataObjects { + bitstream: Bitstream, + bitstreamFormat: BitstreamFormat, + bundle: Bundle, + primaryBitstream: Bitstream, + item: Item, +} + +/** + * The results after updating all the fields on submission. + */ +export interface UpdateResult { + metadataUpdateRD: RemoteData, + primaryUpdateRD: RemoteData, + formatUpdateRD: RemoteData, +} + +/** + * Key prefix used to generate form messages + */ +export const KEY_PREFIX = 'bitstream.edit.form.'; + +/** + * Key suffix used to generate form labels + */ +export const LABEL_KEY_SUFFIX = '.label'; + +/** + * Key suffix used to generate form labels + */ +export const HINT_KEY_SUFFIX = '.hint'; + +/** + * Key prefix used to generate notification messages + */ +export const NOTIFICATIONS_PREFIX = 'bitstream.edit.notifications.'; + +/** + * IIIF image width metadata key + */ +export const IMAGE_WIDTH_METADATA = 'iiif.image.width'; + +/** + * IIIF image height metadata key + */ +export const IMAGE_HEIGHT_METADATA = 'iiif.image.height'; + +/** + * IIIF table of contents metadata key + */ +export const IIIF_TOC_METADATA = 'iiif.toc'; + +/** + * IIIF label metadata key + */ +export const IIIF_LABEL_METADATA = 'iiif.label'; @Component({ selector: 'ds-edit-bitstream-page', @@ -45,6 +108,8 @@ import { FindAllDataImpl } from '../../core/data/base/find-all-data'; */ export class EditBitstreamPageComponent implements OnInit, OnDestroy { + isLoading$: BehaviorSubject = new BehaviorSubject(true); + /** * The bitstream's remote data observable * Tracks changes and updates the view @@ -62,49 +127,14 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { bitstream: Bitstream; /** - * The originally selected format - */ - originalFormat: BitstreamFormat; - - /** - * @type {string} Key prefix used to generate form messages - */ - KEY_PREFIX = 'bitstream.edit.form.'; - - /** - * @type {string} Key suffix used to generate form labels - */ - LABEL_KEY_SUFFIX = '.label'; - - /** - * @type {string} Key suffix used to generate form labels + * The format of the bitstream to edit */ - HINT_KEY_SUFFIX = '.hint'; + bitstreamFormat: BitstreamFormat; /** - * @type {string} Key prefix used to generate notification messages + * The item that the bitstream belongs to */ - NOTIFICATIONS_PREFIX = 'bitstream.edit.notifications.'; - - /** - * IIIF image width metadata key - */ - IMAGE_WIDTH_METADATA = 'iiif.image.width'; - - /** - * IIIF image height metadata key - */ - IMAGE_HEIGHT_METADATA = 'iiif.image.height'; - - /** - * IIIF table of contents metadata key - */ - IIIF_TOC_METADATA = 'iiif.toc'; - - /** - * IIIF label metadata key - */ - IIIF_LABEL_METADATA = 'iiif.label'; + item: Item; /** * Options for fetching all bitstream formats @@ -163,7 +193,7 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { findAllFactory: this.findAllFormatsServiceFactory(), formatFunction: (format: BitstreamFormat | string) => { if (format instanceof BitstreamFormat) { - return hasValue(format) && format.supportLevel === BitstreamFormatSupportLevel.Unknown ? this.translate.instant(this.KEY_PREFIX + 'selectedFormat.unknown') : format.shortDescription; + return hasValue(format) && format.supportLevel === BitstreamFormatSupportLevel.Unknown ? this.translate.instant(KEY_PREFIX + 'selectedFormat.unknown') : format.shortDescription; } else { return format; } @@ -352,13 +382,6 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { */ formGroup: UntypedFormGroup; - /** - * The ID of the item the bitstream originates from - * Taken from the current query parameters when present - * This will determine the route of the item edit page to return to - */ - itemId: string; - /** * The entity type of the item the bitstream originates from * Taken from the current query parameters when present @@ -399,7 +422,7 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { private notificationsService: NotificationsService, private bitstreamFormatService: BitstreamFormatDataService, private primaryBitstreamService: PrimaryBitstreamService, - ) { + ) { } /** @@ -409,14 +432,43 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { * - Translate the form labels and hints */ ngOnInit(): void { - - this.itemId = this.route.snapshot.queryParams.itemId; - this.entityType = this.route.snapshot.queryParams.entityType; this.bitstreamRD$ = this.route.data.pipe(map((data: any) => data.bitstream)); + const dataObservables = this.getDataObservables(); + + this.subs.push( + observableCombineLatest( + dataObservables + ).pipe() + .subscribe((dataObjects: DataObjects) => { + this.isLoading$.next(false); + + this.setFields(dataObjects); + + this.setForm(); + }) + ); + + this.subs.push( + this.translate.onLangChange + .subscribe(() => { + this.updateFieldTranslations(); + }) + ); + } + + /** + * Create all the observables necessary to create and fill the bitstream form, + * and collect them in a {@link ObservablesDictionary} object. + */ + protected getDataObservables(): ObservablesDictionary { const bitstream$ = this.bitstreamRD$.pipe( - getFirstSucceededRemoteData(), - getRemoteDataPayload(), + getFirstSucceededRemoteDataPayload() + ); + + const bitstreamFormat$ = bitstream$.pipe( + switchMap((bitstream: Bitstream) => this.bitstreamFormatService.findByHref(bitstream._links.format.href, false)), + getFirstSucceededRemoteDataPayload(), ); const bundle$ = bitstream$.pipe( @@ -426,7 +478,7 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { const primaryBitstream$ = bundle$.pipe( hasValueOperator(), - switchMap((bundle: Bundle) => this.bitstreamService.findByHref(bundle._links.primaryBitstream.href)), + switchMap((bundle: Bundle) => this.bitstreamService.findByHref(bundle._links.primaryBitstream.href, false)), getFirstSucceededRemoteDataPayload(), ); @@ -434,58 +486,71 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { switchMap((bundle: Bundle) => bundle.item), getFirstSucceededRemoteDataPayload(), ); - const format$ = bitstream$.pipe( - switchMap(bitstream => bitstream.format), - getFirstSucceededRemoteDataPayload(), - ); - this.subs.push( - observableCombineLatest( - bitstream$, - bundle$, - primaryBitstream$, - item$, - format$, - ).subscribe(([bitstream, bundle, primaryBitstream, item, format]) => { - this.bitstream = bitstream as Bitstream; - this.bundle = bundle; - this.selectedFormat = format; - // hasValue(primaryBitstream) because if there's no primaryBitstream on the bundle it will - // be a success response, but empty - this.primaryBitstreamUUID = hasValue(primaryBitstream) ? primaryBitstream.uuid : null; - this.itemId = item.uuid; - this.setIiifStatus(this.bitstream); - }), - format$.pipe(take(1)).subscribe( - (format) => this.originalFormat = format, - ), - ); + return { + bitstream: bitstream$, + bitstreamFormat: bitstreamFormat$, + bundle: bundle$, + primaryBitstream: primaryBitstream$, + item: item$, + }; + } - this.subs.push( - this.translate.onLangChange - .subscribe(() => { - this.updateFieldTranslations(); - }) - ); + /** + * Sets all required fields with the data in the provided dataObjects + * @protected + */ + protected setFields(dataObjects: DataObjects) { + this.bitstream = dataObjects.bitstream; + this.bitstreamFormat = dataObjects.bitstreamFormat; + this.selectedFormat = dataObjects.bitstreamFormat; + this.bundle = dataObjects.bundle; + // hasValue(primaryBitstream) because if there's no primaryBitstream on the bundle it will + // be a success response, but empty + this.primaryBitstreamUUID = hasValue(dataObjects.primaryBitstream) ? dataObjects.primaryBitstream.uuid : null; + this.item = dataObjects.item; + + this.isIIIF = this.getIiifStatus(); } /** * Initializes the form. */ setForm() { - this.formGroup = this.formService.createFormGroup(this.formModel); - this.updateForm(this.bitstream); + this.updateFormModel(); + this.formGroup = this.getFormGroup(); + + this.updateForm(); this.updateFieldTranslations(); + + this.changeDetectorRef.detectChanges(); } /** - * Update the current form values with bitstream properties - * @param bitstream + * Updates the formModel with additional fields & options, depending on the current data */ - updateForm(bitstream: Bitstream) { + updateFormModel() { + if (this.isIIIF) { + this.appendFormWithIiifFields(); + } + } + + /** + * Creates a formGroup from the current formModel + */ + getFormGroup(): FormGroup { + return this.formService.createFormGroup(this.formModel); + } + + /** + * Update the current form values with the current bitstream properties + */ + updateForm() { + const bitstream = this.bitstream; + this.formGroup.patchValue({ fileNamePrimaryContainer: { - fileName: bitstream.name, + fileName: this.dsoNameService.getName(bitstream), primaryBitstream: this.primaryBitstreamUUID === bitstream.uuid }, descriptionContainer: { @@ -499,19 +564,20 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { if (this.isIIIF) { this.formGroup.patchValue({ iiifLabelContainer: { - iiifLabel: bitstream.firstMetadataValue(this.IIIF_LABEL_METADATA) + iiifLabel: bitstream.firstMetadataValue(IIIF_LABEL_METADATA) }, iiifTocContainer: { - iiifToc: bitstream.firstMetadataValue(this.IIIF_TOC_METADATA) + iiifToc: bitstream.firstMetadataValue(IIIF_TOC_METADATA) }, iiifWidthContainer: { - iiifWidth: bitstream.firstMetadataValue(this.IMAGE_WIDTH_METADATA) + iiifWidth: bitstream.firstMetadataValue(IMAGE_WIDTH_METADATA) }, iiifHeightContainer: { - iiifHeight: bitstream.firstMetadataValue(this.IMAGE_HEIGHT_METADATA) + iiifHeight: bitstream.firstMetadataValue(IMAGE_HEIGHT_METADATA) } }); } + this.updateNewFormatLayout(); } @@ -552,9 +618,9 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { * @param fieldModel */ private updateFieldTranslation(fieldModel) { - fieldModel.label = this.translate.instant(this.KEY_PREFIX + fieldModel.id + this.LABEL_KEY_SUFFIX); + fieldModel.label = this.translate.instant(KEY_PREFIX + fieldModel.id + LABEL_KEY_SUFFIX); if (fieldModel.id !== this.primaryBitstreamModel.id) { - fieldModel.hint = this.translate.instant(this.KEY_PREFIX + fieldModel.id + this.HINT_KEY_SUFFIX); + fieldModel.hint = this.translate.instant(KEY_PREFIX + fieldModel.id + HINT_KEY_SUFFIX); } } @@ -575,93 +641,87 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { */ onSubmit() { const updatedValues = this.formGroup.getRawValue(); - const updatedBitstream = this.formToBitstream(updatedValues); - const isNewFormat = this.selectedFormat.id !== this.originalFormat.id; - const isPrimary = updatedValues.fileNamePrimaryContainer.primaryBitstream; - const wasPrimary = this.primaryBitstreamUUID === this.bitstream.uuid; - let bitstream$; - let bundle$: Observable; - let errorWhileSaving = false; + this.subs.push(combineLatest(this.getUpdateObservables(updatedValues)) + .subscribe((updateResult: UpdateResult) => { + this.handleUpdateResult(updateResult); + }) + ); + } - if (wasPrimary !== isPrimary) { - let bundleRd$: Observable>; - if (wasPrimary) { - bundleRd$ = this.primaryBitstreamService.delete(this.bundle); - } else if (hasValue(this.primaryBitstreamUUID)) { - bundleRd$ = this.primaryBitstreamService.put(this.bitstream, this.bundle); - } else { - bundleRd$ = this.primaryBitstreamService.create(this.bitstream, this.bundle); - } + /** + * Collects all observables that update the different parts of the bitstream. + */ + getUpdateObservables(updatedValues: any): ObservablesDictionary { + return { + metadataUpdateRD: this.updateBitstreamMetadataRD$(updatedValues), + primaryUpdateRD: this.updatePrimaryBitstreamRD$(updatedValues), + formatUpdateRD: this.updateBitstreamFormatRD$(), + }; + } - const completedBundleRd$ = bundleRd$.pipe(getFirstCompletedRemoteData()); - - this.subs.push(completedBundleRd$.pipe( - filter((bundleRd: RemoteData) => bundleRd.hasFailed) - ).subscribe((bundleRd: RemoteData) => { - this.notificationsService.error( - this.translate.instant(this.NOTIFICATIONS_PREFIX + 'error.primaryBitstream.title'), - bundleRd.errorMessage - ); - errorWhileSaving = true; - })); - - bundle$ = completedBundleRd$.pipe( - map((bundleRd: RemoteData) => { - if (bundleRd.hasSucceeded) { - return bundleRd.payload; - } else { - return this.bundle; - } - }) - ); + /** + * Creates and returns an observable that updates the bitstream metadata according to the data in the form. + */ + updateBitstreamMetadataRD$(updatedValues: any): Observable> { + const updatedBitstream = this.formToBitstream(updatedValues); - this.subs.push(bundle$.pipe( - hasValueOperator(), - switchMap((bundle: Bundle) => this.bitstreamService.findByHref(bundle._links.primaryBitstream.href, false)), - getFirstSucceededRemoteDataPayload() - ).subscribe((bitstream: Bitstream) => { - this.primaryBitstreamUUID = hasValue(bitstream) ? bitstream.uuid : null; - })); + return this.bitstreamService.update(updatedBitstream).pipe( + getFirstCompletedRemoteData() + ); + } - } else { - bundle$ = observableOf(this.bundle); + /** + * Creates and returns an observable that will update the primary bitstream in the bundle of the + * current bitstream, if necessary according to the provided updated values. + * When an update is necessary, the observable fires once with the completed RemoteData of the bundle update. + * When no update is necessary, the observable fires once with a null value. + * @param updatedValues The raw updated values in the bitstream edit form + */ + updatePrimaryBitstreamRD$(updatedValues: any): Observable> { + // Whether the edited bitstream should be the primary bitstream according to the form + const shouldBePrimary: boolean = updatedValues.fileNamePrimaryContainer.primaryBitstream; + // Whether the edited bitstream currently is the primary bitstream + const isPrimary = this.primaryBitstreamUUID === this.bitstream.uuid; + + // If the primary bitstream status should not be changed, there is nothing to do + if (shouldBePrimary === isPrimary) { + return observableOf(null); } - if (isNewFormat) { - bitstream$ = this.bitstreamService.updateFormat(this.bitstream, this.selectedFormat).pipe( - getFirstCompletedRemoteData(), - map((formatResponse: RemoteData) => { - if (hasValue(formatResponse) && formatResponse.hasFailed) { - this.notificationsService.error( - this.translate.instant(this.NOTIFICATIONS_PREFIX + 'error.format.title'), - formatResponse.errorMessage - ); - } else { - return formatResponse.payload; - } - }) - ); + + let updatedBundleRD$: Observable>; + if (isPrimary) { + updatedBundleRD$ = this.primaryBitstreamService.delete(this.bundle); + } else if (hasValue(this.primaryBitstreamUUID)) { + updatedBundleRD$ = this.primaryBitstreamService.put(this.bitstream, this.bundle); } else { - bitstream$ = observableOf(this.bitstream); + updatedBundleRD$ = this.primaryBitstreamService.create(this.bitstream, this.bundle); } - combineLatest([bundle$, bitstream$]).pipe( - tap(([bundle]) => this.bundle = bundle), - switchMap(() => { - return this.bitstreamService.update(updatedBitstream).pipe( - getFirstSucceededRemoteDataPayload() - ); - }) - ).subscribe(() => { - this.bitstreamService.commitUpdates(); - this.notificationsService.success( - this.translate.instant(this.NOTIFICATIONS_PREFIX + 'saved.title'), - this.translate.instant(this.NOTIFICATIONS_PREFIX + 'saved.content') - ); - if (!errorWhileSaving) { - this.navigateToItemEditBitstreams(); - } - }); + return updatedBundleRD$.pipe( + getFirstCompletedRemoteData() + ); + } + + /** + * Creates and returns an observable that will update the bitstream format + * if necessary according to the provided updated values. + * When an update is necessary, the observable fires once with the completed RemoteData of the bitstream update. + * When no update is necessary, the observable fires once with a null value. + * @param updatedValues The raw updated values in the bitstream edit form + */ + updateBitstreamFormatRD$(): Observable> { + const selectedFormat = this.selectedFormat; + const formatChanged = selectedFormat.id !== this.bitstreamFormat.id; + + // If the format has not changed, there is nothing to do + if (!formatChanged) { + return observableOf(null); + } + + return this.bitstreamService.updateFormat(this.bitstream, selectedFormat).pipe( + getFirstCompletedRemoteData(), + ); } /** @@ -683,24 +743,24 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { // remove an existing "table of contents" entry. if (isEmpty(rawForm.iiifLabelContainer.iiifLabel)) { - delete newMetadata[this.IIIF_LABEL_METADATA]; + delete newMetadata[IIIF_LABEL_METADATA]; } else { - Metadata.setFirstValue(newMetadata, this.IIIF_LABEL_METADATA, rawForm.iiifLabelContainer.iiifLabel); + Metadata.setFirstValue(newMetadata, IIIF_LABEL_METADATA, rawForm.iiifLabelContainer.iiifLabel); } if (isEmpty(rawForm.iiifTocContainer.iiifToc)) { - delete newMetadata[this.IIIF_TOC_METADATA]; + delete newMetadata[IIIF_TOC_METADATA]; } else { - Metadata.setFirstValue(newMetadata, this.IIIF_TOC_METADATA, rawForm.iiifTocContainer.iiifToc); + Metadata.setFirstValue(newMetadata, IIIF_TOC_METADATA, rawForm.iiifTocContainer.iiifToc); } if (isEmpty(rawForm.iiifWidthContainer.iiifWidth)) { - delete newMetadata[this.IMAGE_WIDTH_METADATA]; + delete newMetadata[IMAGE_WIDTH_METADATA]; } else { - Metadata.setFirstValue(newMetadata, this.IMAGE_WIDTH_METADATA, rawForm.iiifWidthContainer.iiifWidth); + Metadata.setFirstValue(newMetadata, IMAGE_WIDTH_METADATA, rawForm.iiifWidthContainer.iiifWidth); } if (isEmpty(rawForm.iiifHeightContainer.iiifHeight)) { - delete newMetadata[this.IMAGE_HEIGHT_METADATA]; + delete newMetadata[IMAGE_HEIGHT_METADATA]; } else { - Metadata.setFirstValue(newMetadata, this.IMAGE_HEIGHT_METADATA, rawForm.iiifHeightContainer.iiifHeight); + Metadata.setFirstValue(newMetadata, IMAGE_HEIGHT_METADATA, rawForm.iiifHeightContainer.iiifHeight); } } if (isNotEmpty(rawForm.formatContainer.newFormat)) { @@ -710,6 +770,47 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { return updatedBitstream; } + /** + * Handle the update result by checking for errors. + * When there are no errors, the user is redirected to the edit-bitstreams page. + * When there are errors, a notification is shown. + */ + handleUpdateResult(updateResult: UpdateResult) { + let errorWhileSaving = false; + + // Check for errors during the primary bitstream update + const primaryUpdateRD = updateResult.primaryUpdateRD; + if (hasValue(primaryUpdateRD) && primaryUpdateRD.hasFailed) { + this.notificationsService.error( + this.translate.instant(NOTIFICATIONS_PREFIX + 'error.primaryBitstream.title'), + primaryUpdateRD.errorMessage + ); + + errorWhileSaving = true; + } + + // Check for errors during the bitstream format update + const formatUpdateRD = updateResult.formatUpdateRD; + if (hasValue(formatUpdateRD) && formatUpdateRD.hasFailed) { + this.notificationsService.error( + this.translate.instant(NOTIFICATIONS_PREFIX + 'error.format.title'), + formatUpdateRD.errorMessage + ); + + errorWhileSaving = true; + } + + this.bitstreamService.commitUpdates(); + this.notificationsService.success( + this.translate.instant(NOTIFICATIONS_PREFIX + 'saved.title'), + this.translate.instant(NOTIFICATIONS_PREFIX + 'saved.content') + ); + + if (!errorWhileSaving) { + this.navigateToItemEditBitstreams(); + } + } + /** * Cancel the form and return to the previous page */ @@ -718,63 +819,44 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { } /** - * When the item ID is present, navigate back to the item's edit bitstreams page, - * otherwise retrieve the item ID based on the owning bundle's link + * Navigate back to the item's edit bitstreams page */ navigateToItemEditBitstreams() { - this.router.navigate([getEntityEditRoute(this.entityType, this.itemId), 'bitstreams']); + void this.router.navigate([getEntityEditRoute(null, this.item.uuid), 'bitstreams']); } /** * Verifies that the parent item is iiif-enabled. Checks bitstream mimetype to be * sure it's an image, excluding bitstreams in the THUMBNAIL or OTHERCONTENT bundles. - * @param bitstream */ - setIiifStatus(bitstream: Bitstream) { + getIiifStatus(): boolean { const regexExcludeBundles = /OTHERCONTENT|THUMBNAIL|LICENSE/; const regexIIIFItem = /true|yes/i; - const isImage$ = this.bitstream.format.pipe( - getFirstSucceededRemoteData(), - map((format: RemoteData) => format.payload.mimetype.includes('image/'))); - - const isIIIFBundle$ = this.bitstream.bundle.pipe( - getFirstSucceededRemoteData(), - map((bundle: RemoteData) => - this.dsoNameService.getName(bundle.payload).match(regexExcludeBundles) == null)); - - const isEnabled$ = this.bitstream.bundle.pipe( - getFirstSucceededRemoteData(), - map((bundle: RemoteData) => bundle.payload.item.pipe( - getFirstSucceededRemoteData(), - map((item: RemoteData) => - (item.payload.firstMetadataValue('dspace.iiif.enabled') && - item.payload.firstMetadataValue('dspace.iiif.enabled').match(regexIIIFItem) !== null) - )))); - - const iiifSub = combineLatest( - isImage$, - isIIIFBundle$, - isEnabled$ - ).subscribe(([isImage, isIIIFBundle, isEnabled]) => { - if (isImage && isIIIFBundle && isEnabled) { - this.isIIIF = true; - this.inputModels.push(this.iiifLabelModel); - this.formModel.push(this.iiifLabelContainer); - this.inputModels.push(this.iiifTocModel); - this.formModel.push(this.iiifTocContainer); - this.inputModels.push(this.iiifWidthModel); - this.formModel.push(this.iiifWidthContainer); - this.inputModels.push(this.iiifHeightModel); - this.formModel.push(this.iiifHeightContainer); - } - this.setForm(); - this.changeDetectorRef.detectChanges(); - }); + const isImage = this.bitstreamFormat.mimetype.includes('image/'); - this.subs.push(iiifSub); + const isIIIFBundle = this.dsoNameService.getName(this.bundle).match(regexExcludeBundles) === null; + const isEnabled = + this.item.firstMetadataValue('dspace.iiif.enabled') && + this.item.firstMetadataValue('dspace.iiif.enabled').match(regexIIIFItem) !== null; + + return isImage && isIIIFBundle && isEnabled; + } + + /** + * Extend the form with IIIF fields + */ + appendFormWithIiifFields(): void { + this.inputModels.push(this.iiifLabelModel); + this.formModel.push(this.iiifLabelContainer); + this.inputModels.push(this.iiifTocModel); + this.formModel.push(this.iiifTocContainer); + this.inputModels.push(this.iiifWidthModel); + this.formModel.push(this.iiifWidthContainer); + this.inputModels.push(this.iiifHeightModel); + this.formModel.push(this.iiifHeightContainer); } /** diff --git a/src/app/shared/utils/observables-dictionary.ts b/src/app/shared/utils/observables-dictionary.ts new file mode 100644 index 00000000000..905b994633f --- /dev/null +++ b/src/app/shared/utils/observables-dictionary.ts @@ -0,0 +1,72 @@ +import { Observable } from 'rxjs'; + +/** + * Utility type that allows stricter type checking when creating a method with output that is intended to be used + * in a 'combineLatest' or similar call. + */ +export type ObservablesDictionary = { + [key in keyof T]: Observable +}; + +/* + How to use an ObservablesDictionary: + + Suppose that you require multiple observables to fire before you can start with a task such as creating a form. The + usual way to implement this is to create all the necessary observables, combine them in an array, pass the array as + argument in a 'combineLatest' call, and subscribe to the resulting observable to handle the result. + + Having to deconstruct the array into its components can be tedious and error-prone. RxJS supports dictionaries of + observables as input argument in 'combineLatest', so it would be nice to be able to use this while maximally making + use of TypeScript's type safety. That is where the ObservablesDictionary type comes in. + + You start by defining the interface that should be the output of the 'combineLatest' method. + e.g.: + + interface MyData { + collection: Collection; + bitstreams: PaginatedList; + title: string; + } + + Now the input for the 'combineLatest' should be of type ObservablesDictionary. + In essence ObservablesDictionary creates a copy of the defined interface T, while making observables of all of T's + fields. ObservablesDictionary also applies the additional constraint that all the keys of T must be strings, which + is required for objects used in 'combineLatest'. + + ObservablesDictionary is equivalent to the following: + + interface ObservablesDictionaryMyData { + collection: Observable; + bitstreams: Observable>; + title: Observable; + } + + This does not follow the convention of appending fieldNames of observables with the dollar sign ($). This is because + RxJS maps the input names one-to-one to the output names, so they must be exactly the same. + + + By using these types it becomes much easier to separate the process into multiple parts while maximally making use of + the type system: The first function creates all the necessary observables and returns an object of type + ObservablesDictionary. The second function takes as argument an object of type MyData and performs whatever + action you want to with the retrieved data. The final function then simply handles the necessary plumbing by calling + the first method, placing the result as argument in a 'combineLatest' method, and in the subscription simply passing + the result through to the second function. + + + + An example of this type in action can be found in the edit-bitstream-page component (as of writing this explainer). + The edit-bitstream-page has the following interface that contains the required data: + + interface DataObjects { + bitstream: Bitstream, + bitstreamFormat: BitstreamFormat, + bitstreamFormatOptions: PaginatedList, + bundle: Bundle, + primaryBitstream: Bitstream, + item: Item, + } + + The getDataObservables provides all the observables in an ObservablesDictionary object + which is used in the ngOnInit method to retrieve all the data necessary to create the edit form. +*/ +