Skip to content

Commit 7ad0510

Browse files
authored
Show a per-bitstream embargo-date badge in the Item View (#1378)
Fixes dataquest-dev/dspace-customers#823 (bitstream portion). Ports the real vanilla DSpace feature that already exists on this fork's DSpace 9.x lines (e.g. customer/mendelu): a unified AccessStatusBadgeComponent that renders an access-status badge for either an Item or a Bitstream, instead of a separate embargo-only component (an earlier draft of this PR introduced a standalone EmbargoBadgeComponent copied from a since-abandoned upstream design (PR dataquest-dev/dspace-angular#3882 / commit 5f83139); that component has no live upstream reference on 9.3/main/mendelu and has been removed in favor of this unified approach). Change set: - Bitstream model gains an `accessStatus` HAL link (mirrors the existing thumbnail/format link shape). - AccessStatusObject (both the canonical copy and a pre-existing duplicate under object-list/) gains `embargoDate`. - AccessStatusBadgeComponent's `object` input widens from `Item` to `Item | Bitstream`; it now resolves the accessStatus link lazily via LinkService.resolveLink(..., followLink('accessStatus', { isOptional: true })) instead of eagerly reading `_links.accessStatus.href` directly. isOptional: true is required (unlike upstream, which never needs it because its backend always has the link already) because this backend doesn't expose the bitstream-level link yet - without it, resolveLink() throws for every bitstream instead of failing closed. - file-download-link.component.html renders <ds-themed-access-status-badge [object]="bitstream"> next to each file, gated by the existing item.bitstream.showAccessStatuses flag. - That flag now defaults to false (matching upstream/mendelu) instead of true - flip it once the backend companion PR (DSpace#1377) is deployed, not before. - i18n copy aligned with vanilla/mendelu: "Embargo until {{ date }}" (en) / "Embargo do {{ date }}" (cs). - Removed the unused findBitstreamAccessStatusFor() and the deleted EmbargoBadgeComponent/ThemedEmbargoBadgeComponent + their shared .module.ts registration. The accessibility fix (aria-label/role/sr-only on the lock icon) that was previously bundled into this branch has been split out to its own PR (#1390) for independent review - this branch was rebuilt from scratch off customer/zcu-pub to drop those commits and keep this PR scoped to the embargo-badge feature only. Test evidence: npx ng lint --quiet -> All files pass linting. npx ng build --configuration production -> build succeeded, no budget warnings npx ng test (access-status-badge + file-download-link + thumbnail specs) -> 39/39 SUCCESS, including a regression test that constructs a real Bitstream lacking _links.accessStatus and asserts ngOnInit doesn't throw and the file list renders without a badge. Manual: local Docker stack running the CURRENT (pre-#1377) zcu-pub backend image, showAccessStatuses temporarily forced true - an embargoed item's file list rendered normally, zero console errors, and no accessStatus network request was even attempted (LinkService detects the missing _links key up front).
1 parent f721075 commit 7ad0510

14 files changed

Lines changed: 279 additions & 108 deletions

File tree

src/app/core/shared/bitstream.model.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import {Bundle} from './bundle.model';
1212
import { ChildHALResource } from './child-hal-resource.model';
1313
import { BITSTREAM_CHECKSUM } from './bitstream-checksum.resource';
1414
import { BitstreamChecksum } from './bitstream-checksum.model';
15+
import { AccessStatusObject } from '../../shared/object-collection/shared/badges/access-status-badge/access-status.model';
16+
import { ACCESS_STATUS } from '../../shared/object-collection/shared/badges/access-status-badge/access-status.resource-type';
1517

1618
// Store number if the bitstream is stored in the both stores (S3 and local)
1719
export const SYNCHRONIZED_STORES_NUMBER = 77;
@@ -56,6 +58,7 @@ export class Bitstream extends DSpaceObject implements ChildHALResource {
5658
content: HALLink;
5759
thumbnail: HALLink;
5860
checksum: HALLink;
61+
accessStatus: HALLink;
5962
};
6063

6164
/**
@@ -85,6 +88,13 @@ export class Bitstream extends DSpaceObject implements ChildHALResource {
8588
@link(BITSTREAM_CHECKSUM)
8689
checksum?: Observable<RemoteData<BitstreamChecksum>>;
8790

91+
/**
92+
* The access status for this Bitstream
93+
* Will be undefined unless the access status {@link HALLink} has been resolved.
94+
*/
95+
@link(ACCESS_STATUS)
96+
accessStatus?: Observable<RemoteData<AccessStatusObject>>;
97+
8898
getParentLinkKey(): keyof this['_links'] {
8999
return 'format';
90100
}

src/app/shared/file-download-link/file-download-link.component.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
<span *ngIf="!(canDownload$ |async)" class="pr-1"><i class="fas fa-lock"></i></span>
33
<ng-container *ngTemplateOutlet="content"></ng-container>
44
</a>
5+
<ds-themed-access-status-badge [object]="bitstream"></ds-themed-access-status-badge>
56

67
<ng-template #content>
78
<ng-content></ng-content>

src/app/shared/file-download-link/file-download-link.component.spec.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
2+
import { NO_ERRORS_SCHEMA } from '@angular/core';
23
import { FileDownloadLinkComponent } from './file-download-link.component';
34
import { Bitstream } from '../../core/shared/bitstream.model';
45
import { By } from '@angular/platform-browser';
@@ -42,7 +43,8 @@ describe('FileDownloadLinkComponent', () => {
4243
declarations: [FileDownloadLinkComponent, RouterLinkDirectiveStub],
4344
providers: [
4445
{provide: AuthorizationDataService, useValue: authorizationService},
45-
]
46+
],
47+
schemas: [NO_ERRORS_SCHEMA],
4648
})
4749
.compileComponents();
4850
}
Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
<ng-container *ngIf="showAccessStatus">
2-
<span *ngIf="accessStatus$ | async as accessStatus">
3-
<span [class]="'badge badge-secondary access-status-list-element-badge ' + accessStatusClass">{{ accessStatus | translate }}</span>
4-
</span>
2+
<ng-container *ngIf="accessStatus$ | async as accessStatus">
3+
<span *ngIf="embargoDate$ | async as embargoDate; else noEmbargoDate"
4+
[class]="'badge badge-secondary access-status-list-element-badge ' + accessStatusClass">{{ accessStatus | translate: { date: embargoDate } }}</span>
5+
<ng-template #noEmbargoDate>
6+
<span [class]="'badge badge-secondary access-status-list-element-badge ' + accessStatusClass">{{ accessStatus | translate }}</span>
7+
</ng-template>
8+
</ng-container>
59
</ng-container>
Lines changed: 164 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Item } from '../../../../../core/shared/item.model';
2+
import { Bitstream } from '../../../../../core/shared/bitstream.model';
23
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
34
import { TranslateModule } from '@ngx-translate/core';
45
import { TruncatePipe } from '../../../../utils/truncate.pipe';
@@ -7,10 +8,11 @@ import { AccessStatusBadgeComponent } from './access-status-badge.component';
78
import { createSuccessfulRemoteDataObject$ } from '../../../../remote-data.utils';
89
import { By } from '@angular/platform-browser';
910
import { AccessStatusObject } from './access-status.model';
10-
import { AccessStatusDataService } from 'src/app/core/data/access-status-data.service';
11+
import { LinkService } from 'src/app/core/cache/builders/link.service';
1112
import { environment } from 'src/environments/environment';
13+
import { EMPTY } from 'rxjs';
1214

