From dde57b93871c544a903721e526d3d3e430cb2cb7 Mon Sep 17 00:00:00 2001 From: Art Lowel Date: Mon, 31 May 2021 16:19:44 +0200 Subject: [PATCH 01/38] update citation_pdf_url logic --- src/app/core/metadata/metadata.service.ts | 133 ++++++++++++++++++---- 1 file changed, 111 insertions(+), 22 deletions(-) diff --git a/src/app/core/metadata/metadata.service.ts b/src/app/core/metadata/metadata.service.ts index 807f7a42ab..268d361567 100644 --- a/src/app/core/metadata/metadata.service.ts +++ b/src/app/core/metadata/metadata.service.ts @@ -5,10 +5,10 @@ import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; -import { BehaviorSubject, combineLatest, Observable } from 'rxjs'; -import { catchError, distinctUntilKeyChanged, filter, first, map, take } from 'rxjs/operators'; +import { BehaviorSubject, combineLatest, Observable, of as observableOf, EMPTY } from 'rxjs'; +import { distinctUntilKeyChanged, filter, map, take, switchMap, expand } from 'rxjs/operators'; -import { hasValue, isNotEmpty } from '../../shared/empty.util'; +import { hasValue, hasNoValue } from '../../shared/empty.util'; import { DSONameService } from '../breadcrumbs/dso-name.service'; import { CacheableObject } from '../cache/object-cache.reducer'; import { BitstreamDataService } from '../data/bitstream-data.service'; @@ -21,11 +21,15 @@ import { DSpaceObject } from '../shared/dspace-object.model'; import { Item } from '../shared/item.model'; import { getFirstSucceededRemoteDataPayload, - getFirstSucceededRemoteListPayload + getFirstCompletedRemoteData } from '../shared/operators'; import { environment } from '../../../environments/environment'; import { RootDataService } from '../data/root-data.service'; import { getBitstreamDownloadRoute } from '../../app-routing-paths'; +import { BundleDataService } from '../data/bundle-data.service'; +import { followLink } from '../../shared/utils/follow-link-config.model'; +import { Bundle } from '../shared/bundle.model'; +import { PaginatedList } from '../data/paginated-list.model'; @Injectable() export class MetadataService { @@ -42,6 +46,7 @@ export class MetadataService { private meta: Meta, private title: Title, private dsoNameService: DSONameService, + private bundleDataService: BundleDataService, private bitstreamDataService: BitstreamDataService, private bitstreamFormatDataService: BitstreamFormatDataService, private rootService: RootDataService @@ -275,26 +280,110 @@ export class MetadataService { private setCitationPdfUrlTag(): void { if (this.currentObject.value instanceof Item) { const item = this.currentObject.value as Item; - this.bitstreamDataService.findAllByItemAndBundleName(item, 'ORIGINAL') - .pipe( - getFirstSucceededRemoteListPayload(), - first((files) => isNotEmpty(files)), - catchError((error) => { - console.debug(error.message); - return []; - })) - .subscribe((bitstreams: Bitstream[]) => { - for (const bitstream of bitstreams) { - this.bitstreamFormatDataService.findByBitstream(bitstream).pipe( - getFirstSucceededRemoteDataPayload() - ).subscribe((format: BitstreamFormat) => { - if (format.mimetype === 'application/pdf') { - const bitstreamLink = getBitstreamDownloadRoute(bitstream); - this.addMetaTag('citation_pdf_url', bitstreamLink); + + // Retrieve the ORIGINAL bundle for the item + this.bundleDataService.findByItemAndName( + item, + 'ORIGINAL', + true, + true, + followLink('primaryBitstream'), + followLink('bitstreams', + undefined, + true, + true, + true, + followLink('format') + ) + ).pipe( + getFirstSucceededRemoteDataPayload(), + switchMap((bundle: Bundle) => + + // First try the primary bitstream + bundle.primaryBitstream.pipe( + getFirstCompletedRemoteData(), + map((rd: RemoteData) => { + if (hasValue(rd.payload)) { + return rd.payload; + } else { + return null; } - }); + }), + // return the bundle as well so we can use it again if there's no primary bitstream + map((bitstream: Bitstream) => [bundle, bitstream]) + ) + ), + switchMap(([bundle, primaryBitstream]: [Bundle, Bitstream]) => { + if (hasValue(primaryBitstream)) { + // If there was a primary bitstream, emit its link + return [getBitstreamDownloadRoute(primaryBitstream)]; + } else { + // Otherwise consider the regular bitstreams in the bundle + return bundle.bitstreams.pipe( + getFirstCompletedRemoteData(), + switchMap((bitstreamRd: RemoteData>) => { + if (hasValue(bitstreamRd.payload) && bitstreamRd.payload.totalElements === 1) { + // If there's only one bitstream in the bundle, emit its link + return [getBitstreamDownloadRoute(bitstreamRd.payload.page[0])]; + } else { + // Otherwise check all bitstreams to see if one matches the format whitelist + return observableOf(bitstreamRd.payload).pipe( + // Because there can be more than one page of bitstreams, this expand operator + // will retrieve them in turn due to the take(1) at the bottom, it will only + // retrieve pages until a match is found + expand((paginatedList: PaginatedList) => { + if (hasNoValue(paginatedList.next)) { + // If there's no next page, stop. + return EMPTY; + } else { + // Otherwise retrieve the next page + return this.bitstreamDataService.findAllByHref( + paginatedList.next, + undefined, + true, + true, + followLink('format') + ).pipe( + getFirstCompletedRemoteData(), + map((next: RemoteData>) => { + if (hasValue(next.payload)) { + return next.payload; + } else { + return EMPTY; + } + }) + ); + } + }), + // Return the array of bitstreams inside each paginated list + map((paginatedList: PaginatedList) => paginatedList.page), + // Emit the bitstreams in the list one at a time + switchMap((bitstreams: Bitstream[]) => bitstreams), + // Retrieve the format for each bitstream + switchMap((bitstream: Bitstream) => bitstream.format.pipe( + getFirstSucceededRemoteDataPayload(), + // Keep the original bitstream, because it, not the format, is what we'll need + // for the link at the end + map((format: BitstreamFormat) => [bitstream, format]) + )), + // Filter out only pairs with whitelisted formats + filter(([, format]: [Bitstream, BitstreamFormat]) => + hasValue(format) && format.mimetype === 'application/pdf'), // TODO change to check map of mimetypes + // We only need 1 + take(1), + // Emit the link of the match + map(([bitstream, ]: [Bitstream, BitstreamFormat]) => getBitstreamDownloadRoute(bitstream)) + ); + } + }) + ); } - }); + }), + take(1) + ).subscribe((link: string) => { + // Use the found link to set the tag + this.addMetaTag('citation_pdf_url', link); + }); } } From 9fe5a91bc2894b7c6fb04e28190323c4efc34d19 Mon Sep 17 00:00:00 2001 From: Yura Bondarenko Date: Thu, 3 Jun 2021 16:30:57 +0200 Subject: [PATCH 02/38] 79768: Extract download route method for Bundles with multiple Bitstreams --- src/app/core/metadata/metadata.service.ts | 98 ++++++++++++----------- 1 file changed, 51 insertions(+), 47 deletions(-) diff --git a/src/app/core/metadata/metadata.service.ts b/src/app/core/metadata/metadata.service.ts index 268d361567..fa4e81a5d7 100644 --- a/src/app/core/metadata/metadata.service.ts +++ b/src/app/core/metadata/metadata.service.ts @@ -327,53 +327,7 @@ export class MetadataService { return [getBitstreamDownloadRoute(bitstreamRd.payload.page[0])]; } else { // Otherwise check all bitstreams to see if one matches the format whitelist - return observableOf(bitstreamRd.payload).pipe( - // Because there can be more than one page of bitstreams, this expand operator - // will retrieve them in turn due to the take(1) at the bottom, it will only - // retrieve pages until a match is found - expand((paginatedList: PaginatedList) => { - if (hasNoValue(paginatedList.next)) { - // If there's no next page, stop. - return EMPTY; - } else { - // Otherwise retrieve the next page - return this.bitstreamDataService.findAllByHref( - paginatedList.next, - undefined, - true, - true, - followLink('format') - ).pipe( - getFirstCompletedRemoteData(), - map((next: RemoteData>) => { - if (hasValue(next.payload)) { - return next.payload; - } else { - return EMPTY; - } - }) - ); - } - }), - // Return the array of bitstreams inside each paginated list - map((paginatedList: PaginatedList) => paginatedList.page), - // Emit the bitstreams in the list one at a time - switchMap((bitstreams: Bitstream[]) => bitstreams), - // Retrieve the format for each bitstream - switchMap((bitstream: Bitstream) => bitstream.format.pipe( - getFirstSucceededRemoteDataPayload(), - // Keep the original bitstream, because it, not the format, is what we'll need - // for the link at the end - map((format: BitstreamFormat) => [bitstream, format]) - )), - // Filter out only pairs with whitelisted formats - filter(([, format]: [Bitstream, BitstreamFormat]) => - hasValue(format) && format.mimetype === 'application/pdf'), // TODO change to check map of mimetypes - // We only need 1 - take(1), - // Emit the link of the match - map(([bitstream, ]: [Bitstream, BitstreamFormat]) => getBitstreamDownloadRoute(bitstream)) - ); + return this.getBitstreamDownloadRoute(bitstreamRd); } }) ); @@ -387,6 +341,56 @@ export class MetadataService { } } + private getBitstreamDownloadRoute(bitstreamRd: RemoteData>): Observable { + return observableOf(bitstreamRd.payload).pipe( + // Because there can be more than one page of bitstreams, this expand operator + // will retrieve them in turn due to the take(1) at the bottom, it will only + // retrieve pages until a match is found + expand((paginatedList: PaginatedList) => { + if (hasNoValue(paginatedList.next)) { + // If there's no next page, stop. + return EMPTY; + } else { + // Otherwise retrieve the next page + return this.bitstreamDataService.findAllByHref( + paginatedList.next, + undefined, + true, + true, + followLink('format') + ).pipe( + getFirstCompletedRemoteData(), + map((next: RemoteData>) => { + if (hasValue(next.payload)) { + return next.payload; + } else { + return EMPTY; + } + }) + ); + } + }), + // Return the array of bitstreams inside each paginated list + map((paginatedList: PaginatedList) => paginatedList.page), + // Emit the bitstreams in the list one at a time + switchMap((bitstreams: Bitstream[]) => bitstreams), + // Retrieve the format for each bitstream + switchMap((bitstream: Bitstream) => bitstream.format.pipe( + getFirstSucceededRemoteDataPayload(), + // Keep the original bitstream, because it, not the format, is what we'll need + // for the link at the end + map((format: BitstreamFormat) => [bitstream, format]) + )), + // Filter out only pairs with whitelisted formats + filter(([, format]: [Bitstream, BitstreamFormat]) => + hasValue(format) && format.mimetype === 'application/pdf'), // TODO change to check map of mimetypes + // We only need 1 + take(1), + // Emit the link of the match + map(([bitstream, ]: [Bitstream, BitstreamFormat]) => getBitstreamDownloadRoute(bitstream)) + ); + } + /** * Add to the containing the current DSpace version */ From 1caba78b4df6b9fcec8a99c8d10010c0596da0d8 Mon Sep 17 00:00:00 2001 From: Yura Bondarenko Date: Thu, 3 Jun 2021 16:33:58 +0200 Subject: [PATCH 03/38] 79768: Check against list of allowed mimetypes --- src/app/core/metadata/metadata.service.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/app/core/metadata/metadata.service.ts b/src/app/core/metadata/metadata.service.ts index fa4e81a5d7..0b63e13976 100644 --- a/src/app/core/metadata/metadata.service.ts +++ b/src/app/core/metadata/metadata.service.ts @@ -40,6 +40,15 @@ export class MetadataService { private currentObject: BehaviorSubject; + private readonly ALLOWED_MIMETYPES = [ + 'application/pdf', // .pdf + 'application/postscript', // .ps + 'application/msword', // .doc + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document ', // .docx + 'application/rtf', // .rtf + 'application/epub+zip', // .epub + ]; + constructor( private router: Router, private translate: TranslateService, @@ -383,7 +392,7 @@ export class MetadataService { )), // Filter out only pairs with whitelisted formats filter(([, format]: [Bitstream, BitstreamFormat]) => - hasValue(format) && format.mimetype === 'application/pdf'), // TODO change to check map of mimetypes + hasValue(format) && this.ALLOWED_MIMETYPES.includes(format.mimetype)), // We only need 1 take(1), // Emit the link of the match From cb4446b79d17e7acc20b82f4855ef94e39307592 Mon Sep 17 00:00:00 2001 From: Yura Bondarenko Date: Fri, 4 Jun 2021 13:57:30 +0200 Subject: [PATCH 04/38] 79768: Update & add unit tests --- .../core/metadata/metadata.service.spec.ts | 83 +++++++++++-- src/app/core/metadata/metadata.service.ts | 2 +- src/app/shared/mocks/item.mock.ts | 117 ++++++++++++------ 3 files changed, 154 insertions(+), 48 deletions(-) diff --git a/src/app/core/metadata/metadata.service.spec.ts b/src/app/core/metadata/metadata.service.spec.ts index 18421dd489..0a4377e1b7 100644 --- a/src/app/core/metadata/metadata.service.spec.ts +++ b/src/app/core/metadata/metadata.service.spec.ts @@ -15,11 +15,9 @@ import { RemoteData } from '../data/remote-data'; import { Item } from '../shared/item.model'; import { - ItemMock, - MockBitstream1, - MockBitstream2, - MockBitstreamFormat1, - MockBitstreamFormat2 + ItemMock, MockBitstream1, MockBitstream2, MockBitstream3, MockBitstreamFormat1, MockBitstreamFormat2, + MockBitstreamFormat3, + MockOriginalBundle, } from '../../shared/mocks/item.mock'; import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; import { NotificationsService } from '../../shared/notifications/notifications.service'; @@ -51,10 +49,11 @@ import { UUIDService } from '../shared/uuid.service'; import { MetadataService } from './metadata.service'; import { environment } from '../../../environments/environment'; import { storeModuleConfig } from '../../app.reducer'; -import { HardRedirectService } from '../services/hard-redirect.service'; -import { URLCombiner } from '../url-combiner/url-combiner'; import { RootDataService } from '../data/root-data.service'; import { Root } from '../data/root.model'; +import { Bundle } from '../shared/bundle.model'; +import { BundleDataService } from '../data/bundle-data.service'; +import { createPaginatedList } from '../../shared/testing/utils.test'; /* tslint:disable:max-classes-per-file */ @Component({ @@ -92,6 +91,8 @@ describe('MetadataService', () => { let uuidService: UUIDService; let remoteDataBuildService: RemoteDataBuildService; let itemDataService: ItemDataService; + let bundleDataService; + let bitstreamDataService; let authService: AuthService; let rootService: RootDataService; let translateService: TranslateService; @@ -111,7 +112,7 @@ describe('MetadataService', () => { uuidService = new UUIDService(); requestService = new RequestService(objectCacheService, uuidService, store, undefined); remoteDataBuildService = new RemoteDataBuildService(objectCacheService, undefined, requestService); - const mockBitstreamDataService = { + bitstreamDataService = { findAllByItemAndBundleName(item: Item, bundleName: string, options?: FindListOptions, ...linksToFollow: FollowLinkConfig[]): Observable>> { if (item.equals(ItemMock)) { return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [MockBitstream1, MockBitstream2])); @@ -119,6 +120,7 @@ describe('MetadataService', () => { return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])); } }, + findAllByHref: jasmine.createSpy(), }; const mockBitstreamFormatDataService = { findByBitstream(bitstream: Bitstream): Observable> { @@ -129,11 +131,17 @@ describe('MetadataService', () => { case MockBitstream2: return createSuccessfulRemoteDataObject$(MockBitstreamFormat2); break; + case MockBitstream3: + return createSuccessfulRemoteDataObject$(MockBitstreamFormat3); + break; default: return createSuccessfulRemoteDataObject$(new BitstreamFormat()); } } }; + bundleDataService = jasmine.createSpyObj('bundleDataService', { + findByItemAndName: createSuccessfulRemoteDataObject$(MockOriginalBundle), + }); rootService = jasmine.createSpyObj('rootService', { findRoot: createSuccessfulRemoteDataObject$(Object.assign(new Root(), { dspaceVersion: 'mock-dspace-version' @@ -176,7 +184,8 @@ describe('MetadataService', () => { { provide: CommunityDataService, useValue: {} }, { provide: DefaultChangeAnalyzer, useValue: {} }, { provide: BitstreamFormatDataService, useValue: mockBitstreamFormatDataService }, - { provide: BitstreamDataService, useValue: mockBitstreamDataService }, + { provide: BitstreamDataService, useValue: bitstreamDataService }, + { provide: BundleDataService, useValue: bundleDataService }, { provide: RootDataService, useValue: rootService }, Meta, Title, @@ -264,8 +273,42 @@ describe('MetadataService', () => { }); + describe('citation_pdf_url', () => { + it('should link to primary Bitstream URL regardless of format', fakeAsync(() => { + spyOn(itemDataService, 'findById').and.returnValue(mockRemoteData(ItemMock)); + bundleDataService.findByItemAndName.and.returnValue(mockBundleRD$([], MockBitstream3)); + router.navigate(['/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357']); + tick(); + expect(tagStore.get('citation_pdf_url')[0].content).toEqual('/bitstreams/4db100c1-e1f5-4055-9404-9bc3e2d15f29/download'); + })); + + describe('no primary Bitstream', () => { + it('should link to first and only Bitstream regardless of format', fakeAsync(() => { + spyOn(itemDataService, 'findById').and.returnValue(mockRemoteData(ItemMock)); + bundleDataService.findByItemAndName.and.returnValue(mockBundleRD$([MockBitstream3])); + router.navigate(['/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357']); + tick(); + expect(tagStore.get('citation_pdf_url')[0].content).toEqual('/bitstreams/4db100c1-e1f5-4055-9404-9bc3e2d15f29/download'); + })); + + it('should link to first Bitstream with allowed format', fakeAsync(() => { + spyOn(itemDataService, 'findById').and.returnValue(mockRemoteData(ItemMock)); + + const bitstreams = [MockBitstream3, MockBitstream3, MockBitstream1]; + bundleDataService.findByItemAndName.and.returnValue(mockBundleRD$(bitstreams)); + bitstreamDataService.findAllByHref.and.returnValues( + ...mockBitstreamPages$(bitstreams).map(bp => createSuccessfulRemoteDataObject$(bp)), + ); + + router.navigate(['/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357']); + tick(); + expect(tagStore.get('citation_pdf_url')[0].content).toEqual('/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713/download'); + })); + }); + }); + const mockRemoteData = (mockItem: Item): Observable> => { - return createSuccessfulRemoteDataObject$(ItemMock); + return createSuccessfulRemoteDataObject$(mockItem); }; const mockType = (mockItem: Item, type: string): Item => { @@ -285,4 +328,24 @@ describe('MetadataService', () => { return publishedMockItem; }; + const mockBundleRD$ = (bitstreams: Bitstream[], primary?: Bitstream): Observable> => { + return createSuccessfulRemoteDataObject$( + Object.assign(new Bundle(), { + name: 'ORIGINAL', + bitstreams: createSuccessfulRemoteDataObject$(mockBitstreamPages$(bitstreams)[0]), + primaryBitstream: createSuccessfulRemoteDataObject$(primary), + }) + ); + }; + + const mockBitstreamPages$ = (bitstreams: Bitstream[]): PaginatedList[] => { + return bitstreams.map((bitstream, index) => Object.assign(createPaginatedList([bitstream]), { + pageInfo: { + totalElements: bitstreams.length, // announce multiple elements/pages + }, + _links: index < bitstreams.length - 1 + ? { next: { href: 'not empty' }} // fake link to the next bitstream page + : { next: { href: undefined }}, // last page has no link + })); + }; }); diff --git a/src/app/core/metadata/metadata.service.ts b/src/app/core/metadata/metadata.service.ts index 0b63e13976..848b48b8c5 100644 --- a/src/app/core/metadata/metadata.service.ts +++ b/src/app/core/metadata/metadata.service.ts @@ -44,7 +44,7 @@ export class MetadataService { 'application/pdf', // .pdf 'application/postscript', // .ps 'application/msword', // .doc - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document ', // .docx + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // .docx 'application/rtf', // .rtf 'application/epub+zip', // .epub ]; diff --git a/src/app/shared/mocks/item.mock.ts b/src/app/shared/mocks/item.mock.ts index 945b0f7816..10eab2da00 100644 --- a/src/app/shared/mocks/item.mock.ts +++ b/src/app/shared/mocks/item.mock.ts @@ -5,6 +5,7 @@ import { Bitstream } from '../../core/shared/bitstream.model'; import { Item } from '../../core/shared/item.model'; import { createSuccessfulRemoteDataObject$ } from '../remote-data.utils'; import { createPaginatedList } from '../testing/utils.test'; +import { Bundle } from '../../core/shared/bundle.model'; export const MockBitstreamFormat1: BitstreamFormat = Object.assign(new BitstreamFormat(), { shortDescription: 'Microsoft Word XML', @@ -34,11 +35,25 @@ export const MockBitstreamFormat2: BitstreamFormat = Object.assign(new Bitstream } }); +export const MockBitstreamFormat3: BitstreamFormat = Object.assign(new BitstreamFormat(), { + shortDescription: 'Binary', + description: 'Some scary unknown binary file', + mimetype: 'application/octet-stream', + supportLevel: 0, + internal: false, + extensions: null, + _links:{ + self: { + href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreamformats/17' + } + } +}); + export const MockBitstream1: 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: observableOf(MockBitstreamFormat1), + format: createSuccessfulRemoteDataObject$(MockBitstreamFormat1), bundleName: 'ORIGINAL', _links:{ self: { @@ -61,7 +76,7 @@ export const MockBitstream1: Bitstream = Object.assign(new Bitstream(), export const MockBitstream2: Bitstream = Object.assign(new Bitstream(), { sizeBytes: 31302, content: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/99b00f3c-1cc6-4689-8158-91965bee6b28/content', - format: observableOf(MockBitstreamFormat2), + format: createSuccessfulRemoteDataObject$(MockBitstreamFormat2), bundleName: 'ORIGINAL', id: '99b00f3c-1cc6-4689-8158-91965bee6b28', uuid: '99b00f3c-1cc6-4689-8158-91965bee6b28', @@ -82,6 +97,68 @@ export const MockBitstream2: Bitstream = Object.assign(new Bitstream(), { } }); +export const MockBitstream3: Bitstream = Object.assign(new Bitstream(), { + sizeBytes: 4975123, + content: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/4db100c1-e1f5-4055-9404-9bc3e2d15f29/content', + format: createSuccessfulRemoteDataObject$(MockBitstreamFormat3), + bundleName: 'ORIGINAL', + id: '4db100c1-e1f5-4055-9404-9bc3e2d15f29', + uuid: '4db100c1-e1f5-4055-9404-9bc3e2d15f29', + type: 'bitstream', + _links: { + self: { href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/4db100c1-e1f5-4055-9404-9bc3e2d15f29' }, + content: { href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/4db100c1-e1f5-4055-9404-9bc3e2d15f29/content' }, + format: { href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreamformats/17' }, + bundle: { href: '' } + }, + metadata: { + 'dc.title': [ + { + language: null, + value: 'scary' + } + ] + } +}); + +export const MockOriginalBundle: Bundle = Object.assign(new Bundle(), { + name: 'ORIGINAL', + primaryBitstream: createSuccessfulRemoteDataObject$(MockBitstream2), + bitstreams: observableOf(Object.assign({ + _links: { + self: { + href: 'dspace-angular://aggregated/object/1507836003548', + } + }, + requestPending: false, + responsePending: false, + isSuccessful: true, + errorMessage: '', + state: '', + error: undefined, + isRequestPending: false, + isResponsePending: false, + isLoading: false, + hasFailed: false, + hasSucceeded: true, + statusCode: '202', + pageInfo: {}, + payload: { + pageInfo: { + elementsPerPage: 20, + totalElements: 3, + totalPages: 1, + currentPage: 2 + }, + page: [ + MockBitstream1, + MockBitstream2 + ] + } + })) +}); + + /* tslint:disable:no-shadowed-variable */ export const ItemMock: Item = Object.assign(new Item(), { handle: '10673/6', @@ -90,41 +167,7 @@ export const ItemMock: Item = Object.assign(new Item(), { isDiscoverable: true, isWithdrawn: false, bundles: createSuccessfulRemoteDataObject$(createPaginatedList([ - { - name: 'ORIGINAL', - bitstreams: observableOf(Object.assign({ - _links: { - self: { - href: 'dspace-angular://aggregated/object/1507836003548', - } - }, - requestPending: false, - responsePending: false, - isSuccessful: true, - errorMessage: '', - state: '', - error: undefined, - isRequestPending: false, - isResponsePending: false, - isLoading: false, - hasFailed: false, - hasSucceeded: true, - statusCode: '202', - pageInfo: {}, - payload: { - pageInfo: { - elementsPerPage: 20, - totalElements: 3, - totalPages: 1, - currentPage: 2 - }, - page: [ - MockBitstream1, - MockBitstream2 - ] - } - })) - } + MockOriginalBundle, ])), _links:{ self: { From 6f7b76ec39efa00676065bb50ef23c66804d96ca Mon Sep 17 00:00:00 2001 From: Yura Bondarenko Date: Fri, 4 Jun 2021 14:02:44 +0200 Subject: [PATCH 05/38] 79768: Remove og:title & og:description --- src/app/core/metadata/metadata.service.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/app/core/metadata/metadata.service.ts b/src/app/core/metadata/metadata.service.ts index 848b48b8c5..7caa7fe6cf 100644 --- a/src/app/core/metadata/metadata.service.ts +++ b/src/app/core/metadata/metadata.service.ts @@ -62,10 +62,6 @@ export class MetadataService { ) { // TODO: determine what open graph meta tags are needed and whether // the differ per route. potentially add image based on DSpaceObject - this.meta.addTags([ - { property: 'og:title', content: 'DSpace Angular Universal' }, - { property: 'og:description', content: 'The modern front-end for DSpace 7.' } - ]); this.initialized = false; this.tagStore = new Map(); } From 67f8ce7849ad222acb8bf171056e2a27cd66f7b8 Mon Sep 17 00:00:00 2001 From: Yura Bondarenko Date: Fri, 4 Jun 2021 14:02:19 +0200 Subject: [PATCH 06/38] 79768: Rename citation_date to citation_publication_date --- src/app/core/metadata/metadata.service.spec.ts | 2 +- src/app/core/metadata/metadata.service.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/app/core/metadata/metadata.service.spec.ts b/src/app/core/metadata/metadata.service.spec.ts index 0a4377e1b7..749074d0a7 100644 --- a/src/app/core/metadata/metadata.service.spec.ts +++ b/src/app/core/metadata/metadata.service.spec.ts @@ -218,7 +218,7 @@ describe('MetadataService', () => { expect(title.getTitle()).toEqual('Test PowerPoint Document'); expect(tagStore.get('citation_title')[0].content).toEqual('Test PowerPoint Document'); expect(tagStore.get('citation_author')[0].content).toEqual('Doe, Jane'); - expect(tagStore.get('citation_date')[0].content).toEqual('1650-06-26'); + expect(tagStore.get('citation_publication_date')[0].content).toEqual('1650-06-26'); expect(tagStore.get('citation_issn')[0].content).toEqual('123456789'); expect(tagStore.get('citation_language')[0].content).toEqual('en'); expect(tagStore.get('citation_keywords')[0].content).toEqual('keyword1; keyword2; keyword3'); diff --git a/src/app/core/metadata/metadata.service.ts b/src/app/core/metadata/metadata.service.ts index 7caa7fe6cf..3ccc9e5f09 100644 --- a/src/app/core/metadata/metadata.service.ts +++ b/src/app/core/metadata/metadata.service.ts @@ -135,7 +135,7 @@ export class MetadataService { this.setCitationTitleTag(); this.setCitationAuthorTags(); - this.setCitationDateTag(); + this.setCitationPublicationDateTag(); this.setCitationISSNTag(); this.setCitationISBNTag(); @@ -208,9 +208,9 @@ export class MetadataService { /** * Add to the */ - private setCitationDateTag(): void { + private setCitationPublicationDateTag(): void { const value = this.getFirstMetaTagValue(['dc.date.copyright', 'dc.date.issued', 'dc.date.available', 'dc.date.accessioned']); - this.addMetaTag('citation_date', value); + this.addMetaTag('citation_publication_date', value); } /** From 304d8f73862fd577d687813bdaad07d6f7d32b3d Mon Sep 17 00:00:00 2001 From: Yura Bondarenko Date: Fri, 4 Jun 2021 14:08:20 +0200 Subject: [PATCH 07/38] 79768: Fix tag: citation_abstract_html_url --- .../core/metadata/metadata.service.spec.ts | 23 ++++++++++++++++++- src/app/core/metadata/metadata.service.ts | 7 ++++-- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/app/core/metadata/metadata.service.spec.ts b/src/app/core/metadata/metadata.service.spec.ts index 749074d0a7..30ee7f99ea 100644 --- a/src/app/core/metadata/metadata.service.spec.ts +++ b/src/app/core/metadata/metadata.service.spec.ts @@ -230,7 +230,6 @@ describe('MetadataService', () => { tick(); expect(tagStore.get('citation_dissertation_name')[0].content).toEqual('Test PowerPoint Document'); expect(tagStore.get('citation_dissertation_institution')[0].content).toEqual('Mock Publisher'); - expect(tagStore.get('citation_abstract_html_url')[0].content).toEqual([environment.ui.baseUrl, router.url].join('')); expect(tagStore.get('citation_pdf_url')[0].content).toEqual('/bitstreams/99b00f3c-1cc6-4689-8158-91965bee6b28/download'); })); @@ -273,6 +272,22 @@ describe('MetadataService', () => { }); + describe('citation_abstract_html_url', () => { + it('should use dc.identifier.uri if available', fakeAsync(() => { + spyOn(itemDataService, 'findById').and.returnValue(mockRemoteData(mockUri(ItemMock, 'https://ddg.gg'))); + router.navigate(['/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357']); + tick(); + expect(tagStore.get('citation_abstract_html_url')[0].content).toEqual('https://ddg.gg'); + })); + + it('should use current route as fallback', fakeAsync(() => { + spyOn(itemDataService, 'findById').and.returnValue(mockRemoteData(mockUri(ItemMock))); + router.navigate(['/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357']); + tick(); + expect(tagStore.get('citation_abstract_html_url')[0].content).toEqual('/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357'); + })); + }); + describe('citation_pdf_url', () => { it('should link to primary Bitstream URL regardless of format', fakeAsync(() => { spyOn(itemDataService, 'findById').and.returnValue(mockRemoteData(ItemMock)); @@ -328,6 +343,12 @@ describe('MetadataService', () => { return publishedMockItem; }; + const mockUri = (mockItem: Item, uri?: string): Item => { + const publishedMockItem = Object.assign(new Item(), mockItem) as Item; + publishedMockItem.metadata['dc.identifier.uri'] = [{ value: uri }] as MetadataValue[]; + return publishedMockItem; + }; + const mockBundleRD$ = (bitstreams: Bitstream[], primary?: Bitstream): Observable> => { return createSuccessfulRemoteDataObject$( Object.assign(new Bundle(), { diff --git a/src/app/core/metadata/metadata.service.ts b/src/app/core/metadata/metadata.service.ts index 3ccc9e5f09..bc26f36fd1 100644 --- a/src/app/core/metadata/metadata.service.ts +++ b/src/app/core/metadata/metadata.service.ts @@ -274,8 +274,11 @@ export class MetadataService { */ private setCitationAbstractUrlTag(): void { if (this.currentObject.value instanceof Item) { - const value = [environment.ui.baseUrl, this.router.url].join(''); - this.addMetaTag('citation_abstract_html_url', value); + let url = this.getMetaTagValue('dc.identifier.uri'); + if (hasNoValue(url)) { + url = this.router.url; // Google should handle relative URL + } + this.addMetaTag('citation_abstract_html_url', url); } } From 81b76dd32774d68bcf2ce87a5ab9e4deeddd71d7 Mon Sep 17 00:00:00 2001 From: Yura Bondarenko Date: Fri, 4 Jun 2021 14:42:16 +0200 Subject: [PATCH 08/38] 79768: Fix tag: citation_publisher / citation_*_institution --- .../core/metadata/metadata.service.spec.ts | 32 +++++++++++++++++-- src/app/core/metadata/metadata.service.ts | 26 ++++++--------- 2 files changed, 40 insertions(+), 18 deletions(-) diff --git a/src/app/core/metadata/metadata.service.spec.ts b/src/app/core/metadata/metadata.service.spec.ts index 30ee7f99ea..d2656d6c70 100644 --- a/src/app/core/metadata/metadata.service.spec.ts +++ b/src/app/core/metadata/metadata.service.spec.ts @@ -229,7 +229,6 @@ describe('MetadataService', () => { router.navigate(['/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357']); tick(); expect(tagStore.get('citation_dissertation_name')[0].content).toEqual('Test PowerPoint Document'); - expect(tagStore.get('citation_dissertation_institution')[0].content).toEqual('Mock Publisher'); expect(tagStore.get('citation_pdf_url')[0].content).toEqual('/bitstreams/99b00f3c-1cc6-4689-8158-91965bee6b28/download'); })); @@ -284,7 +283,36 @@ describe('MetadataService', () => { spyOn(itemDataService, 'findById').and.returnValue(mockRemoteData(mockUri(ItemMock))); router.navigate(['/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357']); tick(); - expect(tagStore.get('citation_abstract_html_url')[0].content).toEqual('/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357'); + expect(tagStore.get('citation_abstract_html_url')[0].content).toEqual('/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357'); + })); + }); + + describe('citation_*_institution / citation_publisher', () => { + it('should use citation_dissertation_institution tag for dissertations', fakeAsync(() => { + spyOn(itemDataService, 'findById').and.returnValue(mockRemoteData(mockPublisher(mockType(ItemMock, 'Thesis')))); + router.navigate(['/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357']); + tick(); + expect(tagStore.get('citation_dissertation_institution')[0].content).toEqual('Mock Publisher'); + expect(tagStore.get('citation_technical_report_institution')).toBeFalsy(); + expect(tagStore.get('citation_publisher')).toBeFalsy(); + })); + + it('should use citation_tech_report_institution tag for tech reports', fakeAsync(() => { + spyOn(itemDataService, 'findById').and.returnValue(mockRemoteData(mockPublisher(mockType(ItemMock, 'Technical Report')))); + router.navigate(['/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357']); + tick(); + expect(tagStore.get('citation_dissertation_institution')).toBeFalsy(); + expect(tagStore.get('citation_technical_report_institution')[0].content).toEqual('Mock Publisher'); + expect(tagStore.get('citation_publisher')).toBeFalsy(); + })); + + it('should use citation_publisher for other item types', fakeAsync(() => { + spyOn(itemDataService, 'findById').and.returnValue(mockRemoteData(mockPublisher(mockType(ItemMock, 'Some Other Type')))); + router.navigate(['/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357']); + tick(); + expect(tagStore.get('citation_dissertation_institution')).toBeFalsy(); + expect(tagStore.get('citation_technical_report_institution')).toBeFalsy(); + expect(tagStore.get('citation_publisher')[0].content).toEqual('Mock Publisher'); })); }); diff --git a/src/app/core/metadata/metadata.service.ts b/src/app/core/metadata/metadata.service.ts index bc26f36fd1..f4b7b7ca10 100644 --- a/src/app/core/metadata/metadata.service.ts +++ b/src/app/core/metadata/metadata.service.ts @@ -144,14 +144,10 @@ export class MetadataService { this.setCitationAbstractUrlTag(); this.setCitationPdfUrlTag(); + this.setCitationPublisherTag(); if (this.isDissertation()) { this.setCitationDissertationNameTag(); - this.setCitationDissertationInstitutionTag(); - } - - if (this.isTechReport()) { - this.setCitationTechReportInstitutionTag(); } // this.setCitationJournalTitleTag(); @@ -246,19 +242,17 @@ export class MetadataService { } /** - * Add to the + * Add dc.publisher to the . The tag name depends on the item type. */ - private setCitationDissertationInstitutionTag(): void { + private setCitationPublisherTag(): void { const value = this.getMetaTagValue('dc.publisher'); - this.addMetaTag('citation_dissertation_institution', value); - } - - /** - * Add to the - */ - private setCitationTechReportInstitutionTag(): void { - const value = this.getMetaTagValue('dc.publisher'); - this.addMetaTag('citation_technical_report_institution', value); + if (this.isDissertation()) { + this.addMetaTag('citation_dissertation_institution', value); + } else if (this.isTechReport()) { + this.addMetaTag('citation_technical_report_institution', value); + } else { + this.addMetaTag('citation_publisher', value); + } } /** From 6a4e56322f70768b52301adaaf5f903fa5572937 Mon Sep 17 00:00:00 2001 From: Yura Bondarenko Date: Fri, 4 Jun 2021 15:14:57 +0200 Subject: [PATCH 09/38] 79768: Rename method --- src/app/core/metadata/metadata.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/core/metadata/metadata.service.ts b/src/app/core/metadata/metadata.service.ts index f4b7b7ca10..7b886d9067 100644 --- a/src/app/core/metadata/metadata.service.ts +++ b/src/app/core/metadata/metadata.service.ts @@ -329,7 +329,7 @@ export class MetadataService { return [getBitstreamDownloadRoute(bitstreamRd.payload.page[0])]; } else { // Otherwise check all bitstreams to see if one matches the format whitelist - return this.getBitstreamDownloadRoute(bitstreamRd); + return this.getFirstAllowedFormatBitstreamLink(bitstreamRd); } }) ); @@ -343,7 +343,7 @@ export class MetadataService { } } - private getBitstreamDownloadRoute(bitstreamRd: RemoteData>): Observable { + private getFirstAllowedFormatBitstreamLink(bitstreamRd: RemoteData>): Observable { return observableOf(bitstreamRd.payload).pipe( // Because there can be more than one page of bitstreams, this expand operator // will retrieve them in turn due to the take(1) at the bottom, it will only From 2ed16aa66ecf81ad05c85d2fb21f65774d554fa9 Mon Sep 17 00:00:00 2001 From: Yura Bondarenko Date: Mon, 14 Jun 2021 09:34:23 +0200 Subject: [PATCH 10/38] 79768: Update typedocs --- src/app/core/metadata/metadata.service.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/app/core/metadata/metadata.service.ts b/src/app/core/metadata/metadata.service.ts index 7b886d9067..fa1022e4f6 100644 --- a/src/app/core/metadata/metadata.service.ts +++ b/src/app/core/metadata/metadata.service.ts @@ -40,7 +40,13 @@ export class MetadataService { private currentObject: BehaviorSubject; - private readonly ALLOWED_MIMETYPES = [ + /** + * When generating the citation_pdf_url meta tag for Items with more than one Bitstream (and no primary Bitstream), + * the first Bitstream to match one of the following MIME types is selected. + * See {@linkcode getFirstAllowedFormatBitstreamLink} + * @private + */ + private readonly CITATION_PDF_URL_MIMETYPES = [ 'application/pdf', // .pdf 'application/postscript', // .ps 'application/msword', // .doc @@ -202,7 +208,7 @@ export class MetadataService { } /** - * Add to the + * Add to the */ private setCitationPublicationDateTag(): void { const value = this.getFirstMetaTagValue(['dc.date.copyright', 'dc.date.issued', 'dc.date.available', 'dc.date.accessioned']); @@ -343,6 +349,12 @@ export class MetadataService { } } + /** + * For Items with more than one Bitstream (and no primary Bitstream), link to the first Bitstream with a MIME type + * included in {@linkcode CITATION_PDF_URL_MIMETYPES} + * @param bitstreamRd + * @private + */ private getFirstAllowedFormatBitstreamLink(bitstreamRd: RemoteData>): Observable { return observableOf(bitstreamRd.payload).pipe( // Because there can be more than one page of bitstreams, this expand operator @@ -385,7 +397,7 @@ export class MetadataService { )), // Filter out only pairs with whitelisted formats filter(([, format]: [Bitstream, BitstreamFormat]) => - hasValue(format) && this.ALLOWED_MIMETYPES.includes(format.mimetype)), + hasValue(format) && this.CITATION_PDF_URL_MIMETYPES.includes(format.mimetype)), // We only need 1 take(1), // Emit the link of the match From 64049fdcf770b43cff44cb7228b003bc879c2a5b Mon Sep 17 00:00:00 2001 From: Yura Bondarenko Date: Mon, 14 Jun 2021 09:48:33 +0200 Subject: [PATCH 11/38] 79768: Make relative URLs absolute --- src/app/core/metadata/metadata.service.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/app/core/metadata/metadata.service.ts b/src/app/core/metadata/metadata.service.ts index fa1022e4f6..cef3a67054 100644 --- a/src/app/core/metadata/metadata.service.ts +++ b/src/app/core/metadata/metadata.service.ts @@ -30,6 +30,8 @@ import { BundleDataService } from '../data/bundle-data.service'; import { followLink } from '../../shared/utils/follow-link-config.model'; import { Bundle } from '../shared/bundle.model'; import { PaginatedList } from '../data/paginated-list.model'; +import { URLCombiner } from '../url-combiner/url-combiner'; +import { HardRedirectService } from '../services/hard-redirect.service'; @Injectable() export class MetadataService { @@ -64,7 +66,8 @@ export class MetadataService { private bundleDataService: BundleDataService, private bitstreamDataService: BitstreamDataService, private bitstreamFormatDataService: BitstreamFormatDataService, - private rootService: RootDataService + private rootService: RootDataService, + private hardRedirectService: HardRedirectService, ) { // TODO: determine what open graph meta tags are needed and whether // the differ per route. potentially add image based on DSpaceObject @@ -276,7 +279,7 @@ export class MetadataService { if (this.currentObject.value instanceof Item) { let url = this.getMetaTagValue('dc.identifier.uri'); if (hasNoValue(url)) { - url = this.router.url; // Google should handle relative URL + url = new URLCombiner(this.hardRedirectService.getRequestOrigin(), this.router.url).toString(); } this.addMetaTag('citation_abstract_html_url', url); } @@ -344,7 +347,10 @@ export class MetadataService { take(1) ).subscribe((link: string) => { // Use the found link to set the tag - this.addMetaTag('citation_pdf_url', link); + this.addMetaTag( + 'citation_pdf_url', + new URLCombiner(this.hardRedirectService.getRequestOrigin(), link).toString() + ); }); } } From 04b4f1cf58dc2f606f4eb120d66f072e2587bf8b Mon Sep 17 00:00:00 2001 From: Art Lowel Date: Mon, 14 Jun 2021 10:30:17 +0200 Subject: [PATCH 12/38] Fix comment punctuation --- src/app/core/metadata/metadata.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/core/metadata/metadata.service.ts b/src/app/core/metadata/metadata.service.ts index cef3a67054..4e3da11bf2 100644 --- a/src/app/core/metadata/metadata.service.ts +++ b/src/app/core/metadata/metadata.service.ts @@ -364,7 +364,7 @@ export class MetadataService { private getFirstAllowedFormatBitstreamLink(bitstreamRd: RemoteData>): Observable { return observableOf(bitstreamRd.payload).pipe( // Because there can be more than one page of bitstreams, this expand operator - // will retrieve them in turn due to the take(1) at the bottom, it will only + // will retrieve them in turn. Due to the take(1) at the bottom, it will only // retrieve pages until a match is found expand((paginatedList: PaginatedList) => { if (hasNoValue(paginatedList.next)) { From 34b117efe3a4918a81c4d3ee904808f29885fe4d Mon Sep 17 00:00:00 2001 From: Yura Bondarenko Date: Mon, 14 Jun 2021 10:50:59 +0200 Subject: [PATCH 13/38] 80084: Fix unit test & LGTM issues --- src/app/core/metadata/metadata.service.spec.ts | 17 +++++++++++------ src/app/core/metadata/metadata.service.ts | 1 - 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/app/core/metadata/metadata.service.spec.ts b/src/app/core/metadata/metadata.service.spec.ts index d2656d6c70..a2f2272219 100644 --- a/src/app/core/metadata/metadata.service.spec.ts +++ b/src/app/core/metadata/metadata.service.spec.ts @@ -47,13 +47,13 @@ import { PageInfo } from '../shared/page-info.model'; import { UUIDService } from '../shared/uuid.service'; import { MetadataService } from './metadata.service'; -import { environment } from '../../../environments/environment'; import { storeModuleConfig } from '../../app.reducer'; import { RootDataService } from '../data/root-data.service'; import { Root } from '../data/root.model'; import { Bundle } from '../shared/bundle.model'; import { BundleDataService } from '../data/bundle-data.service'; import { createPaginatedList } from '../../shared/testing/utils.test'; +import { HardRedirectService } from '../services/hard-redirect.service'; /* tslint:disable:max-classes-per-file */ @Component({ @@ -96,6 +96,7 @@ describe('MetadataService', () => { let authService: AuthService; let rootService: RootDataService; let translateService: TranslateService; + let hardRedirectService: HardRedirectService; let location: Location; let router: Router; @@ -147,6 +148,9 @@ describe('MetadataService', () => { dspaceVersion: 'mock-dspace-version' })) }); + hardRedirectService = jasmine.createSpyObj('hardRedirectService', { + getRequestOrigin: 'https://request.org', + }); TestBed.configureTestingModule({ imports: [ @@ -187,6 +191,7 @@ describe('MetadataService', () => { { provide: BitstreamDataService, useValue: bitstreamDataService }, { provide: BundleDataService, useValue: bundleDataService }, { provide: RootDataService, useValue: rootService }, + { provide: HardRedirectService, useValue: hardRedirectService }, Meta, Title, // tslint:disable-next-line:no-empty @@ -229,7 +234,7 @@ describe('MetadataService', () => { router.navigate(['/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357']); tick(); expect(tagStore.get('citation_dissertation_name')[0].content).toEqual('Test PowerPoint Document'); - expect(tagStore.get('citation_pdf_url')[0].content).toEqual('/bitstreams/99b00f3c-1cc6-4689-8158-91965bee6b28/download'); + expect(tagStore.get('citation_pdf_url')[0].content).toEqual('https://request.org/bitstreams/99b00f3c-1cc6-4689-8158-91965bee6b28/download'); })); it('items page should set meta tags as published Technical Report', fakeAsync(() => { @@ -283,7 +288,7 @@ describe('MetadataService', () => { spyOn(itemDataService, 'findById').and.returnValue(mockRemoteData(mockUri(ItemMock))); router.navigate(['/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357']); tick(); - expect(tagStore.get('citation_abstract_html_url')[0].content).toEqual('/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357'); + expect(tagStore.get('citation_abstract_html_url')[0].content).toEqual('https://request.org/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357'); })); }); @@ -322,7 +327,7 @@ describe('MetadataService', () => { bundleDataService.findByItemAndName.and.returnValue(mockBundleRD$([], MockBitstream3)); router.navigate(['/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357']); tick(); - expect(tagStore.get('citation_pdf_url')[0].content).toEqual('/bitstreams/4db100c1-e1f5-4055-9404-9bc3e2d15f29/download'); + expect(tagStore.get('citation_pdf_url')[0].content).toEqual('https://request.org/bitstreams/4db100c1-e1f5-4055-9404-9bc3e2d15f29/download'); })); describe('no primary Bitstream', () => { @@ -331,7 +336,7 @@ describe('MetadataService', () => { bundleDataService.findByItemAndName.and.returnValue(mockBundleRD$([MockBitstream3])); router.navigate(['/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357']); tick(); - expect(tagStore.get('citation_pdf_url')[0].content).toEqual('/bitstreams/4db100c1-e1f5-4055-9404-9bc3e2d15f29/download'); + expect(tagStore.get('citation_pdf_url')[0].content).toEqual('https://request.org/bitstreams/4db100c1-e1f5-4055-9404-9bc3e2d15f29/download'); })); it('should link to first Bitstream with allowed format', fakeAsync(() => { @@ -345,7 +350,7 @@ describe('MetadataService', () => { router.navigate(['/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357']); tick(); - expect(tagStore.get('citation_pdf_url')[0].content).toEqual('/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713/download'); + expect(tagStore.get('citation_pdf_url')[0].content).toEqual('https://request.org/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713/download'); })); }); }); diff --git a/src/app/core/metadata/metadata.service.ts b/src/app/core/metadata/metadata.service.ts index 4e3da11bf2..d6518b6164 100644 --- a/src/app/core/metadata/metadata.service.ts +++ b/src/app/core/metadata/metadata.service.ts @@ -23,7 +23,6 @@ import { getFirstSucceededRemoteDataPayload, getFirstCompletedRemoteData } from '../shared/operators'; -import { environment } from '../../../environments/environment'; import { RootDataService } from '../data/root-data.service'; import { getBitstreamDownloadRoute } from '../../app-routing-paths'; import { BundleDataService } from '../data/bundle-data.service'; From 03e2e30510a4ab151f17d158ca8010e99ed09da6 Mon Sep 17 00:00:00 2001 From: Art Lowel Date: Wed, 30 Jun 2021 14:09:18 +0200 Subject: [PATCH 14/38] fix issue where meta tags wouldn't be updated properly based on the route --- .../collection-page.component.ts | 14 +- .../full/full-item-page.component.ts | 10 +- .../+item-page/simple/item-page.component.ts | 4 - .../core/metadata/metadata.service.spec.ts | 503 +++++++++--------- src/app/core/metadata/metadata.service.ts | 47 +- 5 files changed, 261 insertions(+), 317 deletions(-) diff --git a/src/app/+collection-page/collection-page.component.ts b/src/app/+collection-page/collection-page.component.ts index 9eba2e4ab2..366e1da7b1 100644 --- a/src/app/+collection-page/collection-page.component.ts +++ b/src/app/+collection-page/collection-page.component.ts @@ -1,6 +1,11 @@ import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; -import { BehaviorSubject, combineLatest as observableCombineLatest, Observable, Subject } from 'rxjs'; +import { + BehaviorSubject, + combineLatest as observableCombineLatest, + Observable, + Subject +} from 'rxjs'; import { filter, map, mergeMap, startWith, switchMap, take } from 'rxjs/operators'; import { PaginatedSearchOptions } from '../shared/search/paginated-search-options.model'; import { SearchService } from '../core/shared/search/search.service'; @@ -8,8 +13,6 @@ import { SortDirection, SortOptions } from '../core/cache/models/sort-options.mo import { CollectionDataService } from '../core/data/collection-data.service'; import { PaginatedList } from '../core/data/paginated-list.model'; import { RemoteData } from '../core/data/remote-data'; - -import { MetadataService } from '../core/metadata/metadata.service'; import { Bitstream } from '../core/shared/bitstream.model'; import { Collection } from '../core/shared/collection.model'; @@ -65,7 +68,6 @@ export class CollectionPageComponent implements OnInit { constructor( private collectionDataService: CollectionDataService, private searchService: SearchService, - private metadata: MetadataService, private route: ActivatedRoute, private router: Router, private authService: AuthService, @@ -122,10 +124,6 @@ export class CollectionPageComponent implements OnInit { getAllSucceededRemoteDataPayload(), map((collection) => getCollectionPageRoute(collection.id)) ); - - this.route.queryParams.pipe(take(1)).subscribe((params) => { - this.metadata.processRemoteData(this.collectionRD$); - }); } isNotEmpty(object: any) { diff --git a/src/app/+item-page/full/full-item-page.component.ts b/src/app/+item-page/full/full-item-page.component.ts index aea350e58e..da16e134cb 100644 --- a/src/app/+item-page/full/full-item-page.component.ts +++ b/src/app/+item-page/full/full-item-page.component.ts @@ -1,8 +1,8 @@ -import {filter, map} from 'rxjs/operators'; +import { filter, map } from 'rxjs/operators'; import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; -import { Observable , BehaviorSubject } from 'rxjs'; +import { Observable, BehaviorSubject } from 'rxjs'; import { ItemPageComponent } from '../simple/item-page.component'; import { MetadataMap } from '../../core/shared/metadata.models'; @@ -11,8 +11,6 @@ import { ItemDataService } from '../../core/data/item-data.service'; import { RemoteData } from '../../core/data/remote-data'; import { Item } from '../../core/shared/item.model'; -import { MetadataService } from '../../core/metadata/metadata.service'; - import { fadeInOut } from '../../shared/animations/fade'; import { hasValue } from '../../shared/empty.util'; import { AuthService } from '../../core/auth/auth.service'; @@ -35,8 +33,8 @@ export class FullItemPageComponent extends ItemPageComponent implements OnInit { metadata$: Observable; - constructor(route: ActivatedRoute, router: Router, items: ItemDataService, metadataService: MetadataService, authService: AuthService) { - super(route, router, items, metadataService, authService); + constructor(route: ActivatedRoute, router: Router, items: ItemDataService, authService: AuthService) { + super(route, router, items, authService); } /*** AoT inheritance fix, will hopefully be resolved in the near future **/ diff --git a/src/app/+item-page/simple/item-page.component.ts b/src/app/+item-page/simple/item-page.component.ts index 67e278c2fb..d2c238b5e6 100644 --- a/src/app/+item-page/simple/item-page.component.ts +++ b/src/app/+item-page/simple/item-page.component.ts @@ -8,8 +8,6 @@ import { RemoteData } from '../../core/data/remote-data'; import { Item } from '../../core/shared/item.model'; -import { MetadataService } from '../../core/metadata/metadata.service'; - import { fadeInOut } from '../../shared/animations/fade'; import { getAllSucceededRemoteDataPayload, redirectOn4xx } from '../../core/shared/operators'; import { ViewMode } from '../../core/shared/view-mode.model'; @@ -54,7 +52,6 @@ export class ItemPageComponent implements OnInit { private route: ActivatedRoute, private router: Router, private items: ItemDataService, - private metadataService: MetadataService, private authService: AuthService, ) { } @@ -66,7 +63,6 @@ export class ItemPageComponent implements OnInit { map((data) => data.dso as RemoteData), redirectOn4xx(this.router, this.authService) ); - this.metadataService.processRemoteData(this.itemRD$); this.itemPageRoute$ = this.itemRD$.pipe( getAllSucceededRemoteDataPayload(), map((item) => getItemPageRoute(item)) diff --git a/src/app/core/metadata/metadata.service.spec.ts b/src/app/core/metadata/metadata.service.spec.ts index a2f2272219..d18897cc55 100644 --- a/src/app/core/metadata/metadata.service.spec.ts +++ b/src/app/core/metadata/metadata.service.spec.ts @@ -1,82 +1,30 @@ -import { CommonModule, Location } from '@angular/common'; -import { HttpClient } from '@angular/common/http'; -import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; -import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; -import { Meta, MetaDefinition, Title } from '@angular/platform-browser'; -import { ActivatedRoute, Router } from '@angular/router'; -import { RouterTestingModule } from '@angular/router/testing'; +import { fakeAsync, tick } from '@angular/core/testing'; +import { Meta, Title } from '@angular/platform-browser'; +import { Router, NavigationEnd } from '@angular/router'; -import { Store, StoreModule } from '@ngrx/store'; - -import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; -import { EmptyError, Observable, of } from 'rxjs'; +import { TranslateService } from '@ngx-translate/core'; +import { Observable, of } from 'rxjs'; import { RemoteData } from '../data/remote-data'; import { Item } from '../shared/item.model'; +import { ItemMock, MockBitstream1, MockBitstream3, } from '../../shared/mocks/item.mock'; import { - ItemMock, MockBitstream1, MockBitstream2, MockBitstream3, MockBitstreamFormat1, MockBitstreamFormat2, - MockBitstreamFormat3, - MockOriginalBundle, -} from '../../shared/mocks/item.mock'; -import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { AuthService } from '../auth/auth.service'; -import { BrowseService } from '../browse/browse.service'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { ObjectCacheService } from '../cache/object-cache.service'; - -import { CoreState } from '../core.reducers'; -import { BitstreamDataService } from '../data/bitstream-data.service'; -import { BitstreamFormatDataService } from '../data/bitstream-format-data.service'; -import { CommunityDataService } from '../data/community-data.service'; -import { DefaultChangeAnalyzer } from '../data/default-change-analyzer.service'; -import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; - -import { ItemDataService } from '../data/item-data.service'; -import { buildPaginatedList, PaginatedList } from '../data/paginated-list.model'; -import { FindListOptions } from '../data/request.models'; -import { RequestService } from '../data/request.service'; -import { BitstreamFormat } from '../shared/bitstream-format.model'; + createSuccessfulRemoteDataObject$, + createSuccessfulRemoteDataObject +} from '../../shared/remote-data.utils'; +import { PaginatedList } from '../data/paginated-list.model'; import { Bitstream } from '../shared/bitstream.model'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; import { MetadataValue } from '../shared/metadata.models'; -import { PageInfo } from '../shared/page-info.model'; -import { UUIDService } from '../shared/uuid.service'; import { MetadataService } from './metadata.service'; -import { storeModuleConfig } from '../../app.reducer'; import { RootDataService } from '../data/root-data.service'; -import { Root } from '../data/root.model'; import { Bundle } from '../shared/bundle.model'; -import { BundleDataService } from '../data/bundle-data.service'; import { createPaginatedList } from '../../shared/testing/utils.test'; +import { getMockTranslateService } from '../../shared/mocks/translate.service.mock'; +import { DSONameService } from '../breadcrumbs/dso-name.service'; import { HardRedirectService } from '../services/hard-redirect.service'; -/* tslint:disable:max-classes-per-file */ -@Component({ - template: ` - ` -}) -class TestComponent { - constructor(private metadata: MetadataService) { - metadata.listenForRouteChange(); - } -} - -@Component({ template: '' }) -class DummyItemComponent { - constructor(private route: ActivatedRoute, private items: ItemDataService, private metadata: MetadataService) { - this.route.params.subscribe((params) => { - this.metadata.processRemoteData(this.items.findById(params.id)); - }); - } -} - -/* tslint:enable:max-classes-per-file */ - describe('MetadataService', () => { let metadataService: MetadataService; @@ -84,281 +32,306 @@ describe('MetadataService', () => { let title: Title; - let store: Store; + let dsoNameService: DSONameService; - let objectCacheService: ObjectCacheService; - let requestService: RequestService; - let uuidService: UUIDService; - let remoteDataBuildService: RemoteDataBuildService; - let itemDataService: ItemDataService; let bundleDataService; let bitstreamDataService; - let authService: AuthService; let rootService: RootDataService; let translateService: TranslateService; let hardRedirectService: HardRedirectService; - let location: Location; let router: Router; - let fixture: ComponentFixture; - - let tagStore: Map; beforeEach(() => { - - store = new Store(undefined, undefined, undefined); - spyOn(store, 'dispatch'); - - objectCacheService = new ObjectCacheService(store, undefined); - uuidService = new UUIDService(); - requestService = new RequestService(objectCacheService, uuidService, store, undefined); - remoteDataBuildService = new RemoteDataBuildService(objectCacheService, undefined, requestService); - bitstreamDataService = { - findAllByItemAndBundleName(item: Item, bundleName: string, options?: FindListOptions, ...linksToFollow: FollowLinkConfig[]): Observable>> { - if (item.equals(ItemMock)) { - return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [MockBitstream1, MockBitstream2])); - } else { - return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])); - } - }, - findAllByHref: jasmine.createSpy(), - }; - const mockBitstreamFormatDataService = { - findByBitstream(bitstream: Bitstream): Observable> { - switch (bitstream) { - case MockBitstream1: - return createSuccessfulRemoteDataObject$(MockBitstreamFormat1); - break; - case MockBitstream2: - return createSuccessfulRemoteDataObject$(MockBitstreamFormat2); - break; - case MockBitstream3: - return createSuccessfulRemoteDataObject$(MockBitstreamFormat3); - break; - default: - return createSuccessfulRemoteDataObject$(new BitstreamFormat()); - } + rootService = jasmine.createSpyObj({ + findRoot: createSuccessfulRemoteDataObject$({ dspaceVersion: 'mock-dspace-version' }) + }); + bitstreamDataService = jasmine.createSpyObj({ + findAllByHref: createSuccessfulRemoteDataObject$(createPaginatedList([MockBitstream3])) + }); + bundleDataService = jasmine.createSpyObj({ + findByItemAndName: mockBundleRD$([MockBitstream3]) + }); + translateService = getMockTranslateService(); + meta = jasmine.createSpyObj({ + addTag: {}, + removeTag: {} + }); + title = jasmine.createSpyObj({ + setTitle: {} + }); + dsoNameService = jasmine.createSpyObj({ + getName: ItemMock.firstMetadataValue('dc.title') + }); + router = { + url: '/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357', + events: of(new NavigationEnd(1, '', '')), + routerState: { + root: {} } - }; - bundleDataService = jasmine.createSpyObj('bundleDataService', { - findByItemAndName: createSuccessfulRemoteDataObject$(MockOriginalBundle), - }); - rootService = jasmine.createSpyObj('rootService', { - findRoot: createSuccessfulRemoteDataObject$(Object.assign(new Root(), { - dspaceVersion: 'mock-dspace-version' - })) - }); - hardRedirectService = jasmine.createSpyObj('hardRedirectService', { + } as any as Router; + hardRedirectService = jasmine.createSpyObj( { getRequestOrigin: 'https://request.org', }); - - TestBed.configureTestingModule({ - imports: [ - CommonModule, - StoreModule.forRoot({}, storeModuleConfig), - TranslateModule.forRoot({ - loader: { - provide: TranslateLoader, - useClass: TranslateLoaderMock - } - }), - RouterTestingModule.withRoutes([ - { path: 'items/:id', component: DummyItemComponent, pathMatch: 'full' }, - { - path: 'other', - component: DummyItemComponent, - pathMatch: 'full', - data: { title: 'Dummy Title', description: 'This is a dummy item component for testing!' } - } - ]) - ], - declarations: [ - TestComponent, - DummyItemComponent - ], - providers: [ - { provide: ObjectCacheService, useValue: objectCacheService }, - { provide: RequestService, useValue: requestService }, - { provide: RemoteDataBuildService, useValue: remoteDataBuildService }, - { provide: HALEndpointService, useValue: {} }, - { provide: AuthService, useValue: {} }, - { provide: NotificationsService, useValue: {} }, - { provide: HttpClient, useValue: {} }, - { provide: DSOChangeAnalyzer, useValue: {} }, - { provide: CommunityDataService, useValue: {} }, - { provide: DefaultChangeAnalyzer, useValue: {} }, - { provide: BitstreamFormatDataService, useValue: mockBitstreamFormatDataService }, - { provide: BitstreamDataService, useValue: bitstreamDataService }, - { provide: BundleDataService, useValue: bundleDataService }, - { provide: RootDataService, useValue: rootService }, - { provide: HardRedirectService, useValue: hardRedirectService }, - Meta, - Title, - // tslint:disable-next-line:no-empty - { provide: ItemDataService, useValue: { findById: () => {} } }, - BrowseService, - MetadataService - ], - schemas: [CUSTOM_ELEMENTS_SCHEMA] - }); - meta = TestBed.inject(Meta); - title = TestBed.inject(Title); - itemDataService = TestBed.inject(ItemDataService); - metadataService = TestBed.inject(MetadataService); - authService = TestBed.inject(AuthService); - translateService = TestBed.inject(TranslateService); - - router = TestBed.inject(Router); - location = TestBed.inject(Location); - - fixture = TestBed.createComponent(TestComponent); - - tagStore = metadataService.getTagStore(); + metadataService = new MetadataService( + router, + translateService, + meta, + title, + dsoNameService, + bundleDataService, + bitstreamDataService, + undefined, + rootService, + hardRedirectService + ); }); it('items page should set meta tags', fakeAsync(() => { - spyOn(itemDataService, 'findById').and.returnValue(mockRemoteData(ItemMock)); - router.navigate(['/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357']); + (metadataService as any).processRouteChange({ + data: { + value: { + dso: createSuccessfulRemoteDataObject(ItemMock), + } + } + }); tick(); - expect(title.getTitle()).toEqual('Test PowerPoint Document'); - expect(tagStore.get('citation_title')[0].content).toEqual('Test PowerPoint Document'); - expect(tagStore.get('citation_author')[0].content).toEqual('Doe, Jane'); - expect(tagStore.get('citation_publication_date')[0].content).toEqual('1650-06-26'); - expect(tagStore.get('citation_issn')[0].content).toEqual('123456789'); - expect(tagStore.get('citation_language')[0].content).toEqual('en'); - expect(tagStore.get('citation_keywords')[0].content).toEqual('keyword1; keyword2; keyword3'); + expect(title.setTitle).toHaveBeenCalledWith('Test PowerPoint Document'); + expect(meta.addTag).toHaveBeenCalledWith({ + property: 'citation_title', + content: 'Test PowerPoint Document' + }); + expect(meta.addTag).toHaveBeenCalledWith({ property: 'citation_author', content: 'Doe, Jane' }); + expect(meta.addTag).toHaveBeenCalledWith({ + property: 'citation_publication_date', + content: '1650-06-26' + }); + expect(meta.addTag).toHaveBeenCalledWith({ property: 'citation_issn', content: '123456789' }); + expect(meta.addTag).toHaveBeenCalledWith({ property: 'citation_language', content: 'en' }); + expect(meta.addTag).toHaveBeenCalledWith({ + property: 'citation_keywords', + content: 'keyword1; keyword2; keyword3' + }); })); it('items page should set meta tags as published Thesis', fakeAsync(() => { - spyOn(itemDataService, 'findById').and.returnValue(mockRemoteData(mockPublisher(mockType(ItemMock, 'Thesis')))); - router.navigate(['/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357']); + (metadataService as any).processRouteChange({ + data: { + value: { + dso: createSuccessfulRemoteDataObject(mockPublisher(mockType(ItemMock, 'Thesis'))), + } + } + }); tick(); - expect(tagStore.get('citation_dissertation_name')[0].content).toEqual('Test PowerPoint Document'); - expect(tagStore.get('citation_pdf_url')[0].content).toEqual('https://request.org/bitstreams/99b00f3c-1cc6-4689-8158-91965bee6b28/download'); + expect(meta.addTag).toHaveBeenCalledWith({ + property: 'citation_dissertation_name', + content: 'Test PowerPoint Document' + }); + expect(meta.addTag).toHaveBeenCalledWith({ + property: 'citation_pdf_url', + content: 'https://request.org/bitstreams/4db100c1-e1f5-4055-9404-9bc3e2d15f29/download' + }); })); it('items page should set meta tags as published Technical Report', fakeAsync(() => { - spyOn(itemDataService, 'findById').and.returnValue(mockRemoteData(mockPublisher(mockType(ItemMock, 'Technical Report')))); - router.navigate(['/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357']); - tick(); - expect(tagStore.get('citation_technical_report_institution')[0].content).toEqual('Mock Publisher'); - })); - - it('other navigation should add title, description and Generator', fakeAsync(() => { - spyOn(itemDataService, 'findById').and.returnValue(mockRemoteData(ItemMock)); - spyOn(translateService, 'get').and.returnValues(of('DSpace :: '), of('Dummy Title'), of('This is a dummy item component for testing!')); - router.navigate(['/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357']); - tick(); - expect(tagStore.size).toBeGreaterThan(0); - router.navigate(['/other']); - tick(); - expect(tagStore.size).toEqual(3); - expect(title.getTitle()).toEqual('DSpace :: Dummy Title'); - expect(tagStore.get('title')[0].content).toEqual('DSpace :: Dummy Title'); - expect(tagStore.get('description')[0].content).toEqual('This is a dummy item component for testing!'); - expect(tagStore.get('Generator')[0].content).toEqual('mock-dspace-version'); - })); - - describe('when the item has no bitstreams', () => { - - beforeEach(() => { - // this.bitstreamDataService.findAllByItemAndBundleName(this.item, 'ORIGINAL') - // spyOn(MockItem, 'getFiles').and.returnValue(observableOf([])); + (metadataService as any).processRouteChange({ + data: { + value: { + dso: createSuccessfulRemoteDataObject(mockPublisher(mockType(ItemMock, 'Technical Report'))), + } + } }); + tick(); + expect(meta.addTag).toHaveBeenCalledWith({ + property: 'citation_technical_report_institution', + content: 'Mock Publisher' + }); + })); - it('processRemoteData should not produce an EmptyError', fakeAsync(() => { - spyOn(itemDataService, 'findById').and.returnValue(mockRemoteData(ItemMock)); - spyOn(metadataService, 'processRemoteData').and.callThrough(); - router.navigate(['/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357']); + it('other navigation should add title and description', fakeAsync(() => { + (translateService.get as jasmine.Spy).and.returnValues(of('DSpace :: '), of('Dummy Title'), of('This is a dummy item component for testing!')); + (metadataService as any).processRouteChange({ + data: { + value: { + title: 'Dummy Title', + description: 'This is a dummy item component for testing!' + } + } + }); + tick(); + expect(title.setTitle).toHaveBeenCalledWith('DSpace :: Dummy Title'); + expect(meta.addTag).toHaveBeenCalledWith({ + property: 'title', + content: 'DSpace :: Dummy Title' + }); + expect(meta.addTag).toHaveBeenCalledWith({ + property: 'description', + content: 'This is a dummy item component for testing!' + }); + })); + + describe(`listenForRouteChange`, () => { + it(`should call processRouteChange`, fakeAsync(() => { + spyOn(metadataService as any, 'processRouteChange').and.callFake(() => undefined); + metadataService.listenForRouteChange(); tick(); - expect(metadataService.processRemoteData).not.toThrow(new EmptyError()); + expect((metadataService as any).processRouteChange).toHaveBeenCalled(); + })); + it(`should add Generator`, fakeAsync(() => { + spyOn(metadataService as any, 'processRouteChange').and.callFake(() => undefined); + metadataService.listenForRouteChange(); + tick(); + expect(meta.addTag).toHaveBeenCalledWith({ + property: 'Generator', + content: 'mock-dspace-version' + }); })); - }); describe('citation_abstract_html_url', () => { it('should use dc.identifier.uri if available', fakeAsync(() => { - spyOn(itemDataService, 'findById').and.returnValue(mockRemoteData(mockUri(ItemMock, 'https://ddg.gg'))); - router.navigate(['/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357']); + (metadataService as any).processRouteChange({ + data: { + value: { + dso: createSuccessfulRemoteDataObject(mockUri(ItemMock, 'https://ddg.gg')), + } + } + }); tick(); - expect(tagStore.get('citation_abstract_html_url')[0].content).toEqual('https://ddg.gg'); + expect(meta.addTag).toHaveBeenCalledWith({ + property: 'citation_abstract_html_url', + content: 'https://ddg.gg' + }); })); it('should use current route as fallback', fakeAsync(() => { - spyOn(itemDataService, 'findById').and.returnValue(mockRemoteData(mockUri(ItemMock))); - router.navigate(['/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357']); + (metadataService as any).processRouteChange({ + data: { + value: { + dso: createSuccessfulRemoteDataObject(mockUri(ItemMock)), + } + } + }); tick(); - expect(tagStore.get('citation_abstract_html_url')[0].content).toEqual('https://request.org/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357'); + expect(meta.addTag).toHaveBeenCalledWith({ + property: 'citation_abstract_html_url', + content: 'https://request.org/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357' + }); })); }); describe('citation_*_institution / citation_publisher', () => { it('should use citation_dissertation_institution tag for dissertations', fakeAsync(() => { - spyOn(itemDataService, 'findById').and.returnValue(mockRemoteData(mockPublisher(mockType(ItemMock, 'Thesis')))); - router.navigate(['/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357']); + (metadataService as any).processRouteChange({ + data: { + value: { + dso: createSuccessfulRemoteDataObject(mockPublisher(mockType(ItemMock, 'Thesis'))), + } + } + }); tick(); - expect(tagStore.get('citation_dissertation_institution')[0].content).toEqual('Mock Publisher'); - expect(tagStore.get('citation_technical_report_institution')).toBeFalsy(); - expect(tagStore.get('citation_publisher')).toBeFalsy(); + expect(meta.addTag).toHaveBeenCalledWith({ + property: 'citation_dissertation_institution', + content: 'Mock Publisher' + }); + expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ property: 'citation_technical_report_institution' })); + expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ property: 'citation_publisher' })); })); it('should use citation_tech_report_institution tag for tech reports', fakeAsync(() => { - spyOn(itemDataService, 'findById').and.returnValue(mockRemoteData(mockPublisher(mockType(ItemMock, 'Technical Report')))); - router.navigate(['/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357']); + (metadataService as any).processRouteChange({ + data: { + value: { + dso: createSuccessfulRemoteDataObject(mockPublisher(mockType(ItemMock, 'Technical Report'))), + } + } + }); tick(); - expect(tagStore.get('citation_dissertation_institution')).toBeFalsy(); - expect(tagStore.get('citation_technical_report_institution')[0].content).toEqual('Mock Publisher'); - expect(tagStore.get('citation_publisher')).toBeFalsy(); + expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ property: 'citation_dissertation_institution' })); + expect(meta.addTag).toHaveBeenCalledWith({ + property: 'citation_technical_report_institution', + content: 'Mock Publisher' + }); + expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ property: 'citation_publisher' })); })); it('should use citation_publisher for other item types', fakeAsync(() => { - spyOn(itemDataService, 'findById').and.returnValue(mockRemoteData(mockPublisher(mockType(ItemMock, 'Some Other Type')))); - router.navigate(['/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357']); + (metadataService as any).processRouteChange({ + data: { + value: { + dso: createSuccessfulRemoteDataObject(mockPublisher(mockType(ItemMock, 'Some Other Type'))), + } + } + }); tick(); - expect(tagStore.get('citation_dissertation_institution')).toBeFalsy(); - expect(tagStore.get('citation_technical_report_institution')).toBeFalsy(); - expect(tagStore.get('citation_publisher')[0].content).toEqual('Mock Publisher'); + expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ property: 'citation_dissertation_institution' })); + expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ property: 'citation_technical_report_institution' })); + expect(meta.addTag).toHaveBeenCalledWith({ + property: 'citation_publisher', + content: 'Mock Publisher' + }); })); }); describe('citation_pdf_url', () => { it('should link to primary Bitstream URL regardless of format', fakeAsync(() => { - spyOn(itemDataService, 'findById').and.returnValue(mockRemoteData(ItemMock)); - bundleDataService.findByItemAndName.and.returnValue(mockBundleRD$([], MockBitstream3)); - router.navigate(['/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357']); + (bundleDataService.findByItemAndName as jasmine.Spy).and.returnValue(mockBundleRD$([], MockBitstream3)); + + (metadataService as any).processRouteChange({ + data: { + value: { + dso: createSuccessfulRemoteDataObject(ItemMock), + } + } + }); tick(); - expect(tagStore.get('citation_pdf_url')[0].content).toEqual('https://request.org/bitstreams/4db100c1-e1f5-4055-9404-9bc3e2d15f29/download'); + expect(meta.addTag).toHaveBeenCalledWith({ + property: 'citation_pdf_url', + content: 'https://request.org/bitstreams/4db100c1-e1f5-4055-9404-9bc3e2d15f29/download' + }); })); describe('no primary Bitstream', () => { it('should link to first and only Bitstream regardless of format', fakeAsync(() => { - spyOn(itemDataService, 'findById').and.returnValue(mockRemoteData(ItemMock)); - bundleDataService.findByItemAndName.and.returnValue(mockBundleRD$([MockBitstream3])); - router.navigate(['/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357']); + (bundleDataService.findByItemAndName as jasmine.Spy).and.returnValue(mockBundleRD$([MockBitstream3])); + + (metadataService as any).processRouteChange({ + data: { + value: { + dso: createSuccessfulRemoteDataObject(ItemMock), + } + } + }); tick(); - expect(tagStore.get('citation_pdf_url')[0].content).toEqual('https://request.org/bitstreams/4db100c1-e1f5-4055-9404-9bc3e2d15f29/download'); + expect(meta.addTag).toHaveBeenCalledWith({ + property: 'citation_pdf_url', + content: 'https://request.org/bitstreams/4db100c1-e1f5-4055-9404-9bc3e2d15f29/download' + }); })); it('should link to first Bitstream with allowed format', fakeAsync(() => { - spyOn(itemDataService, 'findById').and.returnValue(mockRemoteData(ItemMock)); - const bitstreams = [MockBitstream3, MockBitstream3, MockBitstream1]; - bundleDataService.findByItemAndName.and.returnValue(mockBundleRD$(bitstreams)); - bitstreamDataService.findAllByHref.and.returnValues( + (bundleDataService.findByItemAndName as jasmine.Spy).and.returnValue(mockBundleRD$(bitstreams)); + (bitstreamDataService.findAllByHref as jasmine.Spy).and.returnValues( ...mockBitstreamPages$(bitstreams).map(bp => createSuccessfulRemoteDataObject$(bp)), ); - router.navigate(['/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357']); + (metadataService as any).processRouteChange({ + data: { + value: { + dso: createSuccessfulRemoteDataObject(ItemMock), + } + } + }); tick(); - expect(tagStore.get('citation_pdf_url')[0].content).toEqual('https://request.org/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713/download'); + expect(meta.addTag).toHaveBeenCalledWith({ + property: 'citation_pdf_url', + content: 'https://request.org/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713/download' + }); })); }); }); - const mockRemoteData = (mockItem: Item): Observable> => { - return createSuccessfulRemoteDataObject$(mockItem); - }; - const mockType = (mockItem: Item, type: string): Item => { const typedMockItem = Object.assign(new Item(), mockItem) as Item; typedMockItem.metadata['dc.type'] = [{ value: type }] as MetadataValue[]; diff --git a/src/app/core/metadata/metadata.service.ts b/src/app/core/metadata/metadata.service.ts index d6518b6164..ed17fad2d8 100644 --- a/src/app/core/metadata/metadata.service.ts +++ b/src/app/core/metadata/metadata.service.ts @@ -6,11 +6,10 @@ import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; import { BehaviorSubject, combineLatest, Observable, of as observableOf, EMPTY } from 'rxjs'; -import { distinctUntilKeyChanged, filter, map, take, switchMap, expand } from 'rxjs/operators'; +import { filter, map, take, switchMap, expand } from 'rxjs/operators'; import { hasValue, hasNoValue } from '../../shared/empty.util'; import { DSONameService } from '../breadcrumbs/dso-name.service'; -import { CacheableObject } from '../cache/object-cache.reducer'; import { BitstreamDataService } from '../data/bitstream-data.service'; import { BitstreamFormatDataService } from '../data/bitstream-format-data.service'; @@ -35,11 +34,9 @@ import { HardRedirectService } from '../services/hard-redirect.service'; @Injectable() export class MetadataService { - private initialized: boolean; - private tagStore: Map; - private currentObject: BehaviorSubject; + private currentObject: BehaviorSubject = new BehaviorSubject(undefined); /** * When generating the citation_pdf_url meta tag for Items with more than one Bitstream (and no primary Bitstream), @@ -70,11 +67,13 @@ export class MetadataService { ) { // TODO: determine what open graph meta tags are needed and whether // the differ per route. potentially add image based on DSpaceObject - this.initialized = false; this.tagStore = new Map(); } public listenForRouteChange(): void { + // This never changes, set it only once + this.setGenerator(); + this.router.events.pipe( filter((event) => event instanceof NavigationEnd), map(() => this.router.routerState.root), @@ -86,22 +85,9 @@ export class MetadataService { }); } - public processRemoteData(remoteData: Observable>): void { - remoteData.pipe(map((rd: RemoteData) => rd.payload), - filter((co: CacheableObject) => hasValue(co)), - take(1)) - .subscribe((dspaceObject: DSpaceObject) => { - if (!this.initialized) { - this.initialize(dspaceObject); - } - this.currentObject.next(dspaceObject); - }); - } - private processRouteChange(routeInfo: any): void { - if (routeInfo.params.value.id === undefined) { - this.clearMetaTags(); - } + this.clearMetaTags(); + if (routeInfo.data.value.title) { const titlePrefix = this.translate.get('repository.title.prefix'); const title = this.translate.get(routeInfo.data.value.title, routeInfo.data.value); @@ -116,15 +102,10 @@ export class MetadataService { }); } - this.setGenerator(); - } - - private initialize(dspaceObject: DSpaceObject): void { - this.currentObject = new BehaviorSubject(dspaceObject); - this.currentObject.asObservable().pipe(distinctUntilKeyChanged('uuid')).subscribe(() => { - this.setMetaTags(); - }); - this.initialized = true; + if (hasValue(routeInfo.data.value.dso) && hasValue(routeInfo.data.value.dso.payload)) { + this.currentObject.next(routeInfo.data.value.dso.payload); + this.setDSOMetaTags(); + } } private getCurrentRoute(route: ActivatedRoute): ActivatedRoute { @@ -134,9 +115,7 @@ export class MetadataService { return route; } - private setMetaTags(): void { - - this.clearMetaTags(); + private setDSOMetaTags(): void { this.setTitleTag(); this.setDescriptionTag(); @@ -415,7 +394,7 @@ export class MetadataService { */ private setGenerator(): void { this.rootService.findRoot().pipe(getFirstSucceededRemoteDataPayload()).subscribe((root) => { - this.addMetaTag('Generator', root.dspaceVersion); + this.meta.addTag({ property: 'Generator', content: root.dspaceVersion }); }); } From c86f163cb7e8c6de6da273b3c4c198699339c7cc Mon Sep 17 00:00:00 2001 From: Art Lowel Date: Thu, 1 Jul 2021 11:36:12 +0200 Subject: [PATCH 15/38] move tagstore to ngrx --- src/app/core/core.reducers.ts | 3 ++ src/app/core/metadata/meta-tag.actions.ts | 24 +++++++++ src/app/core/metadata/meta-tag.reducer.ts | 38 ++++++++++++++ src/app/core/metadata/metadata.service.ts | 63 +++++++++++++---------- 4 files changed, 100 insertions(+), 28 deletions(-) create mode 100644 src/app/core/metadata/meta-tag.actions.ts create mode 100644 src/app/core/metadata/meta-tag.reducer.ts diff --git a/src/app/core/core.reducers.ts b/src/app/core/core.reducers.ts index 077aa3dc95..448c1b8641 100644 --- a/src/app/core/core.reducers.ts +++ b/src/app/core/core.reducers.ts @@ -13,6 +13,7 @@ import { BitstreamFormatRegistryState } from '../+admin/admin-registries/bitstream-formats/bitstream-format.reducers'; import { historyReducer, HistoryState } from './history/history.reducer'; +import { metaTagReducer, MetaTagState } from './metadata/meta-tag.reducer'; export interface CoreState { 'bitstreamFormats': BitstreamFormatRegistryState; @@ -24,6 +25,7 @@ export interface CoreState { 'index': MetaIndexState; 'auth': AuthState; 'json/patch': JsonPatchOperationsState; + 'metaTag': MetaTagState; 'route': RouteState; } @@ -37,5 +39,6 @@ export const coreReducers: ActionReducerMap = { 'index': indexReducer, 'auth': authReducer, 'json/patch': jsonPatchOperationsReducer, + 'metaTag': metaTagReducer, 'route': routeReducer }; diff --git a/src/app/core/metadata/meta-tag.actions.ts b/src/app/core/metadata/meta-tag.actions.ts new file mode 100644 index 0000000000..6451e58da2 --- /dev/null +++ b/src/app/core/metadata/meta-tag.actions.ts @@ -0,0 +1,24 @@ +import { type } from '../../shared/ngrx/type'; +import { Action } from '@ngrx/store'; +import { MetaDefinition } from '@angular/platform-browser'; + +// tslint:disable:max-classes-per-file +export const MetaTagTypes = { + ADD: type('dspace/meta-tag/ADD'), + CLEAR: type('dspace/meta-tag/CLEAR') +}; + +export class AddMetaTagAction implements Action { + type = MetaTagTypes.ADD; + payload: string; + + constructor(property: string) { + this.payload = property; + } +} + +export class ClearMetaTagAction implements Action { + type = MetaTagTypes.CLEAR; +} + +export type MetaTagAction = AddMetaTagAction | ClearMetaTagAction; diff --git a/src/app/core/metadata/meta-tag.reducer.ts b/src/app/core/metadata/meta-tag.reducer.ts new file mode 100644 index 0000000000..0af6fb0aab --- /dev/null +++ b/src/app/core/metadata/meta-tag.reducer.ts @@ -0,0 +1,38 @@ +import { + MetaTagAction, + MetaTagTypes, + AddMetaTagAction, + ClearMetaTagAction, +} from './meta-tag.actions'; + +export interface MetaTagState { + tagsInUse: string[]; +} + +const initialstate: MetaTagState = { + tagsInUse: [] +}; + +export const metaTagReducer = (state: MetaTagState = initialstate, action: MetaTagAction): MetaTagState => { + switch (action.type) { + case MetaTagTypes.ADD: { + return addMetaTag(state, action as AddMetaTagAction); + } + case MetaTagTypes.CLEAR: { + return clearMetaTags(state, action as ClearMetaTagAction); + } + default: { + return state; + } + } +}; + +const addMetaTag = (state: MetaTagState, action: AddMetaTagAction): MetaTagState => { + return { + tagsInUse: [...state.tagsInUse, action.payload] + }; +}; + +const clearMetaTags = (state: MetaTagState, action: ClearMetaTagAction): MetaTagState => { + return Object.assign({}, initialstate); +}; diff --git a/src/app/core/metadata/metadata.service.ts b/src/app/core/metadata/metadata.service.ts index ed17fad2d8..8c1e1027dd 100644 --- a/src/app/core/metadata/metadata.service.ts +++ b/src/app/core/metadata/metadata.service.ts @@ -30,12 +30,33 @@ import { Bundle } from '../shared/bundle.model'; import { PaginatedList } from '../data/paginated-list.model'; import { URLCombiner } from '../url-combiner/url-combiner'; import { HardRedirectService } from '../services/hard-redirect.service'; +import { MetaTagState } from './meta-tag.reducer'; +import { Store, createSelector, select, MemoizedSelector } from '@ngrx/store'; +import { AddMetaTagAction, ClearMetaTagAction } from './meta-tag.actions'; +import { coreSelector } from '../core.selectors'; +import { CoreState } from '../core.reducers'; +import { ObjectCacheEntry, ObjectCacheState } from '../cache/object-cache.reducer'; + +/** + * The base selector function to select the metaTag section in the store + */ +const metaTagSelector = createSelector( + coreSelector, + (state: CoreState) => state.metaTag +); + +/** + * Selector function to select the tags in use from the MetaTagState + */ +const tagsInUseSelector = + createSelector( + metaTagSelector, + (state: MetaTagState) => state.tagsInUse, + ); @Injectable() export class MetadataService { - private tagStore: Map; - private currentObject: BehaviorSubject = new BehaviorSubject(undefined); /** @@ -63,11 +84,9 @@ export class MetadataService { private bitstreamDataService: BitstreamDataService, private bitstreamFormatDataService: BitstreamFormatDataService, private rootService: RootDataService, + private store: Store, private hardRedirectService: HardRedirectService, ) { - // TODO: determine what open graph meta tags are needed and whether - // the differ per route. potentially add image based on DSpaceObject - this.tagStore = new Map(); } public listenForRouteChange(): void { @@ -442,7 +461,7 @@ export class MetadataService { if (content) { const tag = { property, content } as MetaDefinition; this.meta.addTag(tag); - this.storeTag(property, tag); + this.storeTag(property); } } @@ -452,33 +471,21 @@ export class MetadataService { } } - private storeTag(key: string, tag: MetaDefinition): void { - const tags: MetaDefinition[] = this.getTags(key); - tags.push(tag); - this.setTags(key, tags); - } - - private getTags(key: string): MetaDefinition[] { - let tags: MetaDefinition[] = this.tagStore.get(key); - if (tags === undefined) { - tags = []; - } - return tags; - } - - private setTags(key: string, tags: MetaDefinition[]): void { - this.tagStore.set(key, tags); + private storeTag(key: string): void { + this.store.dispatch(new AddMetaTagAction(key)); } public clearMetaTags() { - this.tagStore.forEach((tags: MetaDefinition[], property: string) => { - this.meta.removeTag('property=\'' + property + '\''); + this.store.pipe( + select(tagsInUseSelector), + take(1) + ).subscribe((tagsInUse: string[]) => { + for (const property of tagsInUse) { + this.meta.removeTag('property=\'' + property + '\''); + } + this.store.dispatch(new ClearMetaTagAction()); }); - this.tagStore.clear(); } - public getTagStore(): Map { - return this.tagStore; - } } From b23522d39fd80666976a71462c5ba751b2c4a253 Mon Sep 17 00:00:00 2001 From: Marie Verdonck Date: Fri, 28 May 2021 10:33:51 +0200 Subject: [PATCH 16/38] 79700: Auto-refreshing the token & Needed config --- src/app/app.component.ts | 2 +- src/app/core/auth/auth.interceptor.ts | 17 +------- src/app/core/auth/auth.service.ts | 54 ++++++++++++++++++++++++-- src/assets/i18n/en.json5 | 2 + src/config/auth-config.interfaces.ts | 15 ++++++- src/environments/environment.common.ts | 24 ++++++++++-- 6 files changed, 90 insertions(+), 24 deletions(-) diff --git a/src/app/app.component.ts b/src/app/app.component.ts index c9996f275a..2c01bf637b 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -130,7 +130,7 @@ export class AppComponent implements OnInit, AfterViewInit { console.info(environment); } this.storeCSSVariables(); - + this.authService.trackTokenExpiration(); } ngOnInit() { diff --git a/src/app/core/auth/auth.interceptor.ts b/src/app/core/auth/auth.interceptor.ts index 7b9a08de92..d16f46a849 100644 --- a/src/app/core/auth/auth.interceptor.ts +++ b/src/app/core/auth/auth.interceptor.ts @@ -28,7 +28,7 @@ import { AuthMethodType } from './models/auth.method-type'; @Injectable() export class AuthInterceptor implements HttpInterceptor { - // Intercetor is called twice per request, + // Interceptor is called twice per request, // so to prevent RefreshTokenAction is dispatched twice // we're creating a refresh token request list protected refreshTokenRequestUrls = []; @@ -216,23 +216,8 @@ export class AuthInterceptor implements HttpInterceptor { let authorization: string; if (authService.isTokenExpired()) { - authService.setRedirectUrl(this.router.url); - // The access token is expired - // Redirect to the login route - this.store.dispatch(new RedirectWhenTokenExpiredAction('auth.messages.expired')); return observableOf(null); } else if ((!this.isAuthRequest(req) || this.isLogoutResponse(req)) && isNotEmpty(token)) { - // Intercept a request that is not to the authentication endpoint - authService.isTokenExpiring().pipe( - filter((isExpiring) => isExpiring)) - .subscribe(() => { - // If the current request url is already in the refresh token request list, skip it - if (isUndefined(find(this.refreshTokenRequestUrls, req.url))) { - // When a token is about to expire, refresh it - this.store.dispatch(new RefreshTokenAction(token)); - this.refreshTokenRequestUrls.push(req.url); - } - }); // Get the auth header from the service. authorization = authService.buildAuthHeader(token); let newHeaders = req.headers.set('authorization', authorization); diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index ed4fca615c..4903c30f15 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -33,7 +33,7 @@ import { } from './selectors'; import { AppState } from '../../app.reducer'; import { - CheckAuthenticationTokenAction, + CheckAuthenticationTokenAction, RefreshTokenAction, ResetAuthenticationMessagesAction, RetrieveAuthMethodsAction, SetRedirectUrlAction @@ -46,6 +46,9 @@ import { getAllSucceededRemoteDataPayload } from '../shared/operators'; import { AuthMethod } from './models/auth.method'; import { HardRedirectService } from '../services/hard-redirect.service'; import { RemoteData } from '../data/remote-data'; +import { environment } from '../../../environments/environment'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; export const LOGIN_ROUTE = '/login'; export const LOGOUT_ROUTE = '/logout'; @@ -64,6 +67,11 @@ export class AuthService { */ protected _authenticated: boolean; + /** + * Timer to track time until token refresh + */ + private tokenRefreshTimer; + constructor(@Inject(REQUEST) protected req: any, @Inject(NativeWindowService) protected _window: NativeWindowRef, @Optional() @Inject(RESPONSE) private response: any, @@ -73,7 +81,9 @@ export class AuthService { protected routeService: RouteService, protected storage: CookieService, protected store: Store, - protected hardRedirectService: HardRedirectService + protected hardRedirectService: HardRedirectService, + private notificationService: NotificationsService, + private translateService: TranslateService ) { this.store.pipe( select(isAuthenticated), @@ -298,7 +308,7 @@ export class AuthService { */ public getToken(): AuthTokenInfo { let token: AuthTokenInfo; - this.store.pipe(select(getAuthenticationToken)) + this.store.pipe(take(1), select(getAuthenticationToken)) .subscribe((authTokenInfo: AuthTokenInfo) => { // Retrieve authentication token info and check if is valid token = authTokenInfo || null; @@ -306,6 +316,44 @@ export class AuthService { return token; } + /** + * Method that checks when the session token from store expires and refreshes it when needed + */ + public trackTokenExpiration(): void { + let token: AuthTokenInfo; + let currentlyRefreshingToken = false; + this.store.pipe(select(getAuthenticationToken)).subscribe((authTokenInfo: AuthTokenInfo) => { + // If new token is undefined an it wasn't previously => Refresh failed + if (currentlyRefreshingToken && token != undefined && authTokenInfo == undefined) { + // Token refresh failed => Error notification => 10 second wait => Page reloads & user logged out + this.notificationService.error(this.translateService.get('auth.messages.token-refresh-failed')); + setTimeout(() => this.navigateToRedirectUrl(this.hardRedirectService.getCurrentRoute()), 10000); + currentlyRefreshingToken = false; + } + // If new token.expires is different => Refresh succeeded + if (currentlyRefreshingToken && authTokenInfo != undefined && token.expires != authTokenInfo.expires) { + currentlyRefreshingToken = false; + } + // Check if/when token needs to be refreshed + if (!currentlyRefreshingToken) { + token = authTokenInfo || null; + if (token != undefined && token != null) { + let timeLeftBeforeRefresh = token.expires - new Date().getTime() - environment.auth.rest.timeLeftBeforeTokenRefresh; + if (timeLeftBeforeRefresh < 0) { + timeLeftBeforeRefresh = 0; + } + if (hasValue(this.tokenRefreshTimer)) { + clearTimeout(this.tokenRefreshTimer); + } + this.tokenRefreshTimer = setTimeout(() => { + this.store.dispatch(new RefreshTokenAction(token)); + currentlyRefreshingToken = true; + }, timeLeftBeforeRefresh); + } + } + }); + } + /** * Check if a token is next to be expired * @returns {boolean} diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 3e2cc67ecb..cf19be3730 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -526,6 +526,8 @@ "auth.messages.expired": "Your session has expired. Please log in again.", + "auth.messages.token-refresh-failed": "Refreshing your session token failed. Please log in again.", + "bitstream.download.page": "Now downloading {{bitstream}}..." , diff --git a/src/config/auth-config.interfaces.ts b/src/config/auth-config.interfaces.ts index cc3d97c6b8..59ebda12da 100644 --- a/src/config/auth-config.interfaces.ts +++ b/src/config/auth-config.interfaces.ts @@ -6,5 +6,18 @@ export interface AuthTarget { } export interface AuthConfig extends Config { - target: AuthTarget; + target?: AuthTarget; + + ui: { + // The amount of time before the idle warning is shown + timeUntilIdle: number; + // The amount of time the user has to react after the idle warning is shown before they are logged out. + idleGracePeriod: number; + }; + + rest: { + // If the rest token expires in less than this amount of time, it will be refreshed automatically. + // This is independent from the idle warning. + timeLeftBeforeTokenRefresh: number; + }; } diff --git a/src/environments/environment.common.ts b/src/environments/environment.common.ts index 4e246f7243..a7d4ec8a00 100644 --- a/src/environments/environment.common.ts +++ b/src/environments/environment.common.ts @@ -2,7 +2,6 @@ import { GlobalConfig } from '../config/global-config.interface'; import { NotificationAnimationsType } from '../app/shared/notifications/models/notification-animations-type'; import { BrowseByType } from '../app/+browse-by/+browse-by-switcher/browse-by-decorator'; import { RestRequestMethod } from '../app/core/data/rest-request-method'; -import { BASE_THEME_NAME } from '../app/shared/theme-support/theme.constants'; export const environment: GlobalConfig = { production: true, @@ -43,6 +42,25 @@ export const environment: GlobalConfig = { timePerMethod: {[RestRequestMethod.PATCH]: 3} as any // time in seconds } }, + // Authority settings + auth: { + // Authority UI settings + ui: { + // the amount of time before the idle warning is shown + // timeUntilIdle: 15 * 60 * 1000, // 15 minutes + timeUntilIdle: 1 * 60 * 1000, // 1 minutes + // the amount of time the user has to react after the idle warning is shown before they are logged out. + // idleGracePeriod: 5 * 60 * 1000, // 5 minutes + idleGracePeriod: 1 * 60 * 1000, // 1 minutes + }, + // Authority REST settings + rest: { + // If the rest token expires in less than this amount of time, it will be refreshed automatically. + // This is independent from the idle warning. + // timeLeftBeforeTokenRefresh: 2 * 60 * 1000, // 2 minutes + timeLeftBeforeTokenRefresh: 0.25 * 60 * 1000, // 25 seconds + }, + }, // Form settings form: { // NOTE: Map server-side validators to comparative Angular form validators @@ -267,8 +285,8 @@ export const environment: GlobalConfig = { ], // Whether the UI should rewrite file download URLs to match its domain. Only necessary to enable when running UI and REST API on separate domains rewriteDownloadUrls: false, - // Whether to enable media viewer for image and/or video Bitstreams (i.e. Bitstreams whose MIME type starts with "image" or "video"). - // For images, this enables a gallery viewer where you can zoom or page through images. + // Whether to enable media viewer for image and/or video Bitstreams (i.e. Bitstreams whose MIME type starts with "image" or "video"). + // For images, this enables a gallery viewer where you can zoom or page through images. // For videos, this enables embedded video streaming mediaViewer: { image: false, From 38387d1a0f07ef1d1fea0b0dbdb104a91d0ba368 Mon Sep 17 00:00:00 2001 From: Marie Verdonck Date: Fri, 28 May 2021 17:22:26 +0200 Subject: [PATCH 17/38] 79700: Tracking idleness & idle modal --- .../browse-by-metadata-page.component.ts | 1 - src/app/app.module.ts | 2 + src/app/core/auth/auth.actions.ts | 27 ++- src/app/core/auth/auth.effects.ts | 33 +++- src/app/core/auth/auth.reducer.spec.ts | 164 +++++++++++++----- src/app/core/auth/auth.reducer.ts | 22 ++- src/app/core/auth/auth.service.spec.ts | 18 +- src/app/core/auth/auth.service.ts | 39 ++++- src/app/core/auth/selectors.ts | 17 ++ src/app/root/root.component.ts | 26 ++- .../auth-nav-menu.component.spec.ts | 6 +- .../user-menu/user-menu.component.spec.ts | 6 +- .../idle-modal/idle-modal.component.html | 18 ++ .../shared/idle-modal/idle-modal.component.ts | 85 +++++++++ src/app/shared/mocks/auth.service.mock.ts | 7 + src/assets/i18n/en.json5 | 12 +- src/environments/mock-environment.ts | 16 ++ 17 files changed, 433 insertions(+), 66 deletions(-) create mode 100644 src/app/shared/idle-modal/idle-modal.component.html create mode 100644 src/app/shared/idle-modal/idle-modal.component.ts diff --git a/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.ts b/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.ts index f5adefc779..5b84daab6e 100644 --- a/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.ts +++ b/src/app/+browse-by/+browse-by-metadata-page/browse-by-metadata-page.component.ts @@ -167,7 +167,6 @@ export class BrowseByMetadataPageComponent implements OnInit { * @param value The value of the browse-entry to display items for */ updatePageWithItems(searchOptions: BrowseEntrySearchOptions, value: string) { - console.log('updatePAge', searchOptions); this.items$ = this.browseService.getBrowseItemsFor(value, searchOptions); } diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 3d45ffbfc2..18b97c8e9e 100755 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -47,6 +47,7 @@ import { ThemedHeaderComponent } from './header/themed-header.component'; import { ThemedFooterComponent } from './footer/themed-footer.component'; import { ThemedBreadcrumbsComponent } from './breadcrumbs/themed-breadcrumbs.component'; import { ThemedHeaderNavbarWrapperComponent } from './header-nav-wrapper/themed-header-navbar-wrapper.component'; +import { IdleModalComponent } from './shared/idle-modal/idle-modal.component'; export function getBase() { return environment.ui.nameSpace; @@ -144,6 +145,7 @@ const DECLARATIONS = [ ThemedBreadcrumbsComponent, ForbiddenComponent, ThemedForbiddenComponent, + IdleModalComponent ]; const EXPORTS = [ diff --git a/src/app/core/auth/auth.actions.ts b/src/app/core/auth/auth.actions.ts index e2cef3562f..ad3f9a9711 100644 --- a/src/app/core/auth/auth.actions.ts +++ b/src/app/core/auth/auth.actions.ts @@ -34,7 +34,9 @@ export const AuthActionTypes = { RETRIEVE_AUTHENTICATED_EPERSON: type('dspace/auth/RETRIEVE_AUTHENTICATED_EPERSON'), RETRIEVE_AUTHENTICATED_EPERSON_SUCCESS: type('dspace/auth/RETRIEVE_AUTHENTICATED_EPERSON_SUCCESS'), RETRIEVE_AUTHENTICATED_EPERSON_ERROR: type('dspace/auth/RETRIEVE_AUTHENTICATED_EPERSON_ERROR'), - REDIRECT_AFTER_LOGIN_SUCCESS: type('dspace/auth/REDIRECT_AFTER_LOGIN_SUCCESS') + REDIRECT_AFTER_LOGIN_SUCCESS: type('dspace/auth/REDIRECT_AFTER_LOGIN_SUCCESS'), + SET_USER_AS_IDLE: type('dspace/auth/SET_USER_AS_IDLE'), + UNSET_USER_AS_IDLE: type('dspace/auth/UNSET_USER_AS_IDLE') }; /* tslint:disable:max-classes-per-file */ @@ -404,6 +406,24 @@ export class RetrieveAuthenticatedEpersonErrorAction implements Action { this.payload = payload ; } } + +/** + * Set the current user as being idle. + * @class SetUserAsIdleAction + * @implements {Action} + */ +export class SetUserAsIdleAction implements Action { + public type: string = AuthActionTypes.SET_USER_AS_IDLE; +} + +/** + * Unset the current user as being idle. + * @class UnsetUserAsIdleAction + * @implements {Action} + */ +export class UnsetUserAsIdleAction implements Action { + public type: string = AuthActionTypes.UNSET_USER_AS_IDLE; +} /* tslint:enable:max-classes-per-file */ /** @@ -434,4 +454,7 @@ export type AuthActions | RetrieveAuthenticatedEpersonErrorAction | RetrieveAuthenticatedEpersonSuccessAction | SetRedirectUrlAction - | RedirectAfterLoginSuccessAction; + | RedirectAfterLoginSuccessAction + | SetUserAsIdleAction + | UnsetUserAsIdleAction; + diff --git a/src/app/core/auth/auth.effects.ts b/src/app/core/auth/auth.effects.ts index 2ef90dd76c..c133310471 100644 --- a/src/app/core/auth/auth.effects.ts +++ b/src/app/core/auth/auth.effects.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core'; -import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs'; +import { combineLatest as observableCombineLatest, Observable, of as observableOf, timer } from 'rxjs'; import { catchError, filter, map, switchMap, take, tap } from 'rxjs/operators'; // import @ngrx import { Actions, Effect, ofType } from '@ngrx/effects'; @@ -37,9 +37,19 @@ import { RetrieveAuthMethodsAction, RetrieveAuthMethodsErrorAction, RetrieveAuthMethodsSuccessAction, - RetrieveTokenAction + RetrieveTokenAction, SetUserAsIdleAction } from './auth.actions'; import { hasValue } from '../../shared/empty.util'; +import { environment } from '../../../environments/environment'; +import { RequestActionTypes } from '../data/request.actions'; +import { NotificationsActionTypes } from '../../shared/notifications/notifications.actions'; +import { ObjectCacheActionTypes } from '../cache/object-cache.actions'; +import { NO_OP_ACTION_TYPE } from '../../shared/ngrx/no-op.action'; + +// Action Types that do not break/prevent the user from an idle state +const IDLE_TIMER_IGNORE_TYPES: string[] + = [...Object.values(AuthActionTypes).filter((t: string) => t !== AuthActionTypes.UNSET_USER_AS_IDLE), + ...Object.values(RequestActionTypes), ...Object.values(NotificationsActionTypes)]; @Injectable() export class AuthEffects { @@ -242,6 +252,25 @@ export class AuthEffects { }) ); + /** + * For any action that is not in {@link IDLE_TIMER_IGNORE_TYPES} that comes in => Start the idleness timer + * If the idleness timer runs out (so no un-ignored action come through for that amount of time) + * => Return the action to set the user as idle ({@link SetUserAsIdleAction}) + * @method trackIdleness + */ + @Effect() + public trackIdleness$: Observable = this.actions$.pipe( + filter((action: Action) => !IDLE_TIMER_IGNORE_TYPES.includes(action.type)), + // Using switchMap the timer will be interrupted and restarted if a new action comes in, so idleness timer restarts + switchMap(() => { + this.authService.isAuthenticated(); + return timer(environment.auth.ui.timeUntilIdle); + }), + map(() => { + return new SetUserAsIdleAction(); + }) + ); + /** * @constructor * @param {Actions} actions$ diff --git a/src/app/core/auth/auth.reducer.spec.ts b/src/app/core/auth/auth.reducer.spec.ts index 914a1a152d..f721c8c208 100644 --- a/src/app/core/auth/auth.reducer.spec.ts +++ b/src/app/core/auth/auth.reducer.spec.ts @@ -23,7 +23,7 @@ import { RetrieveAuthMethodsAction, RetrieveAuthMethodsErrorAction, RetrieveAuthMethodsSuccessAction, - SetRedirectUrlAction + SetRedirectUrlAction, SetUserAsIdleAction, UnsetUserAsIdleAction } from './auth.actions'; import { AuthTokenInfo } from './models/auth-token-info.model'; import { EPersonMock } from '../../shared/testing/eperson.mock'; @@ -44,6 +44,7 @@ describe('authReducer', () => { loaded: false, blocking: true, loading: false, + idle: false }; const action = new AuthenticateAction('user', 'password'); const newState = authReducer(initialState, action); @@ -53,7 +54,8 @@ describe('authReducer', () => { blocking: true, error: undefined, loading: true, - info: undefined + info: undefined, + idle: false }; expect(newState).toEqual(state); @@ -66,7 +68,8 @@ describe('authReducer', () => { error: undefined, blocking: true, loading: true, - info: undefined + info: undefined, + idle: false }; const action = new AuthenticationSuccessAction(mockTokenInfo); const newState = authReducer(initialState, action); @@ -81,7 +84,8 @@ describe('authReducer', () => { error: undefined, blocking: true, loading: true, - info: undefined + info: undefined, + idle: false }; const action = new AuthenticationErrorAction(mockError); const newState = authReducer(initialState, action); @@ -92,7 +96,8 @@ describe('authReducer', () => { loading: false, info: undefined, authToken: undefined, - error: 'Test error message' + error: 'Test error message', + idle: false }; expect(newState).toEqual(state); @@ -105,7 +110,8 @@ describe('authReducer', () => { loaded: false, error: undefined, loading: true, - info: undefined + info: undefined, + idle: false }; const action = new AuthenticatedAction(mockTokenInfo); const newState = authReducer(initialState, action); @@ -115,7 +121,8 @@ describe('authReducer', () => { loaded: false, error: undefined, loading: true, - info: undefined + info: undefined, + idle: false }; expect(newState).toEqual(state); }); @@ -127,7 +134,8 @@ describe('authReducer', () => { error: undefined, blocking: true, loading: true, - info: undefined + info: undefined, + idle: false }; const action = new AuthenticatedSuccessAction(true, mockTokenInfo, EPersonMock._links.self.href); const newState = authReducer(initialState, action); @@ -138,7 +146,8 @@ describe('authReducer', () => { error: undefined, blocking: true, loading: true, - info: undefined + info: undefined, + idle: false }; expect(newState).toEqual(state); }); @@ -150,7 +159,8 @@ describe('authReducer', () => { error: undefined, blocking: true, loading: true, - info: undefined + info: undefined, + idle: false }; const action = new AuthenticatedErrorAction(mockError); const newState = authReducer(initialState, action); @@ -161,7 +171,8 @@ describe('authReducer', () => { loaded: true, blocking: false, loading: false, - info: undefined + info: undefined, + idle: false }; expect(newState).toEqual(state); }); @@ -172,6 +183,7 @@ describe('authReducer', () => { loaded: false, blocking: false, loading: false, + idle: false }; const action = new CheckAuthenticationTokenAction(); const newState = authReducer(initialState, action); @@ -180,6 +192,7 @@ describe('authReducer', () => { loaded: false, blocking: true, loading: true, + idle: false }; expect(newState).toEqual(state); }); @@ -190,6 +203,7 @@ describe('authReducer', () => { loaded: false, blocking: false, loading: true, + idle: false }; const action = new CheckAuthenticationTokenCookieAction(); const newState = authReducer(initialState, action); @@ -198,6 +212,7 @@ describe('authReducer', () => { loaded: false, blocking: true, loading: true, + idle: false }; expect(newState).toEqual(state); }); @@ -211,7 +226,8 @@ describe('authReducer', () => { blocking: false, loading: false, info: undefined, - userId: EPersonMock.id + userId: EPersonMock.id, + idle: false }; const action = new LogOutAction(); @@ -229,7 +245,8 @@ describe('authReducer', () => { blocking: false, loading: false, info: undefined, - userId: EPersonMock.id + userId: EPersonMock.id, + idle: false }; const action = new LogOutSuccessAction(); @@ -243,7 +260,8 @@ describe('authReducer', () => { loading: true, info: undefined, refreshing: false, - userId: undefined + userId: undefined, + idle: false }; expect(newState).toEqual(state); }); @@ -257,7 +275,8 @@ describe('authReducer', () => { blocking: false, loading: false, info: undefined, - userId: EPersonMock.id + userId: EPersonMock.id, + idle: false }; const action = new LogOutErrorAction(mockError); @@ -270,7 +289,8 @@ describe('authReducer', () => { blocking: false, loading: false, info: undefined, - userId: EPersonMock.id + userId: EPersonMock.id, + idle: false }; expect(newState).toEqual(state); }); @@ -283,7 +303,8 @@ describe('authReducer', () => { error: undefined, blocking: true, loading: true, - info: undefined + info: undefined, + idle: false }; const action = new RetrieveAuthenticatedEpersonSuccessAction(EPersonMock.id); const newState = authReducer(initialState, action); @@ -295,7 +316,8 @@ describe('authReducer', () => { blocking: false, loading: false, info: undefined, - userId: EPersonMock.id + userId: EPersonMock.id, + idle: false }; expect(newState).toEqual(state); }); @@ -307,7 +329,8 @@ describe('authReducer', () => { error: undefined, blocking: true, loading: true, - info: undefined + info: undefined, + idle: false }; const action = new RetrieveAuthenticatedEpersonErrorAction(mockError); const newState = authReducer(initialState, action); @@ -318,7 +341,8 @@ describe('authReducer', () => { loaded: true, blocking: false, loading: false, - info: undefined + info: undefined, + idle: false }; expect(newState).toEqual(state); }); @@ -332,7 +356,8 @@ describe('authReducer', () => { blocking: false, loading: false, info: undefined, - userId: EPersonMock.id + userId: EPersonMock.id, + idle: false }; const newTokenInfo = new AuthTokenInfo('Refreshed token'); const action = new RefreshTokenAction(newTokenInfo); @@ -346,7 +371,8 @@ describe('authReducer', () => { loading: false, info: undefined, userId: EPersonMock.id, - refreshing: true + refreshing: true, + idle: false }; expect(newState).toEqual(state); }); @@ -361,7 +387,8 @@ describe('authReducer', () => { loading: false, info: undefined, userId: EPersonMock.id, - refreshing: true + refreshing: true, + idle: false }; const newTokenInfo = new AuthTokenInfo('Refreshed token'); const action = new RefreshTokenSuccessAction(newTokenInfo); @@ -375,7 +402,8 @@ describe('authReducer', () => { loading: false, info: undefined, userId: EPersonMock.id, - refreshing: false + refreshing: false, + idle: false }; expect(newState).toEqual(state); }); @@ -390,7 +418,8 @@ describe('authReducer', () => { loading: false, info: undefined, userId: EPersonMock.id, - refreshing: true + refreshing: true, + idle: false }; const action = new RefreshTokenErrorAction(); const newState = authReducer(initialState, action); @@ -403,7 +432,8 @@ describe('authReducer', () => { loading: false, info: undefined, refreshing: false, - userId: undefined + userId: undefined, + idle: false }; expect(newState).toEqual(state); }); @@ -417,7 +447,8 @@ describe('authReducer', () => { blocking: false, loading: false, info: undefined, - userId: EPersonMock.id + userId: EPersonMock.id, + idle: false }; state = { @@ -428,7 +459,8 @@ describe('authReducer', () => { loading: false, error: undefined, info: 'Message', - userId: undefined + userId: undefined, + idle: false }; }); @@ -450,6 +482,7 @@ describe('authReducer', () => { loaded: false, blocking: false, loading: false, + idle: false }; const action = new AddAuthenticationMessageAction('Message'); const newState = authReducer(initialState, action); @@ -458,7 +491,8 @@ describe('authReducer', () => { loaded: false, blocking: false, loading: false, - info: 'Message' + info: 'Message', + idle: false }; expect(newState).toEqual(state); }); @@ -470,7 +504,8 @@ describe('authReducer', () => { blocking: false, loading: false, error: 'Error', - info: 'Message' + info: 'Message', + idle: false }; const action = new ResetAuthenticationMessagesAction(); const newState = authReducer(initialState, action); @@ -480,7 +515,8 @@ describe('authReducer', () => { blocking: false, loading: false, error: undefined, - info: undefined + info: undefined, + idle: false }; expect(newState).toEqual(state); }); @@ -490,7 +526,8 @@ describe('authReducer', () => { authenticated: false, loaded: false, blocking: false, - loading: false + loading: false, + idle: false }; const action = new SetRedirectUrlAction('redirect.url'); const newState = authReducer(initialState, action); @@ -499,7 +536,8 @@ describe('authReducer', () => { loaded: false, blocking: false, loading: false, - redirectUrl: 'redirect.url' + redirectUrl: 'redirect.url', + idle: false }; expect(newState).toEqual(state); }); @@ -510,7 +548,8 @@ describe('authReducer', () => { loaded: false, blocking: false, loading: false, - authMethods: [] + authMethods: [], + idle: false }; const action = new RetrieveAuthMethodsAction(new AuthStatus(), true); const newState = authReducer(initialState, action); @@ -519,7 +558,8 @@ describe('authReducer', () => { loaded: false, blocking: true, loading: true, - authMethods: [] + authMethods: [], + idle: false }; expect(newState).toEqual(state); }); @@ -530,7 +570,8 @@ describe('authReducer', () => { loaded: false, blocking: true, loading: true, - authMethods: [] + authMethods: [], + idle: false }; const authMethods = [ new AuthMethod(AuthMethodType.Password), @@ -543,7 +584,8 @@ describe('authReducer', () => { loaded: false, blocking: false, loading: false, - authMethods: authMethods + authMethods: authMethods, + idle: false }; expect(newState).toEqual(state); }); @@ -554,7 +596,8 @@ describe('authReducer', () => { loaded: false, blocking: true, loading: true, - authMethods: [] + authMethods: [], + idle: false }; const authMethods = [ new AuthMethod(AuthMethodType.Password), @@ -588,7 +631,50 @@ describe('authReducer', () => { loaded: false, blocking: false, loading: false, - authMethods: [new AuthMethod(AuthMethodType.Password)] + authMethods: [new AuthMethod(AuthMethodType.Password)], + idle: false + }; + expect(newState).toEqual(state); + }); + + it('should properly set the state, in response to a SET_USER_AS_IDLE action', () => { + initialState = { + authenticated: true, + loaded: true, + blocking: false, + loading: false, + idle: false + }; + + const action = new SetUserAsIdleAction(); + const newState = authReducer(initialState, action); + state = { + authenticated: true, + loaded: true, + blocking: false, + loading: false, + idle: true + }; + expect(newState).toEqual(state); + }); + + it('should properly set the state, in response to a UNSET_USER_AS_IDLE action', () => { + initialState = { + authenticated: true, + loaded: true, + blocking: false, + loading: false, + idle: true + }; + + const action = new UnsetUserAsIdleAction(); + const newState = authReducer(initialState, action); + state = { + authenticated: true, + loaded: true, + blocking: false, + loading: false, + idle: false }; expect(newState).toEqual(state); }); diff --git a/src/app/core/auth/auth.reducer.ts b/src/app/core/auth/auth.reducer.ts index dfe29a3ef2..0424a58898 100644 --- a/src/app/core/auth/auth.reducer.ts +++ b/src/app/core/auth/auth.reducer.ts @@ -59,6 +59,9 @@ export interface AuthState { // all authentication Methods enabled at the backend authMethods?: AuthMethod[]; + // true when the current user is idle + idle: boolean; + } /** @@ -69,7 +72,8 @@ const initialState: AuthState = { loaded: false, blocking: true, loading: false, - authMethods: [] + authMethods: [], + idle: false }; /** @@ -234,6 +238,22 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut blocking: true, }); + case AuthActionTypes.SET_USER_AS_IDLE: + if (state.authenticated) { + return Object.assign({}, state, { + idle: true, + }); + } else { + return Object.assign({}, state, { + idle: false, + }); + } + + case AuthActionTypes.UNSET_USER_AS_IDLE: + return Object.assign({}, state, { + idle: false, + }); + default: return state; } diff --git a/src/app/core/auth/auth.service.spec.ts b/src/app/core/auth/auth.service.spec.ts index 505f925e8e..d54ffdae16 100644 --- a/src/app/core/auth/auth.service.spec.ts +++ b/src/app/core/auth/auth.service.spec.ts @@ -27,6 +27,10 @@ import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.util import { authMethodsMock } from '../../shared/testing/auth-service.stub'; import { AuthMethod } from './models/auth.method'; import { HardRedirectService } from '../services/hard-redirect.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; +import { getMockTranslateService } from '../../shared/mocks/translate.service.mock'; +import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; describe('AuthService test', () => { @@ -107,6 +111,8 @@ describe('AuthService test', () => { { provide: Store, useValue: mockStore }, { provide: EPersonDataService, useValue: mockEpersonDataService }, { provide: HardRedirectService, useValue: hardRedirectService }, + { provide: NotificationsService, useValue: NotificationsServiceStub }, + { provide: TranslateService, useValue: getMockTranslateService() }, CookieService, AuthService ], @@ -207,13 +213,13 @@ describe('AuthService test', () => { }).compileComponents(); })); - beforeEach(inject([CookieService, AuthRequestService, Store, Router, RouteService], (cookieService: CookieService, authReqService: AuthRequestService, store: Store, router: Router, routeService: RouteService) => { + beforeEach(inject([CookieService, AuthRequestService, Store, Router, RouteService], (cookieService: CookieService, authReqService: AuthRequestService, store: Store, router: Router, routeService: RouteService, notificationsService: NotificationsService, translateService: TranslateService) => { store .subscribe((state) => { (state as any).core = Object.create({}); (state as any).core.auth = authenticatedState; }); - authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService); + authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService, notificationsService, translateService); })); it('should return true when user is logged in', () => { @@ -277,7 +283,7 @@ describe('AuthService test', () => { }).compileComponents(); })); - beforeEach(inject([ClientCookieService, AuthRequestService, Store, Router, RouteService], (cookieService: ClientCookieService, authReqService: AuthRequestService, store: Store, router: Router, routeService: RouteService) => { + beforeEach(inject([ClientCookieService, AuthRequestService, Store, Router, RouteService], (cookieService: ClientCookieService, authReqService: AuthRequestService, store: Store, router: Router, routeService: RouteService, notificationsService: NotificationsService, translateService: TranslateService) => { const expiredToken: AuthTokenInfo = new AuthTokenInfo('test_token'); expiredToken.expires = Date.now() - (1000 * 60 * 60); authenticatedState = { @@ -292,7 +298,7 @@ describe('AuthService test', () => { (state as any).core = Object.create({}); (state as any).core.auth = authenticatedState; }); - authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService); + authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService, notificationsService, translateService); storage = (authService as any).storage; routeServiceMock = TestBed.inject(RouteService); routerStub = TestBed.inject(Router); @@ -493,13 +499,13 @@ describe('AuthService test', () => { }).compileComponents(); })); - beforeEach(inject([CookieService, AuthRequestService, Store, Router, RouteService], (cookieService: CookieService, authReqService: AuthRequestService, store: Store, router: Router, routeService: RouteService) => { + beforeEach(inject([CookieService, AuthRequestService, Store, Router, RouteService], (cookieService: CookieService, authReqService: AuthRequestService, store: Store, router: Router, routeService: RouteService, notificationsService: NotificationsService, translateService: TranslateService) => { store .subscribe((state) => { (state as any).core = Object.create({}); (state as any).core.auth = unAuthenticatedState; }); - authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService); + authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService, notificationsService, translateService); })); it('should return null for the shortlived token', () => { diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index 4903c30f15..7b7c61f741 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -29,6 +29,7 @@ import { getRedirectUrl, isAuthenticated, isAuthenticatedLoaded, + isIdle, isTokenRefreshing } from './selectors'; import { AppState } from '../../app.reducer'; @@ -36,7 +37,9 @@ import { CheckAuthenticationTokenAction, RefreshTokenAction, ResetAuthenticationMessagesAction, RetrieveAuthMethodsAction, - SetRedirectUrlAction + SetRedirectUrlAction, + SetUserAsIdleAction, + UnsetUserAsIdleAction } from './auth.actions'; import { NativeWindowRef, NativeWindowService } from '../services/window.service'; import { Base64EncodeUrl } from '../../shared/utils/encode-decode.util'; @@ -197,7 +200,7 @@ export class AuthService { return this.store.pipe( select(getAuthenticatedUserId), hasValueOperator(), - switchMap((id: string) => this.epersonService.findById(id) ), + switchMap((id: string) => this.epersonService.findById(id)), getAllSucceededRemoteDataPayload() ); } @@ -279,7 +282,7 @@ export class AuthService { // Send a request that sign end the session let headers = new HttpHeaders(); headers = headers.append('Content-Type', 'application/x-www-form-urlencoded'); - const options: HttpOptions = Object.create({ headers, responseType: 'text' }); + const options: HttpOptions = Object.create({headers, responseType: 'text'}); return this.authRequestService.postToEndpoint('logout', options).pipe( map((rd: RemoteData) => { const status = rd.payload; @@ -324,20 +327,20 @@ export class AuthService { let currentlyRefreshingToken = false; this.store.pipe(select(getAuthenticationToken)).subscribe((authTokenInfo: AuthTokenInfo) => { // If new token is undefined an it wasn't previously => Refresh failed - if (currentlyRefreshingToken && token != undefined && authTokenInfo == undefined) { + if (currentlyRefreshingToken && token !== undefined && authTokenInfo === undefined) { // Token refresh failed => Error notification => 10 second wait => Page reloads & user logged out this.notificationService.error(this.translateService.get('auth.messages.token-refresh-failed')); setTimeout(() => this.navigateToRedirectUrl(this.hardRedirectService.getCurrentRoute()), 10000); currentlyRefreshingToken = false; } // If new token.expires is different => Refresh succeeded - if (currentlyRefreshingToken && authTokenInfo != undefined && token.expires != authTokenInfo.expires) { + if (currentlyRefreshingToken && authTokenInfo !== undefined && token.expires !== authTokenInfo.expires) { currentlyRefreshingToken = false; } // Check if/when token needs to be refreshed if (!currentlyRefreshingToken) { token = authTokenInfo || null; - if (token != undefined && token != null) { + if (token !== undefined && token !== null) { let timeLeftBeforeRefresh = token.expires - new Date().getTime() - environment.auth.rest.timeLeftBeforeTokenRefresh; if (timeLeftBeforeRefresh < 0) { timeLeftBeforeRefresh = 0; @@ -394,7 +397,7 @@ export class AuthService { // Set the cookie expire date const expires = new Date(expireDate); - const options: CookieAttributes = { expires: expires }; + const options: CookieAttributes = {expires: expires}; // Save cookie with the token return this.storage.set(TOKENITEM, token, options); @@ -483,7 +486,7 @@ export class AuthService { // Set the cookie expire date const expires = new Date(expireDate); - const options: CookieAttributes = { expires: expires }; + const options: CookieAttributes = {expires: expires}; this.storage.set(REDIRECT_COOKIE, url, options); this.store.dispatch(new SetRedirectUrlAction(isNotUndefined(url) ? url : '')); } @@ -576,4 +579,24 @@ export class AuthService { return new RetrieveAuthMethodsAction(authStatus, false); } + /** + * Determines if current user is idle + * @returns {Observable} + */ + public isUserIdle(): Observable { + return this.store.pipe(select(isIdle)); + } + + /** + * Set idle of auth state + * @returns {Observable} + */ + public setIdle(idle: boolean): void { + if (idle) { + this.store.dispatch(new SetUserAsIdleAction()); + } else { + this.store.dispatch(new UnsetUserAsIdleAction()); + } + } + } diff --git a/src/app/core/auth/selectors.ts b/src/app/core/auth/selectors.ts index c4e95a0fb3..9ee9f7eb2e 100644 --- a/src/app/core/auth/selectors.ts +++ b/src/app/core/auth/selectors.ts @@ -115,6 +115,14 @@ const _getRedirectUrl = (state: AuthState) => state.redirectUrl; const _getAuthenticationMethods = (state: AuthState) => state.authMethods; +/** + * Returns true if the user is idle. + * @function _isIdle + * @param {State} state + * @returns {boolean} + */ +const _isIdle = (state: AuthState) => state.idle; + /** * Returns the authentication methods enabled at the backend * @function getAuthenticationMethods @@ -231,3 +239,12 @@ export const getRegistrationError = createSelector(getAuthState, _getRegistratio * @return {string} */ export const getRedirectUrl = createSelector(getAuthState, _getRedirectUrl); + +/** + * Returns true if the user is idle + * @function isIdle + * @param {AuthState} state + * @param {any} props + * @return {boolean} + */ +export const isIdle = createSelector(getAuthState, _isIdle); diff --git a/src/app/root/root.component.ts b/src/app/root/root.component.ts index 576a0152be..c2d3c96951 100644 --- a/src/app/root/root.component.ts +++ b/src/app/root/root.component.ts @@ -1,4 +1,4 @@ -import { map } from 'rxjs/operators'; +import { map, take } from 'rxjs/operators'; import { Component, Inject, OnInit, Optional, Input } from '@angular/core'; import { Router } from '@angular/router'; @@ -21,6 +21,8 @@ import { environment } from '../../environments/environment'; import { LocaleService } from '../core/locale/locale.service'; import { KlaroService } from '../shared/cookies/klaro.service'; import { slideSidebarPadding } from '../shared/animations/slide'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { IdleModalComponent } from '../shared/idle-modal/idle-modal.component'; @Component({ selector: 'ds-root', @@ -47,6 +49,11 @@ export class RootComponent implements OnInit { */ @Input() shouldShowRouteLoader: boolean; + /** + * Whether or not the idle modal is is currently open + */ + idleModalOpen: boolean; + constructor( @Inject(NativeWindowService) private _window: NativeWindowRef, private translate: TranslateService, @@ -60,7 +67,8 @@ export class RootComponent implements OnInit { private menuService: MenuService, private windowService: HostWindowService, private localeService: LocaleService, - @Optional() private cookiesService: KlaroService + @Optional() private cookiesService: KlaroService, + private modalService: NgbModal ) { } @@ -75,5 +83,19 @@ export class RootComponent implements OnInit { .pipe( map(([collapsed, mobile]) => collapsed || mobile) ); + + this.authService.isUserIdle().subscribe((userIdle: boolean) => { + if (userIdle) { + if (!this.idleModalOpen) { + const modalRef = this.modalService.open(IdleModalComponent); + this.idleModalOpen = true; + modalRef.componentInstance.response.pipe(take(1)).subscribe((closed: boolean) => { + if (closed) { + this.idleModalOpen = false; + } + }); + } + } + }); } } diff --git a/src/app/shared/auth-nav-menu/auth-nav-menu.component.spec.ts b/src/app/shared/auth-nav-menu/auth-nav-menu.component.spec.ts index 185cf3e92a..6aad3dd636 100644 --- a/src/app/shared/auth-nav-menu/auth-nav-menu.component.spec.ts +++ b/src/app/shared/auth-nav-menu/auth-nav-menu.component.spec.ts @@ -44,7 +44,8 @@ describe('AuthNavMenuComponent', () => { authenticated: false, loaded: false, blocking: false, - loading: false + loading: false, + idle: false }; authState = { authenticated: true, @@ -52,7 +53,8 @@ describe('AuthNavMenuComponent', () => { blocking: false, loading: false, authToken: new AuthTokenInfo('test_token'), - userId: EPersonMock.id + userId: EPersonMock.id, + idle: false }; } diff --git a/src/app/shared/auth-nav-menu/user-menu/user-menu.component.spec.ts b/src/app/shared/auth-nav-menu/user-menu/user-menu.component.spec.ts index c0f5f13e8f..983fe68274 100644 --- a/src/app/shared/auth-nav-menu/user-menu/user-menu.component.spec.ts +++ b/src/app/shared/auth-nav-menu/user-menu/user-menu.component.spec.ts @@ -37,7 +37,8 @@ describe('UserMenuComponent', () => { blocking: false, loading: false, authToken: new AuthTokenInfo('test_token'), - userId: EPersonMock.id + userId: EPersonMock.id, + idle: false }; authStateLoading = { authenticated: true, @@ -45,7 +46,8 @@ describe('UserMenuComponent', () => { blocking: false, loading: true, authToken: null, - userId: EPersonMock.id + userId: EPersonMock.id, + idle: false }; } diff --git a/src/app/shared/idle-modal/idle-modal.component.html b/src/app/shared/idle-modal/idle-modal.component.html new file mode 100644 index 0000000000..665ebb9672 --- /dev/null +++ b/src/app/shared/idle-modal/idle-modal.component.html @@ -0,0 +1,18 @@ +
+ + + +
diff --git a/src/app/shared/idle-modal/idle-modal.component.ts b/src/app/shared/idle-modal/idle-modal.component.ts new file mode 100644 index 0000000000..750657c2e4 --- /dev/null +++ b/src/app/shared/idle-modal/idle-modal.component.ts @@ -0,0 +1,85 @@ +import { Component, OnInit, Output } from '@angular/core'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { environment } from '../../../environments/environment'; +import { AuthService } from '../../core/auth/auth.service'; +import { Subject } from 'rxjs'; +import { hasValue } from '../empty.util'; + +@Component({ + selector: 'ds-idle-modal', + templateUrl: 'idle-modal.component.html', +}) +export class IdleModalComponent implements OnInit { + + /** + * Total time of idleness before session expires (in minutes) + * (environment.auth.ui.timeUntilIdle + environment.auth.ui.idleGracePeriod / 1000 / 60) + */ + timeToExpire: number; + + /** + * Timer to track time grace period + */ + private graceTimer; + + /** + * An event fired when the modal is closed + */ + @Output() + response: Subject = new Subject(); + + constructor(private activeModal: NgbActiveModal, + private authService: AuthService) { + this.timeToExpire = (environment.auth.ui.timeUntilIdle + environment.auth.ui.idleGracePeriod) / 1000 / 60; // ms => min + } + + ngOnInit() { + if (hasValue(this.graceTimer)) { + clearTimeout(this.graceTimer); + } + this.graceTimer = setTimeout(() => { + this.logOutPressed(); + }, environment.auth.ui.idleGracePeriod); + } + + /** + * When extend session is pressed + */ + extendSessionPressed() { + this.extendSessionAndCloseModal(); + } + + /** + * Close modal and logout + */ + logOutPressed() { + this.authService.logout(); + this.closeModal(); + } + + /** + * When close is pressed + */ + closePressed() { + this.extendSessionAndCloseModal(); + } + + /** + * Close the modal and extend session + */ + extendSessionAndCloseModal() { + if (hasValue(this.graceTimer)) { + clearTimeout(this.graceTimer); + } + this.authService.setIdle(false); + this.closeModal(); + } + + /** + * Close the modal and set the response to true so RootComponent knows the modal was closed + */ + closeModal() { + this.activeModal.close(); + this.response.next(true); + } +} diff --git a/src/app/shared/mocks/auth.service.mock.ts b/src/app/shared/mocks/auth.service.mock.ts index 98878bd6c1..bb39d08284 100644 --- a/src/app/shared/mocks/auth.service.mock.ts +++ b/src/app/shared/mocks/auth.service.mock.ts @@ -19,4 +19,11 @@ export class AuthServiceMock { public setRedirectUrl(url: string) { } + + public trackTokenExpiration(): void { + } + + public isUserIdle(): Observable { + return observableOf(false); + } } diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index cf19be3730..6105c79cd9 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -3691,5 +3691,15 @@ "workflow-item.send-back.button.cancel": "Cancel", - "workflow-item.send-back.button.confirm": "Send back" + "workflow-item.send-back.button.confirm": "Send back", + + + + "idle-modal.header": "Session will expire soon", + + "idle-modal.info": "For security reasons, user sessions expire after {{ timeToExpire }} minutes of inactivity. Your session will expire soon. Would you like to extend it or log out?”", + + "idle-modal.log-out": "Log out", + + "idle-modal.extend-session": "Extend session" } diff --git a/src/environments/mock-environment.ts b/src/environments/mock-environment.ts index 8de5b187ad..050f99ea29 100644 --- a/src/environments/mock-environment.ts +++ b/src/environments/mock-environment.ts @@ -35,6 +35,22 @@ export const environment: Partial = { timePerMethod: {[RestRequestMethod.PATCH]: 3} as any // time in seconds } }, + // Authority settings + auth: { + // Authority UI settings + ui: { + // the amount of time before the idle warning is shown + timeUntilIdle: 20000, // 20 sec + // the amount of time the user has to react after the idle warning is shown before they are logged out. + idleGracePeriod: 20000, // 20 sec + }, + // Authority REST settings + rest: { + // If the rest token expires in less than this amount of time, it will be refreshed automatically. + // This is independent from the idle warning. + timeLeftBeforeTokenRefresh: 20000, // 20 sec + }, + }, // Form settings form: { // NOTE: Map server-side validators to comparative Angular form validators From e88baa1995d86f34d70b0fde13e078ce3c67ea37 Mon Sep 17 00:00:00 2001 From: Marie Verdonck Date: Thu, 3 Jun 2021 14:29:50 +0200 Subject: [PATCH 18/38] 79700: specs for modal, auth check for idleness tracking & stop blocking at token success --- src/app/core/auth/auth.effects.ts | 1 - src/app/core/auth/auth.reducer.ts | 1 + src/app/root/root.component.ts | 30 ++-- .../idle-modal/idle-modal.component.spec.ts | 128 ++++++++++++++++++ src/assets/i18n/en.json5 | 2 +- src/environments/environment.common.ts | 7 +- 6 files changed, 151 insertions(+), 18 deletions(-) create mode 100644 src/app/shared/idle-modal/idle-modal.component.spec.ts diff --git a/src/app/core/auth/auth.effects.ts b/src/app/core/auth/auth.effects.ts index c133310471..f7b81dc4ef 100644 --- a/src/app/core/auth/auth.effects.ts +++ b/src/app/core/auth/auth.effects.ts @@ -263,7 +263,6 @@ export class AuthEffects { filter((action: Action) => !IDLE_TIMER_IGNORE_TYPES.includes(action.type)), // Using switchMap the timer will be interrupted and restarted if a new action comes in, so idleness timer restarts switchMap(() => { - this.authService.isAuthenticated(); return timer(environment.auth.ui.timeUntilIdle); }), map(() => { diff --git a/src/app/core/auth/auth.reducer.ts b/src/app/core/auth/auth.reducer.ts index 0424a58898..f26ddb0182 100644 --- a/src/app/core/auth/auth.reducer.ts +++ b/src/app/core/auth/auth.reducer.ts @@ -193,6 +193,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut return Object.assign({}, state, { authToken: (action as RefreshTokenSuccessAction).payload, refreshing: false, + blocking: false }); case AuthActionTypes.ADD_MESSAGE: diff --git a/src/app/root/root.component.ts b/src/app/root/root.component.ts index c2d3c96951..81ae1a745c 100644 --- a/src/app/root/root.component.ts +++ b/src/app/root/root.component.ts @@ -2,7 +2,12 @@ import { map, take } from 'rxjs/operators'; import { Component, Inject, OnInit, Optional, Input } from '@angular/core'; import { Router } from '@angular/router'; -import { combineLatest as combineLatestObservable, Observable, of } from 'rxjs'; +import { + combineLatest as observableCombineLatest, + combineLatest as combineLatestObservable, + Observable, + of +} from 'rxjs'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; @@ -84,18 +89,19 @@ export class RootComponent implements OnInit { map(([collapsed, mobile]) => collapsed || mobile) ); - this.authService.isUserIdle().subscribe((userIdle: boolean) => { - if (userIdle) { - if (!this.idleModalOpen) { - const modalRef = this.modalService.open(IdleModalComponent); - this.idleModalOpen = true; - modalRef.componentInstance.response.pipe(take(1)).subscribe((closed: boolean) => { - if (closed) { - this.idleModalOpen = false; - } - }); + observableCombineLatest([this.authService.isUserIdle(), this.authService.isAuthenticated()]) + .subscribe(([userIdle, authenticated]) => { + if (userIdle && authenticated) { + if (!this.idleModalOpen) { + const modalRef = this.modalService.open(IdleModalComponent); + this.idleModalOpen = true; + modalRef.componentInstance.response.pipe(take(1)).subscribe((closed: boolean) => { + if (closed) { + this.idleModalOpen = false; + } + }); + } } - } }); } } diff --git a/src/app/shared/idle-modal/idle-modal.component.spec.ts b/src/app/shared/idle-modal/idle-modal.component.spec.ts new file mode 100644 index 0000000000..639cbd6ad1 --- /dev/null +++ b/src/app/shared/idle-modal/idle-modal.component.spec.ts @@ -0,0 +1,128 @@ +import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; +import { IdleModalComponent } from './idle-modal.component'; +import { AuthService } from '../../core/auth/auth.service'; +import { By } from '@angular/platform-browser'; + +describe('IdleModalComponent', () => { + let component: IdleModalComponent; + let fixture: ComponentFixture; + let debugElement: DebugElement; + + const modalStub = jasmine.createSpyObj('modalStub', ['close']); + const authServiceStub = jasmine.createSpyObj('authService', ['setIdle', 'logout']); + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [IdleModalComponent], + providers: [ + { provide: NgbActiveModal, useValue: modalStub }, + { provide: AuthService, useValue: authServiceStub } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(IdleModalComponent); + component = fixture.componentInstance; + debugElement = fixture.debugElement; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('extendSessionPressed', () => { + beforeEach(fakeAsync(() => { + spyOn(component.response, 'next'); + component.extendSessionPressed(); + })); + it('should set idle to false', () => { + expect(authServiceStub.setIdle).toHaveBeenCalledWith(false); + }); + it('should close the modal', () => { + expect(modalStub.close).toHaveBeenCalled(); + }); + it('response \'closed\' should have true as next', () => { + expect(component.response.next).toHaveBeenCalledWith(true); + }); + }); + + describe('logOutPressed', () => { + beforeEach(() => { + component.logOutPressed(); + }); + it('should logout', () => { + expect(authServiceStub.logout).toHaveBeenCalled(); + }); + it('should close the modal', () => { + expect(modalStub.close).toHaveBeenCalled(); + }); + }); + + describe('closePressed', () => { + beforeEach(fakeAsync(() => { + spyOn(component.response, 'next'); + component.closePressed(); + })); + it('should set idle to false', () => { + expect(authServiceStub.setIdle).toHaveBeenCalledWith(false); + }); + it('should close the modal', () => { + expect(modalStub.close).toHaveBeenCalled(); + }); + it('response \'closed\' should have true as next', () => { + expect(component.response.next).toHaveBeenCalledWith(true); + }); + }); + + describe('when the click method emits on extend session button', () => { + beforeEach(fakeAsync(() => { + spyOn(component, 'extendSessionPressed'); + debugElement.query(By.css('button.confirm')).triggerEventHandler('click', { + preventDefault: () => {/**/ + } + }); + tick(); + fixture.detectChanges(); + })); + it('should call the extendSessionPressed method on the component', () => { + expect(component.extendSessionPressed).toHaveBeenCalled(); + }); + }); + + describe('when the click method emits on log out button', () => { + beforeEach(fakeAsync(() => { + spyOn(component, 'logOutPressed'); + debugElement.query(By.css('button.cancel')).triggerEventHandler('click', { + preventDefault: () => {/**/ + } + }); + tick(); + fixture.detectChanges(); + })); + it('should call the logOutPressed method on the component', () => { + expect(component.logOutPressed).toHaveBeenCalled(); + }); + }); + + describe('when the click method emits on close button', () => { + beforeEach(fakeAsync(() => { + spyOn(component, 'closePressed'); + debugElement.query(By.css('.close')).triggerEventHandler('click', { + preventDefault: () => {/**/ + } + }); + tick(); + fixture.detectChanges(); + })); + it('should call the closePressed method on the component', () => { + expect(component.closePressed).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 6105c79cd9..5501b92aa7 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -3697,7 +3697,7 @@ "idle-modal.header": "Session will expire soon", - "idle-modal.info": "For security reasons, user sessions expire after {{ timeToExpire }} minutes of inactivity. Your session will expire soon. Would you like to extend it or log out?”", + "idle-modal.info": "For security reasons, user sessions expire after {{ timeToExpire }} minutes of inactivity. Your session will expire soon. Would you like to extend it or log out?", "idle-modal.log-out": "Log out", diff --git a/src/environments/environment.common.ts b/src/environments/environment.common.ts index a7d4ec8a00..24496386e9 100644 --- a/src/environments/environment.common.ts +++ b/src/environments/environment.common.ts @@ -48,17 +48,16 @@ export const environment: GlobalConfig = { ui: { // the amount of time before the idle warning is shown // timeUntilIdle: 15 * 60 * 1000, // 15 minutes - timeUntilIdle: 1 * 60 * 1000, // 1 minutes + timeUntilIdle: 30 * 1000, // 30 seconds // the amount of time the user has to react after the idle warning is shown before they are logged out. // idleGracePeriod: 5 * 60 * 1000, // 5 minutes - idleGracePeriod: 1 * 60 * 1000, // 1 minutes + idleGracePeriod: 1 * 60 * 1000, // 1 minute }, // Authority REST settings rest: { // If the rest token expires in less than this amount of time, it will be refreshed automatically. // This is independent from the idle warning. - // timeLeftBeforeTokenRefresh: 2 * 60 * 1000, // 2 minutes - timeLeftBeforeTokenRefresh: 0.25 * 60 * 1000, // 25 seconds + timeLeftBeforeTokenRefresh: 2 * 60 * 1000, // 2 minutes }, }, // Form settings From 91b4c81986e6b5b00f8a12b6c6054822bd7b4f48 Mon Sep 17 00:00:00 2001 From: Art Lowel Date: Tue, 15 Jun 2021 15:48:10 +0200 Subject: [PATCH 19/38] run idle timer outside of angular zone --- src/app/core/auth/auth.effects.ts | 34 ++++++++++++------- .../core/utilities/enter-zone.scheduler.ts | 19 +++++++++++ .../core/utilities/leave-zone.scheduler.ts | 19 +++++++++++ 3 files changed, 60 insertions(+), 12 deletions(-) create mode 100644 src/app/core/utilities/enter-zone.scheduler.ts create mode 100644 src/app/core/utilities/leave-zone.scheduler.ts diff --git a/src/app/core/auth/auth.effects.ts b/src/app/core/auth/auth.effects.ts index f7b81dc4ef..8ce10c0c6b 100644 --- a/src/app/core/auth/auth.effects.ts +++ b/src/app/core/auth/auth.effects.ts @@ -1,7 +1,13 @@ -import { Injectable } from '@angular/core'; +import { Injectable, NgZone } from '@angular/core'; -import { combineLatest as observableCombineLatest, Observable, of as observableOf, timer } from 'rxjs'; -import { catchError, filter, map, switchMap, take, tap } from 'rxjs/operators'; +import { + combineLatest as observableCombineLatest, + Observable, + of as observableOf, + timer, + asyncScheduler, queueScheduler +} from 'rxjs'; +import { catchError, filter, map, switchMap, take, tap, observeOn } from 'rxjs/operators'; // import @ngrx import { Actions, Effect, ofType } from '@ngrx/effects'; import { Action, select, Store } from '@ngrx/store'; @@ -43,8 +49,8 @@ import { hasValue } from '../../shared/empty.util'; import { environment } from '../../../environments/environment'; import { RequestActionTypes } from '../data/request.actions'; import { NotificationsActionTypes } from '../../shared/notifications/notifications.actions'; -import { ObjectCacheActionTypes } from '../cache/object-cache.actions'; -import { NO_OP_ACTION_TYPE } from '../../shared/ngrx/no-op.action'; +import { LeaveZoneScheduler } from '../utilities/leave-zone.scheduler'; +import { EnterZoneScheduler } from '../utilities/enter-zone.scheduler'; // Action Types that do not break/prevent the user from an idle state const IDLE_TIMER_IGNORE_TYPES: string[] @@ -261,22 +267,26 @@ export class AuthEffects { @Effect() public trackIdleness$: Observable = this.actions$.pipe( filter((action: Action) => !IDLE_TIMER_IGNORE_TYPES.includes(action.type)), - // Using switchMap the timer will be interrupted and restarted if a new action comes in, so idleness timer restarts - switchMap(() => { - return timer(environment.auth.ui.timeUntilIdle); - }), - map(() => { - return new SetUserAsIdleAction(); - }) + // Using switchMap the effect will stop subscribing to the previous timer if a new action comes + // in, and start a new timer + switchMap(() => + // Start a timer outside of Angular's zone + timer(environment.auth.ui.timeUntilIdle, new LeaveZoneScheduler(this.zone, asyncScheduler)) + ), + // Re-enter the zone to dispatch the action + observeOn(new EnterZoneScheduler(this.zone, queueScheduler)), + map(() => new SetUserAsIdleAction()), ); /** * @constructor * @param {Actions} actions$ + * @param {NgZone} zone * @param {AuthService} authService * @param {Store} store */ constructor(private actions$: Actions, + private zone: NgZone, private authService: AuthService, private store: Store) { } diff --git a/src/app/core/utilities/enter-zone.scheduler.ts b/src/app/core/utilities/enter-zone.scheduler.ts new file mode 100644 index 0000000000..96aee7d9a5 --- /dev/null +++ b/src/app/core/utilities/enter-zone.scheduler.ts @@ -0,0 +1,19 @@ +import { SchedulerLike, Subscription } from 'rxjs'; +import { NgZone } from '@angular/core'; + +/** + * An RXJS scheduler that will re-enter the Angular zone to run what's scheduled + */ +export class EnterZoneScheduler implements SchedulerLike { + constructor(private zone: NgZone, private scheduler: SchedulerLike) { } + + schedule(...args: any[]): Subscription { + return this.zone.run(() => + this.scheduler.schedule.apply(this.scheduler, args) + ); + } + + now (): number { + return this.scheduler.now(); + } +} diff --git a/src/app/core/utilities/leave-zone.scheduler.ts b/src/app/core/utilities/leave-zone.scheduler.ts new file mode 100644 index 0000000000..2587563661 --- /dev/null +++ b/src/app/core/utilities/leave-zone.scheduler.ts @@ -0,0 +1,19 @@ +import { SchedulerLike, Subscription } from 'rxjs'; +import { NgZone } from '@angular/core'; + +/** + * An RXJS scheduler that will run what's scheduled outside of the Angular zone + */ +export class LeaveZoneScheduler implements SchedulerLike { + constructor(private zone: NgZone, private scheduler: SchedulerLike) { } + + schedule(...args: any[]): Subscription { + return this.zone.runOutsideAngular(() => + this.scheduler.schedule.apply(this.scheduler, args) + ); + } + + now (): number { + return this.scheduler.now(); + } +} From 4b1f0864696fb06a8a10457184b90dbe0dc15d75 Mon Sep 17 00:00:00 2001 From: Marie Verdonck Date: Fri, 18 Jun 2021 13:18:17 +0200 Subject: [PATCH 20/38] 79700: Feedback 2021-06-15 applied --- src/app/app.component.ts | 37 +++++++++++++++-- src/app/core/auth/auth.reducer.ts | 12 ++---- src/app/core/auth/auth.service.ts | 13 +++--- src/app/root/root.component.ts | 40 ++----------------- .../idle-modal/idle-modal.component.spec.ts | 12 +++++- .../shared/idle-modal/idle-modal.component.ts | 7 +++- 6 files changed, 64 insertions(+), 57 deletions(-) diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 2c01bf637b..48e1e6f3d1 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,4 +1,4 @@ -import { delay, distinctUntilChanged, filter, take } from 'rxjs/operators'; +import { delay, distinctUntilChanged, filter, take, withLatestFrom } from 'rxjs/operators'; import { AfterViewInit, ChangeDetectionStrategy, @@ -11,7 +11,7 @@ import { } from '@angular/core'; import { NavigationCancel, NavigationEnd, NavigationStart, Router } from '@angular/router'; -import { BehaviorSubject, Observable, of } from 'rxjs'; +import { BehaviorSubject, combineLatest as observableCombineLatest, Observable, of } from 'rxjs'; import { select, Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; @@ -38,6 +38,8 @@ import { ThemeService } from './shared/theme-support/theme.service'; import { BASE_THEME_NAME } from './shared/theme-support/theme.constants'; import { DEFAULT_THEME_CONFIG } from './shared/theme-support/theme.effects'; import { BreadcrumbsService } from './breadcrumbs/breadcrumbs.service'; +import { IdleModalComponent } from './shared/idle-modal/idle-modal.component'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; @Component({ selector: 'ds-app', @@ -70,6 +72,11 @@ export class AppComponent implements OnInit, AfterViewInit { isThemeLoading$: BehaviorSubject = new BehaviorSubject(false); + /** + * Whether or not the idle modal is is currently open + */ + idleModalOpen: boolean; + constructor( @Inject(NativeWindowService) private _window: NativeWindowRef, @Inject(DOCUMENT) private document: any, @@ -87,6 +94,7 @@ export class AppComponent implements OnInit, AfterViewInit { private windowService: HostWindowService, private localeService: LocaleService, private breadcrumbsService: BreadcrumbsService, + private modalService: NgbModal, @Optional() private cookiesService: KlaroService, @Optional() private googleAnalyticsService: GoogleAnalyticsService, ) { @@ -108,6 +116,11 @@ export class AppComponent implements OnInit, AfterViewInit { } }); + if (isPlatformBrowser(this.platformId)) { + this.authService.trackTokenExpiration(); + this.trackIdleModal(); + } + // Load all the languages that are defined as active from the config file translate.addLangs(environment.languages.filter((LangConfig) => LangConfig.active === true).map((a) => a.code)); @@ -130,7 +143,6 @@ export class AppComponent implements OnInit, AfterViewInit { console.info(environment); } this.storeCSSVariables(); - this.authService.trackTokenExpiration(); } ngOnInit() { @@ -229,4 +241,23 @@ export class AppComponent implements OnInit, AfterViewInit { }; head.appendChild(link); } + + private trackIdleModal() { + const isIdle$ = this.authService.isUserIdle(); + const isAuthenticated$ = this.authService.isAuthenticated(); + isIdle$.pipe(withLatestFrom(isAuthenticated$)) + .subscribe(([userIdle, authenticated]) => { + if (userIdle && authenticated) { + if (!this.idleModalOpen) { + const modalRef = this.modalService.open(IdleModalComponent); + this.idleModalOpen = true; + modalRef.componentInstance.response.pipe(take(1)).subscribe((closed: boolean) => { + if (closed) { + this.idleModalOpen = false; + } + }); + } + } + }); + } } diff --git a/src/app/core/auth/auth.reducer.ts b/src/app/core/auth/auth.reducer.ts index f26ddb0182..ef803503c8 100644 --- a/src/app/core/auth/auth.reducer.ts +++ b/src/app/core/auth/auth.reducer.ts @@ -240,15 +240,9 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut }); case AuthActionTypes.SET_USER_AS_IDLE: - if (state.authenticated) { - return Object.assign({}, state, { - idle: true, - }); - } else { - return Object.assign({}, state, { - idle: false, - }); - } + return Object.assign({}, state, { + idle: true, + }); case AuthActionTypes.UNSET_USER_AS_IDLE: return Object.assign({}, state, { diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index 7b7c61f741..a5b5d70704 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -282,7 +282,7 @@ export class AuthService { // Send a request that sign end the session let headers = new HttpHeaders(); headers = headers.append('Content-Type', 'application/x-www-form-urlencoded'); - const options: HttpOptions = Object.create({headers, responseType: 'text'}); + const options: HttpOptions = Object.create({ headers, responseType: 'text' }); return this.authRequestService.postToEndpoint('logout', options).pipe( map((rd: RemoteData) => { const status = rd.payload; @@ -447,11 +447,14 @@ export class AuthService { * @param redirectUrl */ public navigateToRedirectUrl(redirectUrl: string) { - let url = `/reload/${new Date().getTime()}`; - if (isNotEmpty(redirectUrl) && !redirectUrl.startsWith(LOGIN_ROUTE)) { - url += `?redirect=${encodeURIComponent(redirectUrl)}`; + // Don't do redirect if already on reload url + if (!hasValue(redirectUrl) || !redirectUrl.includes('/reload/')) { + let url = `/reload/${new Date().getTime()}`; + if (isNotEmpty(redirectUrl) && !redirectUrl.startsWith(LOGIN_ROUTE)) { + url += `?redirect=${encodeURIComponent(redirectUrl)}`; + } + this.hardRedirectService.redirect(url); } - this.hardRedirectService.redirect(url); } /** diff --git a/src/app/root/root.component.ts b/src/app/root/root.component.ts index 81ae1a745c..209f17b520 100644 --- a/src/app/root/root.component.ts +++ b/src/app/root/root.component.ts @@ -1,13 +1,8 @@ -import { map, take } from 'rxjs/operators'; -import { Component, Inject, OnInit, Optional, Input } from '@angular/core'; +import { map } from 'rxjs/operators'; +import { Component, Inject, OnInit, Input } from '@angular/core'; import { Router } from '@angular/router'; -import { - combineLatest as observableCombineLatest, - combineLatest as combineLatestObservable, - Observable, - of -} from 'rxjs'; +import { combineLatest as combineLatestObservable, Observable, of } from 'rxjs'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; @@ -23,11 +18,7 @@ import { HostWindowService } from '../shared/host-window.service'; import { ThemeConfig } from '../../config/theme.model'; import { Angulartics2DSpace } from '../statistics/angulartics/dspace-provider'; import { environment } from '../../environments/environment'; -import { LocaleService } from '../core/locale/locale.service'; -import { KlaroService } from '../shared/cookies/klaro.service'; import { slideSidebarPadding } from '../shared/animations/slide'; -import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; -import { IdleModalComponent } from '../shared/idle-modal/idle-modal.component'; @Component({ selector: 'ds-root', @@ -54,11 +45,6 @@ export class RootComponent implements OnInit { */ @Input() shouldShowRouteLoader: boolean; - /** - * Whether or not the idle modal is is currently open - */ - idleModalOpen: boolean; - constructor( @Inject(NativeWindowService) private _window: NativeWindowRef, private translate: TranslateService, @@ -70,10 +56,7 @@ export class RootComponent implements OnInit { private router: Router, private cssService: CSSVariableService, private menuService: MenuService, - private windowService: HostWindowService, - private localeService: LocaleService, - @Optional() private cookiesService: KlaroService, - private modalService: NgbModal + private windowService: HostWindowService ) { } @@ -88,20 +71,5 @@ export class RootComponent implements OnInit { .pipe( map(([collapsed, mobile]) => collapsed || mobile) ); - - observableCombineLatest([this.authService.isUserIdle(), this.authService.isAuthenticated()]) - .subscribe(([userIdle, authenticated]) => { - if (userIdle && authenticated) { - if (!this.idleModalOpen) { - const modalRef = this.modalService.open(IdleModalComponent); - this.idleModalOpen = true; - modalRef.componentInstance.response.pipe(take(1)).subscribe((closed: boolean) => { - if (closed) { - this.idleModalOpen = false; - } - }); - } - } - }); } } diff --git a/src/app/shared/idle-modal/idle-modal.component.spec.ts b/src/app/shared/idle-modal/idle-modal.component.spec.ts index 639cbd6ad1..552315d1a4 100644 --- a/src/app/shared/idle-modal/idle-modal.component.spec.ts +++ b/src/app/shared/idle-modal/idle-modal.component.spec.ts @@ -5,6 +5,7 @@ import { TranslateModule } from '@ngx-translate/core'; import { IdleModalComponent } from './idle-modal.component'; import { AuthService } from '../../core/auth/auth.service'; import { By } from '@angular/platform-browser'; +import { HardRedirectService } from '../../core/services/hard-redirect.service'; describe('IdleModalComponent', () => { let component: IdleModalComponent; @@ -12,15 +13,18 @@ describe('IdleModalComponent', () => { let debugElement: DebugElement; const modalStub = jasmine.createSpyObj('modalStub', ['close']); - const authServiceStub = jasmine.createSpyObj('authService', ['setIdle', 'logout']); + const authServiceStub = jasmine.createSpyObj('authService', ['setIdle', 'logout', 'navigateToRedirectUrl']); + let hardRedirectService; beforeEach(waitForAsync(() => { + hardRedirectService = jasmine.createSpyObj('hardRedirectService', ['getCurrentRoute']); TestBed.configureTestingModule({ imports: [TranslateModule.forRoot()], declarations: [IdleModalComponent], providers: [ { provide: NgbActiveModal, useValue: modalStub }, - { provide: AuthService, useValue: authServiceStub } + { provide: AuthService, useValue: authServiceStub }, + { provide: HardRedirectService, useValue: hardRedirectService } ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); @@ -63,6 +67,10 @@ describe('IdleModalComponent', () => { it('should close the modal', () => { expect(modalStub.close).toHaveBeenCalled(); }); + it('should reload', () => { + expect(hardRedirectService.getCurrentRoute).toHaveBeenCalled(); + expect(authServiceStub.navigateToRedirectUrl).toHaveBeenCalled(); + }); }); describe('closePressed', () => { diff --git a/src/app/shared/idle-modal/idle-modal.component.ts b/src/app/shared/idle-modal/idle-modal.component.ts index 750657c2e4..d812d3ffc1 100644 --- a/src/app/shared/idle-modal/idle-modal.component.ts +++ b/src/app/shared/idle-modal/idle-modal.component.ts @@ -4,6 +4,7 @@ import { environment } from '../../../environments/environment'; import { AuthService } from '../../core/auth/auth.service'; import { Subject } from 'rxjs'; import { hasValue } from '../empty.util'; +import { HardRedirectService } from '../../core/services/hard-redirect.service'; @Component({ selector: 'ds-idle-modal', @@ -29,7 +30,8 @@ export class IdleModalComponent implements OnInit { response: Subject = new Subject(); constructor(private activeModal: NgbActiveModal, - private authService: AuthService) { + private authService: AuthService, + protected hardRedirectService: HardRedirectService) { this.timeToExpire = (environment.auth.ui.timeUntilIdle + environment.auth.ui.idleGracePeriod) / 1000 / 60; // ms => min } @@ -53,8 +55,9 @@ export class IdleModalComponent implements OnInit { * Close modal and logout */ logOutPressed() { - this.authService.logout(); this.closeModal(); + this.authService.logout(); + this.authService.navigateToRedirectUrl(this.hardRedirectService.getCurrentRoute()); } /** From c696b783931ccd0cbf35fc019a2a33338c9ad6f7 Mon Sep 17 00:00:00 2001 From: Marie Verdonck Date: Fri, 18 Jun 2021 13:24:34 +0200 Subject: [PATCH 21/38] 79700: idle time and grace period testing times removed --- src/environments/environment.common.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/environments/environment.common.ts b/src/environments/environment.common.ts index 24496386e9..87be9b4260 100644 --- a/src/environments/environment.common.ts +++ b/src/environments/environment.common.ts @@ -47,11 +47,9 @@ export const environment: GlobalConfig = { // Authority UI settings ui: { // the amount of time before the idle warning is shown - // timeUntilIdle: 15 * 60 * 1000, // 15 minutes - timeUntilIdle: 30 * 1000, // 30 seconds + timeUntilIdle: 15 * 60 * 1000, // 15 minutes // the amount of time the user has to react after the idle warning is shown before they are logged out. - // idleGracePeriod: 5 * 60 * 1000, // 5 minutes - idleGracePeriod: 1 * 60 * 1000, // 1 minute + idleGracePeriod: 5 * 60 * 1000, // 5 minutes }, // Authority REST settings rest: { From ddcb27da3f9f7144b2f5e8af2d9480da455cdb15 Mon Sep 17 00:00:00 2001 From: Marie Verdonck Date: Fri, 18 Jun 2021 16:01:53 +0200 Subject: [PATCH 22/38] 79700: logout via store, automatic redirect --- .../idle-modal/idle-modal.component.spec.ts | 23 +++++++++---------- .../shared/idle-modal/idle-modal.component.ts | 9 ++++---- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/app/shared/idle-modal/idle-modal.component.spec.ts b/src/app/shared/idle-modal/idle-modal.component.spec.ts index 552315d1a4..847bf6ac4f 100644 --- a/src/app/shared/idle-modal/idle-modal.component.spec.ts +++ b/src/app/shared/idle-modal/idle-modal.component.spec.ts @@ -5,26 +5,29 @@ import { TranslateModule } from '@ngx-translate/core'; import { IdleModalComponent } from './idle-modal.component'; import { AuthService } from '../../core/auth/auth.service'; import { By } from '@angular/platform-browser'; -import { HardRedirectService } from '../../core/services/hard-redirect.service'; +import { Store } from '@ngrx/store'; +import { LogOutAction } from '../../core/auth/auth.actions'; describe('IdleModalComponent', () => { let component: IdleModalComponent; let fixture: ComponentFixture; let debugElement: DebugElement; - const modalStub = jasmine.createSpyObj('modalStub', ['close']); - const authServiceStub = jasmine.createSpyObj('authService', ['setIdle', 'logout', 'navigateToRedirectUrl']); - let hardRedirectService; + let modalStub; + let authServiceStub; + let storeStub; beforeEach(waitForAsync(() => { - hardRedirectService = jasmine.createSpyObj('hardRedirectService', ['getCurrentRoute']); + modalStub = jasmine.createSpyObj('modalStub', ['close']); + authServiceStub = jasmine.createSpyObj('authService', ['setIdle']); + storeStub = jasmine.createSpyObj('store', ['dispatch']); TestBed.configureTestingModule({ imports: [TranslateModule.forRoot()], declarations: [IdleModalComponent], providers: [ { provide: NgbActiveModal, useValue: modalStub }, { provide: AuthService, useValue: authServiceStub }, - { provide: HardRedirectService, useValue: hardRedirectService } + { provide: Store, useValue: storeStub } ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); @@ -61,15 +64,11 @@ describe('IdleModalComponent', () => { beforeEach(() => { component.logOutPressed(); }); - it('should logout', () => { - expect(authServiceStub.logout).toHaveBeenCalled(); - }); it('should close the modal', () => { expect(modalStub.close).toHaveBeenCalled(); }); - it('should reload', () => { - expect(hardRedirectService.getCurrentRoute).toHaveBeenCalled(); - expect(authServiceStub.navigateToRedirectUrl).toHaveBeenCalled(); + it('should send logout action', () => { + expect(storeStub.dispatch).toHaveBeenCalledWith(new LogOutAction()); }); }); diff --git a/src/app/shared/idle-modal/idle-modal.component.ts b/src/app/shared/idle-modal/idle-modal.component.ts index d812d3ffc1..35fafcf5cf 100644 --- a/src/app/shared/idle-modal/idle-modal.component.ts +++ b/src/app/shared/idle-modal/idle-modal.component.ts @@ -4,7 +4,9 @@ import { environment } from '../../../environments/environment'; import { AuthService } from '../../core/auth/auth.service'; import { Subject } from 'rxjs'; import { hasValue } from '../empty.util'; -import { HardRedirectService } from '../../core/services/hard-redirect.service'; +import { Store } from '@ngrx/store'; +import { AppState } from '../../app.reducer'; +import { LogOutAction } from '../../core/auth/auth.actions'; @Component({ selector: 'ds-idle-modal', @@ -31,7 +33,7 @@ export class IdleModalComponent implements OnInit { constructor(private activeModal: NgbActiveModal, private authService: AuthService, - protected hardRedirectService: HardRedirectService) { + private store: Store) { this.timeToExpire = (environment.auth.ui.timeUntilIdle + environment.auth.ui.idleGracePeriod) / 1000 / 60; // ms => min } @@ -56,8 +58,7 @@ export class IdleModalComponent implements OnInit { */ logOutPressed() { this.closeModal(); - this.authService.logout(); - this.authService.navigateToRedirectUrl(this.hardRedirectService.getCurrentRoute()); + this.store.dispatch(new LogOutAction()); } /** From 829ce12710d7d1c13f93f00e0895614fb4bdebb5 Mon Sep 17 00:00:00 2001 From: Marie Verdonck Date: Fri, 18 Jun 2021 17:53:34 +0200 Subject: [PATCH 23/38] lgtm alerts --- src/app/app.component.ts | 2 +- src/app/core/auth/auth.interceptor.ts | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 48e1e6f3d1..4feee0e585 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -11,7 +11,7 @@ import { } from '@angular/core'; import { NavigationCancel, NavigationEnd, NavigationStart, Router } from '@angular/router'; -import { BehaviorSubject, combineLatest as observableCombineLatest, Observable, of } from 'rxjs'; +import { BehaviorSubject, Observable, of } from 'rxjs'; import { select, Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; diff --git a/src/app/core/auth/auth.interceptor.ts b/src/app/core/auth/auth.interceptor.ts index d16f46a849..a49030110b 100644 --- a/src/app/core/auth/auth.interceptor.ts +++ b/src/app/core/auth/auth.interceptor.ts @@ -1,6 +1,6 @@ import { Observable, of as observableOf, throwError as observableThrowError } from 'rxjs'; -import { catchError, filter, map } from 'rxjs/operators'; +import { catchError, map } from 'rxjs/operators'; import { Injectable, Injector } from '@angular/core'; import { HttpErrorResponse, @@ -12,14 +12,13 @@ import { HttpResponse, HttpResponseBase } from '@angular/common/http'; -import { find } from 'lodash'; import { AppState } from '../../app.reducer'; import { AuthService } from './auth.service'; import { AuthStatus } from './models/auth-status.model'; import { AuthTokenInfo } from './models/auth-token-info.model'; -import { hasValue, isNotEmpty, isNotNull, isUndefined } from '../../shared/empty.util'; -import { RedirectWhenTokenExpiredAction, RefreshTokenAction } from './auth.actions'; +import { hasValue, isNotEmpty, isNotNull } from '../../shared/empty.util'; +import { RedirectWhenTokenExpiredAction } from './auth.actions'; import { Store } from '@ngrx/store'; import { Router } from '@angular/router'; import { AuthMethod } from './models/auth.method'; From 6c219e72d53ece28293c60c22dea5b7ec1012d99 Mon Sep 17 00:00:00 2001 From: Marie Verdonck Date: Thu, 1 Jul 2021 15:50:21 +0200 Subject: [PATCH 24/38] 79700: Doc fixes, Spec tests authService & ariaLabelledBy for idle-modal --- src/app/app.component.ts | 2 +- src/app/core/auth/auth.service.spec.ts | 82 ++++++++++++++++++- .../idle-modal/idle-modal.component.html | 2 +- src/environments/environment.common.ts | 6 +- src/environments/mock-environment.ts | 6 +- 5 files changed, 88 insertions(+), 10 deletions(-) diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 4feee0e585..356025da9e 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -249,7 +249,7 @@ export class AppComponent implements OnInit, AfterViewInit { .subscribe(([userIdle, authenticated]) => { if (userIdle && authenticated) { if (!this.idleModalOpen) { - const modalRef = this.modalService.open(IdleModalComponent); + const modalRef = this.modalService.open(IdleModalComponent, { ariaLabelledBy: 'idle-modal.header' }); this.idleModalOpen = true; modalRef.componentInstance.response.pipe(take(1)).subscribe((closed: boolean) => { if (closed) { diff --git a/src/app/core/auth/auth.service.spec.ts b/src/app/core/auth/auth.service.spec.ts index d54ffdae16..ced8bb94c8 100644 --- a/src/app/core/auth/auth.service.spec.ts +++ b/src/app/core/auth/auth.service.spec.ts @@ -31,6 +31,7 @@ import { NotificationsService } from '../../shared/notifications/notifications.s import { TranslateService } from '@ngx-translate/core'; import { getMockTranslateService } from '../../shared/mocks/translate.service.mock'; import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; +import { SetUserAsIdleAction, UnsetUserAsIdleAction } from './auth.actions'; describe('AuthService test', () => { @@ -51,6 +52,7 @@ describe('AuthService test', () => { let token: AuthTokenInfo; let authenticatedState; let unAuthenticatedState; + let idleState; let linkService; let hardRedirectService; @@ -68,14 +70,24 @@ describe('AuthService test', () => { loaded: true, loading: false, authToken: token, - user: EPersonMock + user: EPersonMock, + idle: false }; unAuthenticatedState = { authenticated: false, loaded: true, loading: false, authToken: undefined, - user: undefined + user: undefined, + idle: false + }; + idleState = { + authenticated: true, + loaded: true, + loading: false, + authToken: token, + user: EPersonMock, + idle: true }; authRequest = new AuthRequestServiceStub(); routeStub = new ActivatedRouteStub(); @@ -186,6 +198,26 @@ describe('AuthService test', () => { expect(authMethods.length).toBe(2); }); }); + + describe('setIdle true', () => { + beforeEach(() => { + authService.setIdle(true); + }); + + it('store should dispatch SetUserAsIdleAction', () => { + expect(mockStore.dispatch).toHaveBeenCalledWith(new SetUserAsIdleAction()); + }); + }); + + describe('setIdle false', () => { + beforeEach(() => { + authService.setIdle(false); + }); + + it('store should dispatch UnsetUserAsIdleAction', () => { + expect(mockStore.dispatch).toHaveBeenCalledWith(new UnsetUserAsIdleAction()); + }); + }); }); describe('', () => { @@ -256,6 +288,12 @@ describe('AuthService test', () => { }); }); + it('isUserIdle should return false when user is not yet idle', () => { + authService.isUserIdle().subscribe((status: boolean) => { + expect(status).toBe(false); + }); + }); + }); describe('', () => { @@ -514,4 +552,44 @@ describe('AuthService test', () => { }); }); }); + + describe('when user is idle', () => { + beforeEach(waitForAsync(() => { + init(); + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({ authReducer }, { + runtimeChecks: { + strictStateImmutability: false, + strictActionImmutability: false + } + }) + ], + providers: [ + { provide: AuthRequestService, useValue: authRequest }, + { provide: REQUEST, useValue: {} }, + { provide: Router, useValue: routerStub }, + { provide: RouteService, useValue: routeServiceStub }, + { provide: RemoteDataBuildService, useValue: linkService }, + CookieService, + AuthService + ] + }).compileComponents(); + })); + + beforeEach(inject([CookieService, AuthRequestService, Store, Router, RouteService], (cookieService: CookieService, authReqService: AuthRequestService, store: Store, router: Router, routeService: RouteService, notificationsService: NotificationsService, translateService: TranslateService) => { + store + .subscribe((state) => { + (state as any).core = Object.create({}); + (state as any).core.auth = idleState; + }); + authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService, notificationsService, translateService); + })); + + it('isUserIdle should return true when user is not idle', () => { + authService.isUserIdle().subscribe((status: boolean) => { + expect(status).toBe(true); + }); + }); + }); }); diff --git a/src/app/shared/idle-modal/idle-modal.component.html b/src/app/shared/idle-modal/idle-modal.component.html index 665ebb9672..beea91fe7b 100644 --- a/src/app/shared/idle-modal/idle-modal.component.html +++ b/src/app/shared/idle-modal/idle-modal.component.html @@ -1,5 +1,5 @@
- From 50400895de6aca9ebe85068a12092d788e7d71ba Mon Sep 17 00:00:00 2001 From: Yura Bondarenko Date: Thu, 1 Jul 2021 22:36:02 +0200 Subject: [PATCH 26/38] 79768: Add unit tests for metaTagReducer --- src/app/core/metadata/meta-tag.actions.ts | 1 - .../core/metadata/meta-tag.reducer.spec.ts | 50 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 src/app/core/metadata/meta-tag.reducer.spec.ts diff --git a/src/app/core/metadata/meta-tag.actions.ts b/src/app/core/metadata/meta-tag.actions.ts index 6451e58da2..cd048d3be2 100644 --- a/src/app/core/metadata/meta-tag.actions.ts +++ b/src/app/core/metadata/meta-tag.actions.ts @@ -1,6 +1,5 @@ import { type } from '../../shared/ngrx/type'; import { Action } from '@ngrx/store'; -import { MetaDefinition } from '@angular/platform-browser'; // tslint:disable:max-classes-per-file export const MetaTagTypes = { diff --git a/src/app/core/metadata/meta-tag.reducer.spec.ts b/src/app/core/metadata/meta-tag.reducer.spec.ts new file mode 100644 index 0000000000..1fcd7d83e3 --- /dev/null +++ b/src/app/core/metadata/meta-tag.reducer.spec.ts @@ -0,0 +1,50 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ + +import { metaTagReducer } from './meta-tag.reducer'; +import { AddMetaTagAction, ClearMetaTagAction } from './meta-tag.actions'; + +const nullAction = { type: null }; + +describe('metaTagReducer', () => { + it('should start with an empty array', () => { + const state0 = metaTagReducer(undefined, nullAction); + expect(state0.tagsInUse).toEqual([]); + }); + + it('should return the current state on invalid action', () => { + const state0 = { + tagsInUse: ['foo', 'bar'], + }; + + const state1 = metaTagReducer(state0, nullAction); + expect(state1).toEqual(state0); + }); + + it('should add tags on AddMetaTagAction', () => { + const state0 = { + tagsInUse: ['foo'], + }; + + const state1 = metaTagReducer(state0, new AddMetaTagAction('bar')); + const state2 = metaTagReducer(state1, new AddMetaTagAction('baz')); + + expect(state1.tagsInUse).toEqual(['foo', 'bar']); + expect(state2.tagsInUse).toEqual(['foo', 'bar', 'baz']); + }); + + it('should clear tags on ClearMetaTagAction', () => { + const state0 = { + tagsInUse: ['foo', 'bar'], + }; + + const state1 = metaTagReducer(state0, new ClearMetaTagAction()); + + expect(state1.tagsInUse).toEqual([]); + }); +}); From fb8f28f17d1047986e3ace7331e3f32111b7c46a Mon Sep 17 00:00:00 2001 From: Yura Bondarenko Date: Thu, 1 Jul 2021 22:37:07 +0200 Subject: [PATCH 27/38] 79768: Update & add MetadataService unit tests --- .../core/metadata/metadata.service.spec.ts | 50 ++++++++++++++++--- src/app/core/metadata/metadata.service.ts | 16 +++--- 2 files changed, 49 insertions(+), 17 deletions(-) diff --git a/src/app/core/metadata/metadata.service.spec.ts b/src/app/core/metadata/metadata.service.spec.ts index d18897cc55..f946120cd2 100644 --- a/src/app/core/metadata/metadata.service.spec.ts +++ b/src/app/core/metadata/metadata.service.spec.ts @@ -1,6 +1,6 @@ import { fakeAsync, tick } from '@angular/core/testing'; import { Meta, Title } from '@angular/platform-browser'; -import { Router, NavigationEnd } from '@angular/router'; +import { NavigationEnd, Router } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; import { Observable, of } from 'rxjs'; @@ -8,11 +8,8 @@ import { Observable, of } from 'rxjs'; import { RemoteData } from '../data/remote-data'; import { Item } from '../shared/item.model'; -import { ItemMock, MockBitstream1, MockBitstream3, } from '../../shared/mocks/item.mock'; -import { - createSuccessfulRemoteDataObject$, - createSuccessfulRemoteDataObject -} from '../../shared/remote-data.utils'; +import { ItemMock, MockBitstream1, MockBitstream3 } from '../../shared/mocks/item.mock'; +import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { PaginatedList } from '../data/paginated-list.model'; import { Bitstream } from '../shared/bitstream.model'; import { MetadataValue } from '../shared/metadata.models'; @@ -24,6 +21,11 @@ import { createPaginatedList } from '../../shared/testing/utils.test'; import { getMockTranslateService } from '../../shared/mocks/translate.service.mock'; import { DSONameService } from '../breadcrumbs/dso-name.service'; import { HardRedirectService } from '../services/hard-redirect.service'; +import { getMockStore, MockStore } from '@ngrx/store/testing'; +import { CoreState } from '../core.reducers'; +import { MetaTagState } from './meta-tag.reducer'; +import { AddMetaTagAction, ClearMetaTagAction } from './meta-tag.actions'; +import { Community } from '../shared/community.model'; describe('MetadataService', () => { let metadataService: MetadataService; @@ -41,6 +43,10 @@ describe('MetadataService', () => { let hardRedirectService: HardRedirectService; let router: Router; + let store; + + const initialState = { 'core': { metaTag: { tagsInUse: ['title', 'description'] }}}; + beforeEach(() => { rootService = jasmine.createSpyObj({ @@ -53,7 +59,7 @@ describe('MetadataService', () => { findByItemAndName: mockBundleRD$([MockBitstream3]) }); translateService = getMockTranslateService(); - meta = jasmine.createSpyObj({ + meta = jasmine.createSpyObj('meta', { addTag: {}, removeTag: {} }); @@ -73,6 +79,11 @@ describe('MetadataService', () => { hardRedirectService = jasmine.createSpyObj( { getRequestOrigin: 'https://request.org', }); + + //@ts-ignore + store = getMockStore({ initialState }); + spyOn(store, 'dispatch'); + metadataService = new MetadataService( router, translateService, @@ -83,6 +94,7 @@ describe('MetadataService', () => { bitstreamDataService, undefined, rootService, + store, hardRedirectService ); }); @@ -332,6 +344,30 @@ describe('MetadataService', () => { }); }); + describe('tagstore', () => { + beforeEach(fakeAsync(() => { + (metadataService as any).processRouteChange({ + data: { + value: { + dso: createSuccessfulRemoteDataObject(ItemMock), + } + } + }); + tick(); + })); + + it('should remove previous tags on route change', fakeAsync(() => { + expect(meta.removeTag).toHaveBeenCalledWith('property=\'title\''); + expect(meta.removeTag).toHaveBeenCalledWith('property=\'description\''); + })); + + it('should clear all tags and add new ones on route change', () => { + expect(store.dispatch.calls.argsFor(0)).toEqual([new ClearMetaTagAction()]); + expect(store.dispatch.calls.argsFor(1)).toEqual([new AddMetaTagAction('title')]); + expect(store.dispatch.calls.argsFor(2)).toEqual([new AddMetaTagAction('description')]); + }); + }); + const mockType = (mockItem: Item, type: string): Item => { const typedMockItem = Object.assign(new Item(), mockItem) as Item; typedMockItem.metadata['dc.type'] = [{ value: type }] as MetadataValue[]; diff --git a/src/app/core/metadata/metadata.service.ts b/src/app/core/metadata/metadata.service.ts index 8c1e1027dd..f42747fbc5 100644 --- a/src/app/core/metadata/metadata.service.ts +++ b/src/app/core/metadata/metadata.service.ts @@ -5,10 +5,10 @@ import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; -import { BehaviorSubject, combineLatest, Observable, of as observableOf, EMPTY } from 'rxjs'; -import { filter, map, take, switchMap, expand } from 'rxjs/operators'; +import { BehaviorSubject, combineLatest, EMPTY, Observable, of as observableOf } from 'rxjs'; +import { expand, filter, map, switchMap, take } from 'rxjs/operators'; -import { hasValue, hasNoValue } from '../../shared/empty.util'; +import { hasNoValue, hasValue } from '../../shared/empty.util'; import { DSONameService } from '../breadcrumbs/dso-name.service'; import { BitstreamDataService } from '../data/bitstream-data.service'; import { BitstreamFormatDataService } from '../data/bitstream-format-data.service'; @@ -18,10 +18,7 @@ import { BitstreamFormat } from '../shared/bitstream-format.model'; import { Bitstream } from '../shared/bitstream.model'; import { DSpaceObject } from '../shared/dspace-object.model'; import { Item } from '../shared/item.model'; -import { - getFirstSucceededRemoteDataPayload, - getFirstCompletedRemoteData -} from '../shared/operators'; +import { getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload } from '../shared/operators'; import { RootDataService } from '../data/root-data.service'; import { getBitstreamDownloadRoute } from '../../app-routing-paths'; import { BundleDataService } from '../data/bundle-data.service'; @@ -31,11 +28,10 @@ import { PaginatedList } from '../data/paginated-list.model'; import { URLCombiner } from '../url-combiner/url-combiner'; import { HardRedirectService } from '../services/hard-redirect.service'; import { MetaTagState } from './meta-tag.reducer'; -import { Store, createSelector, select, MemoizedSelector } from '@ngrx/store'; +import { createSelector, select, Store } from '@ngrx/store'; import { AddMetaTagAction, ClearMetaTagAction } from './meta-tag.actions'; import { coreSelector } from '../core.selectors'; import { CoreState } from '../core.reducers'; -import { ObjectCacheEntry, ObjectCacheState } from '../cache/object-cache.reducer'; /** * The base selector function to select the metaTag section in the store @@ -84,7 +80,7 @@ export class MetadataService { private bitstreamDataService: BitstreamDataService, private bitstreamFormatDataService: BitstreamFormatDataService, private rootService: RootDataService, - private store: Store, + private store: Store, private hardRedirectService: HardRedirectService, ) { } From 8caa9163165df0b669eb8ad9dd4f5d23b9540529 Mon Sep 17 00:00:00 2001 From: Yura Bondarenko Date: Thu, 1 Jul 2021 22:37:55 +0200 Subject: [PATCH 28/38] 79768: Fix typo --- src/app/core/metadata/metadata.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/core/metadata/metadata.service.ts b/src/app/core/metadata/metadata.service.ts index f42747fbc5..bd0ad66c7b 100644 --- a/src/app/core/metadata/metadata.service.ts +++ b/src/app/core/metadata/metadata.service.ts @@ -184,7 +184,7 @@ export class MetadataService { private setDescriptionTag(): void { // TODO: truncate abstract const value = this.getMetaTagValue('dc.description.abstract'); - this.addMetaTag('desciption', value); + this.addMetaTag('description', value); } /** From 5ed41b3f9bc8a611fe034697efc49c1127580e37 Mon Sep 17 00:00:00 2001 From: Yura Bondarenko Date: Thu, 1 Jul 2021 22:47:26 +0200 Subject: [PATCH 29/38] 79768: Fix unused imports & lint issue --- src/app/core/metadata/metadata.service.spec.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/app/core/metadata/metadata.service.spec.ts b/src/app/core/metadata/metadata.service.spec.ts index f946120cd2..b3404e84d5 100644 --- a/src/app/core/metadata/metadata.service.spec.ts +++ b/src/app/core/metadata/metadata.service.spec.ts @@ -21,11 +21,8 @@ import { createPaginatedList } from '../../shared/testing/utils.test'; import { getMockTranslateService } from '../../shared/mocks/translate.service.mock'; import { DSONameService } from '../breadcrumbs/dso-name.service'; import { HardRedirectService } from '../services/hard-redirect.service'; -import { getMockStore, MockStore } from '@ngrx/store/testing'; -import { CoreState } from '../core.reducers'; -import { MetaTagState } from './meta-tag.reducer'; +import { getMockStore } from '@ngrx/store/testing'; import { AddMetaTagAction, ClearMetaTagAction } from './meta-tag.actions'; -import { Community } from '../shared/community.model'; describe('MetadataService', () => { let metadataService: MetadataService; @@ -80,7 +77,7 @@ describe('MetadataService', () => { getRequestOrigin: 'https://request.org', }); - //@ts-ignore + // @ts-ignore store = getMockStore({ initialState }); spyOn(store, 'dispatch'); From a91f16ed62ab4bf050a21c900de9f5d6b8108c91 Mon Sep 17 00:00:00 2001 From: Yura Bondarenko Date: Thu, 1 Jul 2021 23:11:57 +0200 Subject: [PATCH 30/38] 79768: Fix followLink syntax --- src/app/core/metadata/metadata.service.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/app/core/metadata/metadata.service.ts b/src/app/core/metadata/metadata.service.ts index bd0ad66c7b..10e37b4282 100644 --- a/src/app/core/metadata/metadata.service.ts +++ b/src/app/core/metadata/metadata.service.ts @@ -292,13 +292,7 @@ export class MetadataService { true, true, followLink('primaryBitstream'), - followLink('bitstreams', - undefined, - true, - true, - true, - followLink('format') - ) + followLink('bitstreams', {}, followLink('format')), ).pipe( getFirstSucceededRemoteDataPayload(), switchMap((bundle: Bundle) => From badf901361aefb8bed5def41a35d288e6233addd Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Fri, 2 Jul 2021 13:46:17 +0200 Subject: [PATCH 31/38] Use ds-file-download-link component to allow bitstream download during submission --- .../file-download-link.component.html | 5 ++-- .../file-download-link.component.ts | 11 ++++++++ .../file/section-upload-file.component.html | 5 ++-- .../section-upload-file.component.spec.ts | 26 ------------------- .../file/section-upload-file.component.ts | 21 +++++++-------- 5 files changed, 26 insertions(+), 42 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 f1843da5c6..497502d586 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,5 +1,6 @@ - - + + + diff --git a/src/app/shared/file-download-link/file-download-link.component.ts b/src/app/shared/file-download-link/file-download-link.component.ts index 4423b6f5b7..e2f7633275 100644 --- a/src/app/shared/file-download-link/file-download-link.component.ts +++ b/src/app/shared/file-download-link/file-download-link.component.ts @@ -18,6 +18,17 @@ export class FileDownloadLinkComponent implements OnInit { * Optional bitstream instead of href and file name */ @Input() bitstream: Bitstream; + + /** + * Additional css classes to apply to link + */ + @Input() cssClasses = ''; + + /** + * Optional bitstream link, show in same tab or a new tab. + */ + @Input() isBlank = false; + bitstreamPath: string; ngOnInit() { diff --git a/src/app/submission/sections/upload/file/section-upload-file.component.html b/src/app/submission/sections/upload/file/section-upload-file.component.html index 64df1155bf..221d396a39 100644 --- a/src/app/submission/sections/upload/file/section-upload-file.component.html +++ b/src/app/submission/sections/upload/file/section-upload-file.component.html @@ -10,8 +10,9 @@
- - + + +