diff --git a/config/config.example.yml b/config/config.example.yml index 03a558f312f..6b47b3873af 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -706,6 +706,28 @@ layout: default: icon: fas fa-project-diagram style: text-success + # If true the download link in item page will be rendered as an advanced attachment, the view can be then configured with the layout.advancedAttachmentRendering config + showDownloadLinkAsAttachment: false + # Configuration for advanced attachment rendering in item pages. This controls how files are displayed when showDownloadLinkAsAttachment is enabled. + # Defines which metadata/attributes to display for bitstream attachments. + advancedAttachmentRendering: + # Metadata and attributes to display for each attachment + metadata: + - name: dc.title + type: metadata + truncatable: false + - name: dc.type + type: metadata + truncatable: false + - name: dc.description + type: metadata + truncatable: true + - name: size + type: attribute + - name: format + type: attribute + - name: checksum + type: attribute # Configuration for customization of search results searchResults: diff --git a/src/app/bitstream-page/bitstream-download-redirect.guard.spec.ts b/src/app/bitstream-page/bitstream-download-redirect.guard.spec.ts new file mode 100644 index 00000000000..7fcfba7052c --- /dev/null +++ b/src/app/bitstream-page/bitstream-download-redirect.guard.spec.ts @@ -0,0 +1,223 @@ +import { PLATFORM_ID } from '@angular/core'; +import { + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { NotificationsService } from '@dspace/core/notification-system/notifications.service'; +import { getForbiddenRoute } from '@dspace/core/router/core-routing-paths'; +import { createSuccessfulRemoteDataObject$ } from '@dspace/core/utilities/remote-data.utils'; +import { Store } from '@ngrx/store'; +import { of } from 'rxjs'; + +import { AuthService } from '../core/auth/auth.service'; +import { RemoteDataBuildService } from '../core/cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../core/cache/object-cache.service'; +import { BitstreamDataService } from '../core/data/bitstream-data.service'; +import { BitstreamFormatDataService } from '../core/data/bitstream-format-data.service'; +import { DSOChangeAnalyzer } from '../core/data/dso-change-analyzer.service'; +import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service'; +import { SignpostingDataService } from '../core/data/signposting-data.service'; +import { HardRedirectService } from '../core/services/hard-redirect.service'; +import { ServerResponseService } from '../core/services/server-response.service'; +import { + NativeWindowRef, + NativeWindowService, +} from '../core/services/window.service'; +import { Bitstream } from '../core/shared/bitstream.model'; +import { FileService } from '../core/shared/file.service'; +import { HALEndpointService } from '../core/shared/hal-endpoint.service'; +import { UUIDService } from '../core/shared/uuid.service'; +import { bitstreamDownloadRedirectGuard } from './bitstream-download-redirect.guard'; + +describe('BitstreamDownloadRedirectGuard', () => { + let resolver: any; + + let authService: AuthService; + let authorizationService: AuthorizationDataService; + let bitstreamDataService: BitstreamDataService; + let fileService: FileService; + let halEndpointService: HALEndpointService; + let hardRedirectService: HardRedirectService; + let remoteDataBuildService: RemoteDataBuildService; + let uuidService: UUIDService; + let objectCacheService: ObjectCacheService; + let router: Router; + let store: Store; + let bitstream: Bitstream; + let serverResponseService: jasmine.SpyObj; + let signpostingDataService: jasmine.SpyObj; + + let route = { + params: {}, + queryParams: {}, + }; + let state = {}; + + const mocklink = { + href: 'http://test.org', + rel: 'test', + type: 'test', + }; + + const mocklink2 = { + href: 'http://test2.org', + rel: 'test', + type: 'test', + }; + + function init() { + authService = jasmine.createSpyObj('authService', { + isAuthenticated: of(true), + setRedirectUrl: {}, + }); + authorizationService = jasmine.createSpyObj('authorizationSerivice', { + isAuthorized: of(true), + }); + + fileService = jasmine.createSpyObj('fileService', { + retrieveFileDownloadLink: of('content-url-with-headers'), + }); + + hardRedirectService = jasmine.createSpyObj('fileService', { + redirect: {}, + }); + + halEndpointService = jasmine.createSpyObj('halEndpointService', { + getEndpoint: of('https://rest.api/core'), + }); + + remoteDataBuildService = jasmine.createSpyObj('remoteDataBuildService', { + buildSingle: of(new Bitstream()), + }); + + uuidService = jasmine.createSpyObj('uuidService', { + generate: 'test-id', + }); + + bitstream = Object.assign(new Bitstream(), { + uuid: 'bitstreamUuid', + _links: { + content: { href: 'bitstream-content-link' }, + self: { href: 'bitstream-self-link' }, + }, + }); + + router = jasmine.createSpyObj('router', ['navigateByUrl', 'createUrlTree']); + + store = jasmine.createSpyObj('store', { + dispatch: {}, + pipe: of(true), + }); + + serverResponseService = jasmine.createSpyObj('ServerResponseService', { + setHeader: jasmine.createSpy('setHeader'), + }); + + signpostingDataService = jasmine.createSpyObj('SignpostingDataService', { + getLinks: of([mocklink, mocklink2]), + }); + + objectCacheService = jasmine.createSpyObj('objectCacheService', { + getByHref: of(null), + }); + + bitstreamDataService = jasmine.createSpyObj('bitstreamDataService', { + findById: createSuccessfulRemoteDataObject$(Object.assign(new Bitstream(), { + _links: { + content: { href: 'bitstream-content-link' }, + self: { href: 'bitstream-self-link' }, + }, + })), + }); + + resolver = bitstreamDownloadRedirectGuard; + } + + function initTestbed() { + TestBed.configureTestingModule({ + providers: [ + { provide: NativeWindowService, useValue: new NativeWindowRef() }, + { provide: Router, useValue: router }, + { provide: AuthorizationDataService, useValue: authorizationService }, + { provide: AuthService, useValue: authService }, + { provide: FileService, useValue: fileService }, + { provide: HardRedirectService, useValue: hardRedirectService }, + { provide: ServerResponseService, useValue: serverResponseService }, + { provide: SignpostingDataService, useValue: signpostingDataService }, + { provide: ObjectCacheService, useValue: objectCacheService }, + { provide: PLATFORM_ID, useValue: 'server' }, + { provide: UUIDService, useValue: uuidService }, + { provide: Store, useValue: store }, + { provide: RemoteDataBuildService, useValue: remoteDataBuildService }, + { provide: HALEndpointService, useValue: halEndpointService }, + { provide: DSOChangeAnalyzer, useValue: {} }, + { provide: BitstreamFormatDataService, useValue: {} }, + { provide: NotificationsService, useValue: {} }, + { provide: BitstreamDataService, useValue: bitstreamDataService }, + ], + }); + } + + describe('bitstream retrieval', () => { + describe('when the user is authorized and not logged in', () => { + beforeEach(() => { + init(); + (authService.isAuthenticated as jasmine.Spy).and.returnValue(of(false)); + initTestbed(); + }); + it('should redirect to the content link', waitForAsync(() => { + TestBed.runInInjectionContext(() => { + resolver(route, state).subscribe(() => { + expect(hardRedirectService.redirect).toHaveBeenCalledWith('bitstream-content-link', null, true); + }, + ); + }); + })); + }); + describe('when the user is authorized and logged in', () => { + beforeEach(() => { + init(); + initTestbed(); + }); + it('should redirect to an updated content link', waitForAsync(() => { + TestBed.runInInjectionContext(() => { + resolver(route, state).subscribe(() => { + expect(hardRedirectService.redirect).toHaveBeenCalledWith('content-url-with-headers', null, true); + }); + }); + })); + }); + describe('when the user is not authorized and logged in', () => { + beforeEach(() => { + init(); + (authorizationService.isAuthorized as jasmine.Spy).and.returnValue(of(false)); + initTestbed(); + }); + it('should navigate to the forbidden route', waitForAsync(() => { + TestBed.runInInjectionContext(() => { + resolver(route, state).subscribe(() => { + expect(router.createUrlTree).toHaveBeenCalledWith([getForbiddenRoute()]); + }); + }); + })); + }); + describe('when the user is not authorized and not logged in', () => { + beforeEach(() => { + init(); + (authService.isAuthenticated as jasmine.Spy).and.returnValue(of(false)); + (authorizationService.isAuthorized as jasmine.Spy).and.returnValue(of(false)); + initTestbed(); + }); + it('should navigate to the login page', waitForAsync(() => { + + TestBed.runInInjectionContext(() => { + resolver(route, state).subscribe(() => { + expect(authService.setRedirectUrl).toHaveBeenCalled(); + expect(router.createUrlTree).toHaveBeenCalledWith(['login']); + }); + }); + })); + }); + }); +}); diff --git a/src/app/bitstream-page/bitstream-download-redirect.guard.ts b/src/app/bitstream-page/bitstream-download-redirect.guard.ts new file mode 100644 index 00000000000..50d58d6f69e --- /dev/null +++ b/src/app/bitstream-page/bitstream-download-redirect.guard.ts @@ -0,0 +1,125 @@ +import { inject } from '@angular/core'; +import { + ActivatedRouteSnapshot, + CanActivateFn, + Router, + RouterStateSnapshot, + UrlTree, +} from '@angular/router'; +import { getForbiddenRoute } from '@dspace/core/router/core-routing-paths'; +import { + combineLatest, + Observable, + of, +} from 'rxjs'; +import { + filter, + map, + switchMap, + take, +} from 'rxjs/operators'; + +import { AuthService } from '../core/auth/auth.service'; +import { BitstreamDataService } from '../core/data/bitstream-data.service'; +import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service'; +import { FeatureID } from '../core/data/feature-authorization/feature-id'; +import { RemoteData } from '../core/data/remote-data'; +import { HardRedirectService } from '../core/services/hard-redirect.service'; +import { redirectOn4xx } from '../core/shared/authorized.operators'; +import { + Bitstream, + BITSTREAM_PAGE_LINKS_TO_FOLLOW, +} from '../core/shared/bitstream.model'; +import { FileService } from '../core/shared/file.service'; +import { getFirstCompletedRemoteData } from '../core/shared/operators'; +import { + hasValue, + isNotEmpty, +} from '../utils/empty.util'; + +/** + * Guard that handles bitstream download authorization and redirection logic. + * This guard intercepts bitstream download requests and performs the following checks and actions: + * + * 1. **Retrieves the bitstream** by ID from the route parameters + * 2. **Checks authorization** using the CanDownload feature permission + * 3. **Determines authentication status** of the current user + * 4. **Handles different scenarios**: + * - **Authorized + Logged in**: Retrieves a secure download link and redirects to it + * - **Authorized + Not logged in + No access token**: Direct redirect to bitstream content URL + * - **Not authorized + Has access token**: Redirect to content URL with access token appended + * - **Not authorized + Logged in**: Redirect to forbidden page + * - **Not authorized + Not logged in**: Store current URL and redirect to login page + * + * @param route - The activated route snapshot containing the bitstream ID and optional access token + * @param state - The router state snapshot + * @param bitstreamDataService - Service for retrieving bitstream data + * @param authorizationService - Service for checking download authorization + * @param auth - Service for authentication operations + * @param fileService - Service for retrieving secure file download links + * @param hardRedirectService - Service for performing hard redirects to download URLs + * @param router - Angular router for navigation + * @returns Observable that emits a UrlTree for navigation or boolean to allow/prevent route activation + */ +export const bitstreamDownloadRedirectGuard: CanActivateFn = ( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot, + bitstreamDataService: BitstreamDataService = inject(BitstreamDataService), + authorizationService: AuthorizationDataService = inject(AuthorizationDataService), + auth: AuthService = inject(AuthService), + fileService: FileService = inject(FileService), + hardRedirectService: HardRedirectService = inject(HardRedirectService), + router: Router = inject(Router), +): Observable => { + + const bitstreamId = route.params.id; + const accessToken: string = route.queryParams.accessToken; + + return bitstreamDataService.findById(bitstreamId, true, false, ...BITSTREAM_PAGE_LINKS_TO_FOLLOW).pipe( + getFirstCompletedRemoteData(), + redirectOn4xx(router, auth), + switchMap((rd: RemoteData) => { + if (rd.hasSucceeded && !rd.hasNoContent) { + const bitstream = rd.payload; + const isAuthorized$ = authorizationService.isAuthorized(FeatureID.CanDownload, bitstream.self); + const isLoggedIn$ = auth.isAuthenticated(); + return combineLatest([isAuthorized$, isLoggedIn$, of(bitstream)]); + } else { + return of([false, false, null]); + } + }), + filter(([isAuthorized, isLoggedIn, bitstream]: [boolean, boolean, Bitstream]) => hasValue(isAuthorized) && hasValue(isLoggedIn)), + take(1), + switchMap(([isAuthorized, isLoggedIn, bitstream]: [boolean, boolean, Bitstream]) => { + if (isAuthorized && isLoggedIn) { + return fileService.retrieveFileDownloadLink(bitstream._links.content.href).pipe( + filter((fileLink) => hasValue(fileLink)), + take(1), + map((fileLink) => { + return [isAuthorized, isLoggedIn, bitstream, fileLink]; + })); + } else { + return of([isAuthorized, isLoggedIn, bitstream, '']); + } + }), + map(([isAuthorized, isLoggedIn, bitstream, fileLink]: [boolean, boolean, Bitstream, string]) => { + if (isAuthorized && isLoggedIn && isNotEmpty(fileLink)) { + hardRedirectService.redirect(fileLink, null, true); + return false; + } else if (isAuthorized && !isLoggedIn && !hasValue(accessToken)) { + hardRedirectService.redirect(bitstream._links.content.href, null, true); + return false; + } else if (!isAuthorized) { + if (hasValue(accessToken)) { + hardRedirectService.redirect(bitstream._links.content.href + '?accessToken=' + accessToken, null, true); + return false; + } else if (isLoggedIn) { + return router.createUrlTree([getForbiddenRoute()]); + } else if (!isLoggedIn) { + auth.setRedirectUrl(router.url); + return router.createUrlTree(['login']); + } + } + }), + ); +}; diff --git a/src/app/bitstream-page/bitstream-page-routes.ts b/src/app/bitstream-page/bitstream-page-routes.ts index 242ab27c169..d7ef7b214bd 100644 --- a/src/app/bitstream-page/bitstream-page-routes.ts +++ b/src/app/bitstream-page/bitstream-page-routes.ts @@ -9,6 +9,7 @@ import { resourcePolicyResolver } from '../shared/resource-policies/resolvers/re import { resourcePolicyTargetResolver } from '../shared/resource-policies/resolvers/resource-policy-target.resolver'; import { BitstreamAuthorizationsComponent } from './bitstream-authorizations/bitstream-authorizations.component'; import { BitstreamDownloadPageComponent } from './bitstream-download-page/bitstream-download-page.component'; +import { bitstreamDownloadRedirectGuard } from './bitstream-download-redirect.guard'; import { bitstreamPageResolver } from './bitstream-page.resolver'; import { bitstreamPageAuthorizationsGuard } from './bitstream-page-authorizations.guard'; import { ThemedEditBitstreamPageComponent } from './edit-bitstream-page/themed-edit-bitstream-page.component'; @@ -40,6 +41,7 @@ export const ROUTES: Route[] = [ resolve: { bitstream: bitstreamPageResolver, }, + canActivate: [bitstreamDownloadRedirectGuard], }, { path: EDIT_BITSTREAM_PATH, diff --git a/src/app/core/services/hard-redirect.service.ts b/src/app/core/services/hard-redirect.service.ts index 023b84a8af3..06429e164b0 100644 --- a/src/app/core/services/hard-redirect.service.ts +++ b/src/app/core/services/hard-redirect.service.ts @@ -13,8 +13,10 @@ export abstract class HardRedirectService { * the page to redirect to * @param statusCode * optional HTTP status code to use for redirect (default = 302, which is a temporary redirect) + * @param shouldSetCorsHeader + * optional to prevent CORS error on redirect */ - abstract redirect(url: string, statusCode?: number); + abstract redirect(url: string, statusCode?: number, shouldSetCorsHeader?: boolean); /** * Get the current route, with query params included diff --git a/src/app/core/services/server-hard-redirect.service.spec.ts b/src/app/core/services/server-hard-redirect.service.spec.ts index 993d758423b..fb2fddfef1f 100644 --- a/src/app/core/services/server-hard-redirect.service.spec.ts +++ b/src/app/core/services/server-hard-redirect.service.spec.ts @@ -19,12 +19,17 @@ describe('ServerHardRedirectService', () => { }, } as AppConfig; - let service: ServerHardRedirectService = new ServerHardRedirectService(envConfig, mockRequest, mockResponse); + const serverResponseService = jasmine.createSpyObj('ServerResponseService', { + setHeader: jasmine.createSpy('setHeader'), + }); + + let service: ServerHardRedirectService = new ServerHardRedirectService(envConfig, mockRequest, mockResponse, serverResponseService); const origin = 'https://test-host.com:4000'; let originalBaseUrl; beforeEach(() => { mockRequest.protocol = 'https'; + mockRequest.path = '/bitstreams/test-uuid/download'; mockRequest.headers = { host: 'test-host.com:4000', }; @@ -99,7 +104,7 @@ describe('ServerHardRedirectService', () => { ssrBaseUrl: 'https://private-url:4000/server', baseUrl: 'https://public-url/server', } } }; - service = new ServerHardRedirectService(environmentWithSSRUrl, mockRequest, mockResponse); + service = new ServerHardRedirectService(environmentWithSSRUrl, mockRequest, mockResponse, serverResponseService); beforeEach(() => { service.redirect(redirect); diff --git a/src/app/core/services/server-hard-redirect.service.ts b/src/app/core/services/server-hard-redirect.service.ts index 3384b869e5e..a319ba9ca86 100644 --- a/src/app/core/services/server-hard-redirect.service.ts +++ b/src/app/core/services/server-hard-redirect.service.ts @@ -18,6 +18,7 @@ import { RESPONSE, } from '../../../express.tokens'; import { HardRedirectService } from './hard-redirect.service'; +import { ServerResponseService } from './server-response.service'; /** @@ -30,6 +31,7 @@ export class ServerHardRedirectService extends HardRedirectService { @Inject(APP_CONFIG) protected appConfig: AppConfig, @Inject(REQUEST) protected req: Request, @Inject(RESPONSE) protected res: Response, + private responseService: ServerResponseService, ) { super(); } @@ -41,6 +43,7 @@ export class ServerHardRedirectService extends HardRedirectService { * the page to redirect to * @param statusCode * optional HTTP status code to use for redirect (default = 302, which is a temporary redirect) + * @param shouldSetCorsHeader */ redirect(url: string, statusCode?: number) { if (url === this.req.url) { diff --git a/src/app/core/shared/bitstream.model.ts b/src/app/core/shared/bitstream.model.ts index 116cc1aecce..7df40059683 100644 --- a/src/app/core/shared/bitstream.model.ts +++ b/src/app/core/shared/bitstream.model.ts @@ -34,6 +34,11 @@ export const BITSTREAM_PAGE_LINKS_TO_FOLLOW: FollowLinkConfig[] = [ followLink('format'), ]; +export interface ChecksumInfo { + checkSumAlgorithm: string; + value: string; +} + @typedObject @inheritSerialization(DSpaceObject) export class Bitstream extends DSpaceObject implements ChildHALResource { @@ -51,6 +56,12 @@ export class Bitstream extends DSpaceObject implements ChildHALResource { @autoserialize description: string; + /** + * The checksum information of this Bitstream + */ + @autoserialize + checkSum: ChecksumInfo; + /** * The name of the Bundle this Bitstream is part of */ diff --git a/src/app/item-page/full/field-components/file-section/full-file-section.component.html b/src/app/item-page/full/field-components/file-section/full-file-section.component.html index a5025c3246c..c4aeb190b2a 100644 --- a/src/app/item-page/full/field-components/file-section/full-file-section.component.html +++ b/src/app/item-page/full/field-components/file-section/full-file-section.component.html @@ -41,9 +41,9 @@