13-
describe('ItemAccessStatusBadgeComponent', () => {
15+
describe('AccessStatusBadgeComponent', () => {
1416
let component: AccessStatusBadgeComponent;
1517
let fixture: ComponentFixture<AccessStatusBadgeComponent>;
1618

@@ -20,9 +22,10 @@ describe('ItemAccessStatusBadgeComponent', () => {
2022
let embargoStatus: AccessStatusObject;
2123
let restrictedStatus: AccessStatusObject;
2224

23-
let accessStatusDataService: AccessStatusDataService;
25+
let linkService: LinkService;
2426

2527
let item: Item;
28+
let bitstream: Bitstream;
2629

2730
function init() {
2831
unknownStatus = Object.assign(new AccessStatusObject(), {
@@ -38,20 +41,38 @@ describe('ItemAccessStatusBadgeComponent', () => {
3841
});
3942

4043
embargoStatus = Object.assign(new AccessStatusObject(), {
41-
status: 'embargo'
44+
status: 'embargo',
45+
embargoDate: '2050-01-01'
4246
});
4347

4448
restrictedStatus = Object.assign(new AccessStatusObject(), {
4549
status: 'restricted'
4650
});
4751

48-
accessStatusDataService = jasmine.createSpyObj('accessStatusDataService', {
49-
findAccessStatusFor: createSuccessfulRemoteDataObject$(unknownStatus)
52+
linkService = jasmine.createSpyObj('linkService', ['resolveLink']);
53+
// Mirror LinkService.resolveLink's real behavior for a missing + optional link:
54+
// it synchronously attaches EMPTY to the model rather than leaving it undefined
55+
// (and never touches _links.accessStatus directly - see link.service.ts).
56+
(linkService.resolveLink as jasmine.Spy).and.callFake((model: any, linkToFollow: any) => {
57+
model[linkToFollow.name] = EMPTY;
58+
return model;
5059
});
5160

5261
item = Object.assign(new Item(), {
5362
uuid: 'item-uuid',
54-
type: 'item'
63+
type: 'item',
64+
accessStatus: createSuccessfulRemoteDataObject$(unknownStatus)
65+
});
66+
67+
// A bitstream as it looks BEFORE the backend exposes the accessStatus link
68+
// (i.e. today, on this branch's target backend): no accessStatus key in
69+
// _links, and the accessStatus property itself never populated.
70+
bitstream = Object.assign(new Bitstream(), {
71+
uuid: 'bitstream-uuid',
72+
type: 'bitstream',
73+
_links: {
74+
self: { href: 'obj-selflink' }
75+
}
5576
});
5677
}
5778

@@ -61,18 +82,20 @@ describe('ItemAccessStatusBadgeComponent', () => {
6182
declarations: [AccessStatusBadgeComponent, TruncatePipe],
6283
schemas: [NO_ERRORS_SCHEMA],
6384
providers: [
64-
{provide: AccessStatusDataService, useValue: accessStatusDataService}
85+
{ provide: LinkService, useValue: linkService }
6586
]
6687
}).compileComponents();
6788
}
6889

69-
function initFixtureAndComponent() {
90+
function initFixtureAndComponent(object: Item | Bitstream) {
7091
environment.item.showAccessStatuses = true;
92+
environment.item.bitstream.showAccessStatuses = true;
7193
fixture = TestBed.createComponent(AccessStatusBadgeComponent);
7294
component = fixture.componentInstance;
73-
component.object = item;
95+
component.object = object;
7496
fixture.detectChanges();
7597
environment.item.showAccessStatuses = false;
98+
environment.item.bitstream.showAccessStatuses = false;
7699
}
77100

78101
function lookForAccessStatusBadge(status: string) {
@@ -86,79 +109,146 @@ describe('ItemAccessStatusBadgeComponent', () => {
86109
initTestBed();
87110
}));
88111
beforeEach(() => {
89-
initFixtureAndComponent();
112+
initFixtureAndComponent(item);
90113
});
91114
it('should init the component', () => {
92115
expect(component).toBeTruthy();
93116
});
94117
});
95118

96-
describe('When the findAccessStatusFor method returns unknown', () => {
97-
beforeEach(waitForAsync(() => {
98-
init();
99-
initTestBed();
100-
}));
101-
beforeEach(() => {
102-
initFixtureAndComponent();
103-
});
104-
it('should show the unknown badge', () => {
105-
lookForAccessStatusBadge('unknown');
119+
describe('for an Item', () => {
120+
describe('when the access status is unknown', () => {
121+
beforeEach(waitForAsync(() => {
122+
init();
123+
initTestBed();
124+
}));
125+
beforeEach(() => {
126+
initFixtureAndComponent(item);
127+
});
128+
it('should show the unknown badge', () => {
129+
lookForAccessStatusBadge('unknown');
130+
});
131+
});
132+
133+
describe('when the access status is metadata.only', () => {
134+
beforeEach(waitForAsync(() => {
135+
init();
136+
item.accessStatus = createSuccessfulRemoteDataObject$(metadataOnlyStatus);
137+
initTestBed();
138+
}));
139+
beforeEach(() => {
140+
initFixtureAndComponent(item);
141+
});
142+
it('should show the metadata only badge', () => {
143+
lookForAccessStatusBadge('metadata.only');
144+
});
145+
});
146+
147+
describe('when the access status is open.access', () => {
148+
beforeEach(waitForAsync(() => {
149+
init();
150+
item.accessStatus = createSuccessfulRemoteDataObject$(openAccessStatus);
151+
initTestBed();
152+
}));
153+
beforeEach(() => {
154+
initFixtureAndComponent(item);
155+
});
156+
it('should show the open access badge', () => {
157+
lookForAccessStatusBadge('open.access');
158+
});
159+
});
160+
161+
describe('when the access status is embargo', () => {
162+
beforeEach(waitForAsync(() => {
163+
init();
164+
item.accessStatus = createSuccessfulRemoteDataObject$(embargoStatus);
165+
initTestBed();
166+
}));
167+
beforeEach(() => {
168+
initFixtureAndComponent(item);
169+
});
170+
it('should show the embargo badge', () => {
171+
lookForAccessStatusBadge('embargo');
172+
});
173+
});
174+
175+
describe('when the access status is restricted', () => {
176+
beforeEach(waitForAsync(() => {
177+
init();
178+
item.accessStatus = createSuccessfulRemoteDataObject$(restrictedStatus);
179+
initTestBed();
180+
}));
181+
beforeEach(() => {
182+
initFixtureAndComponent(item);
183+
});
184+
it('should show the restricted badge', () => {
185+
lookForAccessStatusBadge('restricted');
186+
});
106187
});
107188
});
108189

109-
describe('When the findAccessStatusFor method returns metadata.only', () => {
110-
beforeEach(waitForAsync(() => {
111-
init();
112-
(accessStatusDataService.findAccessStatusFor as jasmine.Spy).and.returnValue(createSuccessfulRemoteDataObject$(metadataOnlyStatus));
113-
initTestBed();
114-
}));
115-
beforeEach(() => {
116-
initFixtureAndComponent();
117-
});
118-
it('should show the metadata only badge', () => {
119-
lookForAccessStatusBadge('metadata.only');
120-
});
121-
});
122-
123-
describe('When the findAccessStatusFor method returns open.access', () => {
124-
beforeEach(waitForAsync(() => {
125-
init();
126-
(accessStatusDataService.findAccessStatusFor as jasmine.Spy).and.returnValue(createSuccessfulRemoteDataObject$(openAccessStatus));
127-
initTestBed();
128-
}));
129-
beforeEach(() => {
130-
initFixtureAndComponent();
131-
});
132-
it('should show the open access badge', () => {
133-
lookForAccessStatusBadge('open.access');
134-
});
135-
});
136-
137-
describe('When the findAccessStatusFor method returns embargo', () => {
138-
beforeEach(waitForAsync(() => {
139-
init();
140-
(accessStatusDataService.findAccessStatusFor as jasmine.Spy).and.returnValue(createSuccessfulRemoteDataObject$(embargoStatus));
141-
initTestBed();
142-
}));
143-
beforeEach(() => {
144-
initFixtureAndComponent();
145-
});
146-
it('should show the embargo badge', () => {
147-
lookForAccessStatusBadge('embargo');
148-
});
149-
});
150-
151-
describe('When the findAccessStatusFor method returns restricted', () => {
152-
beforeEach(waitForAsync(() => {
153-
init();
154-
(accessStatusDataService.findAccessStatusFor as jasmine.Spy).and.returnValue(createSuccessfulRemoteDataObject$(restrictedStatus));
155-
initTestBed();
156-
}));
157-
beforeEach(() => {
158-
initFixtureAndComponent();
159-
});
160-
it('should show the restricted badge', () => {
161-
lookForAccessStatusBadge('restricted');
190+
describe('for a Bitstream', () => {
191+
describe('when the bitstream is embargoed', () => {
192+
beforeEach(waitForAsync(() => {
193+
init();
194+
bitstream.accessStatus = createSuccessfulRemoteDataObject$(embargoStatus);
195+
initTestBed();
196+
}));
197+
beforeEach(() => {
198+
initFixtureAndComponent(bitstream);
199+
});
200+
it('should show the embargo badge with the embargo date', () => {
201+
const badge = fixture.debugElement.query(By.css('span.badge'));
202+
expect(badge.nativeElement.textContent).toContain('embargo.listelement.badge');
203+
});
204+
});
205+
206+
describe('when the bitstream is open access (no embargo date)', () => {
207+
beforeEach(waitForAsync(() => {
208+
init();
209+
bitstream.accessStatus = createSuccessfulRemoteDataObject$(openAccessStatus);
210+
initTestBed();
211+
}));
212+
beforeEach(() => {
213+
initFixtureAndComponent(bitstream);
214+
});
215+
it('should not show a badge', () => {
216+
const badge = fixture.debugElement.query(By.css('span.badge'));
217+
expect(badge).toBeNull();
218+
});
219+
});
220+
221+
describe('when the backend does not expose the accessStatus link yet (pre-DSpace#1377)', () => {
222+
// Regression test: findBitstreamAccessStatusFor() used to read
223+
// bitstream._links.accessStatus.href synchronously, which threw a
224+
// TypeError (uncaught by any catchError, since it happened before the
225+
// Observable pipe was even constructed) and broke the whole file-list
226+
// render. The component must now fail closed instead: no crash, no
227+
// badge, using a bitstream that has NO accessStatus link and NO
228+
// pre-resolved accessStatus property, exactly like a real bitstream
229+
// from this branch's current (pre-backend-PR) REST API.
230+
beforeEach(waitForAsync(() => {
231+
init();
232+
initTestBed();
233+
}));
234+
235+
it('should not throw when initializing with a bitstream lacking the accessStatus link', () => {
236+
expect(() => initFixtureAndComponent(bitstream)).not.toThrow();
237+
});
238+
239+
it('should render the file list without a badge', () => {
240+
initFixtureAndComponent(bitstream);
241+
const badge = fixture.debugElement.query(By.css('span.badge'));
242+
expect(badge).toBeNull();
243+
});
244+
245+
it('should ask the LinkService to resolve the link as optional', () => {
246+
initFixtureAndComponent(bitstream);
247+
expect(linkService.resolveLink).toHaveBeenCalledWith(
248+
bitstream,
249+
jasmine.objectContaining({ name: 'accessStatus', isOptional: true }),
250+
);
251+
});
162252
});
163253
});
164254
});

0 commit comments

Comments
 (0)