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 acbb38fe276..b502a99a6bb 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 @@
- @if (bitstreamRD?.hasSucceeded) { + @if (bitstreamRD?.hasSucceeded && (isLoading$ | async) === false) {
@@ -33,9 +33,8 @@

{{dsoNameService.getName(bitstreamRD?.payload)}} } - @if (!bitstreamRD || bitstreamRD?.isLoading) { - + @if (!bitstreamRD || bitstreamRD?.isLoading || (isLoading$ | async)) { + }

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 7da9e040ceb..8c395aa56ed 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 @@ -126,6 +126,7 @@ describe('EditBitstreamPageComponent', () => { bitstreamFormatService = jasmine.createSpyObj('bitstreamFormatService', { findAll: createSuccessfulRemoteDataObject$(createPaginatedList(allFormats)), + findByHref: createSuccessfulRemoteDataObject$(selectedFormat), }); notificationsService = jasmine.createSpyObj('notificationsService', @@ -161,6 +162,7 @@ describe('EditBitstreamPageComponent', () => { }); describe('EditBitstreamPageComponent no IIIF fields', () => { + const dsoNameServiceReturnValue = 'ORIGINAL'; beforeEach(waitForAsync(() => { bundle = { @@ -176,7 +178,6 @@ describe('EditBitstreamPageComponent', () => { }, })), }; - const bundleName = 'ORIGINAL'; bitstream = Object.assign(new Bitstream(), { uuid: bitstreamID, @@ -196,6 +197,7 @@ describe('EditBitstreamPageComponent', () => { format: createSuccessfulRemoteDataObject$(selectedFormat), _links: { self: 'bitstream-selflink', + format: 'format-link', }, bundle: createSuccessfulRemoteDataObject$(bundle), }); @@ -209,9 +211,10 @@ describe('EditBitstreamPageComponent', () => { }); bitstreamFormatService = jasmine.createSpyObj('bitstreamFormatService', { findAll: createSuccessfulRemoteDataObject$(createPaginatedList(allFormats)), + findByHref: createSuccessfulRemoteDataObject$(selectedFormat), }); dsoNameService = jasmine.createSpyObj('dsoNameService', { - getName: bundleName, + getName: dsoNameServiceReturnValue, }); TestBed.configureTestingModule({ @@ -253,7 +256,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', () => { @@ -432,7 +435,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']); }); @@ -481,6 +484,7 @@ describe('EditBitstreamPageComponent', () => { format: createSuccessfulRemoteDataObject$(allFormats[1]), _links: { self: 'bitstream-selflink', + format: 'format-link', }, bundle: createSuccessfulRemoteDataObject$({ _links: { @@ -605,7 +609,7 @@ describe('EditBitstreamPageComponent', () => { format: createSuccessfulRemoteDataObject$(allFormats[2]), _links: { self: 'bitstream-selflink', - }, + format: 'format-link' }, bundle: createSuccessfulRemoteDataObject$({ _links: { primaryBitstream: { 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 6ad82b2a74f..9f1b8e0356f 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 @@ -6,7 +6,10 @@ import { OnDestroy, OnInit, } from '@angular/core'; -import { UntypedFormGroup } from '@angular/forms'; +import { + FormGroup, + UntypedFormGroup, +} from '@angular/forms'; import { ActivatedRoute, Router, @@ -25,6 +28,7 @@ import { } from '@ngx-translate/core'; import cloneDeep from 'lodash/cloneDeep'; import { + BehaviorSubject, combineLatest, combineLatest as observableCombineLatest, Observable, @@ -32,15 +36,12 @@ import { Subscription, } from 'rxjs'; import { - filter, map, switchMap, - take, - tap, } from 'rxjs/operators'; +import { ObservablesDictionary } from 'src/app/shared/utils/observables-dictionary'; import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; -import { FindAllDataImpl } from '../../core/data/base/find-all-data'; import { BitstreamDataService } from '../../core/data/bitstream-data.service'; import { BitstreamFormatDataService } from '../../core/data/bitstream-format-data.service'; import { PrimaryBitstreamService } from '../../core/data/primary-bitstream.service'; @@ -54,9 +55,7 @@ import { Item } from '../../core/shared/item.model'; import { Metadata } from '../../core/shared/metadata.utils'; import { getFirstCompletedRemoteData, - getFirstSucceededRemoteData, getFirstSucceededRemoteDataPayload, - getRemoteDataPayload, } from '../../core/shared/operators'; import { getEntityEditRoute } from '../../item-page/item-page-routing-paths'; import { @@ -77,6 +76,66 @@ import { FileSizePipe } from '../../shared/utils/file-size-pipe'; import { VarDirective } from '../../shared/utils/var.directive'; import { ThemedThumbnailComponent } from '../../thumbnail/themed-thumbnail.component'; +/** + * 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-base-edit-bitstream-page', styleUrls: ['./edit-bitstream-page.component.scss'], @@ -100,6 +159,8 @@ import { ThemedThumbnailComponent } from '../../thumbnail/themed-thumbnail.compo */ export class EditBitstreamPageComponent implements OnInit, OnDestroy { + isLoading$: BehaviorSubject = new BehaviorSubject(true); + /** * The bitstream's remote data observable * Tracks changes and updates the view @@ -117,49 +178,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 - */ - HINT_KEY_SUFFIX = '.hint'; - - /** - * @type {string} Key prefix used to generate notification messages - */ - 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 + * The format of the bitstream to edit */ - IIIF_TOC_METADATA = 'iiif.toc'; + bitstreamFormat: BitstreamFormat; /** - * IIIF label metadata key + * The item that the bitstream belongs to */ - IIIF_LABEL_METADATA = 'iiif.label'; + item: Item; /** * Options for fetching all bitstream formats @@ -218,7 +244,7 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { resourceType: BITSTREAM_FORMAT, 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; } @@ -410,13 +436,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 @@ -466,14 +485,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( @@ -483,7 +531,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(), ); @@ -491,58 +539,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(); + } + + /** + * Updates the formModel with additional fields & options, depending on the current data + */ + 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 bitstream properties - * @param bitstream + * Update the current form values with the current bitstream properties */ - updateForm(bitstream: Bitstream) { + updateForm() { + const bitstream = this.bitstream; + this.formGroup.patchValue({ fileNamePrimaryContainer: { - fileName: bitstream.name, + fileName: this.dsoNameService.getName(bitstream), primaryBitstream: this.primaryBitstreamUUID === bitstream.uuid, }, descriptionContainer: { @@ -556,26 +617,26 @@ 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(); } /** * Update the layout of the "Other Format" input depending on the selected format - * @param selectedId */ updateNewFormatLayout() { if (this.isUnknownFormat()) { @@ -586,8 +647,7 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { } /** - * Is the provided format (id) part of the list of unknown formats? - * @param id + * Is the provided format part of the list of unknown formats? */ isUnknownFormat(): boolean { return hasValue(this.selectedFormat) && this.selectedFormat.supportLevel === BitstreamFormatSupportLevel.Unknown; @@ -609,9 +669,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); } } @@ -632,93 +692,86 @@ 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. + */ + 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(), + ); } /** @@ -740,24 +793,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)) { @@ -767,6 +820,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 */ @@ -775,63 +869,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/'); + + const isIIIFBundle = this.dsoNameService.getName(this.bundle).match(regexExcludeBundles) === null; - this.subs.push(iiifSub); + 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); } /** @@ -843,7 +918,4 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { .forEach((subscription) => subscription.unsubscribe()); } - findAllFormatsServiceFactory() { - return () => this.bitstreamFormatService as any as FindAllDataImpl; - } } 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. +*/ +