- - {{ "item.page.filesection.download" | translate }} - + + {{ "item.page.filesection.download" | translate }} +
@@ -93,9 +93,9 @@

- - {{ "item.page.filesection.download" | translate }} - + + {{ "item.page.filesection.download" | translate }} +
diff --git a/src/app/item-page/simple/field-components/file-section/file-section.component.html b/src/app/item-page/simple/field-components/file-section/file-section.component.html index 79440ac6551..8791892943d 100644 --- a/src/app/item-page/simple/field-components/file-section/file-section.component.html +++ b/src/app/item-page/simple/field-components/file-section/file-section.component.html @@ -3,17 +3,26 @@
@for (file of bitstreams; track file; let last = $last) { - - - @if (primaryBitstreamId === file.id) { - {{ 'item.page.bitstreams.primary' | translate }} - } - {{ dsoNameService.getName(file) }} - - ({{(file?.sizeBytes) | dsFileSize }}) + + + @if (primaryBitstreamId === file.id) { + + {{ 'item.page.bitstreams.primary' | translate }} + + } + {{ dsoNameService.getName(file) }} + + + ({{ file?.sizeBytes | dsFileSize }}) + @if (!last) { - + } + + + } @if (isLoading) { diff --git a/src/app/item-page/simple/field-components/file-section/file-section.component.ts b/src/app/item-page/simple/field-components/file-section/file-section.component.ts index 8f5df0c4de9..cbaed60dc41 100644 --- a/src/app/item-page/simple/field-components/file-section/file-section.component.ts +++ b/src/app/item-page/simple/field-components/file-section/file-section.component.ts @@ -15,7 +15,11 @@ import { PaginatedList } from '@dspace/core/data/paginated-list.model'; import { RemoteData } from '@dspace/core/data/remote-data'; import { NotificationsService } from '@dspace/core/notification-system/notifications.service'; import { Bitstream } from '@dspace/core/shared/bitstream.model'; -import { followLink } from '@dspace/core/shared/follow-link-config.model'; +import { + followLink, + FollowLinkConfig, +} from '@dspace/core/shared/follow-link-config.model'; +import { HALResource } from '@dspace/core/shared/hal-resource.model'; import { Item } from '@dspace/core/shared/item.model'; import { getFirstCompletedRemoteData } from '@dspace/core/shared/operators'; import { hasValue } from '@dspace/shared/utils/empty.util'; @@ -68,6 +72,8 @@ export class FileSectionComponent implements OnInit { primaryBitstreamId: string; + showDownloadLinkAsAttachment: boolean; + constructor( protected bitstreamDataService: BitstreamDataService, protected notificationsService: NotificationsService, @@ -76,6 +82,7 @@ export class FileSectionComponent implements OnInit { @Inject(APP_CONFIG) protected appConfig: AppConfig, ) { this.pageSize = this.appConfig.item.bitstream.pageSize; + this.showDownloadLinkAsAttachment = this.appConfig.layout.showDownloadLinkAsAttachment; } ngOnInit(): void { @@ -106,10 +113,20 @@ export class FileSectionComponent implements OnInit { } else { this.currentPage++; } + const followLinks: FollowLinkConfig[] = [ + followLink('accessStatus'), + ]; + + if (this.showDownloadLinkAsAttachment) { + followLinks.push( + ...[followLink('thumbnail'), followLink('format')], + ); + } + this.bitstreamDataService.findAllByItemAndBundleName(this.item, 'ORIGINAL', { currentPage: this.currentPage, elementsPerPage: this.pageSize, - }, true, true, followLink('accessStatus')).pipe( + }, true, true, ...followLinks).pipe( getFirstCompletedRemoteData(), ).subscribe((bitstreamsRD: RemoteData>) => { if (bitstreamsRD.errorMessage) { diff --git a/src/app/item-page/simple/item-types/publication/publication.component.html b/src/app/item-page/simple/item-types/publication/publication.component.html index 38a072a0ad5..f39dfe1625d 100644 --- a/src/app/item-page/simple/item-types/publication/publication.component.html +++ b/src/app/item-page/simple/item-types/publication/publication.component.html @@ -29,7 +29,9 @@
} - + @if (showDownloadLinkAsAttachment !== true) { + + }
+ @if (showDownloadLinkAsAttachment) { + + } { getThumbnailFor(item: Item): Observable> { return createSuccessfulRemoteDataObject$(new Bitstream()); }, + findPrimaryBitstreamByItemAndName(item: Item, bundleName: string, useCachedVersionIfAvailable: boolean, reRequestOnStale: boolean): Observable { + return of(null); + }, + findAllByItemAndBundleName(item: Item, bundleName: string, options: any, useCachedVersionIfAvailable: boolean, reRequestOnStale: boolean, ...linksToFollow: any[]): Observable> { + return createSuccessfulRemoteDataObject$(createPaginatedList([])); + }, }; TestBed.configureTestingModule({ imports: [ @@ -270,4 +276,63 @@ describe('PublicationComponent', () => { })); }); + + describe('when showDownloadLinkAsAttachment is false', () => { + beforeEach(waitForAsync(() => { + TestBed.overrideComponent(PublicationComponent, { + add: { changeDetection: ChangeDetectionStrategy.Default }, + remove: { + imports: [ + ThemedFileSectionComponent, + ], + }, + }); + TestBed.compileComponents(); + fixture = TestBed.createComponent(PublicationComponent); + comp = fixture.componentInstance; + comp.object = getItem(noMetadata); + comp.showDownloadLinkAsAttachment = false; + fixture.detectChanges(); + })); + + it('should display the file section component', () => { + const fileSectionElements = fixture.debugElement.queryAll(By.css('ds-item-page-file-section')); + expect(fileSectionElements.length).toBe(1); + }); + + it('should not display the attachment section component', () => { + const attachmentSectionElements = fixture.debugElement.queryAll(By.css('ds-item-page-attachment-section')); + expect(attachmentSectionElements.length).toBe(0); + }); + }); + + describe('when showDownloadLinkAsAttachment is true', () => { + beforeEach(waitForAsync(() => { + TestBed.overrideComponent(PublicationComponent, { + add: { changeDetection: ChangeDetectionStrategy.Default }, + remove: { + imports: [ + ThemedFileSectionComponent, + ], + }, + }); + TestBed.compileComponents(); + fixture = TestBed.createComponent(PublicationComponent); + comp = fixture.componentInstance; + comp.object = getItem(noMetadata); + comp.showDownloadLinkAsAttachment = true; + fixture.detectChanges(); + })); + + it('should display the attachment section component', () => { + const attachmentSectionElements = fixture.debugElement.queryAll(By.css('ds-item-page-attachment-section')); + expect(attachmentSectionElements.length).toBe(1); + }); + + it('should not display the file section component', () => { + const fileSectionElements = fixture.debugElement.queryAll(By.css('ds-item-page-file-section')); + expect(fileSectionElements.length).toBe(0); + }); + }); + }); diff --git a/src/app/item-page/simple/item-types/publication/publication.component.ts b/src/app/item-page/simple/item-types/publication/publication.component.ts index 83578272f0d..496697963a0 100644 --- a/src/app/item-page/simple/item-types/publication/publication.component.ts +++ b/src/app/item-page/simple/item-types/publication/publication.component.ts @@ -7,6 +7,7 @@ import { RouterLink } from '@angular/router'; import { ViewMode } from '@dspace/core/shared/view-mode.model'; import { TranslateModule } from '@ngx-translate/core'; +import { AttachmentSectionComponent } from '../../../../shared/bitstream-attachment/section/attachment-section.component'; import { DsoEditMenuComponent } from '../../../../shared/dso-page/dso-edit-menu/dso-edit-menu.component'; import { MetadataFieldWrapperComponent } from '../../../../shared/metadata-field-wrapper/metadata-field-wrapper.component'; import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; @@ -38,6 +39,7 @@ import { ItemComponent } from '../shared/item.component'; changeDetection: ChangeDetectionStrategy.OnPush, imports: [ AsyncPipe, + AttachmentSectionComponent, CollectionsComponent, DsoEditMenuComponent, GenericItemPageFieldComponent, diff --git a/src/app/item-page/simple/item-types/shared/item.component.ts b/src/app/item-page/simple/item-types/shared/item.component.ts index 2c4c50eb532..99bc027a8d0 100644 --- a/src/app/item-page/simple/item-types/shared/item.component.ts +++ b/src/app/item-page/simple/item-types/shared/item.component.ts @@ -79,10 +79,13 @@ export class ItemComponent implements OnInit { */ geospatialItemPageFieldsEnabled = false; + showDownloadLinkAsAttachment: boolean; + constructor(protected routeService: RouteService, protected router: Router) { this.mediaViewer = environment.mediaViewer; this.geospatialItemPageFieldsEnabled = environment.geospatialMapViewer.enableItemPageFields; + this.showDownloadLinkAsAttachment = environment.layout.showDownloadLinkAsAttachment; } /** diff --git a/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.html b/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.html index f3aa32dd799..0cfb7cfc8a3 100644 --- a/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.html +++ b/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.html @@ -30,7 +30,9 @@
} - + @if (showDownloadLinkAsAttachment !== true) { + + }
+ @if (showDownloadLinkAsAttachment) { + + } { getThumbnailFor(item: Item): Observable> { return createSuccessfulRemoteDataObject$(new Bitstream()); }, + findPrimaryBitstreamByItemAndName(item: Item, bundleName: string, useCachedVersionIfAvailable: boolean, reRequestOnStale: boolean): Observable { + return of(null); + }, + findAllByItemAndBundleName(item: Item, bundleName: string, options: any, useCachedVersionIfAvailable: boolean, reRequestOnStale: boolean, ...linksToFollow: any[]): Observable> { + return createSuccessfulRemoteDataObject$(createPaginatedList([])); + }, }; TestBed.configureTestingModule({ imports: [ @@ -295,4 +301,62 @@ describe('UntypedItemComponent', () => { }); + describe('when showDownloadLinkAsAttachment is false', () => { + beforeEach(waitForAsync(() => { + TestBed.overrideComponent(UntypedItemComponent, { + add: { changeDetection: ChangeDetectionStrategy.Default }, + remove: { + imports: [ + ThemedFileSectionComponent, + ], + }, + }); + TestBed.compileComponents(); + fixture = TestBed.createComponent(UntypedItemComponent); + comp = fixture.componentInstance; + comp.object = getItem(noMetadata); + comp.showDownloadLinkAsAttachment = false; + fixture.detectChanges(); + })); + + it('should display the file section component', () => { + const fileSectionElements = fixture.debugElement.queryAll(By.css('ds-item-page-file-section')); + expect(fileSectionElements.length).toBe(1); + }); + + it('should not display the attachment section component', () => { + const attachmentSectionElements = fixture.debugElement.queryAll(By.css('ds-item-page-attachment-section')); + expect(attachmentSectionElements.length).toBe(0); + }); + }); + + describe('when showDownloadLinkAsAttachment is true', () => { + beforeEach(waitForAsync(() => { + TestBed.overrideComponent(UntypedItemComponent, { + add: { changeDetection: ChangeDetectionStrategy.Default }, + remove: { + imports: [ + ThemedFileSectionComponent, + ], + }, + }); + TestBed.compileComponents(); + fixture = TestBed.createComponent(UntypedItemComponent); + comp = fixture.componentInstance; + comp.object = getItem(noMetadata); + comp.showDownloadLinkAsAttachment = true; + fixture.detectChanges(); + })); + + it('should display the attachment section component', () => { + const attachmentSectionElements = fixture.debugElement.queryAll(By.css('ds-item-page-attachment-section')); + expect(attachmentSectionElements.length).toBe(1); + }); + + it('should not display the file section component', () => { + const fileSectionElements = fixture.debugElement.queryAll(By.css('ds-item-page-file-section')); + expect(fileSectionElements.length).toBe(0); + }); + }); + }); diff --git a/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.ts b/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.ts index 3e2da51dd47..fa6f3af17a9 100644 --- a/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.ts +++ b/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.ts @@ -8,6 +8,7 @@ import { Item } from '@dspace/core/shared/item.model'; import { ViewMode } from '@dspace/core/shared/view-mode.model'; import { TranslateModule } from '@ngx-translate/core'; +import { AttachmentSectionComponent } from '../../../../shared/bitstream-attachment/section/attachment-section.component'; import { DsoEditMenuComponent } from '../../../../shared/dso-page/dso-edit-menu/dso-edit-menu.component'; import { MetadataFieldWrapperComponent } from '../../../../shared/metadata-field-wrapper/metadata-field-wrapper.component'; import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; @@ -39,6 +40,7 @@ import { ItemComponent } from '../shared/item.component'; changeDetection: ChangeDetectionStrategy.OnPush, imports: [ AsyncPipe, + AttachmentSectionComponent, CollectionsComponent, DsoEditMenuComponent, GenericItemPageFieldComponent, diff --git a/src/app/shared/bitstream-attachment/attachment-render/types/file-download-button/file-download-button.component.html b/src/app/shared/bitstream-attachment/attachment-render/types/file-download-button/file-download-button.component.html new file mode 100644 index 00000000000..366996ee1f0 --- /dev/null +++ b/src/app/shared/bitstream-attachment/attachment-render/types/file-download-button/file-download-button.component.html @@ -0,0 +1,18 @@ +@if (!hasNoDownload) { + @if (bitstreamPath$ | async; as bitstreamLink) { + @if (canDownload$ | async) { + + } @else { + + } + } +} diff --git a/src/app/shared/bitstream-attachment/attachment-render/types/file-download-button/file-download-button.component.scss b/src/app/shared/bitstream-attachment/attachment-render/types/file-download-button/file-download-button.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/app/shared/bitstream-attachment/attachment-render/types/file-download-button/file-download-button.component.spec.ts b/src/app/shared/bitstream-attachment/attachment-render/types/file-download-button/file-download-button.component.spec.ts new file mode 100644 index 00000000000..5a0b960d035 --- /dev/null +++ b/src/app/shared/bitstream-attachment/attachment-render/types/file-download-button/file-download-button.component.spec.ts @@ -0,0 +1,118 @@ +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { RouterTestingModule } from '@angular/router/testing'; +import { ConfigurationDataService } from '@dspace/core/data/configuration-data.service'; +import { ConfigurationProperty } from '@dspace/core/shared/configuration-property.model'; +import { TranslateLoaderMock } from '@dspace/core/testing/translate-loader.mock'; +import { createSuccessfulRemoteDataObject$ } from '@dspace/core/utilities/remote-data.utils'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; +import { of } from 'rxjs'; +import { AuthorizationDataService } from 'src/app/core/data/feature-authorization/authorization-data.service'; +import { Bitstream } from 'src/app/core/shared/bitstream.model'; +import { Item } from 'src/app/core/shared/item.model'; + +import { FileDownloadButtonComponent } from './file-download-button.component'; + +describe('FileDownloadButtonComponent', () => { + let component: FileDownloadButtonComponent; + let fixture: ComponentFixture; + + let authorizationService: AuthorizationDataService; + + let bitstream: Bitstream; + let item: Item; + let configurationDataService: ConfigurationDataService; + + function init() { + authorizationService = jasmine.createSpyObj('authorizationService', { + isAuthorized: jasmine.createSpy('isAuthorized'), + }); + bitstream = Object.assign(new Bitstream(), { + uuid: 'bitstreamUuid', + _links: { + self: { href: 'obj-selflink' }, + }, + }); + item = Object.assign(new Item(), { + uuid: 'itemUuid', + _links: { + self: { href: 'obj-selflink' }, + }, + }); + configurationDataService = jasmine.createSpyObj('configurationDataService', { + findByPropertyName: createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), { + name: 'request.item.type', + values: [], + })), + }); + } + + + beforeEach(async () => { + + init(); + + await TestBed.configureTestingModule({ + imports: [ + RouterTestingModule, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock, + }, + }), + FileDownloadButtonComponent, + ], + providers: [ + { provide: AuthorizationDataService, useValue: authorizationService }, + { provide: ConfigurationDataService, useValue: configurationDataService }, + ], + }) + .compileComponents(); + }); + beforeEach(() => { + fixture = TestBed.createComponent(FileDownloadButtonComponent); + component = fixture.componentInstance; + component.bitstream = bitstream; + component.item = item; + (authorizationService.isAuthorized as jasmine.Spy).and.returnValue(of(true)); + component.bitstreamPath$ = of({ + routerLink: 'test', + queryParams: {}, + }); + component.ngOnInit(); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should show download button', () => { + expect(fixture.debugElement.query(By.css('[data-test="download"]'))).toBeTruthy(); + expect(fixture.debugElement.query(By.css('[data-test="requestACopy"]'))).toBeFalsy(); + }); + + it('should show can request a copy button', () => { + (authorizationService.isAuthorized as jasmine.Spy).and.returnValue(of(false)); + component.ngOnInit(); + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('[data-test="download"]'))).toBeFalsy(); + expect(fixture.debugElement.query(By.css('[data-test="requestACopy"]'))).toBeTruthy(); + }); + + it('should show a disabled can request a copy button when request.item.type has no value', () => { + (authorizationService.isAuthorized as jasmine.Spy).and.returnValue(of(false)); + component.ngOnInit(); + fixture.detectChanges(); + const btn = fixture.debugElement.query(By.css('[data-test="requestACopy"]')); + expect(btn.nativeNode.getAttribute('aria-disabled')).toBe('true'); + expect(btn.nativeNode.classList.contains('disabled')).toBeTrue(); + }); +}); diff --git a/src/app/shared/bitstream-attachment/attachment-render/types/file-download-button/file-download-button.component.ts b/src/app/shared/bitstream-attachment/attachment-render/types/file-download-button/file-download-button.component.ts new file mode 100644 index 00000000000..739aa758c6c --- /dev/null +++ b/src/app/shared/bitstream-attachment/attachment-render/types/file-download-button/file-download-button.component.ts @@ -0,0 +1,36 @@ +import { AsyncPipe } from '@angular/common'; +import { + Component, + OnInit, +} from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; +import { BtnDisabledDirective } from 'src/app/shared/btn-disabled.directive'; + +import { FileDownloadLinkComponent } from '../../../../file-download-link/file-download-link.component'; + + +@Component({ + selector: 'ds-file-download-button', + templateUrl: './file-download-button.component.html', + styleUrls: ['./file-download-button.component.scss'], + imports: [ + AsyncPipe, + BtnDisabledDirective, + RouterLink, + TranslateModule, + ], +}) +/** + * Component displaying a download button or the request a copy button depending on authorization + */ +export class FileDownloadButtonComponent extends FileDownloadLinkComponent implements OnInit { + + hasNoDownload = true; + + ngOnInit() { + super.ngOnInit(); + this.hasNoDownload = this.bitstream?.allMetadataValues('bitstream.viewer.provider').includes('nodownload'); + } + +} diff --git a/src/app/shared/bitstream-attachment/bitstream-attachment.component.html b/src/app/shared/bitstream-attachment/bitstream-attachment.component.html new file mode 100644 index 00000000000..c49023d1758 --- /dev/null +++ b/src/app/shared/bitstream-attachment/bitstream-attachment.component.html @@ -0,0 +1,79 @@ +@if (attachment) { +
+ +
+ +
+ +
+
+
+
+ +
+
+
+
+ +
+ @for (attachmentConf of metadataConfig; track $index) { + @if (attachment.firstMetadataValue(attachmentConf.name) || attachmentConf.type === AdvancedAttachmentElementType.Attribute) { +
+ {{ 'layout.advanced-attachment.' + attachmentConf.name | translate }} + + @if (attachmentConf.type === AdvancedAttachmentElementType.Metadata) { + + @if(!attachmentConf.truncatable && attachmentConf.name === attachmentTypeMetadata) { +

+ {{attachment.firstMetadataValue(attachmentConf.name) | titlecase}} +

+ } + + @if(!attachmentConf.truncatable && attachmentConf.name !== attachmentTypeMetadata) { +

+ {{attachment.firstMetadataValue(attachmentConf.name)}} +

+ } + + @if (attachmentConf.truncatable) { + + + {{attachment.firstMetadataValue(attachmentConf.name)}} + + + } + } + @if (attachmentConf.type === AdvancedAttachmentElementType.Attribute) { + @if (attachmentConf.name === 'format') { + @if ((bitstreamFormat$ | async) === null || (bitstreamFormat$ | async) === undefined) { +

+ {{'layout.advanced-attachment.label.not-present' | translate}} +

+ } @else { +

{{(bitstreamFormat$ | async)}}

+ } + } + + @if (attachmentConf.name === 'size' && bitstreamSize) { +

{{bitstreamSize | dsFileSize}}

+ } + + @if (attachmentConf.name === 'checksum') { + @if (checksumInfo?.value === null || checksumInfo?.value === undefined) { +

+ {{'layout.advanced-attachment.label.not-present' | translate}} +

+ } @else { +

({{checksumInfo.checkSumAlgorithm}}):{{ checksumInfo.value }}

+ } + } + } + +
+ } + } + +
+ +
+} diff --git a/src/app/shared/bitstream-attachment/bitstream-attachment.component.scss b/src/app/shared/bitstream-attachment/bitstream-attachment.component.scss new file mode 100644 index 00000000000..7b9417a0f23 --- /dev/null +++ b/src/app/shared/bitstream-attachment/bitstream-attachment.component.scss @@ -0,0 +1,33 @@ +.thumbnail-placeholder { + border: var(--ds-thumbnail-placeholder-border); + color: var(--ds-thumbnail-placeholder-color); + font-weight: var(--ds-advanced-attachment-thumbnail-placeholder-font-weight); +} + +.divider { + border-left: 1px solid var(--bs-gray-200); +} + +.card-container { + background-color: var(--bs-gray-100); + background-clip: border-box; + border-radius: 0.25rem; + padding: 1rem; +} + +i.custom-icon { + display: inline-block; + width: 20px; + height: 20px; + background-size: contain; + text-align: center; + vertical-align: middle; +} + +.thumbnail-wrapper { + min-width: 120px; +} + +.word-break { + word-break: break-word; +} diff --git a/src/app/shared/bitstream-attachment/bitstream-attachment.component.spec.ts b/src/app/shared/bitstream-attachment/bitstream-attachment.component.spec.ts new file mode 100644 index 00000000000..af0a1bc9742 --- /dev/null +++ b/src/app/shared/bitstream-attachment/bitstream-attachment.component.spec.ts @@ -0,0 +1,101 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { APP_CONFIG } from '@dspace/config/app-config.interface'; +import { BitstreamDataService } from '@dspace/core/data/bitstream-data.service'; +import { LocaleService } from '@dspace/core/locale/locale.service'; +import { Bitstream } from '@dspace/core/shared/bitstream.model'; +import { Item } from '@dspace/core/shared/item.model'; +import { TranslateLoaderMock } from '@dspace/core/testing/translate-loader.mock'; +import { createSuccessfulRemoteDataObject$ } from '@dspace/core/utilities/remote-data.utils'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; +import { of } from 'rxjs'; +import { ThemedThumbnailComponent } from 'src/app/thumbnail/themed-thumbnail.component'; + +import { environment } from '../../../environments/environment'; +import { TruncatableComponent } from '../truncatable/truncatable.component'; +import { TruncatablePartComponent } from '../truncatable/truncatable-part/truncatable-part.component'; +import { FileSizePipe } from '../utils/file-size-pipe'; +import { FileDownloadButtonComponent } from './attachment-render/types/file-download-button/file-download-button.component'; +import { BitstreamAttachmentComponent } from './bitstream-attachment.component'; + +describe('BitstreamAttachmentComponent', () => { + let component: BitstreamAttachmentComponent; + let fixture: ComponentFixture; + const attachmentMock: any = Object.assign(new Bitstream(), + { + checkSum: { + checkSumAlgorithm: 'MD5', + value: 'checksum', + }, + thumbnail: createSuccessfulRemoteDataObject$(new Bitstream()), + }, + ); + const languageList = ['en;q=1', 'de;q=0.8']; + const mockLocaleService = jasmine.createSpyObj('LocaleService', { + getCurrentLanguageCode: of('en'), + getLanguageCodeList: of(languageList), + }); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + BitstreamAttachmentComponent, + FileSizePipe, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock, + }, + }), + ], + providers: [ + provideRouter([]), + { provide: BitstreamDataService, useValue: {} }, + { provide: APP_CONFIG, useValue: environment }, + { provide: LocaleService, useValue: mockLocaleService }, + ], + schemas: [NO_ERRORS_SCHEMA], + }) + .overrideComponent(BitstreamAttachmentComponent, { remove: { imports: [ThemedThumbnailComponent, TruncatableComponent, TruncatablePartComponent, FileDownloadButtonComponent] } }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(BitstreamAttachmentComponent); + component = fixture.componentInstance; + component.attachment = attachmentMock; + component.item = new Item(); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should initialize attachment properties on init', () => { + const formatMock$ = createSuccessfulRemoteDataObject$({ + shortDescription: 'PDF', + } as any); + + attachmentMock.sizeBytes = 12345; + attachmentMock.checkSum = { + checkSumAlgorithm: 'MD5', + value: 'abc123', + }; + attachmentMock.format = formatMock$; + attachmentMock.allMetadataValues = jasmine.createSpy() + .and.returnValue(['provider1']); + + component.ngOnInit(); + + expect(component.bitstreamSize).toBe(12345); + expect(component.checksumInfo.value).toBe('abc123'); + expect(component.allAttachmentProviders).toEqual(['provider1']); + }); +}); diff --git a/src/app/shared/bitstream-attachment/bitstream-attachment.component.ts b/src/app/shared/bitstream-attachment/bitstream-attachment.component.ts new file mode 100644 index 00000000000..f33ff805202 --- /dev/null +++ b/src/app/shared/bitstream-attachment/bitstream-attachment.component.ts @@ -0,0 +1,163 @@ +import { + AsyncPipe, + TitleCasePipe, +} from '@angular/common'; +import { + Component, + Inject, + Input, + OnInit, +} from '@angular/core'; +import { + ActivatedRoute, + Router, +} from '@angular/router'; +import { + AdvancedAttachmentElementType, + AttachmentMetadataConfig, +} from '@dspace/config/advanced-attachment-rendering.config'; +import { + APP_CONFIG, + AppConfig, +} from '@dspace/config/app-config.interface'; +import { BitstreamDataService } from '@dspace/core/data/bitstream-data.service'; +import { RemoteData } from '@dspace/core/data/remote-data'; +import { + Bitstream, + ChecksumInfo, +} from '@dspace/core/shared/bitstream.model'; +import { BitstreamFormat } from '@dspace/core/shared/bitstream-format.model'; +import { Item } from '@dspace/core/shared/item.model'; +import { getFirstCompletedRemoteData } from '@dspace/core/shared/operators'; +import { + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; +import { + BehaviorSubject, + map, + Observable, + take, +} from 'rxjs'; + +import { ThemedThumbnailComponent } from '../../thumbnail/themed-thumbnail.component'; +import { TruncatableComponent } from '../truncatable/truncatable.component'; +import { TruncatablePartComponent } from '../truncatable/truncatable-part/truncatable-part.component'; +import { FileSizePipe } from '../utils/file-size-pipe'; +import { FileDownloadButtonComponent } from './attachment-render/types/file-download-button/file-download-button.component'; + + +@Component({ + selector: 'ds-bitstream-attachment', + templateUrl: './bitstream-attachment.component.html', + styleUrls: ['./bitstream-attachment.component.scss'], + imports: [ + AsyncPipe, + FileDownloadButtonComponent, + FileSizePipe, + ThemedThumbnailComponent, + TitleCasePipe, + TranslateModule, + TruncatableComponent, + TruncatablePartComponent, + ], +}) +export class BitstreamAttachmentComponent implements OnInit { + + /** + * Environment variables configuring the fields to be viewed + */ + metadataConfig: AttachmentMetadataConfig[]; + + /** + * All item providers to show buttons of + */ + allAttachmentProviders: string[] = []; + + /** + * Attachment metadata to be displayed in title case + */ + attachmentTypeMetadata = 'dc.type'; + + /** + * Attachment to be displayed + */ + @Input() attachment: Bitstream; + + /** + * The item which the bitstream belongs to + */ + @Input() item: Item; + + /** + * Format of the bitstream + */ + bitstreamFormat$: Observable; + + /** + * Size of the bitstream + */ + bitstreamSize: number; + /** + * Checksum info of the bitstream + */ + checksumInfo: ChecksumInfo; + + thumbnail$: BehaviorSubject> = new BehaviorSubject>(null); + + /** + * Configuration type enum + */ + AdvancedAttachmentElementType = AdvancedAttachmentElementType; + + constructor( + protected readonly bitstreamDataService: BitstreamDataService, + protected readonly translateService: TranslateService, + protected readonly router: Router, + protected readonly route: ActivatedRoute, + @Inject(APP_CONFIG) protected appConfig: AppConfig, + ) { + this.metadataConfig = this.appConfig.layout.advancedAttachmentRendering.metadata; + } + + ngOnInit() { + this.attachment.thumbnail.pipe( + getFirstCompletedRemoteData(), + ).subscribe((thumbnail: RemoteData) => { + this.thumbnail$.next(thumbnail); + }); + this.allAttachmentProviders = this.attachment?.allMetadataValues('bitstream.viewer.provider'); + this.bitstreamFormat$ = this.getFormat(this.attachment); + this.bitstreamSize = this.getSize(this.attachment); + this.checksumInfo = this.getChecksum(this.attachment); + } + + /** + * Returns the size of given bitstreams in bytes + * @param bitstream + */ + getSize(bitstream: Bitstream): number { + return bitstream.sizeBytes; + } + + /** + * Returns format of given bistream + * @param bitstream + */ + getFormat(bitstream: Bitstream): Observable { + return bitstream.format?.pipe( + map((rd: RemoteData) => { + return rd.payload?.shortDescription; + }), + take(1), + ); + } + + /** + * Returns the size of given bitstreams in bytes + * @param bitstream + */ + getChecksum(bitstream: Bitstream): ChecksumInfo { + return bitstream.checkSum; + } +} diff --git a/src/app/shared/bitstream-attachment/section/attachment-section.component.html b/src/app/shared/bitstream-attachment/section/attachment-section.component.html new file mode 100644 index 00000000000..19be86dbc01 --- /dev/null +++ b/src/app/shared/bitstream-attachment/section/attachment-section.component.html @@ -0,0 +1,26 @@ +@let bitstreams = (bitstreams$ | async); + + @if (bitstreams?.length > 0) { + +
+ + @for (file of bitstreams; track file) { + + } + @if (isLoading) { + + } + @if (!isLastPage) { +
+ +
+ } + @if (isLastPage && currentPage !== 1) { +
+ +
+ } +
+
+ } +
diff --git a/src/app/shared/bitstream-attachment/section/attachment-section.component.spec.ts b/src/app/shared/bitstream-attachment/section/attachment-section.component.spec.ts new file mode 100644 index 00000000000..1baaccd50dd --- /dev/null +++ b/src/app/shared/bitstream-attachment/section/attachment-section.component.spec.ts @@ -0,0 +1,139 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { ActivatedRoute } from '@angular/router'; +import { APP_CONFIG } from '@dspace/config/app-config.interface'; +import { BitstreamDataService } from '@dspace/core/data/bitstream-data.service'; +import { APP_DATA_SERVICES_MAP } from '@dspace/core/data-services-map-type'; +import { LocaleService } from '@dspace/core/locale/locale.service'; +import { NotificationsService } from '@dspace/core/notification-system/notifications.service'; +import { Bitstream } from '@dspace/core/shared/bitstream.model'; +import { ActivatedRouteStub } from '@dspace/core/testing/active-router.stub'; +import { MockBitstreamFormat1 } from '@dspace/core/testing/item.mock'; +import { NotificationsServiceStub } from '@dspace/core/testing/notifications-service.stub'; +import { TranslateLoaderMock } from '@dspace/core/testing/translate-loader.mock'; +import { createPaginatedList } from '@dspace/core/testing/utils.test'; +import { createSuccessfulRemoteDataObject$ } from '@dspace/core/utilities/remote-data.utils'; +import { XSRFService } from '@dspace/core/xsrf/xsrf.service'; +import { provideMockStore } from '@ngrx/store/testing'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; +import { of } from 'rxjs'; +import { environment } from 'src/environments/environment'; + +import { MetadataFieldWrapperComponent } from '../../metadata-field-wrapper/metadata-field-wrapper.component'; +import { getMockThemeService } from '../../theme-support/test/theme-service.mock'; +import { ThemeService } from '../../theme-support/theme.service'; +import { FileSizePipe } from '../../utils/file-size-pipe'; +import { VarDirective } from '../../utils/var.directive'; +import { BitstreamAttachmentComponent } from '../bitstream-attachment.component'; +import { AttachmentSectionComponent } from './attachment-section.component'; + +describe('AttachmentSectionComponent', () => { + let comp: AttachmentSectionComponent; + let fixture: ComponentFixture; + let localeService: any; + const languageList = ['en;q=1', 'de;q=0.8']; + const mockLocaleService = jasmine.createSpyObj('LocaleService', { + getCurrentLanguageCode: jasmine.createSpy('getCurrentLanguageCode'), + getLanguageCodeList: of(languageList), + }); + + const bitstreamDataService = jasmine.createSpyObj('bitstreamDataService', { + findAllByItemAndBundleName: createSuccessfulRemoteDataObject$(createPaginatedList([])), + findPrimaryBitstreamByItemAndName: of(null), + }); + + const mockBitstream: Bitstream = Object.assign(new Bitstream(), + { + sizeBytes: 10201, + content: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713/content', + format: of(MockBitstreamFormat1), + bundleName: 'ORIGINAL', + _links: { + self: { + href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713', + }, + content: { + href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713/content', + }, + }, + id: 'cf9b0c8e-a1eb-4b65-afd0-567366448713', + uuid: 'cf9b0c8e-a1eb-4b65-afd0-567366448713', + type: 'bitstream', + metadata: { + 'dc.title': [ + { + language: null, + value: 'test_word.docx', + }, + ], + }, + }); + + beforeEach(waitForAsync(() => { + + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock, + }, + }), BrowserAnimationsModule, AttachmentSectionComponent, VarDirective, FileSizePipe], + providers: [ + { provide: APP_DATA_SERVICES_MAP, useValue: {} }, + { provide: XSRFService, useValue: {} }, + { provide: BitstreamDataService, useValue: bitstreamDataService }, + { provide: NotificationsService, useValue: new NotificationsServiceStub() }, + { provide: APP_CONFIG, useValue: environment }, + { provide: ThemeService, useValue: getMockThemeService() }, + { provide: ActivatedRoute, useValue: new ActivatedRouteStub() }, + { provide: LocaleService, useValue: mockLocaleService }, + provideMockStore(), + ], + schemas: [NO_ERRORS_SCHEMA], + }) + .overrideComponent(AttachmentSectionComponent, { + remove: { + imports: [ + MetadataFieldWrapperComponent, + BitstreamAttachmentComponent, + ], + }, + }) + .compileComponents(); + })); + + beforeEach(waitForAsync(() => { + localeService = TestBed.inject(LocaleService); + localeService.getCurrentLanguageCode.and.returnValue(of('en')); + fixture = TestBed.createComponent(AttachmentSectionComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + })); + + it('should create', () => { + expect(comp).toBeTruthy(); + }); + + it('should set the id of primary bitstream', () => { + comp.primaryBitstreamId = undefined; + bitstreamDataService.findPrimaryBitstreamByItemAndName.and.returnValue(of(mockBitstream)); + comp.ngOnInit(); + expect(comp.primaryBitstreamId).toBe(mockBitstream.id); + }); + + it('should not set the id of primary bitstream', () => { + comp.primaryBitstreamId = undefined; + bitstreamDataService.findPrimaryBitstreamByItemAndName.and.returnValue(of(null)); + comp.ngOnInit(); + expect(comp.primaryBitstreamId).toBeUndefined(); + }); + +}); diff --git a/src/app/shared/bitstream-attachment/section/attachment-section.component.ts b/src/app/shared/bitstream-attachment/section/attachment-section.component.ts new file mode 100644 index 00000000000..94460420c2e --- /dev/null +++ b/src/app/shared/bitstream-attachment/section/attachment-section.component.ts @@ -0,0 +1,26 @@ +import { AsyncPipe } from '@angular/common'; +import { Component } from '@angular/core'; +import { TranslateModule } from '@ngx-translate/core'; + +import { FileSectionComponent } from '../../../item-page/simple/field-components/file-section/file-section.component'; +import { ThemedLoadingComponent } from '../../loading/themed-loading.component'; +import { MetadataFieldWrapperComponent } from '../../metadata-field-wrapper/metadata-field-wrapper.component'; +import { BitstreamAttachmentComponent } from '../bitstream-attachment.component'; + +/** + * This component renders the attachment section of the item + */ +@Component({ + selector: 'ds-item-page-attachment-section', + templateUrl: './attachment-section.component.html', + imports: [ + AsyncPipe, + BitstreamAttachmentComponent, + MetadataFieldWrapperComponent, + ThemedLoadingComponent, + TranslateModule, + ], +}) +export class AttachmentSectionComponent extends FileSectionComponent { + +} 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 43926113f6e..aab0b39b19c 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 @@ -1,15 +1,19 @@ @if (showAccessStatusBadge) { } - - @if ((canDownload$ | async) === false && (canDownloadWithToken$ | async) === false) { + @if ((canDownload) === false && (canDownloadWithToken) === false) { - } @else if ((canDownloadWithToken$ | async) && (canDownload$ | async) === false) { + } @else if (canDownloadWithToken && (canDownload === false)) { MD5 message-digest algorithm can be used to verify the integrity of the file that you have uploaded. You can calculate its value locally via tools that are generally available in each operative system like md5sum", + + "layout.advanced-attachment.format": "Format", + + "layout.advanced-attachment.dc.type": "Type", + + "layout.advanced-attachment.dc.description": "Description", + + "layout.advanced-attachment.no_thumbnail": "No Thumbnail Available", + + "layout.advanced-attachment.download": "Download", + + "layout.advanced-attachment.requestACopy": "Request a copy", + + "layout.advanced-attachment.viewMore": "View More", + + "layout.advanced-attachment.label.not-present": "(not present)", + "loading.bitstream": "Loading bitstream...", "loading.bitstreams": "Loading bitstreams...", @@ -7216,6 +7240,8 @@ "file-download-link.download": "Download ", + "file-download-button.download": "Download", + "register-page.registration.aria.label": "Enter your e-mail address", "forgot-email.form.aria.label": "Enter your e-mail address", @@ -7336,6 +7362,8 @@ "file-download-link.request-copy": "Request a copy of ", + "file-download-button.request-copy": "Request a copy", + "item.preview.organization.url": "URL", "item.preview.organization.address.addressLocality": "City", diff --git a/src/config/advanced-attachment-rendering.config.ts b/src/config/advanced-attachment-rendering.config.ts new file mode 100644 index 00000000000..cbbf4a6f9d0 --- /dev/null +++ b/src/config/advanced-attachment-rendering.config.ts @@ -0,0 +1,24 @@ +/** + * Interface configuration to define the advanced attachment rendering settings + */ +export interface AdvancedAttachmentRenderingConfig { + metadata: AttachmentMetadataConfig[]; +} + +/** + * Interface configuration to select which are the advanced attachment information to show + */ +export interface AttachmentMetadataConfig { + name: string; + type: AdvancedAttachmentElementType; + truncatable?: boolean; +} + +/** + * Interface configuration to define the type for each element shown in the advanced attachment feature + */ +export enum AdvancedAttachmentElementType { + Metadata = 'metadata', + Attribute = 'attribute' +} + diff --git a/src/config/default-app-config.ts b/src/config/default-app-config.ts index c70ba1882fc..3a8fc0bc6c8 100644 --- a/src/config/default-app-config.ts +++ b/src/config/default-app-config.ts @@ -5,6 +5,7 @@ import { AccessibilitySettingsConfig } from './accessibility-settings.config'; import { ActuatorsConfig } from './actuators.config'; import { AddToAnyPluginConfig } from './add-to-any-plugin-config'; import { AdminNotifyMetricsRow } from './admin-notify-metrics.config'; +import { AdvancedAttachmentElementType } from './advanced-attachment-rendering.config'; import { AppConfig } from './app-config.interface'; import { AuthConfig } from './auth-config.interfaces'; import { BrowseByConfig } from './browse-by-config.interface'; @@ -748,6 +749,38 @@ export class DefaultAppConfig implements AppConfig { }, }, ], + showDownloadLinkAsAttachment: false, + advancedAttachmentRendering: { + metadata: [ + { + name: 'dc.title', + type: AdvancedAttachmentElementType.Metadata, + truncatable: false, + }, + { + name: 'dc.type', + type: AdvancedAttachmentElementType.Metadata, + truncatable: false, + }, + { + name: 'dc.description', + type: AdvancedAttachmentElementType.Metadata, + truncatable: true, + }, + { + name: 'size', + type: AdvancedAttachmentElementType.Attribute, + }, + { + name: 'format', + type: AdvancedAttachmentElementType.Attribute, + }, + { + name: 'checksum', + type: AdvancedAttachmentElementType.Attribute, + }, + ], + }, }; // Search result configuration for authority metadata processing diff --git a/src/config/layout-config.interfaces.ts b/src/config/layout-config.interfaces.ts index a506880444b..709292a0068 100644 --- a/src/config/layout-config.interfaces.ts +++ b/src/config/layout-config.interfaces.ts @@ -1,3 +1,4 @@ +import { AdvancedAttachmentRenderingConfig } from './advanced-attachment-rendering.config'; import { Config } from './config.interface'; /** @@ -66,4 +67,10 @@ export interface LayoutConfig extends Config { * Each entry defines how entities of a specific type should be visually represented with icons and styles. */ authorityRef: AuthorityRefConfig[]; + showDownloadLinkAsAttachment: boolean; + /** + * Configuration for advanced attachment rendering features. + * Controls pagination and metadata display for bitstream attachments. + */ + advancedAttachmentRendering: AdvancedAttachmentRenderingConfig; } diff --git a/src/environments/environment.test.ts b/src/environments/environment.test.ts index 6c3a569e485..34f254cba71 100644 --- a/src/environments/environment.test.ts +++ b/src/environments/environment.test.ts @@ -1,4 +1,5 @@ // This configuration is only used for unit tests, end-to-end tests use environment.production.ts +import { AdvancedAttachmentElementType } from '@dspace/config/advanced-attachment-rendering.config'; import { NotificationAnimationsType } from '@dspace/config/notifications-config.interfaces'; import { RestRequestMethod } from '@dspace/config/rest-request-method'; import { BuildConfig } from 'src/config/build-config.interface'; @@ -545,15 +546,45 @@ export const environment: BuildConfig = { }, }, ], + showDownloadLinkAsAttachment: false, + advancedAttachmentRendering: { + metadata: [ + { + name: 'dc.title', + type: AdvancedAttachmentElementType.Metadata, + truncatable: false, + }, + { + name: 'dc.type', + type: AdvancedAttachmentElementType.Metadata, + truncatable: false, + }, + { + name: 'dc.description', + type: AdvancedAttachmentElementType.Metadata, + truncatable: true, + }, + { + name: 'size', + type: AdvancedAttachmentElementType.Attribute, + }, + { + name: 'format', + type: AdvancedAttachmentElementType.Attribute, + }, + { + name: 'checksum', + type: AdvancedAttachmentElementType.Attribute, + }, + ], + }, }, searchResult: { authorMetadata: ['dc.contributor.author', 'dc.creator', 'dc.contributor.*'], followAuthorityMaxItemLimit: 100, - followAuthorityMetadataValuesLimit: 5, - - followAuthorityMetadata: [ + followAuthorityMetadata: [ { type: 'Publication', metadata: ['dc.contributor.author'], diff --git a/src/styles/_custom_variables.scss b/src/styles/_custom_variables.scss index c9410b60329..51b020d7cd8 100644 --- a/src/styles/_custom_variables.scss +++ b/src/styles/_custom_variables.scss @@ -85,6 +85,11 @@ --ds-thumbnail-placeholder-border: 1px solid #{$gray-300}; --ds-thumbnail-placeholder-color: #{lighten($gray-800, 7%)}; + --ds-advanced-attachment-image-max-height: 160px; + --ds-advanced-attachment-image-object-fit: cover; + --ds-advanced-attachment-image-object-position: 0% 0%; + --ds-advanced-attachment-thumbnail-placeholder-font-weight: bold; + --ds-dso-selector-list-max-height: 475px; --ds-dso-selector-current-background-color: #eeeeee; --ds-dso-selector-current-background-hover-color: #{darken(#eeeeee, 10%)}; diff --git a/src/themes/custom/app/item-page/full/field-components/file-section/full-file-section.component.ts b/src/themes/custom/app/item-page/full/field-components/file-section/full-file-section.component.ts index 783be8f2caf..21c459ef47d 100644 --- a/src/themes/custom/app/item-page/full/field-components/file-section/full-file-section.component.ts +++ b/src/themes/custom/app/item-page/full/field-components/file-section/full-file-section.component.ts @@ -10,6 +10,7 @@ import { FileSizePipe } from '../../../../../../../app/shared/utils/file-size-pi import { VarDirective } from '../../../../../../../app/shared/utils/var.directive'; import { ThemedThumbnailComponent } from '../../../../../../../app/thumbnail/themed-thumbnail.component'; + @Component({ selector: 'ds-themed-item-page-full-file-section', // styleUrls: ['./full-file-section.component.scss'], diff --git a/src/themes/custom/app/item-page/simple/field-components/file-section/file-section.component.ts b/src/themes/custom/app/item-page/simple/field-components/file-section/file-section.component.ts index fef3deb7b5d..1fd701b9e0f 100644 --- a/src/themes/custom/app/item-page/simple/field-components/file-section/file-section.component.ts +++ b/src/themes/custom/app/item-page/simple/field-components/file-section/file-section.component.ts @@ -10,6 +10,7 @@ import { MetadataFieldWrapperComponent } from '../../../../../../../app/shared/m import { FileSizePipe } from '../../../../../../../app/shared/utils/file-size-pipe'; import { VarDirective } from '../../../../../../../app/shared/utils/var.directive'; + @Component({ selector: 'ds-themed-item-page-file-section', // templateUrl: './file-section.component.html', diff --git a/src/themes/custom/app/item-page/simple/item-types/publication/publication.component.ts b/src/themes/custom/app/item-page/simple/item-types/publication/publication.component.ts index 97d7d18e463..9181140b228 100644 --- a/src/themes/custom/app/item-page/simple/item-types/publication/publication.component.ts +++ b/src/themes/custom/app/item-page/simple/item-types/publication/publication.component.ts @@ -21,6 +21,7 @@ import { ItemPageUriFieldComponent } from '../../../../../../../app/item-page/si import { PublicationComponent as BaseComponent } from '../../../../../../../app/item-page/simple/item-types/publication/publication.component'; import { ThemedMetadataRepresentationListComponent } from '../../../../../../../app/item-page/simple/metadata-representation-list/themed-metadata-representation-list.component'; import { RelatedItemsComponent } from '../../../../../../../app/item-page/simple/related-items/related-items-component'; +import { AttachmentSectionComponent } from '../../../../../../../app/shared/bitstream-attachment/section/attachment-section.component'; import { DsoEditMenuComponent } from '../../../../../../../app/shared/dso-page/dso-edit-menu/dso-edit-menu.component'; import { MetadataFieldWrapperComponent } from '../../../../../../../app/shared/metadata-field-wrapper/metadata-field-wrapper.component'; import { listableObjectComponent } from '../../../../../../../app/shared/object-collection/shared/listable-object/listable-object.decorator'; @@ -37,6 +38,7 @@ import { ThemedThumbnailComponent } from '../../../../../../../app/thumbnail/the changeDetection: ChangeDetectionStrategy.OnPush, imports: [ AsyncPipe, + AttachmentSectionComponent, CollectionsComponent, DsoEditMenuComponent, GenericItemPageFieldComponent, diff --git a/src/themes/custom/app/item-page/simple/item-types/untyped-item/untyped-item.component.ts b/src/themes/custom/app/item-page/simple/item-types/untyped-item/untyped-item.component.ts index ff4f8d530a9..e613dcb437f 100644 --- a/src/themes/custom/app/item-page/simple/item-types/untyped-item/untyped-item.component.ts +++ b/src/themes/custom/app/item-page/simple/item-types/untyped-item/untyped-item.component.ts @@ -22,6 +22,7 @@ import { ThemedItemPageTitleFieldComponent } from '../../../../../../../app/item import { ItemPageUriFieldComponent } from '../../../../../../../app/item-page/simple/field-components/specific-field/uri/item-page-uri-field.component'; import { UntypedItemComponent as BaseComponent } from '../../../../../../../app/item-page/simple/item-types/untyped-item/untyped-item.component'; import { ThemedMetadataRepresentationListComponent } from '../../../../../../../app/item-page/simple/metadata-representation-list/themed-metadata-representation-list.component'; +import { AttachmentSectionComponent } from '../../../../../../../app/shared/bitstream-attachment/section/attachment-section.component'; import { DsoEditMenuComponent } from '../../../../../../../app/shared/dso-page/dso-edit-menu/dso-edit-menu.component'; import { MetadataFieldWrapperComponent } from '../../../../../../../app/shared/metadata-field-wrapper/metadata-field-wrapper.component'; import { listableObjectComponent } from '../../../../../../../app/shared/object-collection/shared/listable-object/listable-object.decorator'; @@ -40,6 +41,7 @@ import { ThemedThumbnailComponent } from '../../../../../../../app/thumbnail/the changeDetection: ChangeDetectionStrategy.OnPush, imports: [ AsyncPipe, + AttachmentSectionComponent, CollectionsComponent, DsoEditMenuComponent, GenericItemPageFieldComponent,