diff --git a/.gitignore b/.gitignore index bdd0d4e589..7d065aca06 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,5 @@ package-lock.json /nbproject/ junit.xml + +/src/mirador-viewer/config.local.js diff --git a/server.ts b/server.ts index 3e10677a8b..d64b80b4ab 100644 --- a/server.ts +++ b/server.ts @@ -26,7 +26,6 @@ import * as ejs from 'ejs'; import * as compression from 'compression'; import * as expressStaticGzip from 'express-static-gzip'; /* eslint-enable import/no-namespace */ - import axios from 'axios'; import LRU from 'lru-cache'; import isbot from 'isbot'; @@ -34,7 +33,7 @@ import { createCertificate } from 'pem'; import { createServer } from 'https'; import { json } from 'body-parser'; -import { existsSync, readFileSync } from 'fs'; +import { readFileSync } from 'fs'; import { join } from 'path'; import { enableProdMode } from '@angular/core'; @@ -180,6 +179,15 @@ export function app() { changeOrigin: true })); + /** + * Proxy the linksets + */ + router.use('/signposting**', createProxyMiddleware({ + target: `${environment.rest.baseUrl}`, + pathRewrite: path => path.replace(environment.ui.nameSpace, '/'), + changeOrigin: true + })); + /** * Checks if the rateLimiter property is present * When it is present, the rateLimiter will be enabled. When it is undefined, the rateLimiter will be disabled. diff --git a/src/app/access-control/epeople-registry/epeople-registry.component.ts b/src/app/access-control/epeople-registry/epeople-registry.component.ts index 706dcab690..fb045ebb88 100644 --- a/src/app/access-control/epeople-registry/epeople-registry.component.ts +++ b/src/app/access-control/epeople-registry/epeople-registry.component.ts @@ -287,14 +287,17 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy { /** * This method will set everything to stale, which will cause the lists on this page to update. */ - reset() { + reset(): void { this.epersonService.getBrowseEndpoint().pipe( - take(1) - ).subscribe((href: string) => { - this.requestService.setStaleByHrefSubstring(href).pipe(take(1)).subscribe(() => { - this.epersonService.cancelEditEPerson(); - this.isEPersonFormShown = false; - }); + take(1), + switchMap((href: string) => { + return this.requestService.setStaleByHrefSubstring(href).pipe( + take(1), + ); + }) + ).subscribe(()=>{ + this.epersonService.cancelEditEPerson(); + this.isEPersonFormShown = false; }); } } diff --git a/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.ts b/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.ts index c60de00aed..d009d56058 100644 --- a/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.ts +++ b/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.ts @@ -8,7 +8,7 @@ import { } from '@ng-dynamic-forms/core'; import { TranslateService } from '@ngx-translate/core'; import { combineLatest as observableCombineLatest, Observable, of as observableOf, Subscription } from 'rxjs'; -import { debounceTime, switchMap, take } from 'rxjs/operators'; +import { debounceTime, finalize, map, switchMap, take } from 'rxjs/operators'; import { PaginatedList } from '../../../core/data/paginated-list.model'; import { RemoteData } from '../../../core/data/remote-data'; import { EPersonDataService } from '../../../core/eperson/eperson-data.service'; @@ -463,31 +463,42 @@ export class EPersonFormComponent implements OnInit, OnDestroy { * Deletes the EPerson from the Repository. The EPerson will be the only that this form is showing. * It'll either show a success or error message depending on whether the delete was successful or not. */ - delete() { - this.epersonService.getActiveEPerson().pipe(take(1)).subscribe((eperson: EPerson) => { - const modalRef = this.modalService.open(ConfirmationModalComponent); - modalRef.componentInstance.dso = eperson; - modalRef.componentInstance.headerLabel = 'confirmation-modal.delete-eperson.header'; - modalRef.componentInstance.infoLabel = 'confirmation-modal.delete-eperson.info'; - modalRef.componentInstance.cancelLabel = 'confirmation-modal.delete-eperson.cancel'; - modalRef.componentInstance.confirmLabel = 'confirmation-modal.delete-eperson.confirm'; - modalRef.componentInstance.brandColor = 'danger'; - modalRef.componentInstance.confirmIcon = 'fas fa-trash'; - modalRef.componentInstance.response.pipe(take(1)).subscribe((confirm: boolean) => { - if (confirm) { - if (hasValue(eperson.id)) { - this.epersonService.deleteEPerson(eperson).pipe(getFirstCompletedRemoteData()).subscribe((restResponse: RemoteData) => { - if (restResponse.hasSucceeded) { - this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', { name: this.dsoNameService.getName(eperson) })); - this.submitForm.emit(); - } else { - this.notificationsService.error('Error occured when trying to delete EPerson with id: ' + eperson.id + ' with code: ' + restResponse.statusCode + ' and message: ' + restResponse.errorMessage); - } - this.cancelForm.emit(); - }); - } - } - }); + delete(): void { + this.epersonService.getActiveEPerson().pipe( + take(1), + switchMap((eperson: EPerson) => { + const modalRef = this.modalService.open(ConfirmationModalComponent); + modalRef.componentInstance.dso = eperson; + modalRef.componentInstance.headerLabel = 'confirmation-modal.delete-eperson.header'; + modalRef.componentInstance.infoLabel = 'confirmation-modal.delete-eperson.info'; + modalRef.componentInstance.cancelLabel = 'confirmation-modal.delete-eperson.cancel'; + modalRef.componentInstance.confirmLabel = 'confirmation-modal.delete-eperson.confirm'; + modalRef.componentInstance.brandColor = 'danger'; + modalRef.componentInstance.confirmIcon = 'fas fa-trash'; + + return modalRef.componentInstance.response.pipe( + take(1), + switchMap((confirm: boolean) => { + if (confirm && hasValue(eperson.id)) { + this.canDelete$ = observableOf(false); + return this.epersonService.deleteEPerson(eperson).pipe( + getFirstCompletedRemoteData(), + map((restResponse: RemoteData) => ({ restResponse, eperson })) + ); + } else { + return observableOf(null); + } + }), + finalize(() => this.canDelete$ = observableOf(true)) + ); + }) + ).subscribe(({ restResponse, eperson }: { restResponse: RemoteData | null, eperson: EPerson }) => { + if (restResponse?.hasSucceeded) { + this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', { name: this.dsoNameService.getName(eperson) })); + } else { + this.notificationsService.error(`Error occurred when trying to delete EPerson with id: ${eperson?.id} with code: ${restResponse?.statusCode} and message: ${restResponse?.errorMessage}`); + } + this.cancelForm.emit(); }); } @@ -523,7 +534,6 @@ export class EPersonFormComponent implements OnInit, OnDestroy { * Cancel the current edit when component is destroyed & unsub all subscriptions */ ngOnDestroy(): void { - this.onCancel(); this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); this.paginationService.clearPagination(this.config.id); if (hasValue(this.emailValueChangeSubscribe)) { diff --git a/src/app/admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.spec.ts b/src/app/admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.spec.ts index 8d416c2df8..b758767ddb 100644 --- a/src/app/admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.spec.ts +++ b/src/app/admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.spec.ts @@ -1,5 +1,4 @@ import { ComponentFixture, inject, TestBed, waitForAsync } from '@angular/core/testing'; - import { MetadataSchemaFormComponent } from './metadata-schema-form.component'; import { NO_ERRORS_SCHEMA } from '@angular/core'; import { CommonModule } from '@angular/common'; @@ -29,14 +28,16 @@ describe('MetadataSchemaFormComponent', () => { createFormGroup: () => { return { patchValue: () => { - } + }, + reset(_value?: any, _options?: { onlySelf?: boolean; emitEvent?: boolean; }): void { + }, }; } }; /* eslint-enable no-empty, @typescript-eslint/no-empty-function */ beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ + return TestBed.configureTestingModule({ imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule], declarations: [MetadataSchemaFormComponent, EnumKeysPipe], providers: [ @@ -64,7 +65,7 @@ describe('MetadataSchemaFormComponent', () => { const expected = Object.assign(new MetadataSchema(), { namespace: namespace, prefix: prefix - }); + } as MetadataSchema); beforeEach(() => { spyOn(component.submitForm, 'emit'); @@ -79,11 +80,10 @@ describe('MetadataSchemaFormComponent', () => { fixture.detectChanges(); }); - it('should emit a new schema using the correct values', waitForAsync(() => { - fixture.whenStable().then(() => { - expect(component.submitForm.emit).toHaveBeenCalledWith(expected); - }); - })); + it('should emit a new schema using the correct values', async () => { + await fixture.whenStable(); + expect(component.submitForm.emit).toHaveBeenCalledWith(expected); + }); }); describe('with an active schema', () => { @@ -91,7 +91,7 @@ describe('MetadataSchemaFormComponent', () => { id: 1, namespace: namespace, prefix: prefix - }); + } as MetadataSchema); beforeEach(() => { spyOn(registryService, 'getActiveMetadataSchema').and.returnValue(observableOf(expectedWithId)); @@ -99,11 +99,10 @@ describe('MetadataSchemaFormComponent', () => { fixture.detectChanges(); }); - it('should edit the existing schema using the correct values', waitForAsync(() => { - fixture.whenStable().then(() => { - expect(component.submitForm.emit).toHaveBeenCalledWith(expectedWithId); - }); - })); + it('should edit the existing schema using the correct values', async () => { + await fixture.whenStable(); + expect(component.submitForm.emit).toHaveBeenCalledWith(expectedWithId); + }); }); }); }); diff --git a/src/app/admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.ts b/src/app/admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.ts index 6d3138987a..1992289ff7 100644 --- a/src/app/admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.ts +++ b/src/app/admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.ts @@ -77,19 +77,24 @@ export class MetadataSchemaFormComponent implements OnInit, OnDestroy { } ngOnInit() { - combineLatest( + combineLatest([ this.translateService.get(`${this.messagePrefix}.name`), this.translateService.get(`${this.messagePrefix}.namespace`) - ).subscribe(([name, namespace]) => { + ]).subscribe(([name, namespace]) => { this.name = new DynamicInputModel({ id: 'name', label: name, name: 'name', validators: { required: null, - pattern: '^[^ ,_]{1,32}$' + pattern: '^[^. ,]*$', + maxLength: 32, }, required: true, + errorMessages: { + pattern: 'error.validation.metadata.name.invalid-pattern', + maxLength: 'error.validation.metadata.name.max-length', + }, }); this.namespace = new DynamicInputModel({ id: 'namespace', @@ -97,8 +102,12 @@ export class MetadataSchemaFormComponent implements OnInit, OnDestroy { name: 'namespace', validators: { required: null, + maxLength: 256, }, required: true, + errorMessages: { + maxLength: 'error.validation.metadata.namespace.max-length', + }, }); this.formModel = [ new DynamicFormGroupModel( @@ -108,13 +117,18 @@ export class MetadataSchemaFormComponent implements OnInit, OnDestroy { }) ]; this.formGroup = this.formBuilderService.createFormGroup(this.formModel); - this.registryService.getActiveMetadataSchema().subscribe((schema) => { - this.formGroup.patchValue({ - metadatadataschemagroup:{ - name: schema != null ? schema.prefix : '', - namespace: schema != null ? schema.namespace : '' - } - }); + this.registryService.getActiveMetadataSchema().subscribe((schema: MetadataSchema) => { + if (schema == null) { + this.clearFields(); + } else { + this.formGroup.patchValue({ + metadatadataschemagroup: { + name: schema.prefix, + namespace: schema.namespace, + }, + }); + this.name.disabled = true; + } }); }); } @@ -132,10 +146,10 @@ export class MetadataSchemaFormComponent implements OnInit, OnDestroy { * When the schema has no id attached -> Create new schema * Emit the updated/created schema using the EventEmitter submitForm */ - onSubmit() { + onSubmit(): void { this.registryService.clearMetadataSchemaRequests().subscribe(); this.registryService.getActiveMetadataSchema().pipe(take(1)).subscribe( - (schema) => { + (schema: MetadataSchema) => { const values = { prefix: this.name.value, namespace: this.namespace.value @@ -147,9 +161,9 @@ export class MetadataSchemaFormComponent implements OnInit, OnDestroy { } else { this.registryService.createOrUpdateMetadataSchema(Object.assign(new MetadataSchema(), schema, { id: schema.id, - prefix: (values.prefix ? values.prefix : schema.prefix), - namespace: (values.namespace ? values.namespace : schema.namespace) - })).subscribe((updatedSchema) => { + prefix: schema.prefix, + namespace: values.namespace, + })).subscribe((updatedSchema: MetadataSchema) => { this.submitForm.emit(updatedSchema); }); } @@ -162,13 +176,9 @@ export class MetadataSchemaFormComponent implements OnInit, OnDestroy { /** * Reset all input-fields to be empty */ - clearFields() { - this.formGroup.patchValue({ - metadatadataschemagroup:{ - prefix: '', - namespace: '' - } - }); + clearFields(): void { + this.formGroup.reset('metadatadataschemagroup'); + this.name.disabled = false; } /** diff --git a/src/app/admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.spec.ts b/src/app/admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.spec.ts index e13180d633..ad7b54945d 100644 --- a/src/app/admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.spec.ts +++ b/src/app/admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.spec.ts @@ -39,14 +39,16 @@ describe('MetadataFieldFormComponent', () => { createFormGroup: () => { return { patchValue: () => { - } + }, + reset(_value?: any, _options?: { onlySelf?: boolean; emitEvent?: boolean; }): void { + }, }; } }; /* eslint-enable no-empty, @typescript-eslint/no-empty-function */ beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ + return TestBed.configureTestingModule({ imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule], declarations: [MetadataFieldFormComponent, EnumKeysPipe], providers: [ @@ -98,11 +100,10 @@ describe('MetadataFieldFormComponent', () => { fixture.detectChanges(); }); - it('should emit a new field using the correct values', waitForAsync(() => { - fixture.whenStable().then(() => { - expect(component.submitForm.emit).toHaveBeenCalledWith(expected); - }); - })); + it('should emit a new field using the correct values', async () => { + await fixture.whenStable(); + expect(component.submitForm.emit).toHaveBeenCalledWith(expected); + }); }); describe('with an active field', () => { @@ -120,11 +121,10 @@ describe('MetadataFieldFormComponent', () => { fixture.detectChanges(); }); - it('should edit the existing field using the correct values', waitForAsync(() => { - fixture.whenStable().then(() => { - expect(component.submitForm.emit).toHaveBeenCalledWith(expectedWithId); - }); - })); + it('should edit the existing field using the correct values', async () => { + await fixture.whenStable(); + expect(component.submitForm.emit).toHaveBeenCalledWith(expectedWithId); + }); }); }); }); diff --git a/src/app/admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.ts b/src/app/admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.ts index 0beb306d6c..773e0600fb 100644 --- a/src/app/admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.ts +++ b/src/app/admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.ts @@ -98,25 +98,39 @@ export class MetadataFieldFormComponent implements OnInit, OnDestroy { * Initialize the component, setting up the necessary Models for the dynamic form */ ngOnInit() { - combineLatest( + combineLatest([ this.translateService.get(`${this.messagePrefix}.element`), this.translateService.get(`${this.messagePrefix}.qualifier`), this.translateService.get(`${this.messagePrefix}.scopenote`) - ).subscribe(([element, qualifier, scopenote]) => { + ]).subscribe(([element, qualifier, scopenote]) => { this.element = new DynamicInputModel({ id: 'element', label: element, name: 'element', validators: { required: null, + pattern: '^[^. ,]*$', + maxLength: 64, }, required: true, + errorMessages: { + pattern: 'error.validation.metadata.element.invalid-pattern', + maxLength: 'error.validation.metadata.element.max-length', + }, }); this.qualifier = new DynamicInputModel({ id: 'qualifier', label: qualifier, name: 'qualifier', + validators: { + pattern: '^[^. ,]*$', + maxLength: 64, + }, required: false, + errorMessages: { + pattern: 'error.validation.metadata.qualifier.invalid-pattern', + maxLength: 'error.validation.metadata.qualifier.max-length', + }, }); this.scopeNote = new DynamicInputModel({ id: 'scopeNote', @@ -132,14 +146,20 @@ export class MetadataFieldFormComponent implements OnInit, OnDestroy { }) ]; this.formGroup = this.formBuilderService.createFormGroup(this.formModel); - this.registryService.getActiveMetadataField().subscribe((field) => { - this.formGroup.patchValue({ - metadatadatafieldgroup: { - element: field != null ? field.element : '', - qualifier: field != null ? field.qualifier : '', - scopeNote: field != null ? field.scopeNote : '' - } - }); + this.registryService.getActiveMetadataField().subscribe((field: MetadataField): void => { + if (field == null) { + this.clearFields(); + } else { + this.formGroup.patchValue({ + metadatadatafieldgroup: { + element: field.element, + qualifier: field.qualifier, + scopeNote: field.scopeNote, + }, + }); + this.element.disabled = true; + this.qualifier.disabled = true; + } }); }); } @@ -157,25 +177,24 @@ export class MetadataFieldFormComponent implements OnInit, OnDestroy { * When the field has no id attached -> Create new field * Emit the updated/created field using the EventEmitter submitForm */ - onSubmit() { + onSubmit(): void { this.registryService.getActiveMetadataField().pipe(take(1)).subscribe( - (field) => { - const values = { - element: this.element.value, - qualifier: this.qualifier.value, - scopeNote: this.scopeNote.value - }; + (field: MetadataField) => { if (field == null) { - this.registryService.createMetadataField(Object.assign(new MetadataField(), values), this.metadataSchema).subscribe((newField) => { + this.registryService.createMetadataField(Object.assign(new MetadataField(), { + element: this.element.value, + qualifier: this.qualifier.value, + scopeNote: this.scopeNote.value, + }), this.metadataSchema).subscribe((newField: MetadataField) => { this.submitForm.emit(newField); }); } else { this.registryService.updateMetadataField(Object.assign(new MetadataField(), field, { id: field.id, - element: (values.element ? values.element : field.element), - qualifier: (values.qualifier ? values.qualifier : field.qualifier), - scopeNote: (values.scopeNote ? values.scopeNote : field.scopeNote) - })).subscribe((updatedField) => { + element: field.element, + qualifier: field.qualifier, + scopeNote: this.scopeNote.value, + })).subscribe((updatedField: MetadataField) => { this.submitForm.emit(updatedField); }); } @@ -188,14 +207,10 @@ export class MetadataFieldFormComponent implements OnInit, OnDestroy { /** * Reset all input-fields to be empty */ - clearFields() { - this.formGroup.patchValue({ - metadatadatafieldgroup: { - element: '', - qualifier: '', - scopeNote: '' - } - }); + clearFields(): void { + this.formGroup.reset('metadatadatafieldgroup'); + this.element.disabled = false; + this.qualifier.disabled = false; } /** diff --git a/src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.spec.ts b/src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.spec.ts index e84b254eae..59261e56d2 100644 --- a/src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.spec.ts +++ b/src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.spec.ts @@ -11,6 +11,9 @@ import { ActivatedRoute, Router } from '@angular/router'; import { getForbiddenRoute } from '../../app-routing-paths'; import { TranslateModule } from '@ngx-translate/core'; import { CommonModule } from '@angular/common'; +import { SignpostingDataService } from '../../core/data/signposting-data.service'; +import { ServerResponseService } from '../../core/services/server-response.service'; +import { PLATFORM_ID } from '@angular/core'; describe('BitstreamDownloadPageComponent', () => { let component: BitstreamDownloadPageComponent; @@ -24,6 +27,20 @@ describe('BitstreamDownloadPageComponent', () => { let router; let bitstream: Bitstream; + let serverResponseService: jasmine.SpyObj; + let signpostingDataService: jasmine.SpyObj; + + const mocklink = { + href: 'http://test.org', + rel: 'test', + type: 'test' + }; + + const mocklink2 = { + href: 'http://test2.org', + rel: 'test', + type: 'test' + }; function init() { authService = jasmine.createSpyObj('authService', { @@ -44,8 +61,8 @@ describe('BitstreamDownloadPageComponent', () => { bitstream = Object.assign(new Bitstream(), { uuid: 'bitstreamUuid', _links: { - content: {href: 'bitstream-content-link'}, - self: {href: 'bitstream-self-link'}, + content: { href: 'bitstream-content-link' }, + self: { href: 'bitstream-self-link' }, } }); @@ -54,10 +71,21 @@ describe('BitstreamDownloadPageComponent', () => { bitstream: createSuccessfulRemoteDataObject( bitstream ) + }), + params: observableOf({ + id: 'testid' }) }; router = jasmine.createSpyObj('router', ['navigateByUrl']); + + serverResponseService = jasmine.createSpyObj('ServerResponseService', { + setHeader: jasmine.createSpy('setHeader'), + }); + + signpostingDataService = jasmine.createSpyObj('SignpostingDataService', { + getLinks: observableOf([mocklink, mocklink2]) + }); } function initTestbed() { @@ -65,12 +93,15 @@ describe('BitstreamDownloadPageComponent', () => { imports: [CommonModule, TranslateModule.forRoot()], declarations: [BitstreamDownloadPageComponent], providers: [ - {provide: ActivatedRoute, useValue: activatedRoute}, - {provide: Router, useValue: router}, - {provide: AuthorizationDataService, useValue: authorizationService}, - {provide: AuthService, useValue: authService}, - {provide: FileService, useValue: fileService}, - {provide: HardRedirectService, useValue: hardRedirectService}, + { provide: ActivatedRoute, useValue: activatedRoute }, + { provide: Router, useValue: router }, + { provide: AuthorizationDataService, useValue: authorizationService }, + { provide: AuthService, useValue: authService }, + { provide: FileService, useValue: fileService }, + { provide: HardRedirectService, useValue: hardRedirectService }, + { provide: ServerResponseService, useValue: serverResponseService }, + { provide: SignpostingDataService, useValue: signpostingDataService }, + { provide: PLATFORM_ID, useValue: 'server' } ] }) .compileComponents(); @@ -107,6 +138,9 @@ describe('BitstreamDownloadPageComponent', () => { it('should redirect to the content link', () => { expect(hardRedirectService.redirect).toHaveBeenCalledWith('bitstream-content-link'); }); + it('should add the signposting links', () => { + expect(serverResponseService.setHeader).toHaveBeenCalled(); + }); }); describe('when the user is authorized and logged in', () => { beforeEach(waitForAsync(() => { @@ -134,7 +168,7 @@ describe('BitstreamDownloadPageComponent', () => { fixture.detectChanges(); }); it('should navigate to the forbidden route', () => { - expect(router.navigateByUrl).toHaveBeenCalledWith(getForbiddenRoute(), {skipLocationChange: true}); + expect(router.navigateByUrl).toHaveBeenCalledWith(getForbiddenRoute(), { skipLocationChange: true }); }); }); describe('when the user is not authorized and not logged in', () => { diff --git a/src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.ts b/src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.ts index bf1a565daa..cf8d8e7767 100644 --- a/src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.ts +++ b/src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.ts @@ -1,8 +1,8 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, Inject, OnInit, PLATFORM_ID } from '@angular/core'; import { filter, map, switchMap, take } from 'rxjs/operators'; import { ActivatedRoute, Router } from '@angular/router'; import { hasValue, isNotEmpty } from '../../shared/empty.util'; -import { getRemoteDataPayload} from '../../core/shared/operators'; +import { getRemoteDataPayload } from '../../core/shared/operators'; import { Bitstream } from '../../core/shared/bitstream.model'; import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; import { FeatureID } from '../../core/data/feature-authorization/feature-id'; @@ -13,8 +13,11 @@ import { HardRedirectService } from '../../core/services/hard-redirect.service'; import { getForbiddenRoute } from '../../app-routing-paths'; import { RemoteData } from '../../core/data/remote-data'; import { redirectOn4xx } from '../../core/shared/authorized.operators'; -import { Location } from '@angular/common'; +import { isPlatformServer, Location } from '@angular/common'; import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { SignpostingDataService } from '../../core/data/signposting-data.service'; +import { ServerResponseService } from '../../core/services/server-response.service'; +import { SignpostingLink } from '../../core/data/signposting-links.model'; @Component({ selector: 'ds-bitstream-download-page', @@ -28,7 +31,6 @@ export class BitstreamDownloadPageComponent implements OnInit { bitstream$: Observable; bitstreamRD$: Observable>; - constructor( private route: ActivatedRoute, protected router: Router, @@ -38,8 +40,11 @@ export class BitstreamDownloadPageComponent implements OnInit { private hardRedirectService: HardRedirectService, private location: Location, public dsoNameService: DSONameService, + private signpostingDataService: SignpostingDataService, + private responseService: ServerResponseService, + @Inject(PLATFORM_ID) protected platformId: string ) { - + this.initPageLinks(); } back(): void { @@ -89,4 +94,26 @@ export class BitstreamDownloadPageComponent implements OnInit { } }); } + + /** + * Create page links if any are retrieved by signposting endpoint + * + * @private + */ + private initPageLinks(): void { + if (isPlatformServer(this.platformId)) { + this.route.params.subscribe(params => { + this.signpostingDataService.getLinks(params.id).pipe(take(1)).subscribe((signpostingLinks: SignpostingLink[]) => { + let links = ''; + + signpostingLinks.forEach((link: SignpostingLink) => { + links = links + (isNotEmpty(links) ? ', ' : '') + `<${link.href}> ; rel="${link.rel}"` + (isNotEmpty(link.type) ? ` ; type="${link.type}" ` : ' '); + links = links + (isNotEmpty(links) ? ', ' : '') + `<${link.href}> ; rel="${link.rel}" ; type="${link.type}" `; + }); + + this.responseService.setHeader('Link', links); + }); + }); + } + } } diff --git a/src/app/browse-by/browse-by-guard.spec.ts b/src/app/browse-by/browse-by-guard.spec.ts index 671e098762..9ef76b1212 100644 --- a/src/app/browse-by/browse-by-guard.spec.ts +++ b/src/app/browse-by/browse-by-guard.spec.ts @@ -2,8 +2,8 @@ import { first } from 'rxjs/operators'; import { BrowseByGuard } from './browse-by-guard'; import { of as observableOf } from 'rxjs'; import { createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils'; -import { BrowseDefinition } from '../core/shared/browse-definition.model'; import { BrowseByDataType } from './browse-by-switcher/browse-by-decorator'; +import { ValueListBrowseDefinition } from '../core/shared/value-list-browse-definition.model'; import { DSONameServiceMock } from '../shared/mocks/dso-name.service.mock'; import { DSONameService } from '../core/breadcrumbs/dso-name.service'; @@ -20,7 +20,7 @@ describe('BrowseByGuard', () => { const id = 'author'; const scope = '1234-65487-12354-1235'; const value = 'Filter'; - const browseDefinition = Object.assign(new BrowseDefinition(), { type: BrowseByDataType.Metadata, metadataKeys: ['dc.contributor'] }); + const browseDefinition = Object.assign(new ValueListBrowseDefinition(), { type: BrowseByDataType.Metadata, metadataKeys: ['dc.contributor'] }); beforeEach(() => { dsoService = { diff --git a/src/app/browse-by/browse-by-switcher/browse-by-decorator.ts b/src/app/browse-by/browse-by-switcher/browse-by-decorator.ts index ceb4c6a6c6..b59a46cae1 100644 --- a/src/app/browse-by/browse-by-switcher/browse-by-decorator.ts +++ b/src/app/browse-by/browse-by-switcher/browse-by-decorator.ts @@ -26,7 +26,7 @@ const map = new Map(); * @param browseByType The type of page * @param theme The optional theme for the component */ -export function rendersBrowseBy(browseByType: BrowseByDataType, theme = DEFAULT_THEME) { +export function rendersBrowseBy(browseByType: string, theme = DEFAULT_THEME) { return function decorator(component: any) { if (hasNoValue(map.get(browseByType))) { map.set(browseByType, new Map()); diff --git a/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.spec.ts b/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.spec.ts index c2e1c9cb68..c13405dd4d 100644 --- a/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.spec.ts +++ b/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.spec.ts @@ -3,9 +3,11 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { BROWSE_BY_COMPONENT_FACTORY, BrowseByDataType } from './browse-by-decorator'; -import { BrowseDefinition } from '../../core/shared/browse-definition.model'; import { BehaviorSubject } from 'rxjs'; import { ThemeService } from '../../shared/theme-support/theme.service'; +import { FlatBrowseDefinition } from '../../core/shared/flat-browse-definition.model'; +import { ValueListBrowseDefinition } from '../../core/shared/value-list-browse-definition.model'; +import { NonHierarchicalBrowseDefinition } from '../../core/shared/non-hierarchical-browse-definition'; describe('BrowseBySwitcherComponent', () => { let comp: BrowseBySwitcherComponent; @@ -13,33 +15,33 @@ describe('BrowseBySwitcherComponent', () => { const types = [ Object.assign( - new BrowseDefinition(), { + new FlatBrowseDefinition(), { id: 'title', dataType: BrowseByDataType.Title, } ), Object.assign( - new BrowseDefinition(), { + new FlatBrowseDefinition(), { id: 'dateissued', dataType: BrowseByDataType.Date, metadataKeys: ['dc.date.issued'] } ), Object.assign( - new BrowseDefinition(), { + new ValueListBrowseDefinition(), { id: 'author', dataType: BrowseByDataType.Metadata, } ), Object.assign( - new BrowseDefinition(), { + new ValueListBrowseDefinition(), { id: 'subject', dataType: BrowseByDataType.Metadata, } ), ]; - const data = new BehaviorSubject(createDataWithBrowseDefinition(new BrowseDefinition())); + const data = new BehaviorSubject(createDataWithBrowseDefinition(new FlatBrowseDefinition())); const activatedRouteStub = { data @@ -70,7 +72,7 @@ describe('BrowseBySwitcherComponent', () => { comp = fixture.componentInstance; })); - types.forEach((type: BrowseDefinition) => { + types.forEach((type: NonHierarchicalBrowseDefinition) => { describe(`when switching to a browse-by page for "${type.id}"`, () => { beforeEach(() => { data.next(createDataWithBrowseDefinition(type)); diff --git a/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.ts b/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.ts index 0d3a35bebf..35e4edf900 100644 --- a/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.ts +++ b/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.ts @@ -31,7 +31,7 @@ export class BrowseBySwitcherComponent implements OnInit { */ ngOnInit(): void { this.browseByComponent = this.route.data.pipe( - map((data: { browseDefinition: BrowseDefinition }) => this.getComponentByBrowseByType(data.browseDefinition.dataType, this.themeService.getThemeName())) + map((data: { browseDefinition: BrowseDefinition }) => this.getComponentByBrowseByType(data.browseDefinition.getRenderType(), this.themeService.getThemeName())) ); } diff --git a/src/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.html b/src/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.html new file mode 100644 index 0000000000..0ae3da6847 --- /dev/null +++ b/src/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.html @@ -0,0 +1,14 @@ + diff --git a/src/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.scss b/src/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.spec.ts b/src/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.spec.ts new file mode 100644 index 0000000000..c724017b1f --- /dev/null +++ b/src/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.spec.ts @@ -0,0 +1,91 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { BrowseByTaxonomyPageComponent } from './browse-by-taxonomy-page.component'; +import { VocabularyEntryDetail } from '../../core/submission/vocabularies/models/vocabulary-entry-detail.model'; +import { TranslateModule } from '@ngx-translate/core'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { BehaviorSubject } from 'rxjs'; +import { createDataWithBrowseDefinition } from '../browse-by-switcher/browse-by-switcher.component.spec'; +import { HierarchicalBrowseDefinition } from '../../core/shared/hierarchical-browse-definition.model'; +import { ThemeService } from '../../shared/theme-support/theme.service'; + +describe('BrowseByTaxonomyPageComponent', () => { + let component: BrowseByTaxonomyPageComponent; + let fixture: ComponentFixture; + let themeService: ThemeService; + let detail1: VocabularyEntryDetail; + let detail2: VocabularyEntryDetail; + + const data = new BehaviorSubject(createDataWithBrowseDefinition(new HierarchicalBrowseDefinition())); + const activatedRouteStub = { + data + }; + + beforeEach(async () => { + themeService = jasmine.createSpyObj('themeService', { + getThemeName: 'dspace', + }); + + await TestBed.configureTestingModule({ + imports: [ TranslateModule.forRoot() ], + declarations: [ BrowseByTaxonomyPageComponent ], + providers: [ + { provide: ActivatedRoute, useValue: activatedRouteStub }, + { provide: ThemeService, useValue: themeService }, + ], + schemas: [NO_ERRORS_SCHEMA] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(BrowseByTaxonomyPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + detail1 = new VocabularyEntryDetail(); + detail2 = new VocabularyEntryDetail(); + detail1.value = 'HUMANITIES and RELIGION'; + detail2.value = 'TECHNOLOGY'; + detail1.id = 'id-1'; + detail2.id = 'id-2'; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should handle select event', () => { + component.onSelect(detail1); + expect(component.selectedItems.length).toBe(1); + expect(component.selectedItems).toContain(detail1); + expect(component.selectedItems.length).toBe(1); + expect(component.filterValues).toEqual(['HUMANITIES and RELIGION,equals'] ); + }); + + it('should handle select event with multiple selected items', () => { + component.onSelect(detail1); + component.onSelect(detail2); + expect(component.selectedItems.length).toBe(2); + expect(component.selectedItems).toContain(detail1, detail2); + expect(component.selectedItems.length).toBe(2); + expect(component.filterValues).toEqual(['HUMANITIES and RELIGION,equals', 'TECHNOLOGY,equals'] ); + }); + + it('should handle deselect event', () => { + component.onSelect(detail1); + component.onSelect(detail2); + expect(component.selectedItems.length).toBe(2); + expect(component.selectedItems.length).toBe(2); + component.onDeselect(detail1); + expect(component.selectedItems.length).toBe(1); + expect(component.selectedItems).toContain(detail2); + expect(component.selectedItems.length).toBe(1); + expect(component.filterValues).toEqual(['TECHNOLOGY,equals'] ); + }); + + afterEach(() => { + fixture.destroy(); + component = null; + }); +}); diff --git a/src/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.ts b/src/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.ts new file mode 100644 index 0000000000..cf6345bf39 --- /dev/null +++ b/src/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.ts @@ -0,0 +1,118 @@ +import { Component, OnInit, Inject, OnDestroy } from '@angular/core'; +import { VocabularyOptions } from '../../core/submission/vocabularies/models/vocabulary-options.model'; +import { VocabularyEntryDetail } from '../../core/submission/vocabularies/models/vocabulary-entry-detail.model'; +import { ActivatedRoute } from '@angular/router'; +import { Observable, Subscription } from 'rxjs'; +import { BrowseDefinition } from '../../core/shared/browse-definition.model'; +import { GenericConstructor } from '../../core/shared/generic-constructor'; +import { BROWSE_BY_COMPONENT_FACTORY } from '../browse-by-switcher/browse-by-decorator'; +import { map } from 'rxjs/operators'; +import { ThemeService } from 'src/app/shared/theme-support/theme.service'; +import { HierarchicalBrowseDefinition } from '../../core/shared/hierarchical-browse-definition.model'; + +@Component({ + selector: 'ds-browse-by-taxonomy-page', + templateUrl: './browse-by-taxonomy-page.component.html', + styleUrls: ['./browse-by-taxonomy-page.component.scss'] +}) +/** + * Component for browsing items by metadata in a hierarchical controlled vocabulary + */ +export class BrowseByTaxonomyPageComponent implements OnInit, OnDestroy { + + /** + * The {@link VocabularyOptions} object + */ + vocabularyOptions: VocabularyOptions; + + /** + * The selected vocabulary entries + */ + selectedItems: VocabularyEntryDetail[] = []; + + /** + * The query parameters, contain the selected entries + */ + filterValues: string[]; + + /** + * The facet the use when filtering + */ + facetType: string; + + /** + * The used vocabulary + */ + vocabularyName: string; + + /** + * The parameters used in the URL + */ + queryParams: any; + + /** + * Resolved browse-by component + */ + browseByComponent: Observable; + + /** + * Subscriptions to track + */ + browseByComponentSubs: Subscription[] = []; + + public constructor( protected route: ActivatedRoute, + protected themeService: ThemeService, + @Inject(BROWSE_BY_COMPONENT_FACTORY) private getComponentByBrowseByType: (browseByType, theme) => GenericConstructor) { + } + + ngOnInit(): void { + this.browseByComponent = this.route.data.pipe( + map((data: { browseDefinition: BrowseDefinition }) => { + this.getComponentByBrowseByType(data.browseDefinition.getRenderType(), this.themeService.getThemeName()); + return data.browseDefinition; + }) + ); + this.browseByComponentSubs.push(this.browseByComponent.subscribe((browseDefinition: HierarchicalBrowseDefinition) => { + this.facetType = browseDefinition.facetType; + this.vocabularyName = browseDefinition.vocabulary; + this.vocabularyOptions = { name: this.vocabularyName, closed: true }; + })); + } + + /** + * Adds detail to selectedItems, transforms it to be used as query parameter + * and adds that to filterValues. + * + * @param detail VocabularyEntryDetail to be added + */ + onSelect(detail: VocabularyEntryDetail): void { + this.selectedItems.push(detail); + this.filterValues = this.selectedItems + .map((item: VocabularyEntryDetail) => `${item.value},equals`); + this.updateQueryParams(); + } + + /** + * Removes detail from selectedItems and filterValues. + * + * @param detail VocabularyEntryDetail to be removed + */ + onDeselect(detail: VocabularyEntryDetail): void { + this.selectedItems = this.selectedItems.filter((entry: VocabularyEntryDetail) => { return entry.id !== detail.id; }); + this.filterValues = this.filterValues.filter((value: string) => { return value !== `${detail.value},equals`; }); + this.updateQueryParams(); + } + + /** + * Updates queryParams based on the current facetType and filterValues. + */ + private updateQueryParams(): void { + this.queryParams = { + ['f.' + this.facetType]: this.filterValues + }; + } + + ngOnDestroy(): void { + this.browseByComponentSubs.forEach((sub: Subscription) => sub.unsubscribe()); + } +} diff --git a/src/app/browse-by/browse-by-taxonomy-page/themed-browse-by-taxonomy-page.component.ts b/src/app/browse-by/browse-by-taxonomy-page/themed-browse-by-taxonomy-page.component.ts new file mode 100644 index 0000000000..212044b853 --- /dev/null +++ b/src/app/browse-by/browse-by-taxonomy-page/themed-browse-by-taxonomy-page.component.ts @@ -0,0 +1,28 @@ +import { Component } from '@angular/core'; +import { ThemedComponent } from '../../shared/theme-support/themed.component'; +import { rendersBrowseBy } from '../browse-by-switcher/browse-by-decorator'; +import { BrowseByTaxonomyPageComponent } from './browse-by-taxonomy-page.component'; + +@Component({ + selector: 'ds-themed-browse-by-taxonomy-page', + templateUrl: '../../shared/theme-support/themed.component.html', + styleUrls: [] +}) +/** + * Themed wrapper for BrowseByTaxonomyPageComponent + */ +@rendersBrowseBy('hierarchy') +export class ThemedBrowseByTaxonomyPageComponent extends ThemedComponent{ + + protected getComponentName(): string { + return 'BrowseByTaxonomyPageComponent'; + } + + protected importThemedComponent(themeName: string): Promise { + return import(`../../../themes/${themeName}/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component`); + } + + protected importUnthemedComponent(): Promise { + return import(`./browse-by-taxonomy-page.component`); + } +} diff --git a/src/app/browse-by/browse-by.module.ts b/src/app/browse-by/browse-by.module.ts index 6c652376d5..c0e2d3f9ff 100644 --- a/src/app/browse-by/browse-by.module.ts +++ b/src/app/browse-by/browse-by.module.ts @@ -4,24 +4,28 @@ import { BrowseByTitlePageComponent } from './browse-by-title-page/browse-by-tit import { BrowseByMetadataPageComponent } from './browse-by-metadata-page/browse-by-metadata-page.component'; import { BrowseByDatePageComponent } from './browse-by-date-page/browse-by-date-page.component'; import { BrowseBySwitcherComponent } from './browse-by-switcher/browse-by-switcher.component'; +import { BrowseByTaxonomyPageComponent } from './browse-by-taxonomy-page/browse-by-taxonomy-page.component'; import { ThemedBrowseBySwitcherComponent } from './browse-by-switcher/themed-browse-by-switcher.component'; import { ComcolModule } from '../shared/comcol/comcol.module'; import { ThemedBrowseByMetadataPageComponent } from './browse-by-metadata-page/themed-browse-by-metadata-page.component'; import { ThemedBrowseByDatePageComponent } from './browse-by-date-page/themed-browse-by-date-page.component'; import { ThemedBrowseByTitlePageComponent } from './browse-by-title-page/themed-browse-by-title-page.component'; +import { ThemedBrowseByTaxonomyPageComponent } from './browse-by-taxonomy-page/themed-browse-by-taxonomy-page.component'; import { SharedBrowseByModule } from '../shared/browse-by/shared-browse-by.module'; import { DsoPageModule } from '../shared/dso-page/dso-page.module'; +import { FormModule } from '../shared/form/form.module'; const ENTRY_COMPONENTS = [ // put only entry components that use custom decorator BrowseByTitlePageComponent, BrowseByMetadataPageComponent, BrowseByDatePageComponent, + BrowseByTaxonomyPageComponent, ThemedBrowseByMetadataPageComponent, ThemedBrowseByDatePageComponent, ThemedBrowseByTitlePageComponent, - + ThemedBrowseByTaxonomyPageComponent, ]; @NgModule({ @@ -29,7 +33,8 @@ const ENTRY_COMPONENTS = [ SharedBrowseByModule, CommonModule, ComcolModule, - DsoPageModule + DsoPageModule, + FormModule, ], declarations: [ BrowseBySwitcherComponent, diff --git a/src/app/core/browse/browse-definition-data.service.ts b/src/app/core/browse/browse-definition-data.service.ts index 88d070000e..bc495a51f4 100644 --- a/src/app/core/browse/browse-definition-data.service.ts +++ b/src/app/core/browse/browse-definition-data.service.ts @@ -1,20 +1,60 @@ +// eslint-disable-next-line max-classes-per-file import { Injectable } from '@angular/core'; import { BROWSE_DEFINITION } from '../shared/browse-definition.resource-type'; -import { BrowseDefinition } from '../shared/browse-definition.model'; import { RequestService } from '../data/request.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { Observable } from 'rxjs'; +import { Observable, of as observableOf } from 'rxjs'; import { RemoteData } from '../data/remote-data'; import { PaginatedList } from '../data/paginated-list.model'; import { FindListOptions } from '../data/find-list-options.model'; import { IdentifiableDataService } from '../data/base/identifiable-data.service'; import { FindAllData, FindAllDataImpl } from '../data/base/find-all-data'; import { dataService } from '../data/base/data-service.decorator'; +import { isNotEmpty, isNotEmptyOperator, hasValue } from '../../shared/empty.util'; +import { take } from 'rxjs/operators'; +import { BrowseDefinitionRestRequest } from '../data/request.models'; import { RequestParam } from '../cache/models/request-param.model'; import { SearchData, SearchDataImpl } from '../data/base/search-data'; +import { BrowseDefinition } from '../shared/browse-definition.model'; + +/** + * Create a GET request for the given href, and send it. + * Use a GET request specific for BrowseDefinitions. + */ +export const createAndSendBrowseDefinitionGetRequest = (requestService: RequestService, + responseMsToLive: number, + href$: string | Observable, + useCachedVersionIfAvailable: boolean = true): void => { + if (isNotEmpty(href$)) { + if (typeof href$ === 'string') { + href$ = observableOf(href$); + } + + href$.pipe( + isNotEmptyOperator(), + take(1) + ).subscribe((href: string) => { + const requestId = requestService.generateRequestId(); + const request = new BrowseDefinitionRestRequest(requestId, href); + if (hasValue(responseMsToLive)) { + request.responseMsToLive = responseMsToLive; + } + requestService.send(request, useCachedVersionIfAvailable); + }); + } +}; + +/** + * Custom extension of {@link FindAllDataImpl} to be able to send BrowseDefinitionRestRequests + */ +class BrowseDefinitionFindAllDataImpl extends FindAllDataImpl { + createAndSendGetRequest(href$: string | Observable, useCachedVersionIfAvailable: boolean = true) { + createAndSendBrowseDefinitionGetRequest(this.requestService, this.responseMsToLive, href$, useCachedVersionIfAvailable); + } +} /** * Data service responsible for retrieving browse definitions from the REST server @@ -24,7 +64,7 @@ import { SearchData, SearchDataImpl } from '../data/base/search-data'; }) @dataService(BROWSE_DEFINITION) export class BrowseDefinitionDataService extends IdentifiableDataService implements FindAllData, SearchData { - private findAllData: FindAllDataImpl; + private findAllData: BrowseDefinitionFindAllDataImpl; private searchData: SearchDataImpl; constructor( @@ -35,7 +75,7 @@ export class BrowseDefinitionDataService extends IdentifiableDataService, useCachedVersionIfAvailable: boolean = true) { + createAndSendBrowseDefinitionGetRequest(this.requestService, this.responseMsToLive, href$, useCachedVersionIfAvailable); + } } diff --git a/src/app/core/browse/browse.service.spec.ts b/src/app/core/browse/browse.service.spec.ts index 46ac8c44f4..9f166e3d19 100644 --- a/src/app/core/browse/browse.service.spec.ts +++ b/src/app/core/browse/browse.service.spec.ts @@ -6,13 +6,15 @@ import { getMockRequestService } from '../../shared/mocks/request.service.mock'; import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RequestService } from '../data/request.service'; -import { BrowseDefinition } from '../shared/browse-definition.model'; import { BrowseEntrySearchOptions } from './browse-entry-search-options.model'; import { BrowseService } from './browse.service'; import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { createPaginatedList, getFirstUsedArgumentOfSpyMethod } from '../../shared/testing/utils.test'; import { getMockHrefOnlyDataService } from '../../shared/mocks/href-only-data.service.mock'; import { RequestEntry } from '../data/request-entry.model'; +import { FlatBrowseDefinition } from '../shared/flat-browse-definition.model'; +import { ValueListBrowseDefinition } from '../shared/value-list-browse-definition.model'; +import { HierarchicalBrowseDefinition } from '../shared/hierarchical-browse-definition.model'; describe('BrowseService', () => { let scheduler: TestScheduler; @@ -23,9 +25,9 @@ describe('BrowseService', () => { const browsesEndpointURL = 'https://rest.api/browses'; const halService: any = new HALEndpointServiceStub(browsesEndpointURL); const browseDefinitions = [ - Object.assign(new BrowseDefinition(), { + Object.assign(new FlatBrowseDefinition(), { id: 'date', - metadataBrowse: false, + browseType: 'flatBrowse', sortOptions: [ { name: 'title', @@ -50,9 +52,9 @@ describe('BrowseService', () => { items: { href: 'https://rest.api/discover/browses/dateissued/items' } } }), - Object.assign(new BrowseDefinition(), { + Object.assign(new ValueListBrowseDefinition(), { id: 'author', - metadataBrowse: true, + browseType: 'valueList', sortOptions: [ { name: 'title', @@ -78,7 +80,23 @@ describe('BrowseService', () => { entries: { href: 'https://rest.api/discover/browses/author/entries' }, items: { href: 'https://rest.api/discover/browses/author/items' } } - }) + }), + Object.assign(new HierarchicalBrowseDefinition(), { + id: 'srsc', + browseType: 'hierarchicalBrowse', + facetType: 'subject', + vocabulary: 'srsc', + type: 'browse', + metadata: [ + 'dc.subject' + ], + _links: { + vocabulary: { 'href': 'https://rest.api/submission/vocabularies/srsc/' }, + items: { 'href': 'https://rest.api/discover/browses/srsc/items' }, + entries: { 'href': 'https://rest.api/discover/browses/srsc/entries' }, + self: { 'href': 'https://rest.api/discover/browses/srsc' } + } + }), ]; let browseDefinitionDataService; @@ -140,7 +158,7 @@ describe('BrowseService', () => { describe('when getBrowseEntriesFor is called with a valid browse definition id', () => { it('should call hrefOnlyDataService.findListByHref with the expected href', () => { - const expected = browseDefinitions[1]._links.entries.href; + const expected = (browseDefinitions[1] as ValueListBrowseDefinition)._links.entries.href; scheduler.schedule(() => service.getBrowseEntriesFor(new BrowseEntrySearchOptions(browseDefinitions[1].id)).subscribe()); scheduler.flush(); diff --git a/src/app/core/browse/browse.service.ts b/src/app/core/browse/browse.service.ts index 989213a978..b210b34949 100644 --- a/src/app/core/browse/browse.service.ts +++ b/src/app/core/browse/browse.service.ts @@ -7,6 +7,7 @@ import { PaginatedList } from '../data/paginated-list.model'; import { RemoteData } from '../data/remote-data'; import { RequestService } from '../data/request.service'; import { BrowseDefinition } from '../shared/browse-definition.model'; +import { FlatBrowseDefinition } from '../shared/flat-browse-definition.model'; import { BrowseEntry } from '../shared/browse-entry.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { Item } from '../shared/item.model'; @@ -240,7 +241,12 @@ export class BrowseService { getPaginatedListPayload(), map((browseDefinitions: BrowseDefinition[]) => browseDefinitions .find((def: BrowseDefinition) => { - const matchingKeys = def.metadataKeys.find((key: string) => searchKeyArray.indexOf(key) >= 0); + let matchingKeys = ''; + + if (Array.isArray((def as FlatBrowseDefinition).metadataKeys)) { + matchingKeys = (def as FlatBrowseDefinition).metadataKeys.find((key: string) => searchKeyArray.indexOf(key) >= 0); + } + return isNotEmpty(matchingKeys); }) ), diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index d35900ebe2..8cd24b0140 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -177,6 +177,10 @@ import { IdentifierData } from '../shared/object-list/identifier-data/identifier import { Subscription } from '../shared/subscriptions/models/subscription.model'; import { SupervisionOrderDataService } from './supervision-order/supervision-order-data.service'; import { ItemRequest } from './shared/item-request.model'; +import { HierarchicalBrowseDefinition } from './shared/hierarchical-browse-definition.model'; +import { FlatBrowseDefinition } from './shared/flat-browse-definition.model'; +import { ValueListBrowseDefinition } from './shared/value-list-browse-definition.model'; +import { NonHierarchicalBrowseDefinition } from './shared/non-hierarchical-browse-definition'; /** * When not in production, endpoint responses can be mocked for testing purposes @@ -333,6 +337,10 @@ export const models = AuthStatus, BrowseEntry, BrowseDefinition, + NonHierarchicalBrowseDefinition, + FlatBrowseDefinition, + ValueListBrowseDefinition, + HierarchicalBrowseDefinition, ClaimedTask, TaskObject, PoolTask, diff --git a/src/app/core/data/bitstream-data.service.spec.ts b/src/app/core/data/bitstream-data.service.spec.ts index c67eaa2d68..89178f8dd2 100644 --- a/src/app/core/data/bitstream-data.service.spec.ts +++ b/src/app/core/data/bitstream-data.service.spec.ts @@ -1,13 +1,14 @@ +import { TestBed } from '@angular/core/testing'; import { BitstreamDataService } from './bitstream-data.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { RequestService } from './request.service'; import { Bitstream } from '../shared/bitstream.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { BitstreamFormatDataService } from './bitstream-format-data.service'; -import { of as observableOf } from 'rxjs'; +import { Observable, of as observableOf } from 'rxjs'; import { BitstreamFormat } from '../shared/bitstream-format.model'; import { BitstreamFormatSupportLevel } from '../shared/bitstream-format-support-level'; -import { PutRequest } from './request.models'; +import { PatchRequest, PutRequest } from './request.models'; import { getMockRequestService } from '../../shared/mocks/request.service.mock'; import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; @@ -15,6 +16,11 @@ import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-bu import { testSearchDataImplementation } from './base/search-data.spec'; import { testPatchDataImplementation } from './base/patch-data.spec'; import { testDeleteDataImplementation } from './base/delete-data.spec'; +import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import objectContaining = jasmine.objectContaining; +import { RemoteData } from './remote-data'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; describe('BitstreamDataService', () => { let service: BitstreamDataService; @@ -25,10 +31,18 @@ describe('BitstreamDataService', () => { let rdbService: RemoteDataBuildService; const bitstreamFormatHref = 'rest-api/bitstreamformats'; - const bitstream = Object.assign(new Bitstream(), { - uuid: 'fake-bitstream', + const bitstream1 = Object.assign(new Bitstream(), { + id: 'fake-bitstream1', + uuid: 'fake-bitstream1', _links: { - self: { href: 'fake-bitstream-self' } + self: { href: 'fake-bitstream1-self' } + } + }); + const bitstream2 = Object.assign(new Bitstream(), { + id: 'fake-bitstream2', + uuid: 'fake-bitstream2', + _links: { + self: { href: 'fake-bitstream2-self' } } }); const format = Object.assign(new BitstreamFormat(), { @@ -50,7 +64,18 @@ describe('BitstreamDataService', () => { }); rdbService = getMockRemoteDataBuildService(); - service = new BitstreamDataService(requestService, rdbService, objectCache, halService, null, bitstreamFormatService, null, null); + TestBed.configureTestingModule({ + providers: [ + { provide: ObjectCacheService, useValue: objectCache }, + { provide: RequestService, useValue: requestService }, + { provide: HALEndpointService, useValue: halService }, + { provide: BitstreamFormatDataService, useValue: bitstreamFormatService }, + { provide: RemoteDataBuildService, useValue: rdbService }, + { provide: DSOChangeAnalyzer, useValue: {} }, + { provide: NotificationsService, useValue: {} }, + ], + }); + service = TestBed.inject(BitstreamDataService); }); describe('composition', () => { @@ -62,11 +87,49 @@ describe('BitstreamDataService', () => { describe('when updating the bitstream\'s format', () => { beforeEach(() => { - service.updateFormat(bitstream, format); + service.updateFormat(bitstream1, format); }); it('should send a put request', () => { expect(requestService.send).toHaveBeenCalledWith(jasmine.any(PutRequest)); }); }); + + describe('removeMultiple', () => { + function mockBuildFromRequestUUIDAndAwait(requestUUID$: string | Observable, callback: (rd?: RemoteData) => Observable, ..._linksToFollow: FollowLinkConfig[]): Observable> { + callback(); + return; + } + + beforeEach(() => { + spyOn(service, 'invalidateByHref'); + spyOn(rdbService, 'buildFromRequestUUIDAndAwait').and.callFake((requestUUID$: string | Observable, callback: (rd?: RemoteData) => Observable, ...linksToFollow: FollowLinkConfig[]) => mockBuildFromRequestUUIDAndAwait(requestUUID$, callback, ...linksToFollow)); + }); + + it('should be able to 1 bitstream', () => { + service.removeMultiple([bitstream1]); + + expect(requestService.send).toHaveBeenCalledWith(objectContaining({ + href: `${url}/bitstreams`, + body: [ + { op: 'remove', path: '/bitstreams/fake-bitstream1' }, + ], + } as PatchRequest)); + expect(service.invalidateByHref).toHaveBeenCalledWith('fake-bitstream1-self'); + }); + + it('should be able to delete multiple bitstreams', () => { + service.removeMultiple([bitstream1, bitstream2]); + + expect(requestService.send).toHaveBeenCalledWith(objectContaining({ + href: `${url}/bitstreams`, + body: [ + { op: 'remove', path: '/bitstreams/fake-bitstream1' }, + { op: 'remove', path: '/bitstreams/fake-bitstream2' }, + ], + } as PatchRequest)); + expect(service.invalidateByHref).toHaveBeenCalledWith('fake-bitstream1-self'); + expect(service.invalidateByHref).toHaveBeenCalledWith('fake-bitstream2-self'); + }); + }); }); diff --git a/src/app/core/data/bitstream-data.service.ts b/src/app/core/data/bitstream-data.service.ts index 6bdcefe187..bb4ec28166 100644 --- a/src/app/core/data/bitstream-data.service.ts +++ b/src/app/core/data/bitstream-data.service.ts @@ -1,7 +1,7 @@ import { HttpHeaders } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; -import { map, switchMap, take } from 'rxjs/operators'; +import { find, map, switchMap, take } from 'rxjs/operators'; import { hasValue } from '../../shared/empty.util'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; @@ -14,7 +14,7 @@ import { Item } from '../shared/item.model'; import { BundleDataService } from './bundle-data.service'; import { buildPaginatedList, PaginatedList } from './paginated-list.model'; import { RemoteData } from './remote-data'; -import { PutRequest } from './request.models'; +import { PatchRequest, PutRequest } from './request.models'; import { RequestService } from './request.service'; import { BitstreamFormatDataService } from './bitstream-format-data.service'; import { BitstreamFormat } from '../shared/bitstream-format.model'; @@ -33,7 +33,7 @@ import { NotificationsService } from '../../shared/notifications/notifications.s import { NoContent } from '../shared/NoContent.model'; import { IdentifiableDataService } from './base/identifiable-data.service'; import { dataService } from './base/data-service.decorator'; -import { Operation } from 'fast-json-patch'; +import { Operation, RemoveOperation } from 'fast-json-patch'; /** * A service to retrieve {@link Bitstream}s from the REST API @@ -277,4 +277,34 @@ export class BitstreamDataService extends IdentifiableDataService imp deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable> { return this.deleteData.deleteByHref(href, copyVirtualMetadata); } + + /** + * Delete multiple {@link Bitstream}s at once by sending a PATCH request to the backend + * + * @param bitstreams The bitstreams that should be removed + */ + removeMultiple(bitstreams: Bitstream[]): Observable> { + const operations: RemoveOperation[] = bitstreams.map((bitstream: Bitstream) => { + return { + op: 'remove', + path: `/bitstreams/${bitstream.id}`, + }; + }); + const requestId: string = this.requestService.generateRequestId(); + + const hrefObs: Observable = this.halService.getEndpoint(this.linkPath); + + hrefObs.pipe( + find((href: string) => hasValue(href)), + ).subscribe((href: string) => { + const request = new PatchRequest(requestId, href, operations); + if (hasValue(this.responseMsToLive)) { + request.responseMsToLive = this.responseMsToLive; + } + this.requestService.send(request); + }); + + return this.rdbService.buildFromRequestUUIDAndAwait(requestId, () => observableCombineLatest(bitstreams.map((bitstream: Bitstream) => this.invalidateByHref(bitstream._links.self.href)))); + } + } diff --git a/src/app/core/data/browse-response-parsing.service.spec.ts b/src/app/core/data/browse-response-parsing.service.spec.ts new file mode 100644 index 0000000000..9fa7239ef7 --- /dev/null +++ b/src/app/core/data/browse-response-parsing.service.spec.ts @@ -0,0 +1,64 @@ +import { getMockObjectCacheService } from '../../shared/mocks/object-cache.service.mock'; +import { BrowseResponseParsingService } from './browse-response-parsing.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { HIERARCHICAL_BROWSE_DEFINITION } from '../shared/hierarchical-browse-definition.resource-type'; +import { FLAT_BROWSE_DEFINITION } from '../shared/flat-browse-definition.resource-type'; +import { VALUE_LIST_BROWSE_DEFINITION } from '../shared/value-list-browse-definition.resource-type'; + +class TestService extends BrowseResponseParsingService { + constructor(protected objectCache: ObjectCacheService) { + super(objectCache); + } + + // Overwrite method to make it public for testing + public deserialize(obj): any { + return super.deserialize(obj); + } +} + +describe('BrowseResponseParsingService', () => { + let service: TestService; + + + beforeEach(() => { + service = new TestService(getMockObjectCacheService()); + }); + + describe('', () => { + const mockFlatBrowse = { + id: 'title', + browseType: 'flatBrowse', + type: 'browse', + }; + + const mockValueList = { + id: 'author', + browseType: 'valueList', + type: 'browse', + }; + + const mockHierarchicalBrowse = { + id: 'srsc', + browseType: 'hierarchicalBrowse', + type: 'browse', + }; + + it('should deserialize flatBrowses correctly', () => { + let deserialized = service.deserialize(mockFlatBrowse); + expect(deserialized.type).toBe(FLAT_BROWSE_DEFINITION); + expect(deserialized.id).toBe(mockFlatBrowse.id); + }); + + it('should deserialize valueList browses correctly', () => { + let deserialized = service.deserialize(mockValueList); + expect(deserialized.type).toBe(VALUE_LIST_BROWSE_DEFINITION); + expect(deserialized.id).toBe(mockValueList.id); + }); + + it('should deserialize hierarchicalBrowses correctly', () => { + let deserialized = service.deserialize(mockHierarchicalBrowse); + expect(deserialized.type).toBe(HIERARCHICAL_BROWSE_DEFINITION); + expect(deserialized.id).toBe(mockHierarchicalBrowse.id); + }); + }); +}); diff --git a/src/app/core/data/browse-response-parsing.service.ts b/src/app/core/data/browse-response-parsing.service.ts new file mode 100644 index 0000000000..a568cdb617 --- /dev/null +++ b/src/app/core/data/browse-response-parsing.service.ts @@ -0,0 +1,48 @@ +import { Injectable } from '@angular/core'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { hasValue } from '../../shared/empty.util'; +import { + HIERARCHICAL_BROWSE_DEFINITION +} from '../shared/hierarchical-browse-definition.resource-type'; +import { FLAT_BROWSE_DEFINITION } from '../shared/flat-browse-definition.resource-type'; +import { HierarchicalBrowseDefinition } from '../shared/hierarchical-browse-definition.model'; +import { FlatBrowseDefinition } from '../shared/flat-browse-definition.model'; +import { DspaceRestResponseParsingService } from './dspace-rest-response-parsing.service'; +import { Serializer } from '../serializer'; +import { BrowseDefinition } from '../shared/browse-definition.model'; +import { BROWSE_DEFINITION } from '../shared/browse-definition.resource-type'; +import { ValueListBrowseDefinition } from '../shared/value-list-browse-definition.model'; +import { VALUE_LIST_BROWSE_DEFINITION } from '../shared/value-list-browse-definition.resource-type'; + +/** + * A ResponseParsingService used to parse a REST API response to a BrowseDefinition object + */ +@Injectable({ + providedIn: 'root', +}) +export class BrowseResponseParsingService extends DspaceRestResponseParsingService { + constructor( + protected objectCache: ObjectCacheService, + ) { + super(objectCache); + } + + protected deserialize(obj): any { + const browseType: string = obj.browseType; + if (obj.type === BROWSE_DEFINITION.value && hasValue(browseType)) { + let serializer: Serializer; + if (browseType === HIERARCHICAL_BROWSE_DEFINITION.value) { + serializer = new this.serializerConstructor(HierarchicalBrowseDefinition); + } else if (browseType === FLAT_BROWSE_DEFINITION.value) { + serializer = new this.serializerConstructor(FlatBrowseDefinition); + } else if (browseType === VALUE_LIST_BROWSE_DEFINITION.value) { + serializer = new this.serializerConstructor(ValueListBrowseDefinition); + } else { + throw new Error('An error occurred while retrieving the browse definitions.'); + } + return serializer.deserialize(obj); + } else { + throw new Error('An error occurred while retrieving the browse definitions.'); + } + } +} diff --git a/src/app/core/data/dso-response-parsing.service.ts b/src/app/core/data/dso-response-parsing.service.ts index fd5a22fae9..74117e79d3 100644 --- a/src/app/core/data/dso-response-parsing.service.ts +++ b/src/app/core/data/dso-response-parsing.service.ts @@ -10,6 +10,10 @@ import { hasNoValue, hasValue } from '../../shared/empty.util'; import { DSpaceObject } from '../shared/dspace-object.model'; import { RestRequest } from './rest-request.model'; +/** + * @deprecated use DspaceRestResponseParsingService for new code, this is only left to support a + * few legacy use cases, and should get removed eventually + */ @Injectable() export class DSOResponseParsingService extends BaseResponseParsingService implements ResponseParsingService { protected toCache = true; diff --git a/src/app/core/data/request.models.ts b/src/app/core/data/request.models.ts index 6ab3f180d3..9809bc0fde 100644 --- a/src/app/core/data/request.models.ts +++ b/src/app/core/data/request.models.ts @@ -11,6 +11,7 @@ import { TaskResponseParsingService } from '../tasks/task-response-parsing.servi import { ContentSourceResponseParsingService } from './content-source-response-parsing.service'; import { RestRequestWithResponseParser } from './rest-request-with-response-parser.model'; import { DspaceRestResponseParsingService } from './dspace-rest-response-parsing.service'; +import { BrowseResponseParsingService } from './browse-response-parsing.service'; import { FindListOptions } from './find-list-options.model'; @@ -118,6 +119,15 @@ export class PatchRequest extends DSpaceRestRequest { } } +/** + * Class representing a BrowseDefinition HTTP Rest request object + */ +export class BrowseDefinitionRestRequest extends DSpaceRestRequest { + getResponseParser(): GenericConstructor { + return BrowseResponseParsingService; + } +} + export class FindListRequest extends GetRequest { constructor( uuid: string, diff --git a/src/app/core/data/request.service.ts b/src/app/core/data/request.service.ts index 94a6020975..1f6680203e 100644 --- a/src/app/core/data/request.service.ts +++ b/src/app/core/data/request.service.ts @@ -331,7 +331,7 @@ export class RequestService { map((request: RequestEntry) => isStale(request.state)), filter((stale: boolean) => stale), take(1), - ); + ); } /** diff --git a/src/app/core/data/signposting-data.service.spec.ts b/src/app/core/data/signposting-data.service.spec.ts new file mode 100644 index 0000000000..c76899221e --- /dev/null +++ b/src/app/core/data/signposting-data.service.spec.ts @@ -0,0 +1,97 @@ +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { SignpostingDataService } from './signposting-data.service'; +import { DspaceRestService } from '../dspace-rest/dspace-rest.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { of } from 'rxjs'; +import { SignpostingLink } from './signposting-links.model'; + +describe('SignpostingDataService', () => { + let service: SignpostingDataService; + let restServiceSpy: jasmine.SpyObj; + let halServiceSpy: jasmine.SpyObj; + const mocklink = { + href: 'http://test.org', + rel: 'test', + type: 'test' + }; + + const mocklink2 = { + href: 'http://test2.org', + rel: 'test', + type: 'test' + }; + + const mockResponse: any = { + statusCode: 200, + payload: [mocklink, mocklink2] + }; + + const mockErrResponse: any = { + statusCode: 500 + }; + + beforeEach(() => { + const restSpy = jasmine.createSpyObj('DspaceRestService', ['get', 'getWithHeaders']); + const halSpy = jasmine.createSpyObj('HALEndpointService', ['getRootHref']); + + TestBed.configureTestingModule({ + providers: [ + SignpostingDataService, + { provide: DspaceRestService, useValue: restSpy }, + { provide: HALEndpointService, useValue: halSpy } + ] + }); + + service = TestBed.inject(SignpostingDataService); + restServiceSpy = TestBed.inject(DspaceRestService) as jasmine.SpyObj; + halServiceSpy = TestBed.inject(HALEndpointService) as jasmine.SpyObj; + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should return signposting links', fakeAsync(() => { + const uuid = '123'; + const baseUrl = 'http://localhost:8080'; + + halServiceSpy.getRootHref.and.returnValue(`${baseUrl}/api`); + + restServiceSpy.get.and.returnValue(of(mockResponse)); + + let result: SignpostingLink[]; + + const expectedResult: SignpostingLink[] = [mocklink, mocklink2]; + + service.getLinks(uuid).subscribe((links) => { + result = links; + }); + + tick(); + + expect(result).toEqual(expectedResult); + expect(halServiceSpy.getRootHref).toHaveBeenCalled(); + expect(restServiceSpy.get).toHaveBeenCalledWith(`${baseUrl}/signposting/links/${uuid}`); + })); + + it('should handle error and return an empty array', fakeAsync(() => { + const uuid = '123'; + const baseUrl = 'http://localhost:8080'; + + halServiceSpy.getRootHref.and.returnValue(`${baseUrl}/api`); + + restServiceSpy.get.and.returnValue(of(mockErrResponse)); + + let result: any; + + service.getLinks(uuid).subscribe((data) => { + result = data; + }); + + tick(); + + expect(result).toEqual([]); + expect(halServiceSpy.getRootHref).toHaveBeenCalled(); + expect(restServiceSpy.get).toHaveBeenCalledWith(`${baseUrl}/signposting/links/${uuid}`); + })); +}); diff --git a/src/app/core/data/signposting-data.service.ts b/src/app/core/data/signposting-data.service.ts new file mode 100644 index 0000000000..d051ecf8db --- /dev/null +++ b/src/app/core/data/signposting-data.service.ts @@ -0,0 +1,38 @@ +import { Injectable } from '@angular/core'; + +import { catchError, map } from 'rxjs/operators'; +import { Observable, of as observableOf } from 'rxjs'; + +import { DspaceRestService } from '../dspace-rest/dspace-rest.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; +import { SignpostingLink } from './signposting-links.model'; + +/** + * Service responsible for handling requests related to the Signposting endpoint + */ +@Injectable({ + providedIn: 'root' +}) +export class SignpostingDataService { + + constructor(private restService: DspaceRestService, protected halService: HALEndpointService) { + } + + /** + * Retrieve the list of signposting links related to the given resource's id + * + * @param uuid + */ + getLinks(uuid: string): Observable { + const baseUrl = this.halService.getRootHref().replace('/api', ''); + + return this.restService.get(`${baseUrl}/signposting/links/${uuid}`).pipe( + catchError((err) => { + return observableOf([]); + }), + map((res: RawRestResponse) => res.statusCode === 200 ? res.payload as SignpostingLink[] : []) + ); + } + +} diff --git a/src/app/core/data/signposting-links.model.ts b/src/app/core/data/signposting-links.model.ts new file mode 100644 index 0000000000..11d2cafe00 --- /dev/null +++ b/src/app/core/data/signposting-links.model.ts @@ -0,0 +1,8 @@ +/** + * Represents the link object received by the signposting endpoint + */ +export interface SignpostingLink { + href?: string, + rel?: string, + type?: string +} diff --git a/src/app/core/services/server-hard-redirect.service.ts b/src/app/core/services/server-hard-redirect.service.ts index 94b9ed6198..de8b45b0e5 100644 --- a/src/app/core/services/server-hard-redirect.service.ts +++ b/src/app/core/services/server-hard-redirect.service.ts @@ -46,6 +46,7 @@ export class ServerHardRedirectService extends HardRedirectService { } console.log(`Redirecting from ${this.req.url} to ${url} with ${status}`); + this.res.redirect(status, url); this.res.end(); // I haven't found a way to correctly stop Angular rendering. diff --git a/src/app/core/services/server-response.service.ts b/src/app/core/services/server-response.service.ts index 02e00446bc..0b193d536c 100644 --- a/src/app/core/services/server-response.service.ts +++ b/src/app/core/services/server-response.service.ts @@ -1,7 +1,11 @@ import { RESPONSE } from '@nguniversal/express-engine/tokens'; import { Inject, Injectable, Optional } from '@angular/core'; + import { Response } from 'express'; +/** + * Service responsible to provide method to manage the response object + */ @Injectable() export class ServerResponseService { private response: Response; @@ -10,6 +14,12 @@ export class ServerResponseService { this.response = response; } + /** + * Set a status code to response + * + * @param code + * @param message + */ setStatus(code: number, message?: string): this { if (this.response) { this.response.statusCode = code; @@ -20,19 +30,51 @@ export class ServerResponseService { return this; } + /** + * Set Unauthorized status + * + * @param message + */ setUnauthorized(message = 'Unauthorized'): this { return this.setStatus(401, message); } + /** + * Set Forbidden status + * + * @param message + */ setForbidden(message = 'Forbidden'): this { return this.setStatus(403, message); } + /** + * Set Not found status + * + * @param message + */ setNotFound(message = 'Not found'): this { return this.setStatus(404, message); } + /** + * Set Internal Server Error status + * + * @param message + */ setInternalServerError(message = 'Internal Server Error'): this { return this.setStatus(500, message); } + + /** + * Set a response's header + * + * @param header + * @param content + */ + setHeader(header: string, content: string) { + if (this.response) { + this.response.setHeader(header, content); + } + } } diff --git a/src/app/core/shared/browse-definition.model.ts b/src/app/core/shared/browse-definition.model.ts index 863f454422..a5bed53c9f 100644 --- a/src/app/core/shared/browse-definition.model.ts +++ b/src/app/core/shared/browse-definition.model.ts @@ -1,50 +1,16 @@ -import { autoserialize, autoserializeAs, deserialize } from 'cerialize'; -import { typedObject } from '../cache/builders/build-decorators'; -import { excludeFromEquals } from '../utilities/equals.decorators'; -import { BROWSE_DEFINITION } from './browse-definition.resource-type'; -import { HALLink } from './hal-link.model'; -import { ResourceType } from './resource-type'; -import { SortOption } from './sort-option.model'; +import { autoserialize } from 'cerialize'; import { CacheableObject } from '../cache/cacheable-object.model'; -import { BrowseByDataType } from '../../browse-by/browse-by-switcher/browse-by-decorator'; -@typedObject -export class BrowseDefinition extends CacheableObject { - static type = BROWSE_DEFINITION; - - /** - * The object type - */ - @excludeFromEquals - @autoserialize - type: ResourceType; +/** + * Base class for BrowseDefinition models + */ +export abstract class BrowseDefinition extends CacheableObject { @autoserialize id: string; - @autoserialize - metadataBrowse: boolean; - - @autoserialize - sortOptions: SortOption[]; - - @autoserializeAs('order') - defaultSortOrder: string; - - @autoserializeAs('metadata') - metadataKeys: string[]; - - @autoserialize - dataType: BrowseByDataType; - - get self(): string { - return this._links.self.href; - } - - @deserialize - _links: { - self: HALLink; - entries: HALLink; - items: HALLink; - }; + /** + * Get the render type of the BrowseDefinition model + */ + abstract getRenderType(): string; } diff --git a/src/app/core/shared/flat-browse-definition.model.ts b/src/app/core/shared/flat-browse-definition.model.ts new file mode 100644 index 0000000000..086fca891b --- /dev/null +++ b/src/app/core/shared/flat-browse-definition.model.ts @@ -0,0 +1,36 @@ +import { inheritSerialization, deserialize } from 'cerialize'; +import { typedObject } from '../cache/builders/build-decorators'; +import { excludeFromEquals } from '../utilities/equals.decorators'; +import { FLAT_BROWSE_DEFINITION } from './flat-browse-definition.resource-type'; +import { ResourceType } from './resource-type'; +import { NonHierarchicalBrowseDefinition } from './non-hierarchical-browse-definition'; +import { HALLink } from './hal-link.model'; + +/** + * BrowseDefinition model for browses of type 'flatBrowse' + */ +@typedObject +@inheritSerialization(NonHierarchicalBrowseDefinition) +export class FlatBrowseDefinition extends NonHierarchicalBrowseDefinition { + static type = FLAT_BROWSE_DEFINITION; + + /** + * The object type + */ + @excludeFromEquals + type: ResourceType = FLAT_BROWSE_DEFINITION; + + get self(): string { + return this._links.self.href; + } + + @deserialize + _links: { + self: HALLink; + items: HALLink; + }; + + getRenderType(): string { + return this.dataType; + } +} diff --git a/src/app/core/shared/flat-browse-definition.resource-type.ts b/src/app/core/shared/flat-browse-definition.resource-type.ts new file mode 100644 index 0000000000..bfb01cd98c --- /dev/null +++ b/src/app/core/shared/flat-browse-definition.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from './resource-type'; + +/** + * The resource type for FlatBrowseDefinition + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const FLAT_BROWSE_DEFINITION = new ResourceType('flatBrowse'); diff --git a/src/app/core/shared/hierarchical-browse-definition.model.ts b/src/app/core/shared/hierarchical-browse-definition.model.ts new file mode 100644 index 0000000000..d561fff643 --- /dev/null +++ b/src/app/core/shared/hierarchical-browse-definition.model.ts @@ -0,0 +1,45 @@ +import { autoserialize, autoserializeAs, deserialize, inheritSerialization } from 'cerialize'; +import { typedObject } from '../cache/builders/build-decorators'; +import { excludeFromEquals } from '../utilities/equals.decorators'; +import { HIERARCHICAL_BROWSE_DEFINITION } from './hierarchical-browse-definition.resource-type'; +import { HALLink } from './hal-link.model'; +import { ResourceType } from './resource-type'; +import { BrowseDefinition } from './browse-definition.model'; + +/** + * BrowseDefinition model for browses of type 'hierarchicalBrowse' + */ +@typedObject +@inheritSerialization(BrowseDefinition) +export class HierarchicalBrowseDefinition extends BrowseDefinition { + static type = HIERARCHICAL_BROWSE_DEFINITION; + + /** + * The object type + */ + @excludeFromEquals + type: ResourceType = HIERARCHICAL_BROWSE_DEFINITION; + + @autoserialize + facetType: string; + + @autoserialize + vocabulary: string; + + @autoserializeAs('metadata') + metadataKeys: string[]; + + get self(): string { + return this._links.self.href; + } + + @deserialize + _links: { + self: HALLink; + vocabulary: HALLink; + }; + + getRenderType(): string { + return 'hierarchy'; + } +} diff --git a/src/app/core/shared/hierarchical-browse-definition.resource-type.ts b/src/app/core/shared/hierarchical-browse-definition.resource-type.ts new file mode 100644 index 0000000000..df06d67c7a --- /dev/null +++ b/src/app/core/shared/hierarchical-browse-definition.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from './resource-type'; + +/** + * The resource type for HierarchicalBrowseDefinition + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const HIERARCHICAL_BROWSE_DEFINITION = new ResourceType('hierarchicalBrowse'); diff --git a/src/app/core/shared/non-hierarchical-browse-definition.ts b/src/app/core/shared/non-hierarchical-browse-definition.ts new file mode 100644 index 0000000000..d5481fcc8d --- /dev/null +++ b/src/app/core/shared/non-hierarchical-browse-definition.ts @@ -0,0 +1,24 @@ +import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize'; +import { SortOption } from './sort-option.model'; +import { BrowseByDataType } from '../../browse-by/browse-by-switcher/browse-by-decorator'; +import { BrowseDefinition } from './browse-definition.model'; + +/** + * Super class for NonHierarchicalBrowseDefinition models, + * e.g. FlatBrowseDefinition and ValueListBrowseDefinition + */ +@inheritSerialization(BrowseDefinition) +export abstract class NonHierarchicalBrowseDefinition extends BrowseDefinition { + + @autoserialize + sortOptions: SortOption[]; + + @autoserializeAs('order') + defaultSortOrder: string; + + @autoserializeAs('metadata') + metadataKeys: string[]; + + @autoserialize + dataType: BrowseByDataType; +} diff --git a/src/app/core/shared/value-list-browse-definition.model.ts b/src/app/core/shared/value-list-browse-definition.model.ts new file mode 100644 index 0000000000..3378263962 --- /dev/null +++ b/src/app/core/shared/value-list-browse-definition.model.ts @@ -0,0 +1,36 @@ +import { inheritSerialization, deserialize } from 'cerialize'; +import { typedObject } from '../cache/builders/build-decorators'; +import { excludeFromEquals } from '../utilities/equals.decorators'; +import { VALUE_LIST_BROWSE_DEFINITION } from './value-list-browse-definition.resource-type'; +import { ResourceType } from './resource-type'; +import { NonHierarchicalBrowseDefinition } from './non-hierarchical-browse-definition'; +import { HALLink } from './hal-link.model'; + +/** + * BrowseDefinition model for browses of type 'valueList' + */ +@typedObject +@inheritSerialization(NonHierarchicalBrowseDefinition) +export class ValueListBrowseDefinition extends NonHierarchicalBrowseDefinition { + static type = VALUE_LIST_BROWSE_DEFINITION; + + /** + * The object type + */ + @excludeFromEquals + type: ResourceType = VALUE_LIST_BROWSE_DEFINITION; + + get self(): string { + return this._links.self.href; + } + + @deserialize + _links: { + self: HALLink; + entries: HALLink; + }; + + getRenderType(): string { + return this.dataType; + } +} diff --git a/src/app/core/shared/value-list-browse-definition.resource-type.ts b/src/app/core/shared/value-list-browse-definition.resource-type.ts new file mode 100644 index 0000000000..8904dc472f --- /dev/null +++ b/src/app/core/shared/value-list-browse-definition.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from './resource-type'; + +/** + * The resource type for ValueListBrowseDefinition + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const VALUE_LIST_BROWSE_DEFINITION = new ResourceType('valueList'); diff --git a/src/app/core/submission/vocabularies/vocabulary.service.ts b/src/app/core/submission/vocabularies/vocabulary.service.ts index f2c1747658..1ff5b30ee0 100644 --- a/src/app/core/submission/vocabularies/vocabulary.service.ts +++ b/src/app/core/submission/vocabularies/vocabulary.service.ts @@ -223,13 +223,15 @@ export class VocabularyService { * no valid cached version. Defaults to true * @param reRequestOnStale Whether or not the request should automatically be re- * requested after the response becomes stale + * @param constructId Whether constructing the full vocabularyDetail ID + * ({vocabularyName}:{detailName}) is still necessary * @param linksToFollow List of {@link FollowLinkConfig} that indicate which * {@link HALLink}s should be automatically resolved * @return {Observable>} * Return an observable that emits VocabularyEntryDetail object */ - findEntryDetailById(id: string, name: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { - const findId = `${name}:${id}`; + findEntryDetailById(id: string, name: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, constructId: boolean = true, ...linksToFollow: FollowLinkConfig[]): Observable> { + const findId: string = (constructId ? `${name}:${id}` : id); return this.vocabularyEntryDetailDataService.findById(findId, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); } diff --git a/src/app/dso-shared/dso-edit-metadata/metadata-field-selector/metadata-field-selector.component.spec.ts b/src/app/dso-shared/dso-edit-metadata/metadata-field-selector/metadata-field-selector.component.spec.ts index e0fde0e8f2..de8736df94 100644 --- a/src/app/dso-shared/dso-edit-metadata/metadata-field-selector/metadata-field-selector.component.spec.ts +++ b/src/app/dso-shared/dso-edit-metadata/metadata-field-selector/metadata-field-selector.component.spec.ts @@ -12,6 +12,7 @@ import { createPaginatedList } from '../../../shared/testing/utils.test'; import { followLink } from '../../../shared/utils/follow-link-config.model'; import { By } from '@angular/platform-browser'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model'; describe('MetadataFieldSelectorComponent', () => { let component: MetadataFieldSelectorComponent; @@ -79,7 +80,7 @@ describe('MetadataFieldSelectorComponent', () => { }); it('should query the registry service for metadata fields and include the schema', () => { - expect(registryService.queryMetadataFields).toHaveBeenCalledWith(query, null, true, false, followLink('schema')); + expect(registryService.queryMetadataFields).toHaveBeenCalledWith(query, { elementsPerPage: 10, sort: new SortOptions('fieldName', SortDirection.ASC) }, true, false, followLink('schema')); }); }); diff --git a/src/app/dso-shared/dso-edit-metadata/metadata-field-selector/metadata-field-selector.component.ts b/src/app/dso-shared/dso-edit-metadata/metadata-field-selector/metadata-field-selector.component.ts index 5f76d87265..fc0d57046d 100644 --- a/src/app/dso-shared/dso-edit-metadata/metadata-field-selector/metadata-field-selector.component.ts +++ b/src/app/dso-shared/dso-edit-metadata/metadata-field-selector/metadata-field-selector.component.ts @@ -9,10 +9,11 @@ import { Output, ViewChild } from '@angular/core'; -import { switchMap, debounceTime, distinctUntilChanged, map, tap, take } from 'rxjs/operators'; +import { debounceTime, distinctUntilChanged, map, switchMap, take, tap } from 'rxjs/operators'; import { followLink } from '../../../shared/utils/follow-link-config.model'; import { - getAllSucceededRemoteData, getFirstCompletedRemoteData, + getAllSucceededRemoteData, + getFirstCompletedRemoteData, metadataFieldsToString } from '../../../core/shared/operators'; import { Observable } from 'rxjs/internal/Observable'; @@ -24,6 +25,7 @@ import { Subscription } from 'rxjs/internal/Subscription'; import { of } from 'rxjs/internal/observable/of'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { TranslateService } from '@ngx-translate/core'; +import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model'; @Component({ selector: 'ds-metadata-field-selector', @@ -127,7 +129,7 @@ export class MetadataFieldSelectorComponent implements OnInit, OnDestroy, AfterV switchMap((query: string) => { this.showInvalid = false; if (query !== null) { - return this.registryService.queryMetadataFields(query, null, true, false, followLink('schema')).pipe( + return this.registryService.queryMetadataFields(query, { elementsPerPage: 10, sort: new SortOptions('fieldName', SortDirection.ASC) }, true, false, followLink('schema')).pipe( getAllSucceededRemoteData(), metadataFieldsToString(), ); diff --git a/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.spec.ts b/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.spec.ts index 67d047d776..10e1812131 100644 --- a/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.spec.ts +++ b/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.spec.ts @@ -25,6 +25,7 @@ import { getMockRequestService } from '../../../shared/mocks/request.service.moc import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; import { createPaginatedList } from '../../../shared/testing/utils.test'; import { FieldChangeType } from '../../../core/data/object-updates/field-change-type.model'; +import { BitstreamDataServiceStub } from '../../../shared/testing/bitstream-data-service.stub'; let comp: ItemBitstreamsComponent; let fixture: ComponentFixture; @@ -71,7 +72,7 @@ let objectUpdatesService: ObjectUpdatesService; let router: any; let route: ActivatedRoute; let notificationsService: NotificationsService; -let bitstreamService: BitstreamDataService; +let bitstreamService: BitstreamDataServiceStub; let objectCache: ObjectCacheService; let requestService: RequestService; let searchConfig: SearchConfigurationService; @@ -112,9 +113,7 @@ describe('ItemBitstreamsComponent', () => { success: successNotification } ); - bitstreamService = jasmine.createSpyObj('bitstreamService', { - delete: jasmine.createSpy('delete') - }); + bitstreamService = new BitstreamDataServiceStub(); objectCache = jasmine.createSpyObj('objectCache', { remove: jasmine.createSpy('remove') }); @@ -179,15 +178,16 @@ describe('ItemBitstreamsComponent', () => { describe('when submit is called', () => { beforeEach(() => { + spyOn(bitstreamService, 'removeMultiple').and.callThrough(); comp.submit(); }); - it('should call delete on the bitstreamService for the marked field', () => { - expect(bitstreamService.delete).toHaveBeenCalledWith(bitstream2.id); + it('should call removeMultiple on the bitstreamService for the marked field', () => { + expect(bitstreamService.removeMultiple).toHaveBeenCalledWith([bitstream2]); }); - it('should not call delete on the bitstreamService for the unmarked field', () => { - expect(bitstreamService.delete).not.toHaveBeenCalledWith(bitstream1.id); + it('should not call removeMultiple on the bitstreamService for the unmarked field', () => { + expect(bitstreamService.removeMultiple).not.toHaveBeenCalledWith([bitstream1]); }); }); @@ -210,7 +210,6 @@ describe('ItemBitstreamsComponent', () => { comp.dropBitstream(bundle, { fromIndex: 0, toIndex: 50, - // eslint-disable-next-line no-empty, @typescript-eslint/no-empty-function finish: () => { done(); } diff --git a/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts b/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts index 0c7dfb1e34..ee53bd919c 100644 --- a/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts +++ b/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts @@ -1,7 +1,7 @@ import { ChangeDetectorRef, Component, NgZone, OnDestroy } from '@angular/core'; import { AbstractItemUpdateComponent } from '../abstract-item-update/abstract-item-update.component'; import { filter, map, switchMap, take } from 'rxjs/operators'; -import { Observable, of as observableOf, Subscription, zip as observableZip } from 'rxjs'; +import { Observable, Subscription, zip as observableZip } from 'rxjs'; import { ItemDataService } from '../../../core/data/item-data.service'; import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service'; import { ActivatedRoute, Router } from '@angular/router'; @@ -133,20 +133,16 @@ export class ItemBitstreamsComponent extends AbstractItemUpdateComponent impleme ); // Send out delete requests for all deleted bitstreams - const removedResponses$ = removedBitstreams$.pipe( + const removedResponses$: Observable> = removedBitstreams$.pipe( take(1), - switchMap((removedBistreams: Bitstream[]) => { - if (isNotEmpty(removedBistreams)) { - return observableZip(...removedBistreams.map((bitstream: Bitstream) => this.bitstreamService.delete(bitstream.id))); - } else { - return observableOf(undefined); - } + switchMap((removedBitstreams: Bitstream[]) => { + return this.bitstreamService.removeMultiple(removedBitstreams); }) ); // Perform the setup actions from above in order and display notifications - removedResponses$.pipe(take(1)).subscribe((responses: RemoteData[]) => { - this.displayNotifications('item.edit.bitstreams.notifications.remove', responses); + removedResponses$.subscribe((responses: RemoteData) => { + this.displayNotifications('item.edit.bitstreams.notifications.remove', [responses]); this.submitting = false; }); } diff --git a/src/app/item-page/field-components/metadata-values/metadata-values.component.ts b/src/app/item-page/field-components/metadata-values/metadata-values.component.ts index 49ce453fbe..cbbae9006d 100644 --- a/src/app/item-page/field-components/metadata-values/metadata-values.component.ts +++ b/src/app/item-page/field-components/metadata-values/metadata-values.component.ts @@ -3,6 +3,7 @@ import { MetadataValue } from '../../../core/shared/metadata.models'; import { APP_CONFIG, AppConfig } from '../../../../config/app-config.interface'; import { BrowseDefinition } from '../../../core/shared/browse-definition.model'; import { hasValue } from '../../../shared/empty.util'; +import { VALUE_LIST_BROWSE_DEFINITION } from '../../../core/shared/value-list-browse-definition.resource-type'; /** * This component renders the configured 'values' into the ds-metadata-field-wrapper component. @@ -84,7 +85,7 @@ export class MetadataValuesComponent implements OnChanges { */ getQueryParams(value) { let queryParams = {startsWith: value}; - if (this.browseDefinition.metadataBrowse) { + if (this.browseDefinition.getRenderType() === VALUE_LIST_BROWSE_DEFINITION.value) { return {value: value}; } return queryParams; diff --git a/src/app/item-page/full/full-item-page.component.spec.ts b/src/app/item-page/full/full-item-page.component.spec.ts index 53e36be1d1..9fc078c2cd 100644 --- a/src/app/item-page/full/full-item-page.component.spec.ts +++ b/src/app/item-page/full/full-item-page.component.spec.ts @@ -2,7 +2,7 @@ import { ComponentFixture, fakeAsync, TestBed, waitForAsync } from '@angular/cor import { ItemDataService } from '../../core/data/item-data.service'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; -import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA, PLATFORM_ID } from '@angular/core'; import { TruncatePipe } from '../../shared/utils/truncate.pipe'; import { FullItemPageComponent } from './full-item-page.component'; import { MetadataService } from '../../core/metadata/metadata.service'; @@ -20,6 +20,9 @@ import { createPaginatedList } from '../../shared/testing/utils.test'; import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; import { createRelationshipsObservable } from '../simple/item-types/shared/item.component.spec'; import { RemoteData } from '../../core/data/remote-data'; +import { ServerResponseService } from '../../core/services/server-response.service'; +import { SignpostingDataService } from '../../core/data/signposting-data.service'; +import { LinkHeadService } from '../../core/services/link-head.service'; const mockItem: Item = Object.assign(new Item(), { bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])), @@ -55,8 +58,21 @@ describe('FullItemPageComponent', () => { let routeStub: ActivatedRouteStub; let routeData; let authorizationDataService: AuthorizationDataService; + let serverResponseService: jasmine.SpyObj; + let signpostingDataService: jasmine.SpyObj; + let linkHeadService: jasmine.SpyObj; + const mocklink = { + href: 'http://test.org', + rel: 'test', + type: 'test' + }; + const mocklink2 = { + href: 'http://test2.org', + rel: 'test', + type: 'test' + }; beforeEach(waitForAsync(() => { authService = jasmine.createSpyObj('authService', { @@ -76,6 +92,19 @@ describe('FullItemPageComponent', () => { isAuthorized: observableOf(false), }); + serverResponseService = jasmine.createSpyObj('ServerResponseService', { + setHeader: jasmine.createSpy('setHeader'), + }); + + signpostingDataService = jasmine.createSpyObj('SignpostingDataService', { + getLinks: observableOf([mocklink, mocklink2]), + }); + + linkHeadService = jasmine.createSpyObj('LinkHeadService', { + addTag: jasmine.createSpy('setHeader'), + removeTag: jasmine.createSpy('removeTag'), + }); + TestBed.configureTestingModule({ imports: [TranslateModule.forRoot({ loader: { @@ -90,8 +119,11 @@ describe('FullItemPageComponent', () => { { provide: MetadataService, useValue: metadataServiceStub }, { provide: AuthService, useValue: authService }, { provide: AuthorizationDataService, useValue: authorizationDataService }, + { provide: ServerResponseService, useValue: serverResponseService }, + { provide: SignpostingDataService, useValue: signpostingDataService }, + { provide: LinkHeadService, useValue: linkHeadService }, + { provide: PLATFORM_ID, useValue: 'server' } ], - schemas: [NO_ERRORS_SCHEMA] }).overrideComponent(FullItemPageComponent, { set: { changeDetection: ChangeDetectionStrategy.Default } @@ -143,6 +175,11 @@ describe('FullItemPageComponent', () => { const objectLoader = fixture.debugElement.query(By.css('.full-item-info')); expect(objectLoader.nativeElement).not.toBeNull(); }); + + it('should add the signposting links', () => { + expect(serverResponseService.setHeader).toHaveBeenCalled(); + expect(linkHeadService.addTag).toHaveBeenCalledTimes(2); + }); }); describe('when the item is withdrawn and the user is not an admin', () => { beforeEach(() => { @@ -167,6 +204,11 @@ describe('FullItemPageComponent', () => { const objectLoader = fixture.debugElement.query(By.css('.full-item-info')); expect(objectLoader).not.toBeNull(); }); + + it('should add the signposting links', () => { + expect(serverResponseService.setHeader).toHaveBeenCalled(); + expect(linkHeadService.addTag).toHaveBeenCalledTimes(2); + }); }); describe('when the item is not withdrawn and the user is not an admin', () => { @@ -179,5 +221,10 @@ describe('FullItemPageComponent', () => { const objectLoader = fixture.debugElement.query(By.css('.full-item-info')); expect(objectLoader).not.toBeNull(); }); + + it('should add the signposting links', () => { + expect(serverResponseService.setHeader).toHaveBeenCalled(); + expect(linkHeadService.addTag).toHaveBeenCalledTimes(2); + }); }); }); 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 118e436004..31dd2c5fc2 100644 --- a/src/app/item-page/full/full-item-page.component.ts +++ b/src/app/item-page/full/full-item-page.component.ts @@ -1,5 +1,5 @@ import { filter, map } from 'rxjs/operators'; -import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, Inject, OnDestroy, OnInit, PLATFORM_ID } from '@angular/core'; import { ActivatedRoute, Data, Router } from '@angular/router'; import { BehaviorSubject, Observable } from 'rxjs'; @@ -16,7 +16,9 @@ import { hasValue } from '../../shared/empty.util'; import { AuthService } from '../../core/auth/auth.service'; import { Location } from '@angular/common'; import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; - +import { ServerResponseService } from '../../core/services/server-response.service'; +import { SignpostingDataService } from '../../core/data/signposting-data.service'; +import { LinkHeadService } from '../../core/services/link-head.service'; /** * This component renders a full item page. @@ -43,13 +45,19 @@ export class FullItemPageComponent extends ItemPageComponent implements OnInit, subs = []; - constructor(protected route: ActivatedRoute, - router: Router, - items: ItemDataService, - authService: AuthService, - authorizationService: AuthorizationDataService, - private _location: Location) { - super(route, router, items, authService, authorizationService); + constructor( + protected route: ActivatedRoute, + protected router: Router, + protected items: ItemDataService, + protected authService: AuthService, + protected authorizationService: AuthorizationDataService, + protected _location: Location, + protected responseService: ServerResponseService, + protected signpostingDataService: SignpostingDataService, + protected linkHeadService: LinkHeadService, + @Inject(PLATFORM_ID) protected platformId: string, + ) { + super(route, router, items, authService, authorizationService, responseService, signpostingDataService, linkHeadService, platformId); } /*** AoT inheritance fix, will hopefully be resolved in the near future **/ diff --git a/src/app/item-page/orcid-page/orcid-queue/orcid-queue.component.html b/src/app/item-page/orcid-page/orcid-queue/orcid-queue.component.html index 9358bcf835..6ba318f7fd 100644 --- a/src/app/item-page/orcid-page/orcid-queue/orcid-queue.component.html +++ b/src/app/item-page/orcid-page/orcid-queue/orcid-queue.component.html @@ -34,11 +34,13 @@
diff --git a/src/app/item-page/simple/item-page.component.spec.ts b/src/app/item-page/simple/item-page.component.spec.ts index 9b0e87939d..b3202108f4 100644 --- a/src/app/item-page/simple/item-page.component.spec.ts +++ b/src/app/item-page/simple/item-page.component.spec.ts @@ -2,7 +2,7 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; import { ItemDataService } from '../../core/data/item-data.service'; -import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA, PLATFORM_ID } from '@angular/core'; import { ItemPageComponent } from './item-page.component'; import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRouteStub } from '../../shared/testing/active-router.stub'; @@ -22,6 +22,10 @@ import { import { AuthService } from '../../core/auth/auth.service'; import { createPaginatedList } from '../../shared/testing/utils.test'; import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; +import { ServerResponseService } from '../../core/services/server-response.service'; +import { SignpostingDataService } from '../../core/data/signposting-data.service'; +import { LinkDefinition, LinkHeadService } from '../../core/services/link-head.service'; +import { SignpostingLink } from '../../core/data/signposting-links.model'; const mockItem: Item = Object.assign(new Item(), { bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])), @@ -36,11 +40,28 @@ const mockWithdrawnItem: Item = Object.assign(new Item(), { isWithdrawn: true }); +const mocklink = { + href: 'http://test.org', + rel: 'rel1', + type: 'type1' +}; + +const mocklink2 = { + href: 'http://test2.org', + rel: 'rel2', + type: undefined +}; + +const mockSignpostingLinks: SignpostingLink[] = [mocklink, mocklink2]; + describe('ItemPageComponent', () => { let comp: ItemPageComponent; let fixture: ComponentFixture; let authService: AuthService; let authorizationDataService: AuthorizationDataService; + let serverResponseService: jasmine.SpyObj; + let signpostingDataService: jasmine.SpyObj; + let linkHeadService: jasmine.SpyObj; const mockMetadataService = { /* eslint-disable no-empty,@typescript-eslint/no-empty-function */ @@ -60,6 +81,18 @@ describe('ItemPageComponent', () => { authorizationDataService = jasmine.createSpyObj('authorizationDataService', { isAuthorized: observableOf(false), }); + serverResponseService = jasmine.createSpyObj('ServerResponseService', { + setHeader: jasmine.createSpy('setHeader'), + }); + + signpostingDataService = jasmine.createSpyObj('SignpostingDataService', { + getLinks: observableOf([mocklink, mocklink2]), + }); + + linkHeadService = jasmine.createSpyObj('LinkHeadService', { + addTag: jasmine.createSpy('setHeader'), + removeTag: jasmine.createSpy('removeTag'), + }); TestBed.configureTestingModule({ imports: [TranslateModule.forRoot({ @@ -76,6 +109,10 @@ describe('ItemPageComponent', () => { { provide: Router, useValue: {} }, { provide: AuthService, useValue: authService }, { provide: AuthorizationDataService, useValue: authorizationDataService }, + { provide: ServerResponseService, useValue: serverResponseService }, + { provide: SignpostingDataService, useValue: signpostingDataService }, + { provide: LinkHeadService, useValue: linkHeadService }, + { provide: PLATFORM_ID, useValue: 'server' }, ], schemas: [NO_ERRORS_SCHEMA] @@ -126,6 +163,33 @@ describe('ItemPageComponent', () => { const objectLoader = fixture.debugElement.query(By.css('ds-listable-object-component-loader')); expect(objectLoader.nativeElement).toBeDefined(); }); + + it('should add the signposting links', () => { + expect(serverResponseService.setHeader).toHaveBeenCalled(); + expect(linkHeadService.addTag).toHaveBeenCalledTimes(2); + }); + + + it('should add link tags correctly', () => { + + expect(comp.signpostingLinks).toEqual([mocklink, mocklink2]); + + // Check if linkHeadService.addTag() was called with the correct arguments + expect(linkHeadService.addTag).toHaveBeenCalledTimes(mockSignpostingLinks.length); + let expected: LinkDefinition = mockSignpostingLinks[0] as LinkDefinition; + expect(linkHeadService.addTag).toHaveBeenCalledWith(expected); + expected = { + href: 'http://test2.org', + rel: 'rel2' + }; + expect(linkHeadService.addTag).toHaveBeenCalledWith(expected); + }); + + it('should set Link header on the server', () => { + + expect(serverResponseService.setHeader).toHaveBeenCalledWith('Link', ' ; rel="rel1" ; type="type1" , ; rel="rel2" '); + }); + }); describe('when the item is withdrawn and the user is not an admin', () => { beforeEach(() => { @@ -150,6 +214,11 @@ describe('ItemPageComponent', () => { const objectLoader = fixture.debugElement.query(By.css('ds-listable-object-component-loader')); expect(objectLoader.nativeElement).toBeDefined(); }); + + it('should add the signposting links', () => { + expect(serverResponseService.setHeader).toHaveBeenCalled(); + expect(linkHeadService.addTag).toHaveBeenCalledTimes(2); + }); }); describe('when the item is not withdrawn and the user is not an admin', () => { @@ -162,6 +231,11 @@ describe('ItemPageComponent', () => { const objectLoader = fixture.debugElement.query(By.css('ds-listable-object-component-loader')); expect(objectLoader.nativeElement).toBeDefined(); }); + + it('should add the signposting links', () => { + expect(serverResponseService.setHeader).toHaveBeenCalled(); + expect(linkHeadService.addTag).toHaveBeenCalledTimes(2); + }); }); }); diff --git a/src/app/item-page/simple/item-page.component.ts b/src/app/item-page/simple/item-page.component.ts index 6e0db386db..b9be6bebfb 100644 --- a/src/app/item-page/simple/item-page.component.ts +++ b/src/app/item-page/simple/item-page.component.ts @@ -1,8 +1,9 @@ -import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, Inject, OnDestroy, OnInit, PLATFORM_ID } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; +import { isPlatformServer } from '@angular/common'; import { Observable } from 'rxjs'; -import { map } from 'rxjs/operators'; +import { map, take } from 'rxjs/operators'; import { ItemDataService } from '../../core/data/item-data.service'; import { RemoteData } from '../../core/data/remote-data'; @@ -15,6 +16,11 @@ import { getItemPageRoute } from '../item-page-routing-paths'; import { redirectOn4xx } from '../../core/shared/authorized.operators'; import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; import { FeatureID } from '../../core/data/feature-authorization/feature-id'; +import { ServerResponseService } from '../../core/services/server-response.service'; +import { SignpostingDataService } from '../../core/data/signposting-data.service'; +import { SignpostingLink } from '../../core/data/signposting-links.model'; +import { isNotEmpty } from '../../shared/empty.util'; +import { LinkDefinition, LinkHeadService } from '../../core/services/link-head.service'; /** * This component renders a simple item page. @@ -28,7 +34,7 @@ import { FeatureID } from '../../core/data/feature-authorization/feature-id'; changeDetection: ChangeDetectionStrategy.OnPush, animations: [fadeInOut] }) -export class ItemPageComponent implements OnInit { +export class ItemPageComponent implements OnInit, OnDestroy { /** * The item's id @@ -57,13 +63,23 @@ export class ItemPageComponent implements OnInit { itemUrl: string; + /** + * Contains a list of SignpostingLink related to the item + */ + signpostingLinks: SignpostingLink[] = []; + constructor( protected route: ActivatedRoute, - private router: Router, - private items: ItemDataService, - private authService: AuthService, - private authorizationService: AuthorizationDataService + protected router: Router, + protected items: ItemDataService, + protected authService: AuthService, + protected authorizationService: AuthorizationDataService, + protected responseService: ServerResponseService, + protected signpostingDataService: SignpostingDataService, + protected linkHeadService: LinkHeadService, + @Inject(PLATFORM_ID) protected platformId: string ) { + this.initPageLinks(); } /** @@ -82,4 +98,42 @@ export class ItemPageComponent implements OnInit { this.isAdmin$ = this.authorizationService.isAuthorized(FeatureID.AdministratorOf); } + + /** + * Create page links if any are retrieved by signposting endpoint + * + * @private + */ + private initPageLinks(): void { + this.route.params.subscribe(params => { + this.signpostingDataService.getLinks(params.id).pipe(take(1)).subscribe((signpostingLinks: SignpostingLink[]) => { + let links = ''; + this.signpostingLinks = signpostingLinks; + + signpostingLinks.forEach((link: SignpostingLink) => { + links = links + (isNotEmpty(links) ? ', ' : '') + `<${link.href}> ; rel="${link.rel}"` + (isNotEmpty(link.type) ? ` ; type="${link.type}" ` : ' '); + let tag: LinkDefinition = { + href: link.href, + rel: link.rel + }; + if (isNotEmpty(link.type)) { + tag = Object.assign(tag, { + type: link.type + }); + } + this.linkHeadService.addTag(tag); + }); + + if (isPlatformServer(this.platformId)) { + this.responseService.setHeader('Link', links); + } + }); + }); + } + + ngOnDestroy(): void { + this.signpostingLinks.forEach((link: SignpostingLink) => { + this.linkHeadService.removeTag(`href='${link.href}'`); + }); + } } diff --git a/src/app/navbar/navbar.component.spec.ts b/src/app/navbar/navbar.component.spec.ts index ada9be9d0b..983eace055 100644 --- a/src/app/navbar/navbar.component.spec.ts +++ b/src/app/navbar/navbar.component.spec.ts @@ -16,7 +16,6 @@ import { RouterTestingModule } from '@angular/router/testing'; import { BrowseService } from '../core/browse/browse.service'; import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils'; import { buildPaginatedList } from '../core/data/paginated-list.model'; -import { BrowseDefinition } from '../core/shared/browse-definition.model'; import { BrowseByDataType } from '../browse-by/browse-by-switcher/browse-by-decorator'; import { Item } from '../core/shared/item.model'; import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service'; @@ -28,6 +27,9 @@ import { authReducer } from '../core/auth/auth.reducer'; import { provideMockStore } from '@ngrx/store/testing'; import { AuthTokenInfo } from '../core/auth/models/auth-token-info.model'; import { EPersonMock } from '../shared/testing/eperson.mock'; +import { FlatBrowseDefinition } from '../core/shared/flat-browse-definition.model'; +import { ValueListBrowseDefinition } from '../core/shared/value-list-browse-definition.model'; +import { HierarchicalBrowseDefinition } from '../core/shared/hierarchical-browse-definition.model'; let comp: NavbarComponent; let fixture: ComponentFixture; @@ -66,30 +68,35 @@ describe('NavbarComponent', () => { beforeEach(waitForAsync(() => { browseDefinitions = [ Object.assign( - new BrowseDefinition(), { + new FlatBrowseDefinition(), { id: 'title', dataType: BrowseByDataType.Title, } ), Object.assign( - new BrowseDefinition(), { + new FlatBrowseDefinition(), { id: 'dateissued', dataType: BrowseByDataType.Date, metadataKeys: ['dc.date.issued'] } ), Object.assign( - new BrowseDefinition(), { + new ValueListBrowseDefinition(), { id: 'author', dataType: BrowseByDataType.Metadata, } ), Object.assign( - new BrowseDefinition(), { + new ValueListBrowseDefinition(), { id: 'subject', dataType: BrowseByDataType.Metadata, } ), + Object.assign( + new HierarchicalBrowseDefinition(), { + id: 'srsc', + } + ), ]; initialState = { core: { diff --git a/src/app/process-page/detail/process-detail.component.html b/src/app/process-page/detail/process-detail.component.html index 29cbfc113f..5f905cbfff 100644 --- a/src/app/process-page/detail/process-detail.component.html +++ b/src/app/process-page/detail/process-detail.component.html @@ -1,10 +1,15 @@ -
-
-

{{'process.detail.title' | translate:{ - id: process?.processId, - name: process?.scriptName - } }}

+
+
+
+

+ {{ 'process.detail.title' | translate:{ id: process?.processId, name: process?.scriptName } }} +

+
+
+ Refreshing in {{ seconds }}s +
+
{{ process?.scriptName }}
@@ -17,10 +22,12 @@
+
- {{getFileName(file)}} - ({{(file?.sizeBytes) | dsFileSize }}) + {{getFileName(file)}} + ({{(file?.sizeBytes) | dsFileSize }}) +
@@ -70,7 +77,7 @@ -
+