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 aef2f3f7dce..7cf33ca2d35 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?.hasFailed) { } - @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 c090197a8f2..9ff3e9c7fdf 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 21cce391bbc..a19ca8e23c4 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,14 +6,16 @@ import { OnDestroy, OnInit, } from '@angular/core'; -import { UntypedFormGroup } from '@angular/forms'; +import { + FormGroup, + UntypedFormGroup, +} from '@angular/forms'; import { ActivatedRoute, Router, RouterLink, } from '@angular/router'; import { DSONameService } from '@dspace/core/breadcrumbs/dso-name.service'; -import { FindAllDataImpl } from '@dspace/core/data/base/find-all-data'; import { BitstreamDataService } from '@dspace/core/data/bitstream-data.service'; import { BitstreamFormatDataService } from '@dspace/core/data/bitstream-format-data.service'; import { PrimaryBitstreamService } from '@dspace/core/data/primary-bitstream.service'; @@ -28,9 +30,7 @@ import { Item } from '@dspace/core/shared/item.model'; import { Metadata } from '@dspace/core/shared/metadata.utils'; import { getFirstCompletedRemoteData, - getFirstSucceededRemoteData, getFirstSucceededRemoteDataPayload, - getRemoteDataPayload, } from '@dspace/core/shared/operators'; import { hasValue, @@ -51,6 +51,7 @@ import { } from '@ngx-translate/core'; import cloneDeep from 'lodash/cloneDeep'; import { + BehaviorSubject, combineLatest, combineLatest as observableCombineLatest, Observable, @@ -58,12 +59,10 @@ import { Subscription, } from 'rxjs'; import { - filter, map, switchMap, - take, - tap, } from 'rxjs/operators'; +import { ObservablesDictionary } from 'src/app/shared/utils/observables-dictionary'; import { getEntityEditRoute } from '../../item-page/item-page-routing-paths'; import { ErrorComponent } from '../../shared/error/error.component'; @@ -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'], @@ -99,6 +158,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 @@ -116,49 +177,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 @@ -217,7 +243,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; } @@ -409,13 +435,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 @@ -465,14 +484,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( @@ -482,7 +530,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(), ); @@ -490,58 +538,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: { @@ -555,26 +616,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()) { @@ -585,8 +646,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; @@ -608,9 +668,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); } } @@ -631,93 +691,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$ = of(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 of(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$ = of(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 of(null); + } + + return this.bitstreamService.updateFormat(this.bitstream, selectedFormat).pipe( + getFirstCompletedRemoteData(), + ); } /** @@ -739,24 +792,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)) { @@ -766,6 +819,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 */ @@ -774,63 +868,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); } /** @@ -842,7 +917,4 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { .forEach((subscription) => subscription.unsubscribe()); } - findAllFormatsServiceFactory() { - return () => this.bitstreamFormatService as any as FindAllDataImpl; - } } diff --git a/src/app/core/data/bundle-data.service.ts b/src/app/core/data/bundle-data.service.ts index eb3c51bbf20..01474f3b464 100644 --- a/src/app/core/data/bundle-data.service.ts +++ b/src/app/core/data/bundle-data.service.ts @@ -1,9 +1,16 @@ import { Injectable } from '@angular/core'; import { RestRequestMethod } from '@dspace/config/rest-request-method'; import { hasValue } from '@dspace/shared/utils/empty.util'; -import { Operation } from 'fast-json-patch'; -import { Observable } from 'rxjs'; import { + Operation, + RemoveOperation, +} from 'fast-json-patch'; +import { + combineLatest as observableCombineLatest, + Observable, +} from 'rxjs'; +import { + find, map, switchMap, take, @@ -16,6 +23,7 @@ import { Bundle } from '../shared/bundle.model'; import { FollowLinkConfig } from '../shared/follow-link-config.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { Item } from '../shared/item.model'; +import { NoContent } from '../shared/NoContent.model'; import { PaginatedSearchOptions } from '../shared/search/models/paginated-search-options.model'; import { IdentifiableDataService } from './base/identifiable-data.service'; import { @@ -26,7 +34,10 @@ import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; import { FindListOptions } from './find-list-options.model'; import { PaginatedList } from './paginated-list.model'; import { RemoteData } from './remote-data'; -import { GetRequest } from './request.models'; +import { + GetRequest, + PatchRequest, +} from './request.models'; import { RequestService } from './request.service'; import { RequestEntryState } from './request-entry-state.model'; @@ -182,4 +193,34 @@ export class BundleDataService extends IdentifiableDataService implement public createPatchFromCache(object: Bundle): Observable { return this.patchData.createPatchFromCache(object); } + + /** + * Delete multiple {@link Bundle}s at once by sending a PATCH request to the backend + * This will also delete all bitstreams contained in the bundles. + * + * @param bundles The bundles that should be removed + */ + removeMultiple(bundles: Bundle[]): Observable> { + const operations: RemoveOperation[] = bundles.map((bundle: Bundle) => { + return { + op: 'remove', + path: `/bundles/${bundle.id}`, + }; + }); + const requestId: string = this.requestService.generateRequestId(); + + const hrefObs: Observable = this.getBrowseEndpoint(); + + hrefObs.pipe( + find((href: string) => hasValue(href)), + ).subscribe((href: string) => { + const request = new PatchRequest(requestId, href, operations); + if (hasValue(this.responseMsToLive)) { + request.responseMsToLive = this.responseMsToLive; + } + this.requestService.send(request); + }); + + return this.rdbService.buildFromRequestUUIDAndAwait(requestId, () => observableCombineLatest(bundles.map((bundle: Bundle) => this.invalidateByHref(bundle._links.self.href)))); + } } diff --git a/src/app/core/data/item-data.service.spec.ts b/src/app/core/data/item-data.service.spec.ts index 546c469c5fc..8be3b344af2 100644 --- a/src/app/core/data/item-data.service.spec.ts +++ b/src/app/core/data/item-data.service.spec.ts @@ -199,6 +199,30 @@ describe('ItemDataService', () => { done(); }); }); + + it('should call setStaleByHrefSubstring on the bundles endpoint', (done) => { + const rdbServiceWithSpy = Object.assign({}, rdbService, { + buildFromRequestUUIDAndAwait: (requestUUID$: any, callback: any) => { + // Execute callback and subscribe to verify cache invalidation + callback().subscribe(); + return createSuccessfulRemoteDataObject$({}); + }, + }); + service = new ItemDataService( + requestService, + rdbServiceWithSpy as any, + objectCache, + halEndpointService, + notificationsService, + comparator, + browseService, + bundleService, + ); + service.createBundle(itemId, bundleName).subscribe(() => { + expect(requestService.setStaleByHrefSubstring).toHaveBeenCalled(); + done(); + }); + }); }); describe('when cache is invalidated', () => { diff --git a/src/app/core/data/item-data.service.ts b/src/app/core/data/item-data.service.ts index e6c3dd4fe1f..0e5f71c2755 100644 --- a/src/app/core/data/item-data.service.ts +++ b/src/app/core/data/item-data.service.ts @@ -267,7 +267,12 @@ export abstract class BaseItemDataService extends IdentifiableDataService this.requestService.send(request); }); - return this.rdbService.buildFromRequestUUID(requestId); + return this.rdbService.buildFromRequestUUIDAndAwait(requestId, () => + hrefObs.pipe( + take(1), + switchMap((href: string) => this.requestService.setStaleByHrefSubstring(href)), + ), + ); } /** diff --git a/src/app/item-page/edit-item-page/abstract-item-update/abstract-item-update.component.ts b/src/app/item-page/edit-item-page/abstract-item-update/abstract-item-update.component.ts index bb43db0c5c5..1f1d980938a 100644 --- a/src/app/item-page/edit-item-page/abstract-item-update/abstract-item-update.component.ts +++ b/src/app/item-page/edit-item-page/abstract-item-update/abstract-item-update.component.ts @@ -29,6 +29,7 @@ import { Subscription, } from 'rxjs'; import { + distinctUntilKeyChanged, map, switchMap, take, @@ -96,6 +97,7 @@ export class AbstractItemUpdateComponent extends AbstractTrackableComponent impl return this.itemService.findByHref(rd.payload._links.self.href, true, true, ...getItemPageLinksToFollow()); }), getAllSucceededRemoteData(), + distinctUntilKeyChanged('timeCompleted'), ).subscribe((rd: RemoteData) => { this.setItem(rd.payload); }); diff --git a/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.html b/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.html index 15d713bdafc..89ffcac79a4 100644 --- a/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.html +++ b/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.html @@ -37,20 +37,19 @@ @if (item && bundles?.length > 0) {
- @for (bundle of bundles; track bundle; let isFirst = $first) { - - - } - @if (showLoadMoreLink$ | async) { -
- -
- } + + @for (bundle of bundles; track bundle; let isFirst = $first) { + + + } +
} @if (bundles?.length === 0) { @@ -90,7 +89,6 @@ }
- @if (isProcessingMoveRequest | async) { diff --git a/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.scss b/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.scss index 7fd1f4b31e7..197dd5369ff 100644 --- a/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.scss +++ b/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.scss @@ -1,3 +1,21 @@ +.header-row { + color: var(--bs-table-dark-color); + background-color: var(--bs-table-dark-bg); + border-color: var(--bs-table-dark-border-color); +} + +.bundle-row:not(.table-danger) { + color: var(--bs-table-head-color); + background-color: var(--bs-table-head-bg); + border-color: var(--bs-table-border-color); +} + +.row-element { + padding: 12px; + padding: 0.75em; + border-bottom: var(--bs-table-border-width) solid var(--bs-table-border-color); +} + .drag-handle { &:hover { cursor: move; @@ -53,3 +71,12 @@ top: 50%; left: 50%; } + +.bundle-remove-warning { + white-space: nowrap; + font-size: 0.75rem; + + @media (min-width: map-get($grid-breakpoints, sm)) { + font-size: 0.875rem; + } +} diff --git a/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.spec.ts b/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.spec.ts index d6916ced8a3..2ad3020567e 100644 --- a/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.spec.ts +++ b/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.spec.ts @@ -28,14 +28,17 @@ import { NotificationsService } from '@dspace/core/notification-system/notificat import { Bitstream } from '@dspace/core/shared/bitstream.model'; import { Bundle } from '@dspace/core/shared/bundle.model'; import { Item } from '@dspace/core/shared/item.model'; +import { NoContent } from '@dspace/core/shared/NoContent.model'; import { BitstreamDataServiceStub } from '@dspace/core/testing/bitstream-data-service.stub'; import { getMockRequestService } from '@dspace/core/testing/request.service.mock'; import { RouterStub } from '@dspace/core/testing/router.stub'; import { createPaginatedList } from '@dspace/core/testing/utils.test'; import { + createFailedRemoteDataObject$, createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$, } from '@dspace/core/utilities/remote-data.utils'; +import { hasValue } from '@dspace/shared/utils/empty.util'; import { TranslateModule } from '@ngx-translate/core'; import { of } from 'rxjs'; @@ -128,6 +131,17 @@ describe('ItemBitstreamsComponent', () => { getMoveOperations: of(moveOperations), }, ); + (objectUpdatesService.getFieldUpdatesExclusive as jasmine.Spy).and.callFake((bundleListUrl: string) => { + if (hasValue(bundleListUrl) && bundleListUrl.endsWith('/bundles')) { + return of({ + [bundle.uuid]: { field: bundle, changeType: undefined }, + }); + } + return of({ + [bitstream1.uuid]: fieldUpdate1, + [bitstream2.uuid]: fieldUpdate2, + }); + }); router = Object.assign(new RouterStub(), { url: url, }); @@ -152,6 +166,7 @@ describe('ItemBitstreamsComponent', () => { id: 'item', _links: { self: { href: 'item-selflink' }, + bundles: { href: 'https://rest/api/core/items/item/bundles' }, }, bundles: createSuccessfulRemoteDataObject$(createPaginatedList([bundle])), lastModified: date, @@ -160,7 +175,6 @@ describe('ItemBitstreamsComponent', () => { getBitstreams: () => createSuccessfulRemoteDataObject$(createPaginatedList([bitstream1, bitstream2])), findByHref: () => createSuccessfulRemoteDataObject$(item), findById: () => createSuccessfulRemoteDataObject$(item), - getBundles: () => createSuccessfulRemoteDataObject$(createPaginatedList([bundle])), }); route = Object.assign({ parent: { @@ -171,6 +185,8 @@ describe('ItemBitstreamsComponent', () => { }); bundleService = jasmine.createSpyObj('bundleService', { patch: createSuccessfulRemoteDataObject$({}), + removeMultiple: createSuccessfulRemoteDataObject$({} as NoContent), + findAllByItem: createSuccessfulRemoteDataObject$(createPaginatedList([bundle])), }); itemBitstreamsService = getItemBitstreamsServiceStub(); @@ -222,25 +238,72 @@ describe('ItemBitstreamsComponent', () => { comp.submit(); }); - it('should call removeMarkedBitstreams on the itemBitstreamsService', () => { - expect(itemBitstreamsService.removeMarkedBitstreams).toHaveBeenCalled(); + it('should call removeMarkedBundlesAndBitstreams on the itemBitstreamsService', () => { + expect(itemBitstreamsService.removeMarkedBundlesAndBitstreams).toHaveBeenCalled(); }); }); describe('discard', () => { - it('should discard ALL field updates', () => { + it('should discard item, bundle-list, and per-bundle field updates', () => { comp.discard(); expect(objectUpdatesService.discardAllFieldUpdates).toHaveBeenCalled(); + expect(objectUpdatesService.discardFieldUpdates).toHaveBeenCalled(); }); }); describe('reinstate', () => { - it('should reinstate field updates on the bundle', () => { + it('should reinstate bundle-list and per-bundle field updates', () => { comp.reinstate(); + expect(objectUpdatesService.reinstateFieldUpdates).toHaveBeenCalledWith(`${item.self}/bundles`); expect(objectUpdatesService.reinstateFieldUpdates).toHaveBeenCalledWith(bundle.self); }); }); + describe('displayRemovalNotifications', () => { + beforeEach(() => { + notificationsService.error = jasmine.createSpy('error'); + notificationsService.success = jasmine.createSpy('success'); + }); + + it('should not show any notification when responses array is empty', () => { + comp.displayRemovalNotifications([], false, false); + expect(notificationsService.success).not.toHaveBeenCalled(); + expect(notificationsService.error).not.toHaveBeenCalled(); + }); + + it('should show bitstreams notification when only deleting bitstreams', () => { + const successResponse = createSuccessfulRemoteDataObject({} as NoContent); + comp.displayRemovalNotifications([successResponse], false, true); + expect(notificationsService.success).toHaveBeenCalled(); + const successCall = (notificationsService.success as jasmine.Spy).calls.mostRecent(); + expect(successCall.args[0]).toContain('bitstreams'); + }); + + it('should show bundles notification when only deleting bundles', () => { + const successResponse = createSuccessfulRemoteDataObject({} as NoContent); + comp.displayRemovalNotifications([successResponse], true, false); + expect(notificationsService.success).toHaveBeenCalled(); + const successCall = (notificationsService.success as jasmine.Spy).calls.mostRecent(); + expect(successCall.args[0]).toContain('bundles'); + }); + + it('should show both notification when deleting both bundles and bitstreams', () => { + const successResponse = createSuccessfulRemoteDataObject({} as NoContent); + comp.displayRemovalNotifications([successResponse], true, true); + expect(notificationsService.success).toHaveBeenCalled(); + const successCall = (notificationsService.success as jasmine.Spy).calls.mostRecent(); + expect(successCall.args[0]).toContain('both'); + }); + + it('should show error notification for failed responses', (done) => { + createFailedRemoteDataObject$('Test error').subscribe((failedResponse) => { + comp.displayRemovalNotifications([failedResponse], true, false); + expect(notificationsService.error).toHaveBeenCalled(); + done(); + }); + }); + }); + describe('moveUp', () => { it('should move the selected bitstream up', () => { itemBitstreamsService.hasSelectedBitstream.and.returnValue(true); diff --git a/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts b/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts index 4a3c98e33aa..19b03c17ebf 100644 --- a/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts +++ b/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts @@ -17,20 +17,20 @@ import { import { ObjectCacheService } from '@dspace/core/cache/object-cache.service'; import { BitstreamDataService } from '@dspace/core/data/bitstream-data.service'; import { BundleDataService } from '@dspace/core/data/bundle-data.service'; +import { FindListOptions } from '@dspace/core/data/find-list-options.model'; import { ItemDataService } from '@dspace/core/data/item-data.service'; +import { FieldUpdates } from '@dspace/core/data/object-updates/field-updates.model'; import { ObjectUpdatesService } from '@dspace/core/data/object-updates/object-updates.service'; import { PaginatedList } from '@dspace/core/data/paginated-list.model'; import { RemoteData } from '@dspace/core/data/remote-data'; import { RequestService } from '@dspace/core/data/request.service'; import { NotificationsService } from '@dspace/core/notification-system/notifications.service'; -import { PaginationComponentOptions } from '@dspace/core/pagination/pagination-component-options.model'; import { Bundle } from '@dspace/core/shared/bundle.model'; import { NoContent } from '@dspace/core/shared/NoContent.model'; import { - getFirstSucceededRemoteData, + getAllSucceededRemoteData, getRemoteDataPayload, } from '@dspace/core/shared/operators'; -import { PaginatedSearchOptions } from '@dspace/core/shared/search/models/paginated-search-options.model'; import { hasValue, isNotEmpty, @@ -41,14 +41,16 @@ import { } from '@ngx-translate/core'; import { Operation } from 'fast-json-patch'; import { - BehaviorSubject, combineLatest, Observable, + of, Subscription, } from 'rxjs'; import { + distinctUntilKeyChanged, filter, map, + shareReplay, switchMap, take, } from 'rxjs/operators'; @@ -90,50 +92,49 @@ export class ItemBitstreamsComponent extends AbstractItemUpdateComponent impleme protected readonly AlertType = AlertType; /** - * All bundles for the current item + * The URL used as the key for bundle updates in ObjectUpdatesService */ - private bundlesSubject = new BehaviorSubject([]); + bundleUpdatesUrl: string; /** - * The page options to use for fetching the bundles + * Emits the current bundle list; stays subscribed so cache invalidation (after delete/create) refetches the list. + * Uses {@link BundleDataService#findAllByItem} instead of {@link ItemDataService#getBundles} for correct cache integration. + * Defaults to empty until {@link postItemInit} runs. */ - bundlesOptions: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { - id: 'bundles-pagination-options', - currentPage: 1, - pageSize: 10, - }); + bundles$: Observable = of([]); /** - * The bootstrap sizes used for the columns within this table + * The current field updates for all bundles (e.g. marked for removal) */ - columnSizes: ResponsiveTableSizes; + bundleFieldUpdates$: Observable; /** - * Are we currently submitting the changes? - * Used to disable any action buttons until the submit finishes + * Find-list options for loading all bundles for the item (single request; list refreshes via reactive stream). */ - submitting = false; + readonly bundleListFindOptions: FindListOptions = { + scopeID: 'bundles-pagination-options', + currentPage: 1, + elementsPerPage: 9999, + }; /** - * 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 bitstreams are deleted + * The bootstrap sizes used for the columns within this table */ - itemUpdateSubscription: Subscription; + columnSizes: ResponsiveTableSizes; /** - * The flag indicating to show the load more link + * Are we currently submitting the changes? + * Used to disable any action buttons until the submit finishes */ - showLoadMoreLink$: BehaviorSubject = new BehaviorSubject(true); + submitting = false; /** - * The list of bundles for the current item as an observable + * Subscriptions owned by this component (bundle object-updates wiring) */ - get bundles$(): Observable { - return this.bundlesSubject.asObservable(); - } + subs: Subscription[] = []; /** - * An observable which emits a boolean which represents whether the service is currently handling a 'move' request + * Emits whether the bitstreams service is processing a move request (for UI overlay). */ isProcessingMoveRequest: Observable; @@ -155,13 +156,30 @@ export class ItemBitstreamsComponent extends AbstractItemUpdateComponent impleme super(itemService, objectUpdatesService, router, notificationsService, translateService, route); this.columnSizes = this.itemBitstreamsService.getColumnSizes(); + this.isProcessingMoveRequest = this.itemBitstreamsService.getPerformingMoveRequest$(); } /** * Actions to perform after the item has been initialized */ postItemInit(): void { - this.loadBundles(1); + this.bundleUpdatesUrl = `${this.item.self}/bundles`; + + this.bundles$ = this.bundleService.findAllByItem(this.item, this.bundleListFindOptions).pipe( + getAllSucceededRemoteData(), + distinctUntilKeyChanged('timeCompleted'), + getRemoteDataPayload(), + map((bundlePage: PaginatedList) => bundlePage.page), + shareReplay({ bufferSize: 1, refCount: true }), + ); + + this.subs.push(this.bundles$.subscribe((bundles: Bundle[]) => { + this.objectUpdatesService.initialize(this.bundleUpdatesUrl, bundles, new Date()); + })); + + this.bundleFieldUpdates$ = this.bundles$.pipe( + switchMap((bundles: Bundle[]) => this.objectUpdatesService.getFieldUpdatesExclusive(this.bundleUpdatesUrl, bundles)), + ); } /** @@ -227,54 +245,68 @@ export class ItemBitstreamsComponent extends AbstractItemUpdateComponent impleme } /** - * Load bundles for the current item - * @param currentPage The current page to load - */ - loadBundles(currentPage?: number) { - this.bundlesOptions = Object.assign(new PaginationComponentOptions(), this.bundlesOptions, { - currentPage: currentPage || this.bundlesOptions.currentPage + 1, - }); - this.itemService.getBundles(this.item.id, new PaginatedSearchOptions({ pagination: this.bundlesOptions })).pipe( - getFirstSucceededRemoteData(), - getRemoteDataPayload(), - ).subscribe((bundles: PaginatedList) => { - this.updateBundles(bundles); - }); - } - - /** - * Update the subject containing the bundles with the provided bundles. - * Also updates the showLoadMoreLink observable so it does not show up when it is no longer necessary. + * Submit the current changes + * Bundles marked as deleted send a PATCH to the REST API (which also deletes their bitstreams). + * Bitstreams marked as deleted in non-removed bundles send delete requests. + * Display notifications and reset the current item/updates. */ - updateBundles(newBundlesPL: PaginatedList) { - const currentBundles = this.bundlesSubject.getValue(); - - // Only add bundles to the bundle subject if they are not present yet - const bundlesToAdd = newBundlesPL.page - .filter(bundleToAdd => !currentBundles.some(currentBundle => currentBundle.id === bundleToAdd.id)); - - const updatedBundles = [...currentBundles, ...bundlesToAdd]; + submit() { + this.submitting = true; - this.showLoadMoreLink$.next(updatedBundles.length < newBundlesPL.totalElements); - this.bundlesSubject.next(updatedBundles); + this.subs.push( + this.itemBitstreamsService.removeMarkedBundlesAndBitstreams( + this.bundleUpdatesUrl, + this.bundles$, + this.bundleFieldUpdates$, + ).subscribe(({ responses, deletingBundles, deletingBitstreams }) => { + this.displayRemovalNotifications(responses, deletingBundles, deletingBitstreams); + this.submitting = false; + }), + ); } - /** - * Submit the current changes - * Bitstreams marked as deleted send out a delete request to the rest API - * Display notifications and reset the current item/updates + * Display notifications for removal operations + * Shows different messages based on whether bundles, bitstreams, or both were deleted + * @param responses The responses from the delete operations + * @param deletingBundles Whether bundles were deleted + * @param deletingBitstreams Whether bitstreams were deleted */ - submit() { - this.submitting = true; + displayRemovalNotifications(responses: RemoteData[], deletingBundles: boolean, deletingBitstreams: boolean) { + if (responses.length === 0) { + return; + } + + const failedResponses = responses.filter((response: RemoteData) => hasValue(response) && response.hasFailed); + const successfulResponses = responses.filter((response: RemoteData) => hasValue(response) && response.hasSucceeded); - const removedResponses$ = this.itemBitstreamsService.removeMarkedBitstreams(this.bundles$.pipe(take(1))); + let keySuffix: string; + if (deletingBundles && deletingBitstreams) { + keySuffix = 'both'; + } else if (deletingBundles) { + keySuffix = 'bundles'; + } else { + keySuffix = 'bitstreams'; + } - // Perform the setup actions from above in order and display notifications - removedResponses$.subscribe((responses: RemoteData) => { - this.itemBitstreamsService.displayNotifications('item.edit.bitstreams.notifications.remove', [responses]); - this.submitting = false; + failedResponses.forEach((response: RemoteData) => { + this.notificationsService.error( + this.translateService.instant(`item.edit.bitstreams.notifications.remove.${keySuffix}.failed.title`), + response.errorMessage, + ); }); + + if (successfulResponses.length > 0) { + this.notificationsService.success( + this.translateService.instant(`item.edit.bitstreams.notifications.remove.${keySuffix}.saved.title`), + this.translateService.instant(`item.edit.bitstreams.notifications.remove.${keySuffix}.saved.content`), + ); + // Mark the item's bundles collection stale so findAllByItem emits an updated list (bundle self-links alone may not refresh the list endpoint). + const bundlesHref = this.item?._links?.bundles?.href; + if (hasValue(bundlesHref)) { + this.requestService.removeByHrefSubstring(bundlesHref); + } + } } /** @@ -334,12 +366,23 @@ export class ItemBitstreamsComponent extends AbstractItemUpdateComponent impleme discard() { const undoNotification = this.notificationsService.info(this.getNotificationTitle('discarded'), this.getNotificationContent('discarded'), { timeOut: this.discardTimeOut }); this.objectUpdatesService.discardAllFieldUpdates(this.url, undoNotification); + if (hasValue(this.bundleUpdatesUrl)) { + this.objectUpdatesService.discardFieldUpdates(this.bundleUpdatesUrl, undoNotification); + } + this.bundles$.pipe(take(1)).subscribe((bundles: Bundle[]) => { + bundles.forEach((bundle: Bundle) => { + this.objectUpdatesService.discardFieldUpdates(bundle.self, undoNotification); + }); + }); } /** * Request the object updates service to undo discarding all changes to this item */ reinstate() { + if (hasValue(this.bundleUpdatesUrl)) { + this.objectUpdatesService.reinstateFieldUpdates(this.bundleUpdatesUrl); + } this.bundles$.pipe(take(1)).subscribe((bundles: Bundle[]) => { bundles.forEach((bundle: Bundle) => { this.objectUpdatesService.reinstateFieldUpdates(bundle.self); @@ -352,7 +395,13 @@ export class ItemBitstreamsComponent extends AbstractItemUpdateComponent impleme */ isReinstatable(): Observable { return this.bundles$.pipe( - switchMap((bundles: Bundle[]) => combineLatest(bundles.map((bundle: Bundle) => this.objectUpdatesService.isReinstatable(bundle.self)))), + switchMap((bundles: Bundle[]) => { + const parts = bundles.map((bundle: Bundle) => this.objectUpdatesService.isReinstatable(bundle.self)); + if (hasValue(this.bundleUpdatesUrl)) { + parts.unshift(this.objectUpdatesService.isReinstatable(this.bundleUpdatesUrl)); + } + return parts.length ? combineLatest(parts) : of([false]); + }), map((reinstatable: boolean[]) => reinstatable.includes(true)), ); } @@ -362,7 +411,13 @@ export class ItemBitstreamsComponent extends AbstractItemUpdateComponent impleme */ hasChanges(): Observable { return this.bundles$.pipe( - switchMap((bundles: Bundle[]) => combineLatest(bundles.map((bundle: Bundle) => this.objectUpdatesService.hasUpdates(bundle.self)))), + switchMap((bundles: Bundle[]) => { + const parts = bundles.map((bundle: Bundle) => this.objectUpdatesService.hasUpdates(bundle.self)); + if (hasValue(this.bundleUpdatesUrl)) { + parts.unshift(this.objectUpdatesService.hasUpdates(this.bundleUpdatesUrl)); + } + return parts.length ? combineLatest(parts) : of([false]); + }), map((hasChanges: boolean[]) => hasChanges.includes(true)), ); } @@ -370,8 +425,9 @@ export class ItemBitstreamsComponent extends AbstractItemUpdateComponent impleme /** * Unsubscribe from open subscriptions whenever the component gets destroyed */ - ngOnDestroy(): void { - if (this.itemUpdateSubscription) { + override ngOnDestroy(): void { + this.subs.forEach((sub: Subscription) => sub.unsubscribe()); + if (hasValue(this.itemUpdateSubscription)) { this.itemUpdateSubscription.unsubscribe(); } } diff --git a/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.service.stub.ts b/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.service.stub.ts index 9fc9a4f8849..f7c7a48e5a6 100644 --- a/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.service.stub.ts +++ b/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.service.stub.ts @@ -64,6 +64,9 @@ export class ItemBitstreamsServiceStub { removeMarkedBitstreams = jasmine.createSpy('removeMarkedBitstreams').and .returnValue(createSuccessfulRemoteDataObject$({})); + removeMarkedBundlesAndBitstreams = jasmine.createSpy('removeMarkedBundlesAndBitstreams').and + .returnValue(of({ responses: [], deletingBundles: false, deletingBitstreams: false })); + mapBitstreamsToTableEntries = jasmine.createSpy('mapBitstreamsToTableEntries').and .returnValue([]); diff --git a/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.service.ts b/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.service.ts index d3f3c2add48..be44b4df484 100644 --- a/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.service.ts +++ b/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.service.ts @@ -27,7 +27,9 @@ import { TranslateService } from '@ngx-translate/core'; import { MoveOperation } from 'fast-json-patch'; import { BehaviorSubject, + forkJoin, Observable, + of, zip as observableZip, } from 'rxjs'; import { @@ -472,6 +474,71 @@ export class ItemBitstreamsService { ); } + /** + * Removes bundles marked for deletion (and their bitstreams) and bitstreams marked for deletion in remaining bundles. + * Used by the item bitstreams edit page submit flow. + */ + removeMarkedBundlesAndBitstreams( + bundleUpdatesUrl: string, + bundles$: Observable, + bundleFieldUpdates$: Observable, + ): Observable<{ responses: RemoteData[]; deletingBundles: boolean; deletingBitstreams: boolean }> { + const bundlesOnce$ = bundles$.pipe(take(1)); + const bundleUpdatesOnce$ = bundleFieldUpdates$.pipe(take(1)); + + return forkJoin([bundlesOnce$, bundleUpdatesOnce$]).pipe( + switchMap(([bundles, bundleUpdates]: [Bundle[], FieldUpdates]) => { + const removedBundles: Bundle[] = []; + const nonRemovedBundles: Bundle[] = []; + + bundles.forEach((bundle: Bundle) => { + const update = bundleUpdates[bundle.uuid]; + if (update?.changeType === FieldChangeType.REMOVE) { + removedBundles.push(bundle); + } else { + nonRemovedBundles.push(bundle); + } + }); + + const removedBitstreams$ = nonRemovedBundles.length > 0 ? + forkJoin(nonRemovedBundles.map((bundle: Bundle) => this.objectUpdatesService.getFieldUpdates(bundle.self, [], true).pipe(take(1)))).pipe( + map((fieldUpdates: FieldUpdates[]) => ([] as FieldUpdate[]).concat( + ...fieldUpdates.map((updates: FieldUpdates) => Object.values(updates).filter((fieldUpdate: FieldUpdate) => fieldUpdate.changeType === FieldChangeType.REMOVE)), + )), + map((fieldUpdates: FieldUpdate[]) => fieldUpdates.map((fieldUpdate: FieldUpdate) => fieldUpdate.field as Bitstream)), + ) : of([]); + + return removedBitstreams$.pipe( + switchMap((removedBitstreams: Bitstream[]) => { + const deletingBundles = removedBundles.length > 0; + const deletingBitstreams = removedBitstreams.length > 0; + const responses$: Observable>[] = []; + + if (deletingBundles) { + responses$.push(this.bundleService.removeMultiple(removedBundles).pipe( + getFirstCompletedRemoteData(), + )); + } + + if (deletingBitstreams) { + responses$.push(this.bitstreamService.removeMultiple(removedBitstreams).pipe( + getFirstCompletedRemoteData(), + )); + } + + if (responses$.length === 0) { + return of({ responses: [], deletingBundles: false, deletingBitstreams: false }); + } + + return forkJoin(responses$).pipe( + map((responses: RemoteData[]) => ({ responses, deletingBundles, deletingBitstreams })), + ); + }), + ); + }), + ); + } + /** * Creates an array of {@link BitstreamTableEntry}s from an array of {@link Bitstream}s * @param bitstreams The bitstreams array to map to table entries diff --git a/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.html b/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.html index 8ed06a93672..a3585a036fd 100644 --- a/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.html +++ b/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.html @@ -30,20 +30,40 @@ - - - {{'item.edit.bitstreams.bundle.name' | translate:{ name: bundleName } }} + + +
+ {{'item.edit.bitstreams.bundle.name' | translate:{ name: bundleName } }} + @if (isMarkedForRemoval()) { + + + {{ 'item.edit.bitstreams.bundle.remove-warning' | translate }} + + } +
- + +
-
+ + + +
+
@for (entry of (tableEntries$ | async); track entry) { @if (updates[entry.id]; as update) { @@ -110,7 +132,7 @@