From 5bd912c850d16b47276185e1fbfde1a16cdbef9a Mon Sep 17 00:00:00 2001 From: FrancescoMolinaro Date: Tue, 17 Feb 2026 17:20:57 +0100 Subject: [PATCH 01/11] [DURACOM-455] add bitstream download redirect guard --- .../bitstream-download-redirect.guard.spec.ts | 223 ++++++++++++++++++ .../bitstream-download-redirect.guard.ts | 102 ++++++++ .../bitstream-page/bitstream-page-routes.ts | 2 + .../core/services/hard-redirect.service.ts | 4 +- .../server-hard-redirect.service.spec.ts | 26 +- .../services/server-hard-redirect.service.ts | 17 +- 6 files changed, 370 insertions(+), 4 deletions(-) create mode 100644 src/app/bitstream-page/bitstream-download-redirect.guard.spec.ts create mode 100644 src/app/bitstream-page/bitstream-download-redirect.guard.ts 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..da176ff733c --- /dev/null +++ b/src/app/bitstream-page/bitstream-download-redirect.guard.ts @@ -0,0 +1,102 @@ +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'; + + +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 e6104cefb9c..8475dd116fc 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 43799f56b3c..7716444571c 100644 --- a/src/app/core/services/server-hard-redirect.service.spec.ts +++ b/src/app/core/services/server-hard-redirect.service.spec.ts @@ -18,11 +18,16 @@ 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'; beforeEach(() => { mockRequest.protocol = 'https'; + mockRequest.path = '/bitstreams/test-uuid/download'; mockRequest.headers = { host: 'test-host.com:4000', }; @@ -86,7 +91,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); @@ -98,4 +103,21 @@ describe('ServerHardRedirectService', () => { }); }); + describe('Should add cors header on download path', () => { + const redirect = 'https://private-url:4000/server/api/bitstreams/uuid'; + const environmentWithSSRUrl: any = { ...envConfig, ...{ ...envConfig.rest, rest: { + ssrBaseUrl: 'https://private-url:4000/server', + baseUrl: 'https://public-url/server', + } } }; + service = new ServerHardRedirectService(environmentWithSSRUrl, mockRequest, mockResponse, serverResponseService); + + beforeEach(() => { + service.redirect(redirect, null, true); + }); + + it('should set header', () => { + expect(serverResponseService.setHeader).toHaveBeenCalled(); + }); + }); + }); diff --git a/src/app/core/services/server-hard-redirect.service.ts b/src/app/core/services/server-hard-redirect.service.ts index ecd94d06165..8957ae3dc18 100644 --- a/src/app/core/services/server-hard-redirect.service.ts +++ b/src/app/core/services/server-hard-redirect.service.ts @@ -17,6 +17,7 @@ import { RESPONSE, } from '../../../express.tokens'; import { HardRedirectService } from './hard-redirect.service'; +import { ServerResponseService } from './server-response.service'; /** * Service for performing hard redirects within the server app module @@ -28,6 +29,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(); } @@ -39,8 +41,9 @@ 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) { + redirect(url: string, statusCode?: number, shouldSetCorsHeader?: boolean) { if (url === this.req.url) { return; } @@ -70,6 +73,10 @@ export class ServerHardRedirectService extends HardRedirectService { status = 302; } + if (shouldSetCorsHeader) { + this.setCorsHeader(); + } + console.info(`Redirecting from ${this.req.url} to ${redirectUrl} with ${status}`); this.res.redirect(status, redirectUrl); @@ -96,4 +103,12 @@ export class ServerHardRedirectService extends HardRedirectService { getCurrentOrigin(): string { return this.req.protocol + '://' + this.req.headers.host; } + + /** + * Set CORS header to allow embedding of redirected content. + * The actual security header will be set by the rest + */ + setCorsHeader() { + this.responseService.setHeader('Access-Control-Allow-Origin', '*'); + } } From 64e866e13c01381a952b17e179d8b52716bde5ef Mon Sep 17 00:00:00 2001 From: FrancescoMolinaro Date: Wed, 18 Feb 2026 10:45:12 +0100 Subject: [PATCH 02/11] [DURACOM-455] add download button --- .../full-file-section.component.html | 29 +++-- .../full-file-section.component.ts | 2 + .../file-section/file-section.component.html | 39 ++++-- .../file-section/file-section.component.ts | 5 + .../file-download-button.component.html | 23 ++++ .../file-download-button.component.scss | 0 .../file-download-button.component.spec.ts | 118 ++++++++++++++++++ .../file-download-button.component.ts | 36 ++++++ src/assets/i18n/en.json5 | 2 + src/config/default-app-config.ts | 1 + src/config/layout-config.interfaces.ts | 1 + src/environments/environment.test.ts | 1 + .../full-file-section.component.ts | 2 + .../file-section/file-section.component.ts | 2 + 14 files changed, 241 insertions(+), 20 deletions(-) create mode 100644 src/app/shared/file-download-button/file-download-button.component.html create mode 100644 src/app/shared/file-download-button/file-download-button.component.scss create mode 100644 src/app/shared/file-download-button/file-download-button.component.spec.ts create mode 100644 src/app/shared/file-download-button/file-download-button.component.ts 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 0d7a0ef6dab..f268f7b6b44 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 @@ -40,11 +40,16 @@

- - - {{ "item.page.filesection.download" | translate }} - - + @if (showLinkAsButton) { + + } @else { + + + {{ "item.page.filesection.download" | translate }} + + + } +
} @@ -92,11 +97,15 @@

