diff --git a/src/app/core/shared/bitstream.model.ts b/src/app/core/shared/bitstream.model.ts index fe5aed6dab0..eb828e5d48c 100644 --- a/src/app/core/shared/bitstream.model.ts +++ b/src/app/core/shared/bitstream.model.ts @@ -12,6 +12,8 @@ import {Bundle} from './bundle.model'; import { ChildHALResource } from './child-hal-resource.model'; import { BITSTREAM_CHECKSUM } from './bitstream-checksum.resource'; import { BitstreamChecksum } from './bitstream-checksum.model'; +import { AccessStatusObject } from '../../shared/object-collection/shared/badges/access-status-badge/access-status.model'; +import { ACCESS_STATUS } from '../../shared/object-collection/shared/badges/access-status-badge/access-status.resource-type'; // Store number if the bitstream is stored in the both stores (S3 and local) export const SYNCHRONIZED_STORES_NUMBER = 77; @@ -56,6 +58,7 @@ export class Bitstream extends DSpaceObject implements ChildHALResource { content: HALLink; thumbnail: HALLink; checksum: HALLink; + accessStatus: HALLink; }; /** @@ -85,6 +88,13 @@ export class Bitstream extends DSpaceObject implements ChildHALResource { @link(BITSTREAM_CHECKSUM) checksum?: Observable>; + /** + * The access status for this Bitstream + * Will be undefined unless the access status {@link HALLink} has been resolved. + */ + @link(ACCESS_STATUS) + accessStatus?: Observable>; + getParentLinkKey(): keyof this['_links'] { return 'format'; } diff --git a/src/app/shared/file-download-link/file-download-link.component.html b/src/app/shared/file-download-link/file-download-link.component.html index 8ebe622a5ba..51a18d7e6e1 100644 --- a/src/app/shared/file-download-link/file-download-link.component.html +++ b/src/app/shared/file-download-link/file-download-link.component.html @@ -2,6 +2,7 @@ + diff --git a/src/app/shared/file-download-link/file-download-link.component.spec.ts b/src/app/shared/file-download-link/file-download-link.component.spec.ts index 39004b44c8b..b9c2815535e 100644 --- a/src/app/shared/file-download-link/file-download-link.component.spec.ts +++ b/src/app/shared/file-download-link/file-download-link.component.spec.ts @@ -1,4 +1,5 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { FileDownloadLinkComponent } from './file-download-link.component'; import { Bitstream } from '../../core/shared/bitstream.model'; import { By } from '@angular/platform-browser'; @@ -42,7 +43,8 @@ describe('FileDownloadLinkComponent', () => { declarations: [FileDownloadLinkComponent, RouterLinkDirectiveStub], providers: [ {provide: AuthorizationDataService, useValue: authorizationService}, - ] + ], + schemas: [NO_ERRORS_SCHEMA], }) .compileComponents(); } diff --git a/src/app/shared/object-collection/shared/badges/access-status-badge/access-status-badge.component.html b/src/app/shared/object-collection/shared/badges/access-status-badge/access-status-badge.component.html index 5b20860684b..cdb9f382bb9 100644 --- a/src/app/shared/object-collection/shared/badges/access-status-badge/access-status-badge.component.html +++ b/src/app/shared/object-collection/shared/badges/access-status-badge/access-status-badge.component.html @@ -1,5 +1,9 @@ - - {{ accessStatus | translate }} - + + {{ accessStatus | translate: { date: embargoDate } }} + + {{ accessStatus | translate }} + + diff --git a/src/app/shared/object-collection/shared/badges/access-status-badge/access-status-badge.component.spec.ts b/src/app/shared/object-collection/shared/badges/access-status-badge/access-status-badge.component.spec.ts index f661ed6e005..6c1d60b76ee 100644 --- a/src/app/shared/object-collection/shared/badges/access-status-badge/access-status-badge.component.spec.ts +++ b/src/app/shared/object-collection/shared/badges/access-status-badge/access-status-badge.component.spec.ts @@ -1,4 +1,5 @@ import { Item } from '../../../../../core/shared/item.model'; +import { Bitstream } from '../../../../../core/shared/bitstream.model'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { TranslateModule } from '@ngx-translate/core'; import { TruncatePipe } from '../../../../utils/truncate.pipe'; @@ -7,10 +8,11 @@ import { AccessStatusBadgeComponent } from './access-status-badge.component'; import { createSuccessfulRemoteDataObject$ } from '../../../../remote-data.utils'; import { By } from '@angular/platform-browser'; import { AccessStatusObject } from './access-status.model'; -import { AccessStatusDataService } from 'src/app/core/data/access-status-data.service'; +import { LinkService } from 'src/app/core/cache/builders/link.service'; import { environment } from 'src/environments/environment'; +import { EMPTY } from 'rxjs'; -describe('ItemAccessStatusBadgeComponent', () => { +describe('AccessStatusBadgeComponent', () => { let component: AccessStatusBadgeComponent; let fixture: ComponentFixture; @@ -20,9 +22,10 @@ describe('ItemAccessStatusBadgeComponent', () => { let embargoStatus: AccessStatusObject; let restrictedStatus: AccessStatusObject; - let accessStatusDataService: AccessStatusDataService; + let linkService: LinkService; let item: Item; + let bitstream: Bitstream; function init() { unknownStatus = Object.assign(new AccessStatusObject(), { @@ -38,20 +41,38 @@ describe('ItemAccessStatusBadgeComponent', () => { }); embargoStatus = Object.assign(new AccessStatusObject(), { - status: 'embargo' + status: 'embargo', + embargoDate: '2050-01-01' }); restrictedStatus = Object.assign(new AccessStatusObject(), { status: 'restricted' }); - accessStatusDataService = jasmine.createSpyObj('accessStatusDataService', { - findAccessStatusFor: createSuccessfulRemoteDataObject$(unknownStatus) + linkService = jasmine.createSpyObj('linkService', ['resolveLink']); + // Mirror LinkService.resolveLink's real behavior for a missing + optional link: + // it synchronously attaches EMPTY to the model rather than leaving it undefined + // (and never touches _links.accessStatus directly - see link.service.ts). + (linkService.resolveLink as jasmine.Spy).and.callFake((model: any, linkToFollow: any) => { + model[linkToFollow.name] = EMPTY; + return model; }); item = Object.assign(new Item(), { uuid: 'item-uuid', - type: 'item' + type: 'item', + accessStatus: createSuccessfulRemoteDataObject$(unknownStatus) + }); + + // A bitstream as it looks BEFORE the backend exposes the accessStatus link + // (i.e. today, on this branch's target backend): no accessStatus key in + // _links, and the accessStatus property itself never populated. + bitstream = Object.assign(new Bitstream(), { + uuid: 'bitstream-uuid', + type: 'bitstream', + _links: { + self: { href: 'obj-selflink' } + } }); } @@ -61,18 +82,20 @@ describe('ItemAccessStatusBadgeComponent', () => { declarations: [AccessStatusBadgeComponent, TruncatePipe], schemas: [NO_ERRORS_SCHEMA], providers: [ - {provide: AccessStatusDataService, useValue: accessStatusDataService} + { provide: LinkService, useValue: linkService } ] }).compileComponents(); } - function initFixtureAndComponent() { + function initFixtureAndComponent(object: Item | Bitstream) { environment.item.showAccessStatuses = true; + environment.item.bitstream.showAccessStatuses = true; fixture = TestBed.createComponent(AccessStatusBadgeComponent); component = fixture.componentInstance; - component.object = item; + component.object = object; fixture.detectChanges(); environment.item.showAccessStatuses = false; + environment.item.bitstream.showAccessStatuses = false; } function lookForAccessStatusBadge(status: string) { @@ -86,79 +109,146 @@ describe('ItemAccessStatusBadgeComponent', () => { initTestBed(); })); beforeEach(() => { - initFixtureAndComponent(); + initFixtureAndComponent(item); }); it('should init the component', () => { expect(component).toBeTruthy(); }); }); - describe('When the findAccessStatusFor method returns unknown', () => { - beforeEach(waitForAsync(() => { - init(); - initTestBed(); - })); - beforeEach(() => { - initFixtureAndComponent(); - }); - it('should show the unknown badge', () => { - lookForAccessStatusBadge('unknown'); + describe('for an Item', () => { + describe('when the access status is unknown', () => { + beforeEach(waitForAsync(() => { + init(); + initTestBed(); + })); + beforeEach(() => { + initFixtureAndComponent(item); + }); + it('should show the unknown badge', () => { + lookForAccessStatusBadge('unknown'); + }); + }); + + describe('when the access status is metadata.only', () => { + beforeEach(waitForAsync(() => { + init(); + item.accessStatus = createSuccessfulRemoteDataObject$(metadataOnlyStatus); + initTestBed(); + })); + beforeEach(() => { + initFixtureAndComponent(item); + }); + it('should show the metadata only badge', () => { + lookForAccessStatusBadge('metadata.only'); + }); + }); + + describe('when the access status is open.access', () => { + beforeEach(waitForAsync(() => { + init(); + item.accessStatus = createSuccessfulRemoteDataObject$(openAccessStatus); + initTestBed(); + })); + beforeEach(() => { + initFixtureAndComponent(item); + }); + it('should show the open access badge', () => { + lookForAccessStatusBadge('open.access'); + }); + }); + + describe('when the access status is embargo', () => { + beforeEach(waitForAsync(() => { + init(); + item.accessStatus = createSuccessfulRemoteDataObject$(embargoStatus); + initTestBed(); + })); + beforeEach(() => { + initFixtureAndComponent(item); + }); + it('should show the embargo badge', () => { + lookForAccessStatusBadge('embargo'); + }); + }); + + describe('when the access status is restricted', () => { + beforeEach(waitForAsync(() => { + init(); + item.accessStatus = createSuccessfulRemoteDataObject$(restrictedStatus); + initTestBed(); + })); + beforeEach(() => { + initFixtureAndComponent(item); + }); + it('should show the restricted badge', () => { + lookForAccessStatusBadge('restricted'); + }); }); }); - describe('When the findAccessStatusFor method returns metadata.only', () => { - beforeEach(waitForAsync(() => { - init(); - (accessStatusDataService.findAccessStatusFor as jasmine.Spy).and.returnValue(createSuccessfulRemoteDataObject$(metadataOnlyStatus)); - initTestBed(); - })); - beforeEach(() => { - initFixtureAndComponent(); - }); - it('should show the metadata only badge', () => { - lookForAccessStatusBadge('metadata.only'); - }); - }); - - describe('When the findAccessStatusFor method returns open.access', () => { - beforeEach(waitForAsync(() => { - init(); - (accessStatusDataService.findAccessStatusFor as jasmine.Spy).and.returnValue(createSuccessfulRemoteDataObject$(openAccessStatus)); - initTestBed(); - })); - beforeEach(() => { - initFixtureAndComponent(); - }); - it('should show the open access badge', () => { - lookForAccessStatusBadge('open.access'); - }); - }); - - describe('When the findAccessStatusFor method returns embargo', () => { - beforeEach(waitForAsync(() => { - init(); - (accessStatusDataService.findAccessStatusFor as jasmine.Spy).and.returnValue(createSuccessfulRemoteDataObject$(embargoStatus)); - initTestBed(); - })); - beforeEach(() => { - initFixtureAndComponent(); - }); - it('should show the embargo badge', () => { - lookForAccessStatusBadge('embargo'); - }); - }); - - describe('When the findAccessStatusFor method returns restricted', () => { - beforeEach(waitForAsync(() => { - init(); - (accessStatusDataService.findAccessStatusFor as jasmine.Spy).and.returnValue(createSuccessfulRemoteDataObject$(restrictedStatus)); - initTestBed(); - })); - beforeEach(() => { - initFixtureAndComponent(); - }); - it('should show the restricted badge', () => { - lookForAccessStatusBadge('restricted'); + describe('for a Bitstream', () => { + describe('when the bitstream is embargoed', () => { + beforeEach(waitForAsync(() => { + init(); + bitstream.accessStatus = createSuccessfulRemoteDataObject$(embargoStatus); + initTestBed(); + })); + beforeEach(() => { + initFixtureAndComponent(bitstream); + }); + it('should show the embargo badge with the embargo date', () => { + const badge = fixture.debugElement.query(By.css('span.badge')); + expect(badge.nativeElement.textContent).toContain('embargo.listelement.badge'); + }); + }); + + describe('when the bitstream is open access (no embargo date)', () => { + beforeEach(waitForAsync(() => { + init(); + bitstream.accessStatus = createSuccessfulRemoteDataObject$(openAccessStatus); + initTestBed(); + })); + beforeEach(() => { + initFixtureAndComponent(bitstream); + }); + it('should not show a badge', () => { + const badge = fixture.debugElement.query(By.css('span.badge')); + expect(badge).toBeNull(); + }); + }); + + describe('when the backend does not expose the accessStatus link yet (pre-DSpace#1377)', () => { + // Regression test: findBitstreamAccessStatusFor() used to read + // bitstream._links.accessStatus.href synchronously, which threw a + // TypeError (uncaught by any catchError, since it happened before the + // Observable pipe was even constructed) and broke the whole file-list + // render. The component must now fail closed instead: no crash, no + // badge, using a bitstream that has NO accessStatus link and NO + // pre-resolved accessStatus property, exactly like a real bitstream + // from this branch's current (pre-backend-PR) REST API. + beforeEach(waitForAsync(() => { + init(); + initTestBed(); + })); + + it('should not throw when initializing with a bitstream lacking the accessStatus link', () => { + expect(() => initFixtureAndComponent(bitstream)).not.toThrow(); + }); + + it('should render the file list without a badge', () => { + initFixtureAndComponent(bitstream); + const badge = fixture.debugElement.query(By.css('span.badge')); + expect(badge).toBeNull(); + }); + + it('should ask the LinkService to resolve the link as optional', () => { + initFixtureAndComponent(bitstream); + expect(linkService.resolveLink).toHaveBeenCalledWith( + bitstream, + jasmine.objectContaining({ name: 'accessStatus', isOptional: true }), + ); + }); }); }); }); diff --git a/src/app/shared/object-collection/shared/badges/access-status-badge/access-status-badge.component.ts b/src/app/shared/object-collection/shared/badges/access-status-badge/access-status-badge.component.ts index 5f27ba4f65d..aa909a2c88f 100644 --- a/src/app/shared/object-collection/shared/badges/access-status-badge/access-status-badge.component.ts +++ b/src/app/shared/object-collection/shared/badges/access-status-badge/access-status-badge.component.ts @@ -1,13 +1,15 @@ -import { Component, Input } from '@angular/core'; +import { Component, Input, OnDestroy, OnInit } from '@angular/core'; import { catchError, map } from 'rxjs/operators'; import { Observable, of as observableOf, Subscription } from 'rxjs'; import { AccessStatusObject } from './access-status.model'; import { hasValue } from '../../../../empty.util'; import { environment } from 'src/environments/environment'; -import { AccessStatusDataService } from 'src/app/core/data/access-status-data.service'; +import { LinkService } from 'src/app/core/cache/builders/link.service'; +import { getFirstSucceededRemoteDataPayload } from 'src/app/core/shared/operators'; +import { followLink } from 'src/app/shared/utils/follow-link-config.model'; import { DSpaceObject } from '../../../../../core/shared/dspace-object.model'; import { Item } from '../../../../../core/shared/item.model'; -import { ITEM } from '../../../../../core/shared/item.resource-type'; +import { Bitstream } from '../../../../../core/shared/bitstream.model'; @Component({ selector: 'ds-access-status-badge', @@ -15,12 +17,13 @@ import { ITEM } from '../../../../../core/shared/item.resource-type'; styleUrls: ['./access-status-badge.component.scss'] }) /** - * Component rendering the access status of an item as a badge + * Component rendering the access status of an item or bitstream as a badge */ -export class AccessStatusBadgeComponent { +export class AccessStatusBadgeComponent implements OnInit, OnDestroy { @Input() object: DSpaceObject; accessStatus$: Observable; + embargoDate$: Observable; /** * Whether to show the access status badge or not @@ -40,46 +43,81 @@ export class AccessStatusBadgeComponent { /** * Initialize instance variables * - * @param {AccessStatusDataService} accessStatusDataService + * @param {LinkService} linkService */ - constructor(private accessStatusDataService: AccessStatusDataService) { } + constructor(private linkService: LinkService) { } ngOnInit(): void { - this.showAccessStatus = environment.item.showAccessStatuses; - if (this.object.type.toString() !== ITEM.value || !this.showAccessStatus || this.object == null) { - // Do not show the badge if the feature is inactive or if the item is null. + if (!hasValue(this.object)) { return; } + if (!hasValue((this.object as any).accessStatus)) { + // In case the access status link has not been resolved yet, resolve it lazily. + // isOptional: true is required here (unlike upstream) because the bitstream-level + // accessStatus link doesn't exist on this backend yet (DSpace#1377) - without it, + // resolveLink() throws for any bitstream instead of failing closed. + this.linkService.resolveLink(this.object, followLink('accessStatus', { isOptional: true })); + } + switch (this.object.type.toString()) { + case Item.type.value: + this.handleItem(); + break; + case Bitstream.type.value: + this.handleBitstream(); + break; + } + } - const item = this.object as Item; - if (item.accessStatus == null) { - // In case the access status has not been loaded, do it individually. - item.accessStatus = this.accessStatusDataService.findAccessStatusFor(item); + ngOnDestroy(): void { + this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); + } + + /** + * Method to handle the object type Item + */ + private handleItem(): void { + this.showAccessStatus = environment.item.showAccessStatuses; + if (!this.showAccessStatus) { + // Do not show the badge if the feature is inactive. + return; } - this.accessStatus$ = item.accessStatus.pipe( - map((accessStatusRD) => { - if (accessStatusRD.statusCode !== 401 && hasValue(accessStatusRD.payload)) { - return accessStatusRD.payload; - } else { - return []; - } - }), + this.accessStatus$ = (this.object as Item).accessStatus.pipe( + getFirstSucceededRemoteDataPayload(), map((accessStatus: AccessStatusObject) => hasValue(accessStatus.status) ? accessStatus.status : 'unknown'), map((status: string) => `access-status.${status.toLowerCase()}.listelement.badge`), - catchError(() => observableOf('access-status.unknown.listelement.badge')) + catchError(() => observableOf('access-status.unknown.listelement.badge')), ); // stylesheet based on the access status value this.subs.push( this.accessStatus$.pipe( - map((accessStatusClass: string) => accessStatusClass.replace(/\./g, '-')) + map((accessStatusClass: string) => accessStatusClass.replace(/\./g, '-')), ).subscribe((accessStatusClass: string) => { this.accessStatusClass = accessStatusClass; - }) + }), ); } - ngOnDestroy(): void { - this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); + /** + * Method to handle the object type Bitstream + */ + private handleBitstream(): void { + this.showAccessStatus = environment.item.bitstream.showAccessStatuses; + if (!this.showAccessStatus) { + // Do not show the badge if the feature is inactive. + return; + } + this.embargoDate$ = (this.object as Bitstream).accessStatus.pipe( + getFirstSucceededRemoteDataPayload(), + map((accessStatus: AccessStatusObject) => hasValue(accessStatus.embargoDate) ? accessStatus.embargoDate : null), + catchError(() => observableOf(null)), + ); + this.subs.push( + this.embargoDate$.subscribe((embargoDate: string) => { + if (hasValue(embargoDate)) { + this.accessStatus$ = observableOf('embargo.listelement.badge'); + } + }), + ); } } diff --git a/src/app/shared/object-collection/shared/badges/access-status-badge/access-status.model.ts b/src/app/shared/object-collection/shared/badges/access-status-badge/access-status.model.ts index 69b5e920d00..74158343a93 100644 --- a/src/app/shared/object-collection/shared/badges/access-status-badge/access-status.model.ts +++ b/src/app/shared/object-collection/shared/badges/access-status-badge/access-status.model.ts @@ -23,6 +23,12 @@ export class AccessStatusObject implements CacheableObject { @autoserialize status: string; + /** + * The embargo date value + */ + @autoserialize + embargoDate: string; + /** * The {@link HALLink}s for this AccessStatusObject */ diff --git a/src/app/shared/object-list/access-status-badge/access-status.model.ts b/src/app/shared/object-list/access-status-badge/access-status.model.ts index 69b5e920d00..74158343a93 100644 --- a/src/app/shared/object-list/access-status-badge/access-status.model.ts +++ b/src/app/shared/object-list/access-status-badge/access-status.model.ts @@ -23,6 +23,12 @@ export class AccessStatusObject implements CacheableObject { @autoserialize status: string; + /** + * The embargo date value + */ + @autoserialize + embargoDate: string; + /** * The {@link HALLink}s for this AccessStatusObject */ diff --git a/src/app/thumbnail/thumbnail.component.spec.ts b/src/app/thumbnail/thumbnail.component.spec.ts index 2fde639ff23..0ee01dfc331 100644 --- a/src/app/thumbnail/thumbnail.component.spec.ts +++ b/src/app/thumbnail/thumbnail.component.spec.ts @@ -291,7 +291,8 @@ describe('ThumbnailComponent', () => { format: { href: 'format.url' }, content: { href: CONTENT }, checksum: { href: 'checksum.url' }, - thumbnail: undefined + thumbnail: undefined, + accessStatus: undefined }; }); diff --git a/src/assets/i18n/cs.json5 b/src/assets/i18n/cs.json5 index 52780715b6e..705b4bd7b1b 100644 --- a/src/assets/i18n/cs.json5 +++ b/src/assets/i18n/cs.json5 @@ -8400,4 +8400,6 @@ // "item.page.cc.license.disclaimer": "Except where otherwised noted, this item's license is described as", // TODO New key - Add a translation "item.page.cc.license.disclaimer": "Except where otherwised noted, this item's license is described as", + // "embargo.listelement.badge": "Embargo until {{ date }}", + "embargo.listelement.badge": "Embargo do {{ date }}", } diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 1283f0656c9..e19e906d8ff 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -5929,4 +5929,6 @@ "navbar.about.service-integrations": "Service integrations", "navbar.about.project-partnership": "Project partnerships", + + "embargo.listelement.badge": "Embargo until {{ date }}", } diff --git a/src/config/default-app-config.ts b/src/config/default-app-config.ts index 6f05dedb112..fad2b56ab33 100644 --- a/src/config/default-app-config.ts +++ b/src/config/default-app-config.ts @@ -285,7 +285,12 @@ export class DefaultAppConfig implements AppConfig { // Number of entries in the bitstream list in the item view page. // Rounded to the nearest size in the list of selectable sizes on the // settings menu. See pageSizeOptions in 'pagination-component-options.model.ts'. - pageSize: 5 + pageSize: 5, + // Show the bitstream embargo-date badge. + // Keep this false until the backend accessStatus endpoint for bitstreams + // (DSpace#1377) is deployed - the frontend fails closed either way, but there's + // no reason to eagerly resolve a link that never resolves until then. + showAccessStatuses: false } }; diff --git a/src/config/item-config.interface.ts b/src/config/item-config.interface.ts index 35cb5260aea..f3141f77238 100644 --- a/src/config/item-config.interface.ts +++ b/src/config/item-config.interface.ts @@ -12,5 +12,7 @@ export interface ItemConfig extends Config { // Rounded to the nearest size in the list of selectable sizes on the // settings menu. See pageSizeOptions in 'pagination-component-options.model.ts'. pageSize: number; + // Show the bitstream embargo-date badge + showAccessStatuses: boolean; } } diff --git a/src/environments/environment.test.ts b/src/environments/environment.test.ts index 8a6a5d1fccb..46164ae5da4 100644 --- a/src/environments/environment.test.ts +++ b/src/environments/environment.test.ts @@ -254,7 +254,9 @@ export const environment: BuildConfig = { // Number of entries in the bitstream list in the item view page. // Rounded to the nearest size in the list of selectable sizes on the // settings menu. See pageSizeOptions in 'pagination-component-options.model.ts'. - pageSize: 5 + pageSize: 5, + // Show the bitstream embargo-date badge + showAccessStatuses: false } }, collection: {