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 44e48182fd..13f3ec7f27 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 @@ -24,6 +24,8 @@ import { createPaginatedList } from '../../shared/testing/utils.test'; import { Item } from '../../core/shared/item.model'; import { MetadataValueFilter } from '../../core/shared/metadata.models'; import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { Bundle } from '../../core/shared/bundle.model'; +import { BundleDataService } from '../../core/data/bundle-data.service'; const infoNotification: INotification = new Notification('id', NotificationType.Info, 'info'); const warningNotification: INotification = new Notification('id', NotificationType.Warning, 'warning'); @@ -35,9 +37,14 @@ let bitstreamService: BitstreamDataService; let bitstreamFormatService: BitstreamFormatDataService; let dsoNameService: DSONameService; let bitstream: Bitstream; +let bitstreamID: string; let selectedFormat: BitstreamFormat; let allFormats: BitstreamFormat[]; let router: Router; +let bundleDataService; +let bundleWithCurrentPrimary: Bundle; +let bundleWithDifferentPrimary: Bundle; +let bundleWithNoPrimary: Bundle; let comp: EditBitstreamPageComponent; let fixture: ComponentFixture; @@ -45,6 +52,12 @@ let fixture: ComponentFixture; describe('EditBitstreamPageComponent', () => { beforeEach(() => { + bitstreamID = 'current-bitstream-id'; + bundleWithCurrentPrimary = Object.assign(new Bundle(), { 'primaryBitstreamUUID': bitstreamID }); + bundleWithDifferentPrimary = Object.assign(new Bundle(), { 'primaryBitstreamUUID': '12345-abcde-54321-edcba' }); + bundleWithNoPrimary = Object.assign(new Bundle(), { 'primaryBitstreamUUID': null }); + bundleDataService = jasmine.createSpyObj('BundleDataService', ['patch']); + bundleDataService.patch.and.callFake((a, b) => createSuccessfulRemoteDataObject$(a)); allFormats = [ Object.assign({ id: '1', @@ -53,7 +66,7 @@ describe('EditBitstreamPageComponent', () => { supportLevel: BitstreamFormatSupportLevel.Unknown, mimetype: 'application/octet-stream', _links: { - self: {href: 'format-selflink-1'} + self: { href: 'format-selflink-1' } } }), Object.assign({ @@ -63,7 +76,7 @@ describe('EditBitstreamPageComponent', () => { supportLevel: BitstreamFormatSupportLevel.Known, mimetype: 'image/png', _links: { - self: {href: 'format-selflink-2'} + self: { href: 'format-selflink-2' } } }), Object.assign({ @@ -73,7 +86,7 @@ describe('EditBitstreamPageComponent', () => { supportLevel: BitstreamFormatSupportLevel.Known, mimetype: 'image/gif', _links: { - self: {href: 'format-selflink-3'} + self: { href: 'format-selflink-3' } } }) ] as BitstreamFormat[]; @@ -112,6 +125,8 @@ describe('EditBitstreamPageComponent', () => { const bundleName = 'ORIGINAL'; bitstream = Object.assign(new Bitstream(), { + uuid: bitstreamID, + id: bitstreamID, metadata: { 'dc.description': [ { @@ -155,17 +170,19 @@ describe('EditBitstreamPageComponent', () => { imports: [TranslateModule.forRoot(), RouterTestingModule], declarations: [EditBitstreamPageComponent, FileSizePipe, VarDirective], providers: [ - {provide: NotificationsService, useValue: notificationsService}, - {provide: DynamicFormService, useValue: formService}, - {provide: ActivatedRoute, + { provide: NotificationsService, useValue: notificationsService }, + { provide: DynamicFormService, useValue: formService }, + { + provide: ActivatedRoute, useValue: { - data: observableOf({bitstream: createSuccessfulRemoteDataObject(bitstream)}), - snapshot: {queryParams: {}} + data: observableOf({ bitstream: createSuccessfulRemoteDataObject(bitstream) }), + snapshot: { queryParams: {} } } }, - {provide: BitstreamDataService, useValue: bitstreamService}, - {provide: DSONameService, useValue: dsoNameService}, - {provide: BitstreamFormatDataService, useValue: bitstreamFormatService}, + { provide: BitstreamDataService, useValue: bitstreamService }, + { provide: DSONameService, useValue: dsoNameService }, + { provide: BitstreamFormatDataService, useValue: bitstreamFormatService }, + { provide: BundleDataService, useValue: bundleDataService }, ChangeDetectorRef ], schemas: [NO_ERRORS_SCHEMA] @@ -203,6 +220,27 @@ describe('EditBitstreamPageComponent', () => { it('should put the \"New Format\" input on invisible', () => { expect(comp.formLayout.newFormat.grid.host).toContain('invisible'); }); + describe('when the bitstream is the primary bitstream on the bundle', () => { + beforeEach(() => { + (comp as any).bundle = bundleWithCurrentPrimary; + comp.setForm(); + rawForm = comp.formGroup.getRawValue(); + + }); + it('should enable the primary bitstream toggle', () => { + expect(rawForm.fileNamePrimaryContainer.primaryBitstream).toEqual(true); + }); + }); + describe('when the bitstream is not the primary bitstream on the bundle', () => { + beforeEach(() => { + (comp as any).bundle = bundleWithDifferentPrimary; + comp.setForm(); + rawForm = comp.formGroup.getRawValue(); + }); + it('should disable the primary bitstream toggle', () => { + expect(rawForm.fileNamePrimaryContainer.primaryBitstream).toEqual(false); + }); + }); }); describe('when an unknown format is selected', () => { @@ -216,6 +254,95 @@ describe('EditBitstreamPageComponent', () => { }); describe('onSubmit', () => { + describe('when the primaryBitstream changed', () => { + describe('to the current bitstream', () => { + beforeEach(() => { + const rawValue = Object.assign(comp.formGroup.getRawValue(), { fileNamePrimaryContainer: { primaryBitstream: true } }); + spyOn(comp.formGroup, 'getRawValue').and.returnValue(rawValue); + }); + + describe('from a different primary bitstream', () => { + beforeEach(() => { + (comp as any).bundle = bundleWithDifferentPrimary; + comp.onSubmit(); + }); + + it('should call patch with a replace operation', () => { + expect(bundleDataService.patch).toHaveBeenCalledWith(bundleWithDifferentPrimary, [jasmine.objectContaining({ + op: 'replace' + })]); + }); + + it('should call patch with the correct bitstream uuid', () => { + expect(bundleDataService.patch).toHaveBeenCalledWith(bundleWithDifferentPrimary, [jasmine.objectContaining({ + value: bitstreamID + })]); + }); + }); + describe('from no primary bitstream', () => { + beforeEach(() => { + (comp as any).bundle = bundleWithNoPrimary; + comp.onSubmit(); + }); + + it('should call patch with an add operation', () => { + expect(bundleDataService.patch).toHaveBeenCalledWith(bundleWithNoPrimary, [jasmine.objectContaining({ + op: 'add' + })]); + }); + + it('should call patch with the correct bitstream uuid', () => { + expect(bundleDataService.patch).toHaveBeenCalledWith(bundleWithNoPrimary, [jasmine.objectContaining({ + value: bitstreamID + })]); + }); + }); + }); + describe('to no primary bitstream', () => { + beforeEach(() => { + const rawValue = Object.assign(comp.formGroup.getRawValue(), { fileNamePrimaryContainer: { primaryBitstream: false } }); + spyOn(comp.formGroup, 'getRawValue').and.returnValue(rawValue); + }); + + describe('from the current bitstream', () => { + beforeEach(() => { + (comp as any).bundle = bundleWithCurrentPrimary; + comp.onSubmit(); + }); + + it('should call patch with a remove operation', () => { + expect(bundleDataService.patch).toHaveBeenCalledWith(bundleWithCurrentPrimary, [jasmine.objectContaining({ + op: 'remove' + })]); + }); + }); + }); + }); + describe('when the primaryBitstream did not changed', () => { + describe('the current bitstream stayed the primary bitstream', () => { + beforeEach(() => { + const rawValue = Object.assign(comp.formGroup.getRawValue(), { fileNamePrimaryContainer: { primaryBitstream: true } }); + spyOn(comp.formGroup, 'getRawValue').and.returnValue(rawValue); + (comp as any).bundle = bundleWithCurrentPrimary; + comp.onSubmit(); + }); + it('should not call patch on the bundle data service', () => { + expect(bundleDataService.patch).not.toHaveBeenCalled(); + }); + }); + describe('the bitstream was not and did not become the primary bitstream', () => { + beforeEach(() => { + const rawValue = Object.assign(comp.formGroup.getRawValue(), { fileNamePrimaryContainer: { primaryBitstream: false } }); + spyOn(comp.formGroup, 'getRawValue').and.returnValue(rawValue); + (comp as any).bundle = bundleWithDifferentPrimary; + comp.onSubmit(); + }); + it('should not call patch on the bundle data service', () => { + expect(bundleDataService.patch).not.toHaveBeenCalled(); + }); + }); + }); + describe('when selected format hasn\'t changed', () => { beforeEach(() => { comp.onSubmit(); @@ -357,6 +484,7 @@ describe('EditBitstreamPageComponent', () => { {provide: BitstreamDataService, useValue: bitstreamService}, {provide: DSONameService, useValue: dsoNameService}, {provide: BitstreamFormatDataService, useValue: bitstreamFormatService}, + { provide: BundleDataService, useValue: bundleDataService }, ChangeDetectorRef ], schemas: [NO_ERRORS_SCHEMA] @@ -371,7 +499,6 @@ describe('EditBitstreamPageComponent', () => { spyOn(router, 'navigate'); }); - describe('on startup', () => { let rawForm; @@ -475,6 +602,7 @@ describe('EditBitstreamPageComponent', () => { {provide: BitstreamDataService, useValue: bitstreamService}, {provide: DSONameService, useValue: dsoNameService}, {provide: BitstreamFormatDataService, useValue: bitstreamFormatService}, + { provide: BundleDataService, useValue: bundleDataService }, ChangeDetectorRef ], schemas: [NO_ERRORS_SCHEMA] 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 8e63ec939f..3c3965a70a 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 @@ -52,6 +52,7 @@ import { DsDynamicInputModel } from '../../shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-input.model'; import { DsDynamicTextAreaModel } from '../../shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-textarea.model'; +import { BundleDataService } from '../../core/data/bundle-data.service'; @Component({ selector: 'ds-edit-bitstream-page', @@ -191,19 +192,19 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { * The Dynamic Input Model for the iiif label */ iiifLabelModel = new DsDynamicInputModel({ - hasSelectableMetadata: false, metadataFields: [], repeatable: false, submissionId: '', - id: 'iiifLabel', - name: 'iiifLabel' - }, + hasSelectableMetadata: false, metadataFields: [], repeatable: false, submissionId: '', + id: 'iiifLabel', + name: 'iiifLabel' + }, { - grid: { - host: 'col col-lg-6 d-inline-block' - } + grid: { + host: 'col col-lg-6 d-inline-block' + } }); iiifLabelContainer = new DynamicFormGroupModel({ id: 'iiifLabelContainer', group: [this.iiifLabelModel] - },{ + }, { grid: { host: 'form-row' } @@ -213,7 +214,7 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { hasSelectableMetadata: false, metadataFields: [], repeatable: false, submissionId: '', id: 'iiifToc', name: 'iiifToc', - },{ + }, { grid: { host: 'col col-lg-6 d-inline-block' } @@ -221,7 +222,7 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { iiifTocContainer = new DynamicFormGroupModel({ id: 'iiifTocContainer', group: [this.iiifTocModel] - },{ + }, { grid: { host: 'form-row' } @@ -231,7 +232,7 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { hasSelectableMetadata: false, metadataFields: [], repeatable: false, submissionId: '', id: 'iiifWidth', name: 'iiifWidth', - },{ + }, { grid: { host: 'col col-lg-6 d-inline-block' } @@ -239,7 +240,7 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { iiifWidthContainer = new DynamicFormGroupModel({ id: 'iiifWidthContainer', group: [this.iiifWidthModel] - },{ + }, { grid: { host: 'form-row' } @@ -249,7 +250,7 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { hasSelectableMetadata: false, metadataFields: [], repeatable: false, submissionId: '', id: 'iiifHeight', name: 'iiifHeight' - },{ + }, { grid: { host: 'col col-lg-6 d-inline-block' } @@ -257,7 +258,7 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { iiifHeightContainer = new DynamicFormGroupModel({ id: 'iiifHeightContainer', group: [this.iiifHeightModel] - },{ + }, { grid: { host: 'form-row' } @@ -280,11 +281,11 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { this.fileNameModel, this.primaryBitstreamModel ] - },{ - grid: { - host: 'form-row' - } - }), + }, { + grid: { + host: 'form-row' + } + }), new DynamicFormGroupModel({ id: 'descriptionContainer', group: [ @@ -380,13 +381,23 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { */ isIIIF = false; - /** * Array to track all subscriptions and unsubscribe them onDestroy * @type {Array} */ protected subs: Subscription[] = []; + /** + * The parent bundle containing the Bitstream + * @private + */ + private bundle: Bundle; + + /** + * Path to patch primary bitstream on the bundle + * @private + */ + private readonly primaryBitstreamPath = '/primarybitstream'; constructor(private route: ActivatedRoute, private router: Router, @@ -397,7 +408,8 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { private bitstreamService: BitstreamDataService, private dsoNameService: DSONameService, private notificationsService: NotificationsService, - private bitstreamFormatService: BitstreamFormatDataService) { + private bitstreamFormatService: BitstreamFormatDataService, + private bundleService: BundleDataService) { } /** @@ -423,13 +435,20 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { getRemoteDataPayload() ); + const bundle$ = bitstream$.pipe( + switchMap((bitstream: Bitstream) => bitstream.bundle), + getFirstSucceededRemoteDataPayload(), + ); + this.subs.push( observableCombineLatest( bitstream$, - allFormats$ - ).subscribe(([bitstream, allFormats]) => { + allFormats$, + bundle$ + ).subscribe(([bitstream, allFormats, bundle]) => { this.bitstream = bitstream as Bitstream; this.formats = allFormats.page; + this.bundle = bundle; this.setIiifStatus(this.bitstream); }) ); @@ -437,8 +456,8 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { this.subs.push( this.translate.onLangChange .subscribe(() => { - this.updateFieldTranslations(); - }) + this.updateFieldTranslations(); + }) ); } @@ -460,7 +479,7 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { this.formGroup.patchValue({ fileNamePrimaryContainer: { fileName: bitstream.name, - primaryBitstream: false + primaryBitstream: this.bundle.primaryBitstreamUUID === bitstream.uuid }, descriptionContainer: { description: bitstream.firstMetadataValue('dc.description') @@ -563,6 +582,7 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { } } + /** * Check for changes against the bitstream and send update requests to the REST API */ @@ -571,9 +591,45 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { const updatedBitstream = this.formToBitstream(updatedValues); const selectedFormat = this.formats.find((f: BitstreamFormat) => f.id === updatedValues.formatContainer.selectedFormat); const isNewFormat = selectedFormat.id !== this.originalFormat.id; + const isPrimary = updatedValues.fileNamePrimaryContainer.primaryBitstream; + const wasPrimary = this.bundle.primaryBitstreamUUID === this.bitstream.uuid; let bitstream$; + let bundle$: Observable; + if (wasPrimary !== isPrimary) { + let patchOperation; + // No longer primary bitstream: remove + if (wasPrimary) { + patchOperation = { + path: this.primaryBitstreamPath, + op: 'remove' + }; + } else { + // Has become primary bitstream + // If it already had a value: replace, otherwise: add + patchOperation = { + path: this.primaryBitstreamPath, + op: hasValue(this.bundle.primaryBitstreamUUID) ? 'replace' : 'add', + value: this.bitstream.uuid + }; + } + bundle$ = this.bundleService.patch(this.bundle, [patchOperation]).pipe( + getFirstCompletedRemoteData(), + map((bundleResponse: RemoteData) => { + if (hasValue(bundleResponse) && bundleResponse.hasFailed) { + this.notificationsService.error( + this.translate.instant(this.NOTIFICATIONS_PREFIX + 'error.primaryBitstream.title'), + bundleResponse.errorMessage + ); + } else { + return bundleResponse.payload; + } + }) + ); + } else { + bundle$ = observableOf(this.bundle); + } if (isNewFormat) { bitstream$ = this.bitstreamService.updateFormat(this.bitstream, selectedFormat).pipe( getFirstCompletedRemoteData(), @@ -592,7 +648,7 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { bitstream$ = observableOf(this.bitstream); } - bitstream$.pipe( + combineLatest([bundle$, bitstream$]).pipe( switchMap(() => { return this.bitstreamService.update(updatedBitstream).pipe( getFirstSucceededRemoteDataPayload() @@ -633,11 +689,11 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { } else { Metadata.setFirstValue(newMetadata, this.IIIF_LABEL_METADATA, rawForm.iiifLabelContainer.iiifLabel); } - if (isEmpty(rawForm.iiifTocContainer.iiifToc)) { - delete newMetadata[this.IIIF_TOC_METADATA]; - } else { + if (isEmpty(rawForm.iiifTocContainer.iiifToc)) { + delete newMetadata[this.IIIF_TOC_METADATA]; + } else { Metadata.setFirstValue(newMetadata, this.IIIF_TOC_METADATA, rawForm.iiifTocContainer.iiifToc); - } + } if (isEmpty(rawForm.iiifWidthContainer.iiifWidth)) { delete newMetadata[this.IMAGE_WIDTH_METADATA]; } else { @@ -672,10 +728,10 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { this.router.navigate([getEntityEditRoute(this.entityType, this.itemId), 'bitstreams']); } else { this.bitstream.bundle.pipe(getFirstSucceededRemoteDataPayload(), - mergeMap((bundle: Bundle) => bundle.item.pipe(getFirstSucceededRemoteDataPayload()))) - .subscribe((item) => { - this.router.navigate(([getItemEditRoute(item), 'bitstreams'])); - }); + mergeMap((bundle: Bundle) => bundle.item.pipe(getFirstSucceededRemoteDataPayload()))) + .subscribe((item) => { + this.router.navigate(([getItemEditRoute(item), 'bitstreams'])); + }); } } @@ -701,11 +757,11 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { 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) - )))); + getFirstSucceededRemoteData(), + map((item: RemoteData) => + (item.payload.firstMetadataValue('dspace.iiif.enabled') && + item.payload.firstMetadataValue('dspace.iiif.enabled').match(regexIIIFItem) !== null) + )))); const iiifSub = combineLatest( isImage$, diff --git a/src/app/core/shared/bundle.model.ts b/src/app/core/shared/bundle.model.ts index 36b7012e47..72c466d2d6 100644 --- a/src/app/core/shared/bundle.model.ts +++ b/src/app/core/shared/bundle.model.ts @@ -1,4 +1,4 @@ -import { deserialize, inheritSerialization } from 'cerialize'; +import { autoserializeAs, deserialize, inheritSerialization } from 'cerialize'; import { Observable } from 'rxjs'; @@ -29,6 +29,12 @@ export class Bundle extends DSpaceObject { item: HALLink; }; + /** + * The ID of the primaryBitstream of this Bundle + */ + @autoserializeAs('primarybitstream') + primaryBitstreamUUID: string; + /** * The primary Bitstream of this Bundle * Will be undefined unless the primaryBitstream {@link HALLink} has been resolved.