- - - {{ "item.page.filesection.download" | translate }} - - + @if (showLinkAsButton) { + + } @else { + + + {{ "item.page.filesection.download" | translate }} + + + }
} diff --git a/src/app/item-page/full/field-components/file-section/full-file-section.component.ts b/src/app/item-page/full/field-components/file-section/full-file-section.component.ts index d3106fe8bed..96337d92076 100644 --- a/src/app/item-page/full/field-components/file-section/full-file-section.component.ts +++ b/src/app/item-page/full/field-components/file-section/full-file-section.component.ts @@ -34,6 +34,7 @@ import { tap, } from 'rxjs/operators'; +import { FileDownloadButtonComponent } from '../../../../shared/file-download-button/file-download-button.component'; import { ThemedFileDownloadLinkComponent } from '../../../../shared/file-download-link/themed-file-download-link.component'; import { MetadataFieldWrapperComponent } from '../../../../shared/metadata-field-wrapper/metadata-field-wrapper.component'; import { PaginationComponent } from '../../../../shared/pagination/pagination.component'; @@ -53,6 +54,7 @@ import { FileSectionComponent } from '../../../simple/field-components/file-sect templateUrl: './full-file-section.component.html', imports: [ AsyncPipe, + FileDownloadButtonComponent, FileSizePipe, MetadataFieldWrapperComponent, PaginationComponent, 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..ce27ba2e886 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,18 +3,37 @@
@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 (showLinkAsButton) { + + + + } @else { + + + + } + } @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..28a86e94d1a 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 @@ -25,6 +25,7 @@ import { } from '@ngx-translate/core'; import { BehaviorSubject } from 'rxjs'; +import { FileDownloadButtonComponent } from '../../../../shared/file-download-button/file-download-button.component'; import { ThemedFileDownloadLinkComponent } from '../../../../shared/file-download-link/themed-file-download-link.component'; import { ThemedLoadingComponent } from '../../../../shared/loading/themed-loading.component'; import { MetadataFieldWrapperComponent } from '../../../../shared/metadata-field-wrapper/metadata-field-wrapper.component'; @@ -40,6 +41,7 @@ import { VarDirective } from '../../../../shared/utils/var.directive'; templateUrl: './file-section.component.html', imports: [ CommonModule, + FileDownloadButtonComponent, FileSizePipe, MetadataFieldWrapperComponent, ThemedFileDownloadLinkComponent, @@ -68,6 +70,8 @@ export class FileSectionComponent implements OnInit { primaryBitstreamId: string; + showLinkAsButton: boolean; + constructor( protected bitstreamDataService: BitstreamDataService, protected notificationsService: NotificationsService, @@ -76,6 +80,7 @@ export class FileSectionComponent implements OnInit { @Inject(APP_CONFIG) protected appConfig: AppConfig, ) { this.pageSize = this.appConfig.item.bitstream.pageSize; + this.showLinkAsButton = this.appConfig.layout.showDownloadLinkAsButton; } ngOnInit(): void { diff --git a/src/app/shared/file-download-button/file-download-button.component.html b/src/app/shared/file-download-button/file-download-button.component.html new file mode 100644 index 00000000000..ea189935d66 --- /dev/null +++ b/src/app/shared/file-download-button/file-download-button.component.html @@ -0,0 +1,23 @@ + + + + +@if (!hasNoDownload) { + @if (bitstreamPath$ | async; as bitstreamLink) { + @if (canDownload$ | async) { + + } @else { + + } + } +} + diff --git a/src/app/shared/file-download-button/file-download-button.component.scss b/src/app/shared/file-download-button/file-download-button.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/app/shared/file-download-button/file-download-button.component.spec.ts b/src/app/shared/file-download-button/file-download-button.component.spec.ts new file mode 100644 index 00000000000..5a0b960d035 --- /dev/null +++ b/src/app/shared/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/file-download-button/file-download-button.component.ts b/src/app/shared/file-download-button/file-download-button.component.ts new file mode 100644 index 00000000000..af53b945bd4 --- /dev/null +++ b/src/app/shared/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/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index ec69570545a..5a83646d4ff 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -7326,6 +7326,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/default-app-config.ts b/src/config/default-app-config.ts index 0ab5dd70eeb..376865da8e3 100644 --- a/src/config/default-app-config.ts +++ b/src/config/default-app-config.ts @@ -721,6 +721,7 @@ export class DefaultAppConfig implements AppConfig { }, }, ], + showDownloadLinkAsButton: true, }; searchResult: SearchResultConfig = { diff --git a/src/config/layout-config.interfaces.ts b/src/config/layout-config.interfaces.ts index c2a0a5f7d2e..9c1c348f996 100644 --- a/src/config/layout-config.interfaces.ts +++ b/src/config/layout-config.interfaces.ts @@ -16,4 +16,5 @@ export interface AuthorityRefConfig extends Config { export interface LayoutConfig extends Config { authorityRef: AuthorityRefConfig[]; + showDownloadLinkAsButton: boolean; } diff --git a/src/environments/environment.test.ts b/src/environments/environment.test.ts index dd855edc656..a5e1f76199a 100644 --- a/src/environments/environment.test.ts +++ b/src/environments/environment.test.ts @@ -516,6 +516,7 @@ export const environment: BuildConfig = { }, }, ], + showDownloadLinkAsButton: false, }, searchResult: { 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..5b54e732e67 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 @@ -3,6 +3,7 @@ import { Component } from '@angular/core'; import { TranslateModule } from '@ngx-translate/core'; import { FullFileSectionComponent as BaseComponent } from '../../../../../../../app/item-page/full/field-components/file-section/full-file-section.component'; +import { FileDownloadButtonComponent } from '../../../../../../../app/shared/file-download-button/file-download-button.component'; import { ThemedFileDownloadLinkComponent } from '../../../../../../../app/shared/file-download-link/themed-file-download-link.component'; import { MetadataFieldWrapperComponent } from '../../../../../../../app/shared/metadata-field-wrapper/metadata-field-wrapper.component'; import { PaginationComponent } from '../../../../../../../app/shared/pagination/pagination.component'; @@ -18,6 +19,7 @@ import { ThemedThumbnailComponent } from '../../../../../../../app/thumbnail/the templateUrl: '../../../../../../../app/item-page/full/field-components/file-section/full-file-section.component.html', imports: [ AsyncPipe, + FileDownloadButtonComponent, FileSizePipe, MetadataFieldWrapperComponent, PaginationComponent, 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..6a559cb65a4 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 @@ -4,6 +4,7 @@ import { TranslateModule } from '@ngx-translate/core'; import { FileSectionComponent as BaseComponent } from '../../../../../../../app/item-page/simple/field-components/file-section/file-section.component'; import { slideSidebarPadding } from '../../../../../../../app/shared/animations/slide'; +import { FileDownloadButtonComponent } from '../../../../../../../app/shared/file-download-button/file-download-button.component'; import { ThemedFileDownloadLinkComponent } from '../../../../../../../app/shared/file-download-link/themed-file-download-link.component'; import { ThemedLoadingComponent } from '../../../../../../../app/shared/loading/themed-loading.component'; import { MetadataFieldWrapperComponent } from '../../../../../../../app/shared/metadata-field-wrapper/metadata-field-wrapper.component'; @@ -17,6 +18,7 @@ import { VarDirective } from '../../../../../../../app/shared/utils/var.directiv animations: [slideSidebarPadding], imports: [ CommonModule, + FileDownloadButtonComponent, FileSizePipe, MetadataFieldWrapperComponent, ThemedFileDownloadLinkComponent, From 4f72074b5c2a0e10a7ac82008d5557e9bd942109 Mon Sep 17 00:00:00 2001 From: FrancescoMolinaro Date: Fri, 20 Feb 2026 14:28:07 +0100 Subject: [PATCH 03/11] [DURACOM-455] add advanced attachment --- config/config.example.yml | 19 ++ src/app/core/shared/bitstream.model.ts | 11 ++ .../full-file-section.component.html | 17 +- .../full-file-section.component.ts | 2 - .../file-section/file-section.component.html | 20 +-- .../file-section/file-section.component.ts | 24 ++- .../publication/publication.component.html | 7 +- .../publication/publication.component.ts | 2 + .../item-types/shared/item.component.ts | 3 + .../untyped-item/untyped-item.component.html | 7 +- .../untyped-item/untyped-item.component.ts | 2 + .../file-download-button.component.html | 18 ++ .../file-download-button.component.scss | 0 .../file-download-button.component.spec.ts | 0 .../file-download-button.component.ts | 2 +- .../bitstream-attachment.component.html | 79 +++++++++ .../bitstream-attachment.component.scss | 33 ++++ .../bitstream-attachment.component.spec.ts | 101 +++++++++++ .../bitstream-attachment.component.ts | 163 ++++++++++++++++++ .../section/attachment-section.component.html | 26 +++ .../attachment-section.component.spec.ts | 139 +++++++++++++++ .../section/attachment-section.component.ts | 26 +++ .../file-download-button.component.html | 23 --- src/assets/i18n/en.json5 | 28 +++ .../advanced-attachment-rendering.config.ts | 32 ++++ src/config/app-config.interface.ts | 2 + src/config/default-app-config.ts | 42 ++++- src/config/layout-config.interfaces.ts | 2 +- src/environments/environment.test.ts | 39 ++++- src/styles/_custom_variables.scss | 5 + .../full-file-section.component.ts | 3 +- .../file-section/file-section.component.ts | 3 +- .../publication/publication.component.ts | 2 + .../untyped-item/untyped-item.component.ts | 2 + 34 files changed, 815 insertions(+), 69 deletions(-) create mode 100644 src/app/shared/bitstream-attachment/attachment-render/types/file-download-button/file-download-button.component.html rename src/app/shared/{ => bitstream-attachment/attachment-render/types}/file-download-button/file-download-button.component.scss (100%) rename src/app/shared/{ => bitstream-attachment/attachment-render/types}/file-download-button/file-download-button.component.spec.ts (100%) rename src/app/shared/{ => bitstream-attachment/attachment-render/types}/file-download-button/file-download-button.component.ts (89%) create mode 100644 src/app/shared/bitstream-attachment/bitstream-attachment.component.html create mode 100644 src/app/shared/bitstream-attachment/bitstream-attachment.component.scss create mode 100644 src/app/shared/bitstream-attachment/bitstream-attachment.component.spec.ts create mode 100644 src/app/shared/bitstream-attachment/bitstream-attachment.component.ts create mode 100644 src/app/shared/bitstream-attachment/section/attachment-section.component.html create mode 100644 src/app/shared/bitstream-attachment/section/attachment-section.component.spec.ts create mode 100644 src/app/shared/bitstream-attachment/section/attachment-section.component.ts delete mode 100644 src/app/shared/file-download-button/file-download-button.component.html create mode 100644 src/config/advanced-attachment-rendering.config.ts diff --git a/config/config.example.yml b/config/config.example.yml index e848ea2c2f0..596f8a6c185 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -730,3 +730,22 @@ identifierSubtypes: iconPosition: IdentifierSubtypesIconPositionEnum.LEFT link: https://ror.org +# Configuration for advanced attachment rendering. Controls how bitstream attachments and their metadata are displayed. +advancedAttachmentRendering: + # Pagination settings for attachment lists + pagination: + enabled: true + elementsPerPage: 2 + # Metadata and attributes to display for each attachment. Use 'metadata' type for DSpace metadata fields, 'attribute' for bitstream properties (size, format, checksum). + metadata: + - name: dc.title + type: metadata + truncatable: false + - name: dc.description + type: metadata + truncatable: true + - name: size + type: attribute + - name: format + type: attribute + 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 f268f7b6b44..9b3045fd2f5 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 @@ -40,16 +40,11 @@

- @if (showLinkAsButton) { - - } @else { - + {{ "item.page.filesection.download" | translate }} - - } - +
} @@ -97,15 +92,11 @@

- @if (showLinkAsButton) { - - } @else { - + {{ "item.page.filesection.download" | translate }} - - } +
} diff --git a/src/app/item-page/full/field-components/file-section/full-file-section.component.ts b/src/app/item-page/full/field-components/file-section/full-file-section.component.ts index 96337d92076..d3106fe8bed 100644 --- a/src/app/item-page/full/field-components/file-section/full-file-section.component.ts +++ b/src/app/item-page/full/field-components/file-section/full-file-section.component.ts @@ -34,7 +34,6 @@ import { tap, } from 'rxjs/operators'; -import { FileDownloadButtonComponent } from '../../../../shared/file-download-button/file-download-button.component'; import { ThemedFileDownloadLinkComponent } from '../../../../shared/file-download-link/themed-file-download-link.component'; import { MetadataFieldWrapperComponent } from '../../../../shared/metadata-field-wrapper/metadata-field-wrapper.component'; import { PaginationComponent } from '../../../../shared/pagination/pagination.component'; @@ -54,7 +53,6 @@ import { FileSectionComponent } from '../../../simple/field-components/file-sect templateUrl: './full-file-section.component.html', imports: [ AsyncPipe, - FileDownloadButtonComponent, FileSizePipe, MetadataFieldWrapperComponent, PaginationComponent, 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 ce27ba2e886..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 @@ -19,21 +19,11 @@ } - - @if (showLinkAsButton) { - - - - } @else { - - - - } - + + + } @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 28a86e94d1a..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'; @@ -25,7 +29,6 @@ import { } from '@ngx-translate/core'; import { BehaviorSubject } from 'rxjs'; -import { FileDownloadButtonComponent } from '../../../../shared/file-download-button/file-download-button.component'; import { ThemedFileDownloadLinkComponent } from '../../../../shared/file-download-link/themed-file-download-link.component'; import { ThemedLoadingComponent } from '../../../../shared/loading/themed-loading.component'; import { MetadataFieldWrapperComponent } from '../../../../shared/metadata-field-wrapper/metadata-field-wrapper.component'; @@ -41,7 +44,6 @@ import { VarDirective } from '../../../../shared/utils/var.directive'; templateUrl: './file-section.component.html', imports: [ CommonModule, - FileDownloadButtonComponent, FileSizePipe, MetadataFieldWrapperComponent, ThemedFileDownloadLinkComponent, @@ -70,7 +72,7 @@ export class FileSectionComponent implements OnInit { primaryBitstreamId: string; - showLinkAsButton: boolean; + showDownloadLinkAsAttachment: boolean; constructor( protected bitstreamDataService: BitstreamDataService, @@ -80,7 +82,7 @@ export class FileSectionComponent implements OnInit { @Inject(APP_CONFIG) protected appConfig: AppConfig, ) { this.pageSize = this.appConfig.item.bitstream.pageSize; - this.showLinkAsButton = this.appConfig.layout.showDownloadLinkAsButton; + this.showDownloadLinkAsAttachment = this.appConfig.layout.showDownloadLinkAsAttachment; } ngOnInit(): void { @@ -111,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 a294cc025bc..d292a8e5942 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) { + + }
} - + @if (showDownloadLinkAsAttachment) { + + }
+ @if (showDownloadLinkAsAttachment) { + + } + {{ 'file-download-button.download' | translate }} + + } @else { + + } + } +} diff --git a/src/app/shared/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 similarity index 100% rename from src/app/shared/file-download-button/file-download-button.component.scss rename to src/app/shared/bitstream-attachment/attachment-render/types/file-download-button/file-download-button.component.scss diff --git a/src/app/shared/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 similarity index 100% rename from src/app/shared/file-download-button/file-download-button.component.spec.ts rename to src/app/shared/bitstream-attachment/attachment-render/types/file-download-button/file-download-button.component.spec.ts diff --git a/src/app/shared/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 similarity index 89% rename from src/app/shared/file-download-button/file-download-button.component.ts rename to src/app/shared/bitstream-attachment/attachment-render/types/file-download-button/file-download-button.component.ts index af53b945bd4..739aa758c6c 100644 --- a/src/app/shared/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 @@ -7,7 +7,7 @@ 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'; +import { FileDownloadLinkComponent } from '../../../../file-download-link/file-download-link.component'; @Component({ 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..b2c24795e15 --- /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) { +
+ {{ 'cris-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) { +

+ {{'cris-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) { +

+ {{'cris-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..eaf4f172ba7 --- /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: jasmine.createSpy('getCurrentLanguageCode'), + 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..40496790e69 --- /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.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-button/file-download-button.component.html b/src/app/shared/file-download-button/file-download-button.component.html deleted file mode 100644 index ea189935d66..00000000000 --- a/src/app/shared/file-download-button/file-download-button.component.html +++ /dev/null @@ -1,23 +0,0 @@ - - - - -@if (!hasNoDownload) { - @if (bitstreamPath$ | async; as bitstreamLink) { - @if (canDownload$ | async) { - - } @else { - - } - } -} - diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 5a83646d4ff..1d59e581d76 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -1747,6 +1747,32 @@ "cookies.consent.purpose.sharing": "Sharing", + "cris-layout.advanced-attachment.dc.title": "Name", + + "cris-layout.advanced-attachment.size": "Size", + + "cris-layout.advanced-attachment.checksum": "Checksum", + + "cris-layout.advanced-attachment.checksum.info.MD5": "The 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", + + "cris-layout.advanced-attachment.format": "Format", + + "cris-layout.advanced-attachment.dc.type": "Type", + + "cris-layout.advanced-attachment.dc.description": "Description", + + "cris-layout.advanced-attachment.no_thumbnail": "No Thumbnail Available", + + "cris-layout.advanced-attachment.download": "Download", + + "cris-layout.advanced-attachment.requestACopy": "Request a copy", + + "cris-layout.advanced-attachment.viewMore": "View More", + + "cris-layout.advanced-attachment.label.not-present": "(not present)", + + "cris-layout.attachment.viewMore": "View More", + "curation-task.task.citationpage.label": "Generate Citation Page", "curation-task.task.checklinks.label": "Check Links in Metadata", @@ -7206,6 +7232,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", diff --git a/src/config/advanced-attachment-rendering.config.ts b/src/config/advanced-attachment-rendering.config.ts new file mode 100644 index 00000000000..bbcdd13c941 --- /dev/null +++ b/src/config/advanced-attachment-rendering.config.ts @@ -0,0 +1,32 @@ +/** + * Interface configuration to define the advanced attachment rendering settings + */ +export interface AdvancedAttachmentRenderingConfig { + metadata: AttachmentMetadataConfig[]; + pagination: AdvancedAttachmentRenderingPaginationConfig; +} + +/** + * 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' +} + +/** + * Interface configuration to define the pagination of the advanced attachment rendering + */ +export interface AdvancedAttachmentRenderingPaginationConfig { + enabled: boolean; + elementsPerPage: number; +} diff --git a/src/config/app-config.interface.ts b/src/config/app-config.interface.ts index 297e2a3d61d..7124f9e7a16 100644 --- a/src/config/app-config.interface.ts +++ b/src/config/app-config.interface.ts @@ -6,6 +6,7 @@ import { import { AccessibilitySettingsConfig } from './accessibility-settings.config'; import { ActuatorsConfig } from './actuators.config'; import { AdminNotifyMetricsRow } from './admin-notify-metrics.config'; +import { AdvancedAttachmentRenderingConfig } from './advanced-attachment-rendering.config'; import { AuthConfig } from './auth-config.interfaces'; import { BrowseByConfig } from './browse-by-config.interface'; import { BundleConfig } from './bundle-config.interface'; @@ -81,6 +82,7 @@ interface AppConfig extends Config { followAuthorityMetadata: FollowAuthorityMetadata[]; followAuthorityMaxItemLimit: number; followAuthorityMetadataValuesLimit: number; + advancedAttachmentRendering: AdvancedAttachmentRenderingConfig; } /** diff --git a/src/config/default-app-config.ts b/src/config/default-app-config.ts index 376865da8e3..440e402f472 100644 --- a/src/config/default-app-config.ts +++ b/src/config/default-app-config.ts @@ -4,6 +4,10 @@ import { SearchResultConfig } from '@dspace/config/search-result-config.interfac import { AccessibilitySettingsConfig } from './accessibility-settings.config'; import { ActuatorsConfig } from './actuators.config'; import { AdminNotifyMetricsRow } from './admin-notify-metrics.config'; +import { + AdvancedAttachmentElementType, + AdvancedAttachmentRenderingConfig, +} from './advanced-attachment-rendering.config'; import { AppConfig } from './app-config.interface'; import { AuthConfig } from './auth-config.interfaces'; import { BrowseByConfig } from './browse-by-config.interface'; @@ -721,7 +725,7 @@ export class DefaultAppConfig implements AppConfig { }, }, ], - showDownloadLinkAsButton: true, + showDownloadLinkAsAttachment: true, }; searchResult: SearchResultConfig = { @@ -775,4 +779,40 @@ export class DefaultAppConfig implements AppConfig { }, ]; + advancedAttachmentRendering: AdvancedAttachmentRenderingConfig = { + pagination: { + enabled: true, + elementsPerPage: 2, + }, + 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, + }, + ], + }; + } diff --git a/src/config/layout-config.interfaces.ts b/src/config/layout-config.interfaces.ts index 9c1c348f996..f3ef0f85aea 100644 --- a/src/config/layout-config.interfaces.ts +++ b/src/config/layout-config.interfaces.ts @@ -16,5 +16,5 @@ export interface AuthorityRefConfig extends Config { export interface LayoutConfig extends Config { authorityRef: AuthorityRefConfig[]; - showDownloadLinkAsButton: boolean; + showDownloadLinkAsAttachment: boolean; } diff --git a/src/environments/environment.test.ts b/src/environments/environment.test.ts index a5e1f76199a..cfdcae4f5c9 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'; @@ -516,7 +517,7 @@ export const environment: BuildConfig = { }, }, ], - showDownloadLinkAsButton: false, + showDownloadLinkAsAttachment: false, }, searchResult: { @@ -572,4 +573,40 @@ export const environment: BuildConfig = { metadata: ['dc.contributor.author'], }, ], + + advancedAttachmentRendering: { + pagination: { + enabled: true, + elementsPerPage: 2, + }, + 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, + }, + ], + }, }; 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 5b54e732e67..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 @@ -3,7 +3,6 @@ import { Component } from '@angular/core'; import { TranslateModule } from '@ngx-translate/core'; import { FullFileSectionComponent as BaseComponent } from '../../../../../../../app/item-page/full/field-components/file-section/full-file-section.component'; -import { FileDownloadButtonComponent } from '../../../../../../../app/shared/file-download-button/file-download-button.component'; import { ThemedFileDownloadLinkComponent } from '../../../../../../../app/shared/file-download-link/themed-file-download-link.component'; import { MetadataFieldWrapperComponent } from '../../../../../../../app/shared/metadata-field-wrapper/metadata-field-wrapper.component'; import { PaginationComponent } from '../../../../../../../app/shared/pagination/pagination.component'; @@ -11,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'], @@ -19,7 +19,6 @@ import { ThemedThumbnailComponent } from '../../../../../../../app/thumbnail/the templateUrl: '../../../../../../../app/item-page/full/field-components/file-section/full-file-section.component.html', imports: [ AsyncPipe, - FileDownloadButtonComponent, FileSizePipe, MetadataFieldWrapperComponent, PaginationComponent, 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 6a559cb65a4..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 @@ -4,13 +4,13 @@ import { TranslateModule } from '@ngx-translate/core'; import { FileSectionComponent as BaseComponent } from '../../../../../../../app/item-page/simple/field-components/file-section/file-section.component'; import { slideSidebarPadding } from '../../../../../../../app/shared/animations/slide'; -import { FileDownloadButtonComponent } from '../../../../../../../app/shared/file-download-button/file-download-button.component'; import { ThemedFileDownloadLinkComponent } from '../../../../../../../app/shared/file-download-link/themed-file-download-link.component'; import { ThemedLoadingComponent } from '../../../../../../../app/shared/loading/themed-loading.component'; import { MetadataFieldWrapperComponent } from '../../../../../../../app/shared/metadata-field-wrapper/metadata-field-wrapper.component'; 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', @@ -18,7 +18,6 @@ import { VarDirective } from '../../../../../../../app/shared/utils/var.directiv animations: [slideSidebarPadding], imports: [ CommonModule, - FileDownloadButtonComponent, FileSizePipe, MetadataFieldWrapperComponent, ThemedFileDownloadLinkComponent, 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, From 6878cd6ae68f59c63dd8170831667aa1ae33daa5 Mon Sep 17 00:00:00 2001 From: FrancescoMolinaro Date: Fri, 20 Feb 2026 14:37:25 +0100 Subject: [PATCH 04/11] [DURACOM-455] add config example --- config/config.example.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/config/config.example.yml b/config/config.example.yml index 596f8a6c185..197c9b720ed 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -675,7 +675,8 @@ layout: default: icon: fas fa-project-diagram style: text-success - + # If true the dowmload link in item page will rerendered as an advanced attachment, the view can be then configured with the config advancedAttachmentRendering + showDownloadLinkAsAttachment: true # When the search results are retrieved, for each item type the metadata with a valid authority value are inspected. # Referenced items will be fetched with a find all by id strategy to avoid individual rest requests # to efficiently display the search results. From d361cc34c44d3c893491dae8ccbc50fb3b0dc94d Mon Sep 17 00:00:00 2001 From: FrancescoMolinaro Date: Fri, 20 Feb 2026 16:45:35 +0100 Subject: [PATCH 05/11] [DURACOM-455] fix tests --- .../bitstream-attachment/bitstream-attachment.component.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/shared/bitstream-attachment/bitstream-attachment.component.spec.ts b/src/app/shared/bitstream-attachment/bitstream-attachment.component.spec.ts index eaf4f172ba7..af0a1bc9742 100644 --- a/src/app/shared/bitstream-attachment/bitstream-attachment.component.spec.ts +++ b/src/app/shared/bitstream-attachment/bitstream-attachment.component.spec.ts @@ -39,7 +39,7 @@ describe('BitstreamAttachmentComponent', () => { ); const languageList = ['en;q=1', 'de;q=0.8']; const mockLocaleService = jasmine.createSpyObj('LocaleService', { - getCurrentLanguageCode: jasmine.createSpy('getCurrentLanguageCode'), + getCurrentLanguageCode: of('en'), getLanguageCodeList: of(languageList), }); From a77a7a41972498802b1bbea17ce5d1057b73ff7f Mon Sep 17 00:00:00 2001 From: FrancescoMolinaro Date: Mon, 16 Mar 2026 11:58:40 +0100 Subject: [PATCH 06/11] [DURACOM-455] refactor config property, add example --- config/config.example.yml | 12 ++- .../bitstream-attachment.component.ts | 2 +- src/config/app-config.interface.ts | 2 - src/config/default-app-config.ts | 75 +++++++++---------- src/config/layout-config.interfaces.ts | 6 ++ src/environments/environment.test.ts | 75 +++++++++---------- 6 files changed, 90 insertions(+), 82 deletions(-) diff --git a/config/config.example.yml b/config/config.example.yml index 792e037e42f..add03e48e77 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -706,8 +706,18 @@ layout: default: icon: fas fa-project-diagram style: text-success - # If true the dowmload link in item page will rerendered as an advanced attachment, the view can be then configured with the config advancedAttachmentRendering + # 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: true + # Configuration for advanced attachment rendering in item pages. This controls how files are displayed when showDownloadLinkAsAttachment is enabled. + # Each configuration maps a bundle name to specific metadata fields that should be displayed alongside the file. + advancedAttachmentRendering: + - bundle: ORIGINAL + metadata: + - dc.description + - dc.title + - bundle: LICENSE + metadata: + - dc.rights # Configuration for customization of search results searchResults: diff --git a/src/app/shared/bitstream-attachment/bitstream-attachment.component.ts b/src/app/shared/bitstream-attachment/bitstream-attachment.component.ts index 40496790e69..f33ff805202 100644 --- a/src/app/shared/bitstream-attachment/bitstream-attachment.component.ts +++ b/src/app/shared/bitstream-attachment/bitstream-attachment.component.ts @@ -117,7 +117,7 @@ export class BitstreamAttachmentComponent implements OnInit { protected readonly route: ActivatedRoute, @Inject(APP_CONFIG) protected appConfig: AppConfig, ) { - this.metadataConfig = this.appConfig.advancedAttachmentRendering.metadata; + this.metadataConfig = this.appConfig.layout.advancedAttachmentRendering.metadata; } ngOnInit() { diff --git a/src/config/app-config.interface.ts b/src/config/app-config.interface.ts index 0c79b0f7e47..ba2028481d3 100644 --- a/src/config/app-config.interface.ts +++ b/src/config/app-config.interface.ts @@ -6,7 +6,6 @@ import { import { AccessibilitySettingsConfig } from './accessibility-settings.config'; import { ActuatorsConfig } from './actuators.config'; import { AdminNotifyMetricsRow } from './admin-notify-metrics.config'; -import { AdvancedAttachmentRenderingConfig } from './advanced-attachment-rendering.config'; import { AuthConfig } from './auth-config.interfaces'; import { BrowseByConfig } from './browse-by-config.interface'; import { BundleConfig } from './bundle-config.interface'; @@ -74,7 +73,6 @@ interface AppConfig extends Config { accessibility: AccessibilitySettingsConfig; layout: LayoutConfig; searchResult: SearchResultConfig; - advancedAttachmentRendering: AdvancedAttachmentRenderingConfig; } /** diff --git a/src/config/default-app-config.ts b/src/config/default-app-config.ts index ed658fa80f4..aebc3f032f6 100644 --- a/src/config/default-app-config.ts +++ b/src/config/default-app-config.ts @@ -4,10 +4,7 @@ import { SearchResultConfig } from '@dspace/config/search-result-config.interfac import { AccessibilitySettingsConfig } from './accessibility-settings.config'; import { ActuatorsConfig } from './actuators.config'; import { AdminNotifyMetricsRow } from './admin-notify-metrics.config'; -import { - AdvancedAttachmentElementType, - AdvancedAttachmentRenderingConfig, -} from './advanced-attachment-rendering.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'; @@ -752,6 +749,41 @@ export class DefaultAppConfig implements AppConfig { }, ], showDownloadLinkAsAttachment: true, + advancedAttachmentRendering: { + pagination: { + enabled: true, + elementsPerPage: 2, + }, + 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 @@ -787,40 +819,5 @@ export class DefaultAppConfig implements AppConfig { ], }; - advancedAttachmentRendering: AdvancedAttachmentRenderingConfig = { - pagination: { - enabled: true, - elementsPerPage: 2, - }, - 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, - }, - ], - }; } diff --git a/src/config/layout-config.interfaces.ts b/src/config/layout-config.interfaces.ts index d70ac3e9498..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'; /** @@ -67,4 +68,9 @@ export interface LayoutConfig extends Config { */ 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 1381b86a2d0..f6e4a93c125 100644 --- a/src/environments/environment.test.ts +++ b/src/environments/environment.test.ts @@ -547,15 +547,48 @@ export const environment: BuildConfig = { }, ], showDownloadLinkAsAttachment: false, + advancedAttachmentRendering: { + pagination: { + enabled: true, + elementsPerPage: 2, + }, + 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'], @@ -570,40 +603,4 @@ export const environment: BuildConfig = { }, ], }, - - advancedAttachmentRendering: { - pagination: { - enabled: true, - elementsPerPage: 2, - }, - 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, - }, - ], - }, }; From 7ad401d5a3fb612a20c96e9a1d1371a4214e378a Mon Sep 17 00:00:00 2001 From: FrancescoMolinaro Date: Fri, 20 Mar 2026 10:38:26 +0100 Subject: [PATCH 07/11] [DURACOM-455] set default as false, fix issue with missing default link, add tests --- .../publication/publication.component.spec.ts | 65 +++++++++++++++++++ .../untyped-item/untyped-item.component.html | 2 +- .../untyped-item.component.spec.ts | 64 ++++++++++++++++++ src/config/default-app-config.ts | 2 +- 4 files changed, 131 insertions(+), 2 deletions(-) diff --git a/src/app/item-page/simple/item-types/publication/publication.component.spec.ts b/src/app/item-page/simple/item-types/publication/publication.component.spec.ts index 65174f2e213..f8a551b7a40 100644 --- a/src/app/item-page/simple/item-types/publication/publication.component.spec.ts +++ b/src/app/item-page/simple/item-types/publication/publication.component.spec.ts @@ -95,6 +95,12 @@ describe('PublicationComponent', () => { 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/untyped-item/untyped-item.component.html b/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.html index 91bbf266e5b..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,7 @@
} - @if (showDownloadLinkAsAttachment) { + @if (showDownloadLinkAsAttachment !== true) { } diff --git a/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.spec.ts b/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.spec.ts index 3864cbbaa14..0b70d35d4f6 100644 --- a/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.spec.ts +++ b/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.spec.ts @@ -97,6 +97,12 @@ describe('UntypedItemComponent', () => { 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/config/default-app-config.ts b/src/config/default-app-config.ts index aebc3f032f6..f70e833fa47 100644 --- a/src/config/default-app-config.ts +++ b/src/config/default-app-config.ts @@ -748,7 +748,7 @@ export class DefaultAppConfig implements AppConfig { }, }, ], - showDownloadLinkAsAttachment: true, + showDownloadLinkAsAttachment: false, advancedAttachmentRendering: { pagination: { enabled: true, From 04a1e9f9e141e82cd74ded511884d83b0c247620 Mon Sep 17 00:00:00 2001 From: FrancescoMolinaro Date: Wed, 25 Mar 2026 17:13:16 +0100 Subject: [PATCH 08/11] [DURACOM-455] optimize file download link template --- .../file-download-link.component.html | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) 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)) { Date: Thu, 26 Mar 2026 10:11:02 +0100 Subject: [PATCH 09/11] [DURACOM-455] clean up, fix config example, add inline documentation --- config/config.example.yml | 32 ++++++++---- .../bitstream-download-redirect.guard.ts | 25 +++++++++- .../server-hard-redirect.service.spec.ts | 17 ------- .../services/server-hard-redirect.service.ts | 14 +----- .../bitstream-attachment.component.html | 6 +-- src/assets/i18n/en.json5 | 50 +++++++++---------- 6 files changed, 75 insertions(+), 69 deletions(-) diff --git a/config/config.example.yml b/config/config.example.yml index 76290f71ae9..3d10638b09e 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -707,17 +707,31 @@ layout: 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: true + showDownloadLinkAsAttachment: false # Configuration for advanced attachment rendering in item pages. This controls how files are displayed when showDownloadLinkAsAttachment is enabled. - # Each configuration maps a bundle name to specific metadata fields that should be displayed alongside the file. + # Defines pagination settings and which metadata/attributes to display for bitstream attachments. advancedAttachmentRendering: - - bundle: ORIGINAL - metadata: - - dc.description - - dc.title - - bundle: LICENSE - metadata: - - dc.rights + # Pagination configuration for attachment lists + pagination: + enabled: true + elementsPerPage: 2 + # 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.ts b/src/app/bitstream-page/bitstream-download-redirect.guard.ts index da176ff733c..50d58d6f69e 100644 --- a/src/app/bitstream-page/bitstream-download-redirect.guard.ts +++ b/src/app/bitstream-page/bitstream-download-redirect.guard.ts @@ -37,7 +37,30 @@ import { 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, 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 609689acee8..fb2fddfef1f 100644 --- a/src/app/core/services/server-hard-redirect.service.spec.ts +++ b/src/app/core/services/server-hard-redirect.service.spec.ts @@ -116,21 +116,4 @@ describe('ServerHardRedirectService', () => { }); }); - describe('Should add cors header on download path', () => { - const redirect = 'https://private-url:4000/server/api/bitstreams/uuid'; - const environmentWithSSRUrl: any = { ...envConfig, ...{ ...envConfig.rest, rest: { - ssrBaseUrl: 'https://private-url:4000/server', - baseUrl: 'https://public-url/server', - } } }; - service = new ServerHardRedirectService(environmentWithSSRUrl, mockRequest, mockResponse, serverResponseService); - - beforeEach(() => { - service.redirect(redirect, null, true); - }); - - it('should set header', () => { - expect(serverResponseService.setHeader).toHaveBeenCalled(); - }); - }); - }); diff --git a/src/app/core/services/server-hard-redirect.service.ts b/src/app/core/services/server-hard-redirect.service.ts index 712eff6dfef..a319ba9ca86 100644 --- a/src/app/core/services/server-hard-redirect.service.ts +++ b/src/app/core/services/server-hard-redirect.service.ts @@ -45,7 +45,7 @@ export class ServerHardRedirectService extends HardRedirectService { * optional HTTP status code to use for redirect (default = 302, which is a temporary redirect) * @param shouldSetCorsHeader */ - redirect(url: string, statusCode?: number, shouldSetCorsHeader?: boolean) { + redirect(url: string, statusCode?: number) { if (url === this.req.url) { return; } @@ -75,10 +75,6 @@ export class ServerHardRedirectService extends HardRedirectService { status = 302; } - if (shouldSetCorsHeader) { - this.setCorsHeader(); - } - console.info(`Redirecting from ${this.req.url} to ${redirectUrl} with ${status}`); this.res.redirect(status, redirectUrl); @@ -104,12 +100,4 @@ export class ServerHardRedirectService extends HardRedirectService { getBaseUrl(): string { return environment.ui.baseUrl; } - - /** - * Set CORS header to allow embedding of redirected content. - * The actual security header will be set by the rest - */ - setCorsHeader() { - this.responseService.setHeader('Access-Control-Allow-Origin', '*'); - } } diff --git a/src/app/shared/bitstream-attachment/bitstream-attachment.component.html b/src/app/shared/bitstream-attachment/bitstream-attachment.component.html index b2c24795e15..c49023d1758 100644 --- a/src/app/shared/bitstream-attachment/bitstream-attachment.component.html +++ b/src/app/shared/bitstream-attachment/bitstream-attachment.component.html @@ -19,7 +19,7 @@ @for (attachmentConf of metadataConfig; track $index) { @if (attachment.firstMetadataValue(attachmentConf.name) || attachmentConf.type === AdvancedAttachmentElementType.Attribute) {
- {{ 'cris-layout.advanced-attachment.' + attachmentConf.name | translate }} + {{ 'layout.advanced-attachment.' + attachmentConf.name | translate }} @if (attachmentConf.type === AdvancedAttachmentElementType.Metadata) { @@ -47,7 +47,7 @@ @if (attachmentConf.name === 'format') { @if ((bitstreamFormat$ | async) === null || (bitstreamFormat$ | async) === undefined) {

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

} @else {

{{(bitstreamFormat$ | async)}}

@@ -61,7 +61,7 @@ @if (attachmentConf.name === 'checksum') { @if (checksumInfo?.value === null || checksumInfo?.value === undefined) {

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

} @else {

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

diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 7f559ebfaff..a370fecd329 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -1747,32 +1747,6 @@ "cookies.consent.purpose.sharing": "Sharing", - "cris-layout.advanced-attachment.dc.title": "Name", - - "cris-layout.advanced-attachment.size": "Size", - - "cris-layout.advanced-attachment.checksum": "Checksum", - - "cris-layout.advanced-attachment.checksum.info.MD5": "The 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", - - "cris-layout.advanced-attachment.format": "Format", - - "cris-layout.advanced-attachment.dc.type": "Type", - - "cris-layout.advanced-attachment.dc.description": "Description", - - "cris-layout.advanced-attachment.no_thumbnail": "No Thumbnail Available", - - "cris-layout.advanced-attachment.download": "Download", - - "cris-layout.advanced-attachment.requestACopy": "Request a copy", - - "cris-layout.advanced-attachment.viewMore": "View More", - - "cris-layout.advanced-attachment.label.not-present": "(not present)", - - "cris-layout.attachment.viewMore": "View More", - "curation-task.task.citationpage.label": "Generate Citation Page", "curation-task.task.checklinks.label": "Check Links in Metadata", @@ -3493,6 +3467,30 @@ "iiif.page.description": "Description: ", + "layout.advanced-attachment.dc.title": "Name", + + "layout.advanced-attachment.size": "Size", + + "layout.advanced-attachment.checksum": "Checksum", + + "layout.advanced-attachment.checksum.info.MD5": "The 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...", From 8bceb039501ae33be424b8ef366fbdce86ee1f89 Mon Sep 17 00:00:00 2001 From: FrancescoMolinaro Date: Thu, 26 Mar 2026 15:34:25 +0100 Subject: [PATCH 10/11] [DURACOM-455] remove unnecessary pagination configuration for attachment component --- src/config/advanced-attachment-rendering.config.ts | 8 -------- src/config/default-app-config.ts | 4 ---- src/environments/environment.test.ts | 4 ---- 3 files changed, 16 deletions(-) diff --git a/src/config/advanced-attachment-rendering.config.ts b/src/config/advanced-attachment-rendering.config.ts index bbcdd13c941..cbbf4a6f9d0 100644 --- a/src/config/advanced-attachment-rendering.config.ts +++ b/src/config/advanced-attachment-rendering.config.ts @@ -3,7 +3,6 @@ */ export interface AdvancedAttachmentRenderingConfig { metadata: AttachmentMetadataConfig[]; - pagination: AdvancedAttachmentRenderingPaginationConfig; } /** @@ -23,10 +22,3 @@ export enum AdvancedAttachmentElementType { Attribute = 'attribute' } -/** - * Interface configuration to define the pagination of the advanced attachment rendering - */ -export interface AdvancedAttachmentRenderingPaginationConfig { - enabled: boolean; - elementsPerPage: number; -} diff --git a/src/config/default-app-config.ts b/src/config/default-app-config.ts index 58dff21a7cb..3a8fc0bc6c8 100644 --- a/src/config/default-app-config.ts +++ b/src/config/default-app-config.ts @@ -751,10 +751,6 @@ export class DefaultAppConfig implements AppConfig { ], showDownloadLinkAsAttachment: false, advancedAttachmentRendering: { - pagination: { - enabled: true, - elementsPerPage: 2, - }, metadata: [ { name: 'dc.title', diff --git a/src/environments/environment.test.ts b/src/environments/environment.test.ts index 102b6f3ea41..34f254cba71 100644 --- a/src/environments/environment.test.ts +++ b/src/environments/environment.test.ts @@ -548,10 +548,6 @@ export const environment: BuildConfig = { ], showDownloadLinkAsAttachment: false, advancedAttachmentRendering: { - pagination: { - enabled: true, - elementsPerPage: 2, - }, metadata: [ { name: 'dc.title', From f2e0b3bca84a756d3fa0d5eb81880e9b4b279e7d Mon Sep 17 00:00:00 2001 From: FrancescoMolinaro Date: Thu, 26 Mar 2026 18:12:52 +0100 Subject: [PATCH 11/11] [DURACOM-455] clean up config example --- config/config.example.yml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/config/config.example.yml b/config/config.example.yml index 3d10638b09e..6b47b3873af 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -709,12 +709,8 @@ layout: # 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 pagination settings and which metadata/attributes to display for bitstream attachments. + # Defines which metadata/attributes to display for bitstream attachments. advancedAttachmentRendering: - # Pagination configuration for attachment lists - pagination: - enabled: true - elementsPerPage: 2 # Metadata and attributes to display for each attachment metadata: - name: dc.title