diff --git a/config/config.example.yml b/config/config.example.yml index abf74eb19c..a5980d1879 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -135,6 +135,9 @@ languages: - code: lv label: Latviešu active: true + - code: hi + label: Hindi + active: true - code: hu label: Magyar active: true diff --git a/package.json b/package.json index 33e337121b..661a068299 100644 --- a/package.json +++ b/package.json @@ -104,6 +104,8 @@ "jwt-decode": "^3.1.2", "klaro": "^0.7.10", "lodash": "^4.17.21", + "markdown-it": "^13.0.1", + "markdown-it-mathjax3": "^4.3.1", "mirador": "^3.3.0", "mirador-dl-plugin": "^0.13.0", "mirador-share-plugin": "^0.11.0", @@ -116,6 +118,7 @@ "ngx-moment": "^5.0.0", "ngx-pagination": "5.0.0", "ngx-sortablejs": "^11.1.0", + "ngx-ui-switch": "^11.0.1", "nouislider": "^14.6.3", "pem": "1.14.4", "postcss-cli": "^9.1.0", @@ -123,13 +126,13 @@ "react-copy-to-clipboard": "^5.0.1", "reflect-metadata": "^0.1.13", "rxjs": "^7.5.5", + "sanitize-html": "^2.7.2", "sortablejs": "1.13.0", "tslib": "^2.0.0", "url-parse": "^1.5.6", "uuid": "^8.3.2", "webfontloader": "1.6.28", - "zone.js": "~0.11.5", - "ngx-ui-switch": "^11.0.1" + "zone.js": "~0.11.5" }, "devDependencies": { "@angular-builders/custom-webpack": "~13.1.0", @@ -155,6 +158,7 @@ "@types/js-cookie": "2.2.6", "@types/lodash": "^4.14.165", "@types/node": "^14.14.9", + "@types/sanitize-html": "^2.6.2", "@typescript-eslint/eslint-plugin": "5.11.0", "@typescript-eslint/parser": "5.11.0", "axe-core": "^4.3.3", diff --git a/server.ts b/server.ts index c9cdf3d76a..81137ad56a 100644 --- a/server.ts +++ b/server.ts @@ -76,6 +76,10 @@ export function app() { */ const server = express(); + // Tell Express to trust X-FORWARDED-* headers from proxies + // See https://expressjs.com/en/guide/behind-proxies.html + server.set('trust proxy', environment.ui.useProxies); + /* * If production mode is enabled in the environment file: * - Enable Angular's production mode 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 b99304d037..55233d8173 100644 --- a/src/app/access-control/epeople-registry/epeople-registry.component.ts +++ b/src/app/access-control/epeople-registry/epeople-registry.component.ts @@ -238,7 +238,6 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy { this.epersonService.deleteEPerson(ePerson).pipe(getFirstCompletedRemoteData()).subscribe((restResponse: RemoteData) => { if (restResponse.hasSucceeded) { this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', {name: ePerson.name})); - this.reset(); } else { this.notificationsService.error('Error occured when trying to delete EPerson with id: ' + ePerson.id + ' with code: ' + restResponse.statusCode + ' and message: ' + restResponse.errorMessage); } diff --git a/src/app/access-control/group-registry/groups-registry.component.ts b/src/app/access-control/group-registry/groups-registry.component.ts index 6ba501fcc4..70c9b22852 100644 --- a/src/app/access-control/group-registry/groups-registry.component.ts +++ b/src/app/access-control/group-registry/groups-registry.component.ts @@ -5,11 +5,12 @@ import { TranslateService } from '@ngx-translate/core'; import { BehaviorSubject, combineLatest as observableCombineLatest, + EMPTY, Observable, of as observableOf, Subscription } from 'rxjs'; -import { catchError, map, switchMap, tap } from 'rxjs/operators'; +import { catchError, defaultIfEmpty, map, switchMap, tap } from 'rxjs/operators'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; import { FeatureID } from '../../core/data/feature-authorization/feature-id'; @@ -144,7 +145,7 @@ export class GroupsRegistryComponent implements OnInit, OnDestroy { } return this.authorizationService.isAuthorized(FeatureID.AdministratorOf).pipe( switchMap((isSiteAdmin: boolean) => { - return observableCombineLatest(groups.page.map((group: Group) => { + return observableCombineLatest([...groups.page.map((group: Group) => { if (hasValue(group) && !this.deletedGroupsIds.includes(group.id)) { return observableCombineLatest([ this.authorizationService.isAuthorized(FeatureID.CanDelete, group.self), @@ -165,8 +166,10 @@ export class GroupsRegistryComponent implements OnInit, OnDestroy { } ) ); + } else { + return EMPTY; } - })).pipe(map((dtos: GroupDtoModel[]) => { + })]).pipe(defaultIfEmpty([]), map((dtos: GroupDtoModel[]) => { return buildPaginatedList(groups.pageInfo, dtos); })); }) diff --git a/src/app/admin/admin-import-batch-page/batch-import-page.component.html b/src/app/admin/admin-import-batch-page/batch-import-page.component.html new file mode 100644 index 0000000000..dbc8c74437 --- /dev/null +++ b/src/app/admin/admin-import-batch-page/batch-import-page.component.html @@ -0,0 +1,35 @@ +
+ +

{{'admin.batch-import.page.help' | translate}}

+

+ selected collection: {{getDspaceObjectName()}}  + {{'admin.batch-import.page.remove' | translate}} +

+

+ +

+
+
+ + +
+ + {{'admin.batch-import.page.validateOnly.hint' | translate}} + +
+ + + + +
+ + +
+
diff --git a/src/app/admin/admin-import-batch-page/batch-import-page.component.spec.ts b/src/app/admin/admin-import-batch-page/batch-import-page.component.spec.ts new file mode 100644 index 0000000000..36ba1137c9 --- /dev/null +++ b/src/app/admin/admin-import-batch-page/batch-import-page.component.spec.ts @@ -0,0 +1,151 @@ +import { ComponentFixture, fakeAsync, TestBed, waitForAsync } from '@angular/core/testing'; +import { BatchImportPageComponent } from './batch-import-page.component'; +import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; +import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { FormsModule } from '@angular/forms'; +import { TranslateModule } from '@ngx-translate/core'; +import { RouterTestingModule } from '@angular/router/testing'; +import { FileValueAccessorDirective } from '../../shared/utils/file-value-accessor.directive'; +import { FileValidator } from '../../shared/utils/require-file.validator'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { + BATCH_IMPORT_SCRIPT_NAME, + ScriptDataService +} from '../../core/data/processes/script-data.service'; +import { Router } from '@angular/router'; +import { Location } from '@angular/common'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { ProcessParameter } from '../../process-page/processes/process-parameter.model'; + +describe('BatchImportPageComponent', () => { + let component: BatchImportPageComponent; + let fixture: ComponentFixture; + + let notificationService: NotificationsServiceStub; + let scriptService: any; + let router; + let locationStub; + + function init() { + notificationService = new NotificationsServiceStub(); + scriptService = jasmine.createSpyObj('scriptService', + { + invoke: createSuccessfulRemoteDataObject$({ processId: '46' }) + } + ); + router = jasmine.createSpyObj('router', { + navigateByUrl: jasmine.createSpy('navigateByUrl') + }); + locationStub = jasmine.createSpyObj('location', { + back: jasmine.createSpy('back') + }); + } + + beforeEach(waitForAsync(() => { + init(); + TestBed.configureTestingModule({ + imports: [ + FormsModule, + TranslateModule.forRoot(), + RouterTestingModule.withRoutes([]) + ], + declarations: [BatchImportPageComponent, FileValueAccessorDirective, FileValidator], + providers: [ + { provide: NotificationsService, useValue: notificationService }, + { provide: ScriptDataService, useValue: scriptService }, + { provide: Router, useValue: router }, + { provide: Location, useValue: locationStub }, + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(BatchImportPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('if back button is pressed', () => { + beforeEach(fakeAsync(() => { + const proceed = fixture.debugElement.query(By.css('#backButton')).nativeElement; + proceed.click(); + fixture.detectChanges(); + })); + it('should do location.back', () => { + expect(locationStub.back).toHaveBeenCalled(); + }); + }); + + describe('if file is set', () => { + let fileMock: File; + + beforeEach(() => { + fileMock = new File([''], 'filename.zip', { type: 'application/zip' }); + component.setFile(fileMock); + }); + + describe('if proceed button is pressed without validate only', () => { + beforeEach(fakeAsync(() => { + component.validateOnly = false; + const proceed = fixture.debugElement.query(By.css('#proceedButton')).nativeElement; + proceed.click(); + fixture.detectChanges(); + })); + it('metadata-import script is invoked with --zip fileName and the mockFile', () => { + const parameterValues: ProcessParameter[] = [ + Object.assign(new ProcessParameter(), { name: '--zip', value: 'filename.zip' }), + ]; + parameterValues.push(Object.assign(new ProcessParameter(), { name: '--add' })); + expect(scriptService.invoke).toHaveBeenCalledWith(BATCH_IMPORT_SCRIPT_NAME, parameterValues, [fileMock]); + }); + it('success notification is shown', () => { + expect(notificationService.success).toHaveBeenCalled(); + }); + it('redirected to process page', () => { + expect(router.navigateByUrl).toHaveBeenCalledWith('/processes/46'); + }); + }); + + describe('if proceed button is pressed with validate only', () => { + beforeEach(fakeAsync(() => { + component.validateOnly = true; + const proceed = fixture.debugElement.query(By.css('#proceedButton')).nativeElement; + proceed.click(); + fixture.detectChanges(); + })); + it('metadata-import script is invoked with --zip fileName and the mockFile and -v validate-only', () => { + const parameterValues: ProcessParameter[] = [ + Object.assign(new ProcessParameter(), { name: '--zip', value: 'filename.zip' }), + Object.assign(new ProcessParameter(), { name: '--add' }), + Object.assign(new ProcessParameter(), { name: '-v', value: true }), + ]; + expect(scriptService.invoke).toHaveBeenCalledWith(BATCH_IMPORT_SCRIPT_NAME, parameterValues, [fileMock]); + }); + it('success notification is shown', () => { + expect(notificationService.success).toHaveBeenCalled(); + }); + it('redirected to process page', () => { + expect(router.navigateByUrl).toHaveBeenCalledWith('/processes/46'); + }); + }); + + describe('if proceed is pressed; but script invoke fails', () => { + beforeEach(fakeAsync(() => { + jasmine.getEnv().allowRespy(true); + spyOn(scriptService, 'invoke').and.returnValue(createFailedRemoteDataObject$('Error', 500)); + const proceed = fixture.debugElement.query(By.css('#proceedButton')).nativeElement; + proceed.click(); + fixture.detectChanges(); + })); + it('error notification is shown', () => { + expect(notificationService.error).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/src/app/admin/admin-import-batch-page/batch-import-page.component.ts b/src/app/admin/admin-import-batch-page/batch-import-page.component.ts new file mode 100644 index 0000000000..7171c67585 --- /dev/null +++ b/src/app/admin/admin-import-batch-page/batch-import-page.component.ts @@ -0,0 +1,124 @@ +import { Component } from '@angular/core'; +import { Location } from '@angular/common'; +import { TranslateService } from '@ngx-translate/core'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { BATCH_IMPORT_SCRIPT_NAME, ScriptDataService } from '../../core/data/processes/script-data.service'; +import { Router } from '@angular/router'; +import { ProcessParameter } from '../../process-page/processes/process-parameter.model'; +import { getFirstCompletedRemoteData } from '../../core/shared/operators'; +import { RemoteData } from '../../core/data/remote-data'; +import { Process } from '../../process-page/processes/process.model'; +import { isNotEmpty } from '../../shared/empty.util'; +import { getProcessDetailRoute } from '../../process-page/process-page-routing.paths'; +import { + ImportBatchSelectorComponent +} from '../../shared/dso-selector/modal-wrappers/import-batch-selector/import-batch-selector.component'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { take } from 'rxjs/operators'; +import { DSpaceObject } from '../../core/shared/dspace-object.model'; +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; + +@Component({ + selector: 'ds-batch-import-page', + templateUrl: './batch-import-page.component.html' +}) +export class BatchImportPageComponent { + /** + * The current value of the file + */ + fileObject: File; + + /** + * The validate only flag + */ + validateOnly = true; + /** + * dso object for community or collection + */ + dso: DSpaceObject = null; + + public constructor(private location: Location, + protected translate: TranslateService, + protected notificationsService: NotificationsService, + private scriptDataService: ScriptDataService, + private router: Router, + private modalService: NgbModal, + private dsoNameService: DSONameService) { + } + + /** + * Set file + * @param file + */ + setFile(file) { + this.fileObject = file; + } + + /** + * When return button is pressed go to previous location + */ + public onReturn() { + this.location.back(); + } + + public selectCollection() { + const modalRef = this.modalService.open(ImportBatchSelectorComponent); + modalRef.componentInstance.response.pipe(take(1)).subscribe((dso) => { + this.dso = dso || null; + }); + } + + /** + * Starts import-metadata script with --zip fileName (and the selected file) + */ + public importMetadata() { + if (this.fileObject == null) { + this.notificationsService.error(this.translate.get('admin.metadata-import.page.error.addFile')); + } else { + const parameterValues: ProcessParameter[] = [ + Object.assign(new ProcessParameter(), { name: '--zip', value: this.fileObject.name }), + Object.assign(new ProcessParameter(), { name: '--add' }) + ]; + if (this.dso) { + parameterValues.push(Object.assign(new ProcessParameter(), { name: '--collection', value: this.dso.uuid })); + } + if (this.validateOnly) { + parameterValues.push(Object.assign(new ProcessParameter(), { name: '-v', value: true })); + } + + this.scriptDataService.invoke(BATCH_IMPORT_SCRIPT_NAME, parameterValues, [this.fileObject]).pipe( + getFirstCompletedRemoteData(), + ).subscribe((rd: RemoteData) => { + if (rd.hasSucceeded) { + const title = this.translate.get('process.new.notification.success.title'); + const content = this.translate.get('process.new.notification.success.content'); + this.notificationsService.success(title, content); + if (isNotEmpty(rd.payload)) { + this.router.navigateByUrl(getProcessDetailRoute(rd.payload.processId)); + } + } else { + const title = this.translate.get('process.new.notification.error.title'); + const content = this.translate.get('process.new.notification.error.content'); + this.notificationsService.error(title, content); + } + }); + } + } + + /** + * return selected dspace object name + */ + getDspaceObjectName(): string { + if (this.dso) { + return this.dsoNameService.getName(this.dso); + } + return null; + } + + /** + * remove selected dso object + */ + removeDspaceObject(): void { + this.dso = null; + } +} diff --git a/src/app/admin/admin-routing.module.ts b/src/app/admin/admin-routing.module.ts index ee5cb8737b..1ea20bc9a0 100644 --- a/src/app/admin/admin-routing.module.ts +++ b/src/app/admin/admin-routing.module.ts @@ -7,6 +7,7 @@ import { AdminWorkflowPageComponent } from './admin-workflow-page/admin-workflow import { I18nBreadcrumbsService } from '../core/breadcrumbs/i18n-breadcrumbs.service'; import { AdminCurationTasksComponent } from './admin-curation-tasks/admin-curation-tasks.component'; import { REGISTRIES_MODULE_PATH } from './admin-routing-paths'; +import { BatchImportPageComponent } from './admin-import-batch-page/batch-import-page.component'; @NgModule({ imports: [ @@ -40,6 +41,12 @@ import { REGISTRIES_MODULE_PATH } from './admin-routing-paths'; component: MetadataImportPageComponent, data: { title: 'admin.metadata-import.title', breadcrumbKey: 'admin.metadata-import' } }, + { + path: 'batch-import', + resolve: { breadcrumb: I18nBreadcrumbResolver }, + component: BatchImportPageComponent, + data: { title: 'admin.batch-import.title', breadcrumbKey: 'admin.batch-import' } + }, ]) ], providers: [ diff --git a/src/app/admin/admin.module.ts b/src/app/admin/admin.module.ts index b28a0cf89e..0ddbefd253 100644 --- a/src/app/admin/admin.module.ts +++ b/src/app/admin/admin.module.ts @@ -9,6 +9,7 @@ import { AdminWorkflowModuleModule } from './admin-workflow-page/admin-workflow. import { AdminSearchModule } from './admin-search-page/admin-search.module'; import { AdminSidebarSectionComponent } from './admin-sidebar/admin-sidebar-section/admin-sidebar-section.component'; import { ExpandableAdminSidebarSectionComponent } from './admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component'; +import { BatchImportPageComponent } from './admin-import-batch-page/batch-import-page.component'; const ENTRY_COMPONENTS = [ // put only entry components that use custom decorator @@ -28,7 +29,8 @@ const ENTRY_COMPONENTS = [ ], declarations: [ AdminCurationTasksComponent, - MetadataImportPageComponent + MetadataImportPageComponent, + BatchImportPageComponent ] }) export class AdminModule { diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index 8bec7edc80..422ead99e1 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -1,10 +1,9 @@ import { Store, StoreModule } from '@ngrx/store'; import { ComponentFixture, inject, TestBed, waitForAsync } from '@angular/core/testing'; import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; -import { CommonModule, DOCUMENT } from '@angular/common'; +import { CommonModule } from '@angular/common'; import { ActivatedRoute, Router } from '@angular/router'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; -import { Angulartics2GoogleAnalytics } from 'angulartics2'; // Load the implementations that should be tested import { AppComponent } from './app.component'; @@ -73,7 +72,6 @@ describe('App component', () => { providers: [ { provide: NativeWindowService, useValue: new NativeWindowRef() }, { provide: MetadataService, useValue: new MetadataServiceMock() }, - { provide: Angulartics2GoogleAnalytics, useValue: new AngularticsProviderMock() }, { provide: Angulartics2DSpace, useValue: new AngularticsProviderMock() }, { provide: AuthService, useValue: new AuthServiceMock() }, { provide: Router, useValue: new RouterMock() }, diff --git a/src/app/browse-by/browse-by-date-page/browse-by-date-page.component.ts b/src/app/browse-by/browse-by-date-page/browse-by-date-page.component.ts index c9ee669e51..c4a67349a5 100644 --- a/src/app/browse-by/browse-by-date-page/browse-by-date-page.component.ts +++ b/src/app/browse-by/browse-by-date-page/browse-by-date-page.component.ts @@ -11,7 +11,6 @@ import { ActivatedRoute, Params, Router } from '@angular/router'; import { BrowseService } from '../../core/browse/browse.service'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; import { StartsWithType } from '../../shared/starts-with/starts-with-decorator'; -import { BrowseByDataType, rendersBrowseBy } from '../browse-by-switcher/browse-by-decorator'; import { PaginationService } from '../../core/pagination/pagination.service'; import { map } from 'rxjs/operators'; import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; @@ -29,7 +28,6 @@ import { AppConfig, APP_CONFIG } from '../../../config/app-config.interface'; * A metadata definition (a.k.a. browse id) is a short term used to describe one or multiple metadata fields. * An example would be 'dateissued' for 'dc.date.issued' */ -@rendersBrowseBy(BrowseByDataType.Date) export class BrowseByDatePageComponent extends BrowseByMetadataPageComponent { /** diff --git a/src/app/browse-by/browse-by-date-page/themed-browse-by-date-page.component.ts b/src/app/browse-by/browse-by-date-page/themed-browse-by-date-page.component.ts new file mode 100644 index 0000000000..8eeae0c5de --- /dev/null +++ b/src/app/browse-by/browse-by-date-page/themed-browse-by-date-page.component.ts @@ -0,0 +1,29 @@ +import {Component} from '@angular/core'; +import { ThemedComponent } from '../../shared/theme-support/themed.component'; +import { BrowseByDatePageComponent } from './browse-by-date-page.component'; +import {BrowseByDataType, rendersBrowseBy} from '../browse-by-switcher/browse-by-decorator'; + +/** + * Themed wrapper for BrowseByDatePageComponent + * */ +@Component({ + selector: 'ds-themed-browse-by-metadata-page', + styleUrls: [], + templateUrl: '../../shared/theme-support/themed.component.html', +}) + +@rendersBrowseBy(BrowseByDataType.Date) +export class ThemedBrowseByDatePageComponent + extends ThemedComponent { + protected getComponentName(): string { + return 'BrowseByDatePageComponent'; + } + + protected importThemedComponent(themeName: string): Promise { + return import(`../../../themes/${themeName}/app/browse-by/browse-by-date-page/browse-by-date-page.component`); + } + + protected importUnthemedComponent(): Promise { + return import(`./browse-by-date-page.component`); + } +} diff --git a/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.html b/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.html index eb15ac9523..227fa8aa78 100644 --- a/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.html +++ b/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.html @@ -6,10 +6,10 @@ - - + diff --git a/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.ts b/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.ts index a95aea7c0a..4cfe332da1 100644 --- a/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.ts +++ b/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.ts @@ -1,5 +1,5 @@ import { combineLatest as observableCombineLatest, Observable, Subscription } from 'rxjs'; -import { Component, Inject, OnInit } from '@angular/core'; +import { Component, Inject, OnInit, OnDestroy } from '@angular/core'; import { RemoteData } from '../../core/data/remote-data'; import { PaginatedList } from '../../core/data/paginated-list.model'; import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; @@ -14,7 +14,6 @@ import { getFirstSucceededRemoteData } from '../../core/shared/operators'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { StartsWithType } from '../../shared/starts-with/starts-with-decorator'; -import { BrowseByDataType, rendersBrowseBy } from '../browse-by-switcher/browse-by-decorator'; import { PaginationService } from '../../core/pagination/pagination.service'; import { map } from 'rxjs/operators'; import { APP_CONFIG, AppConfig } from '../../../config/app-config.interface'; @@ -32,8 +31,7 @@ export const BBM_PAGINATION_ID = 'bbm'; * or multiple metadata fields. An example would be 'author' for * 'dc.contributor.*' */ -@rendersBrowseBy(BrowseByDataType.Metadata) -export class BrowseByMetadataPageComponent implements OnInit { +export class BrowseByMetadataPageComponent implements OnInit, OnDestroy { /** * The list of browse-entries to display @@ -93,7 +91,7 @@ export class BrowseByMetadataPageComponent implements OnInit { startsWithOptions; /** - * The value we're browing items for + * The value we're browsing items for * - When the value is not empty, we're browsing items * - When the value is empty, we're browsing browse-entries (values for the given metadata definition) */ diff --git a/src/app/browse-by/browse-by-metadata-page/themed-browse-by-metadata-page.component.ts b/src/app/browse-by/browse-by-metadata-page/themed-browse-by-metadata-page.component.ts new file mode 100644 index 0000000000..b0679258e9 --- /dev/null +++ b/src/app/browse-by/browse-by-metadata-page/themed-browse-by-metadata-page.component.ts @@ -0,0 +1,29 @@ +import {Component} from '@angular/core'; +import { ThemedComponent } from '../../shared/theme-support/themed.component'; +import { BrowseByMetadataPageComponent } from './browse-by-metadata-page.component'; +import {BrowseByDataType, rendersBrowseBy} from '../browse-by-switcher/browse-by-decorator'; + +/** + * Themed wrapper for BrowseByMetadataPageComponent + **/ +@Component({ + selector: 'ds-themed-browse-by-metadata-page', + styleUrls: [], + templateUrl: '../../shared/theme-support/themed.component.html', +}) + +@rendersBrowseBy(BrowseByDataType.Metadata) +export class ThemedBrowseByMetadataPageComponent + extends ThemedComponent { + protected getComponentName(): string { + return 'BrowseByMetadataPageComponent'; + } + + protected importThemedComponent(themeName: string): Promise { + return import(`../../../themes/${themeName}/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component`); + } + + protected importUnthemedComponent(): Promise { + return import(`./browse-by-metadata-page.component`); + } +} diff --git a/src/app/browse-by/browse-by-title-page/browse-by-title-page.component.ts b/src/app/browse-by/browse-by-title-page/browse-by-title-page.component.ts index 85693ccecd..5320d7bb48 100644 --- a/src/app/browse-by/browse-by-title-page/browse-by-title-page.component.ts +++ b/src/app/browse-by/browse-by-title-page/browse-by-title-page.component.ts @@ -9,7 +9,6 @@ import { import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; import { BrowseService } from '../../core/browse/browse.service'; import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; -import { BrowseByDataType, rendersBrowseBy } from '../browse-by-switcher/browse-by-decorator'; import { PaginationService } from '../../core/pagination/pagination.service'; import { map } from 'rxjs/operators'; import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; @@ -23,7 +22,6 @@ import { AppConfig, APP_CONFIG } from '../../../config/app-config.interface'; /** * Component for browsing items by title (dc.title) */ -@rendersBrowseBy(BrowseByDataType.Title) export class BrowseByTitlePageComponent extends BrowseByMetadataPageComponent { public constructor(protected route: ActivatedRoute, diff --git a/src/app/browse-by/browse-by-title-page/themed-browse-by-title-page.component.ts b/src/app/browse-by/browse-by-title-page/themed-browse-by-title-page.component.ts new file mode 100644 index 0000000000..4a1bcc0bc1 --- /dev/null +++ b/src/app/browse-by/browse-by-title-page/themed-browse-by-title-page.component.ts @@ -0,0 +1,29 @@ +import {Component} from '@angular/core'; +import { ThemedComponent } from '../../shared/theme-support/themed.component'; +import { BrowseByTitlePageComponent } from './browse-by-title-page.component'; +import {BrowseByDataType, rendersBrowseBy} from '../browse-by-switcher/browse-by-decorator'; + +/** + * Themed wrapper for BrowseByTitlePageComponent + */ +@Component({ + selector: 'ds-themed-browse-by-title-page', + styleUrls: [], + templateUrl: '../../shared/theme-support/themed.component.html', +}) + +@rendersBrowseBy(BrowseByDataType.Title) +export class ThemedBrowseByTitlePageComponent + extends ThemedComponent { + protected getComponentName(): string { + return 'BrowseByTitlePageComponent'; + } + + protected importThemedComponent(themeName: string): Promise { + return import(`../../../themes/${themeName}/app/browse-by/browse-by-title-page/browse-by-title-page.component`); + } + + protected importUnthemedComponent(): Promise { + return import(`./browse-by-title-page.component`); + } +} diff --git a/src/app/browse-by/browse-by.module.ts b/src/app/browse-by/browse-by.module.ts index e1dfaacea5..14e21f8b4c 100644 --- a/src/app/browse-by/browse-by.module.ts +++ b/src/app/browse-by/browse-by.module.ts @@ -7,12 +7,20 @@ import { BrowseByDatePageComponent } from './browse-by-date-page/browse-by-date- import { BrowseBySwitcherComponent } from './browse-by-switcher/browse-by-switcher.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'; const ENTRY_COMPONENTS = [ // put only entry components that use custom decorator BrowseByTitlePageComponent, BrowseByMetadataPageComponent, - BrowseByDatePageComponent + BrowseByDatePageComponent, + + ThemedBrowseByMetadataPageComponent, + ThemedBrowseByDatePageComponent, + ThemedBrowseByTitlePageComponent, + ]; @NgModule({ diff --git a/src/app/collection-page/collection-page.component.html b/src/app/collection-page/collection-page.component.html index c1df38f793..eebfdbd829 100644 --- a/src/app/collection-page/collection-page.component.html +++ b/src/app/collection-page/collection-page.component.html @@ -17,10 +17,10 @@ - - + - - + + diff --git a/src/app/community-page/edit-community-page/community-roles/community-roles.component.html b/src/app/community-page/edit-community-page/community-roles/community-roles.component.html index 07d5d96fcd..37a70ff403 100644 --- a/src/app/community-page/edit-community-page/community-roles/community-roles.component.html +++ b/src/app/community-page/edit-community-page/community-roles/community-roles.component.html @@ -1,5 +1,5 @@ diff --git a/src/app/community-page/edit-community-page/community-roles/community-roles.component.spec.ts b/src/app/community-page/edit-community-page/community-roles/community-roles.component.spec.ts index baea5fb9d0..9dddb87f8d 100644 --- a/src/app/community-page/edit-community-page/community-roles/community-roles.component.spec.ts +++ b/src/app/community-page/edit-community-page/community-roles/community-roles.component.spec.ts @@ -78,8 +78,9 @@ describe('CommunityRolesComponent', () => { fixture.detectChanges(); }); - it('should display a community admin role component', () => { + it('should display a community admin role component', (done) => { expect(de.query(By.css('ds-comcol-role .community-admin'))) .toBeTruthy(); + done(); }); }); diff --git a/src/app/community-page/edit-community-page/community-roles/community-roles.component.ts b/src/app/community-page/edit-community-page/community-roles/community-roles.component.ts index 3bb2de9a62..9468aa7048 100644 --- a/src/app/community-page/edit-community-page/community-roles/community-roles.component.ts +++ b/src/app/community-page/edit-community-page/community-roles/community-roles.component.ts @@ -19,28 +19,14 @@ export class CommunityRolesComponent implements OnInit { dsoRD$: Observable>; /** - * The community to manage, as an observable. + * The different roles for the community, as an observable. */ - get community$(): Observable { - return this.dsoRD$.pipe( - getFirstSucceededRemoteData(), - getRemoteDataPayload(), - ); - } + comcolRoles$: Observable; /** - * The different roles for the community. + * The community to manage, as an observable. */ - getComcolRoles$(): Observable { - return this.community$.pipe( - map((community) => [ - { - name: 'community-admin', - href: community._links.adminGroup.href, - }, - ]), - ); - } + community$: Observable; constructor( protected route: ActivatedRoute, @@ -52,5 +38,22 @@ export class CommunityRolesComponent implements OnInit { first(), map((data) => data.dso), ); + + this.community$ = this.dsoRD$.pipe( + getFirstSucceededRemoteData(), + getRemoteDataPayload(), + ); + + /** + * The different roles for the community. + */ + this.comcolRoles$ = this.community$.pipe( + map((community) => [ + { + name: 'community-admin', + href: community._links.adminGroup.href, + }, + ]), + ); } } diff --git a/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.spec.ts b/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.spec.ts index 60b1c78f8c..6a9de52f1f 100644 --- a/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.spec.ts +++ b/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.spec.ts @@ -180,17 +180,20 @@ describe('CommunityPageSubCollectionList Component', () => { comp.community = mockCommunity; }); - it('should display a list of collections', () => { - subCollList = collections; - fixture.detectChanges(); - const collList = fixture.debugElement.queryAll(By.css('li')); - expect(collList.length).toEqual(5); - expect(collList[0].nativeElement.textContent).toContain('Collection 1'); - expect(collList[1].nativeElement.textContent).toContain('Collection 2'); - expect(collList[2].nativeElement.textContent).toContain('Collection 3'); - expect(collList[3].nativeElement.textContent).toContain('Collection 4'); - expect(collList[4].nativeElement.textContent).toContain('Collection 5'); + it('should display a list of collections', () => { + waitForAsync(() => { + subCollList = collections; + fixture.detectChanges(); + + const collList = fixture.debugElement.queryAll(By.css('li')); + expect(collList.length).toEqual(5); + expect(collList[0].nativeElement.textContent).toContain('Collection 1'); + expect(collList[1].nativeElement.textContent).toContain('Collection 2'); + expect(collList[2].nativeElement.textContent).toContain('Collection 3'); + expect(collList[3].nativeElement.textContent).toContain('Collection 4'); + expect(collList[4].nativeElement.textContent).toContain('Collection 5'); + }); }); it('should not display the header when list of collections is empty', () => { diff --git a/src/app/community-page/sub-community-list/community-page-sub-community-list.component.spec.ts b/src/app/community-page/sub-community-list/community-page-sub-community-list.component.spec.ts index c3db1bd17d..c75c5b6f6c 100644 --- a/src/app/community-page/sub-community-list/community-page-sub-community-list.component.spec.ts +++ b/src/app/community-page/sub-community-list/community-page-sub-community-list.component.spec.ts @@ -181,17 +181,20 @@ describe('CommunityPageSubCommunityListComponent Component', () => { }); - it('should display a list of sub-communities', () => { - subCommList = subcommunities; - fixture.detectChanges(); - const subComList = fixture.debugElement.queryAll(By.css('li')); - expect(subComList.length).toEqual(5); - expect(subComList[0].nativeElement.textContent).toContain('SubCommunity 1'); - expect(subComList[1].nativeElement.textContent).toContain('SubCommunity 2'); - expect(subComList[2].nativeElement.textContent).toContain('SubCommunity 3'); - expect(subComList[3].nativeElement.textContent).toContain('SubCommunity 4'); - expect(subComList[4].nativeElement.textContent).toContain('SubCommunity 5'); + it('should display a list of sub-communities', () => { + waitForAsync(() => { + subCommList = subcommunities; + fixture.detectChanges(); + + const subComList = fixture.debugElement.queryAll(By.css('li')); + expect(subComList.length).toEqual(5); + expect(subComList[0].nativeElement.textContent).toContain('SubCommunity 1'); + expect(subComList[1].nativeElement.textContent).toContain('SubCommunity 2'); + expect(subComList[2].nativeElement.textContent).toContain('SubCommunity 3'); + expect(subComList[3].nativeElement.textContent).toContain('SubCommunity 4'); + expect(subComList[4].nativeElement.textContent).toContain('SubCommunity 5'); + }); }); it('should not display the header when list of sub-communities is empty', () => { diff --git a/src/app/core/cache/object-cache.actions.ts b/src/app/core/cache/object-cache.actions.ts index 88b4730b3f..c18a20ffd6 100644 --- a/src/app/core/cache/object-cache.actions.ts +++ b/src/app/core/cache/object-cache.actions.ts @@ -13,7 +13,9 @@ export const ObjectCacheActionTypes = { REMOVE: type('dspace/core/cache/object/REMOVE'), RESET_TIMESTAMPS: type('dspace/core/cache/object/RESET_TIMESTAMPS'), ADD_PATCH: type('dspace/core/cache/object/ADD_PATCH'), - APPLY_PATCH: type('dspace/core/cache/object/APPLY_PATCH') + APPLY_PATCH: type('dspace/core/cache/object/APPLY_PATCH'), + ADD_DEPENDENTS: type('dspace/core/cache/object/ADD_DEPENDENTS'), + REMOVE_DEPENDENTS: type('dspace/core/cache/object/REMOVE_DEPENDENTS') }; /** @@ -126,13 +128,55 @@ export class ApplyPatchObjectCacheAction implements Action { } } +/** + * An NgRx action to add dependent request UUIDs to a cached object + */ +export class AddDependentsObjectCacheAction implements Action { + type = ObjectCacheActionTypes.ADD_DEPENDENTS; + payload: { + href: string; + dependentRequestUUIDs: string[]; + }; + + /** + * Create a new AddDependentsObjectCacheAction + * + * @param href the self link of a cached object + * @param dependentRequestUUIDs the UUID of the request that depends on this object + */ + constructor(href: string, dependentRequestUUIDs: string[]) { + this.payload = { + href, + dependentRequestUUIDs, + }; + } +} + +/** + * An NgRx action to remove all dependent request UUIDs from a cached object + */ +export class RemoveDependentsObjectCacheAction implements Action { + type = ObjectCacheActionTypes.REMOVE_DEPENDENTS; + payload: string; + + /** + * Create a new RemoveDependentsObjectCacheAction + * + * @param href the self link of a cached object for which to remove all dependent request UUIDs + */ + constructor(href: string) { + this.payload = href; + } +} /** * A type to encompass all ObjectCacheActions */ export type ObjectCacheAction = AddToObjectCacheAction - | RemoveFromObjectCacheAction - | ResetObjectCacheTimestampsAction - | AddPatchObjectCacheAction - | ApplyPatchObjectCacheAction; + | RemoveFromObjectCacheAction + | ResetObjectCacheTimestampsAction + | AddPatchObjectCacheAction + | ApplyPatchObjectCacheAction + | AddDependentsObjectCacheAction + | RemoveDependentsObjectCacheAction; diff --git a/src/app/core/cache/object-cache.reducer.spec.ts b/src/app/core/cache/object-cache.reducer.spec.ts index 82e2da58b1..e346a00b61 100644 --- a/src/app/core/cache/object-cache.reducer.spec.ts +++ b/src/app/core/cache/object-cache.reducer.spec.ts @@ -2,11 +2,13 @@ import * as deepFreeze from 'deep-freeze'; import { Operation } from 'fast-json-patch'; import { Item } from '../shared/item.model'; import { + AddDependentsObjectCacheAction, AddPatchObjectCacheAction, AddToObjectCacheAction, ApplyPatchObjectCacheAction, + RemoveDependentsObjectCacheAction, RemoveFromObjectCacheAction, - ResetObjectCacheTimestampsAction + ResetObjectCacheTimestampsAction, } from './object-cache.actions'; import { objectCacheReducer } from './object-cache.reducer'; @@ -42,20 +44,22 @@ describe('objectCacheReducer', () => { timeCompleted: new Date().getTime(), msToLive: 900000, requestUUIDs: [requestUUID1], + dependentRequestUUIDs: [], patches: [], isDirty: false, }, [selfLink2]: { data: { type: Item.type, - self: requestUUID2, + self: selfLink2, foo: 'baz', - _links: { self: { href: requestUUID2 } } + _links: { self: { href: selfLink2 } } }, alternativeLinks: [altLink3, altLink4], timeCompleted: new Date().getTime(), msToLive: 900000, - requestUUIDs: [selfLink2], + requestUUIDs: [requestUUID2], + dependentRequestUUIDs: [requestUUID1], patches: [], isDirty: false } @@ -189,4 +193,20 @@ describe('objectCacheReducer', () => { expect((newState[selfLink1].data as any).name).toEqual(newName); }); + it('should add dependent requests on ADD_DEPENDENTS', () => { + let newState = objectCacheReducer(testState, new AddDependentsObjectCacheAction(selfLink1, ['new', 'newer', 'newest'])); + expect(newState[selfLink1].dependentRequestUUIDs).toEqual(['new', 'newer', 'newest']); + + newState = objectCacheReducer(newState, new AddDependentsObjectCacheAction(selfLink2, ['more'])); + expect(newState[selfLink2].dependentRequestUUIDs).toEqual([requestUUID1, 'more']); + }); + + it('should clear dependent requests on REMOVE_DEPENDENTS', () => { + let newState = objectCacheReducer(testState, new RemoveDependentsObjectCacheAction(selfLink1)); + expect(newState[selfLink1].dependentRequestUUIDs).toEqual([]); + + newState = objectCacheReducer(newState, new RemoveDependentsObjectCacheAction(selfLink2)); + expect(newState[selfLink2].dependentRequestUUIDs).toEqual([]); + }); + }); diff --git a/src/app/core/cache/object-cache.reducer.ts b/src/app/core/cache/object-cache.reducer.ts index 1a42408f72..dc3f50db68 100644 --- a/src/app/core/cache/object-cache.reducer.ts +++ b/src/app/core/cache/object-cache.reducer.ts @@ -1,12 +1,13 @@ /* eslint-disable max-classes-per-file */ import { + AddDependentsObjectCacheAction, AddPatchObjectCacheAction, AddToObjectCacheAction, ApplyPatchObjectCacheAction, ObjectCacheAction, - ObjectCacheActionTypes, + ObjectCacheActionTypes, RemoveDependentsObjectCacheAction, RemoveFromObjectCacheAction, - ResetObjectCacheTimestampsAction + ResetObjectCacheTimestampsAction, } from './object-cache.actions'; import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { CacheEntry } from './cache-entry'; @@ -69,6 +70,12 @@ export class ObjectCacheEntry implements CacheEntry { */ requestUUIDs: string[]; + /** + * A list of UUIDs for the requests that depend on this object. + * When this object is invalidated, these requests will be invalidated as well. + */ + dependentRequestUUIDs: string[]; + /** * An array of patches that were made on the client side to this entry, but haven't been sent to the server yet */ @@ -134,6 +141,14 @@ export function objectCacheReducer(state = initialState, action: ObjectCacheActi return applyPatchObjectCache(state, action as ApplyPatchObjectCacheAction); } + case ObjectCacheActionTypes.ADD_DEPENDENTS: { + return addDependentsObjectCacheState(state, action as AddDependentsObjectCacheAction); + } + + case ObjectCacheActionTypes.REMOVE_DEPENDENTS: { + return removeDependentsObjectCacheState(state, action as RemoveDependentsObjectCacheAction); + } + default: { return state; } @@ -159,6 +174,7 @@ function addToObjectCache(state: ObjectCacheState, action: AddToObjectCacheActio timeCompleted: action.payload.timeCompleted, msToLive: action.payload.msToLive, requestUUIDs: [action.payload.requestUUID, ...(existing.requestUUIDs || [])], + dependentRequestUUIDs: existing.dependentRequestUUIDs || [], isDirty: isNotEmpty(existing.patches), patches: existing.patches || [], alternativeLinks: [...(existing.alternativeLinks || []), ...newAltLinks] @@ -252,3 +268,49 @@ function applyPatchObjectCache(state: ObjectCacheState, action: ApplyPatchObject } return newState; } + +/** + * Add a list of dependent request UUIDs to a cached object, used when defining new dependencies + * + * @param state the current state + * @param action an AddDependentsObjectCacheAction + * @return the new state, with the dependent requests of the cached object updated + */ +function addDependentsObjectCacheState(state: ObjectCacheState, action: AddDependentsObjectCacheAction): ObjectCacheState { + const href = action.payload.href; + const newState = Object.assign({}, state); + + if (hasValue(newState[href])) { + newState[href] = Object.assign({}, newState[href], { + dependentRequestUUIDs: [ + ...new Set([ + ...newState[href]?.dependentRequestUUIDs || [], + ...action.payload.dependentRequestUUIDs, + ]) + ] + }); + } + + return newState; +} + + +/** + * Remove all dependent request UUIDs from a cached object, used to clear out-of-date depedencies + * + * @param state the current state + * @param action an AddDependentsObjectCacheAction + * @return the new state, with the dependent requests of the cached object updated + */ +function removeDependentsObjectCacheState(state: ObjectCacheState, action: RemoveDependentsObjectCacheAction): ObjectCacheState { + const href = action.payload; + const newState = Object.assign({}, state); + + if (hasValue(newState[href])) { + newState[href] = Object.assign({}, newState[href], { + dependentRequestUUIDs: [] + }); + } + + return newState; +} diff --git a/src/app/core/cache/object-cache.service.spec.ts b/src/app/core/cache/object-cache.service.spec.ts index f18c262524..6af797be29 100644 --- a/src/app/core/cache/object-cache.service.spec.ts +++ b/src/app/core/cache/object-cache.service.spec.ts @@ -11,10 +11,12 @@ import { coreReducers} from '../core.reducers'; import { RestRequestMethod } from '../data/rest-request-method'; import { Item } from '../shared/item.model'; import { + AddDependentsObjectCacheAction, + RemoveDependentsObjectCacheAction, AddPatchObjectCacheAction, AddToObjectCacheAction, ApplyPatchObjectCacheAction, - RemoveFromObjectCacheAction + RemoveFromObjectCacheAction, } from './object-cache.actions'; import { Patch } from './object-cache.reducer'; import { ObjectCacheService } from './object-cache.service'; @@ -25,6 +27,7 @@ import { storeModuleConfig } from '../../app.reducer'; import { TestColdObservable } from 'jasmine-marbles/src/test-observables'; import { IndexName } from '../index/index-name.model'; import { CoreState } from '../core-state.model'; +import { TestScheduler } from 'rxjs/testing'; describe('ObjectCacheService', () => { let service: ObjectCacheService; @@ -38,6 +41,7 @@ describe('ObjectCacheService', () => { let altLink1; let altLink2; let requestUUID; + let requestUUID2; let alternativeLink; let timestamp; let timestamp2; @@ -55,6 +59,7 @@ describe('ObjectCacheService', () => { altLink1 = 'https://alternative.link/endpoint/1234'; altLink2 = 'https://alternative.link/endpoint/5678'; requestUUID = '4d3a4ce8-a375-4b98-859b-39f0a014d736'; + requestUUID2 = 'c0f486c1-c4d3-4a03-b293-ca5b71ff0054'; alternativeLink = 'https://rest.api/endpoint/5e4f8a5-be98-4c51-9fd8-6bfedcbd59b7/item'; timestamp = new Date().getTime(); timestamp2 = new Date().getTime() - 200; @@ -71,13 +76,17 @@ describe('ObjectCacheService', () => { data: objectToCache, timeCompleted: timestamp, msToLive: msToLive, - alternativeLinks: [altLink1, altLink2] + alternativeLinks: [altLink1, altLink2], + requestUUIDs: [requestUUID], + dependentRequestUUIDs: [], }; cacheEntry2 = { data: objectToCache, timeCompleted: timestamp2, msToLive: msToLive2, - alternativeLinks: [altLink2] + alternativeLinks: [altLink2], + requestUUIDs: [requestUUID2], + dependentRequestUUIDs: [], }; invalidCacheEntry = Object.assign({}, cacheEntry, { msToLive: -1 }); operations = [{ op: 'replace', path: '/name', value: 'random string' } as Operation]; @@ -343,4 +352,122 @@ describe('ObjectCacheService', () => { expect(store.dispatch).toHaveBeenCalledWith(new ApplyPatchObjectCacheAction(selfLink)); }); }); + + describe('request dependencies', () => { + beforeEach(() => { + const state = Object.assign({}, initialState, { + core: Object.assign({}, initialState.core, { + 'cache/object': { + ['objectWithoutDependents']: { + dependentRequestUUIDs: [], + }, + ['objectWithDependents']: { + dependentRequestUUIDs: [requestUUID], + }, + [selfLink]: cacheEntry, + }, + 'index': { + 'object/alt-link-to-self-link': { + [anotherLink]: selfLink, + ['objectWithoutDependentsAlt']: 'objectWithoutDependents', + ['objectWithDependentsAlt']: 'objectWithDependents', + } + } + }) + }); + mockStore.setState(state); + }); + + describe('addDependency', () => { + it('should dispatch an ADD_DEPENDENTS action', () => { + service.addDependency(selfLink, 'objectWithoutDependents'); + expect(store.dispatch).toHaveBeenCalledOnceWith(new AddDependentsObjectCacheAction('objectWithoutDependents', [requestUUID])); + }); + + it('should resolve alt links', () => { + service.addDependency(anotherLink, 'objectWithoutDependentsAlt'); + expect(store.dispatch).toHaveBeenCalledOnceWith(new AddDependentsObjectCacheAction('objectWithoutDependents', [requestUUID])); + }); + + it('should not dispatch if either href cannot be resolved to a cached self link', () => { + service.addDependency(selfLink, 'unknown'); + service.addDependency('unknown', 'objectWithoutDependents'); + service.addDependency('nothing', 'matches'); + expect(store.dispatch).not.toHaveBeenCalled(); + }); + + it('should not dispatch if either href is undefined', () => { + service.addDependency(selfLink, undefined); + service.addDependency(undefined, 'objectWithoutDependents'); + service.addDependency(undefined, undefined); + expect(store.dispatch).not.toHaveBeenCalled(); + }); + + it('should not dispatch if the dependency exists already', () => { + service.addDependency(selfLink, 'objectWithDependents'); + expect(store.dispatch).not.toHaveBeenCalled(); + }); + + it('should work with observable hrefs', () => { + service.addDependency(observableOf(selfLink), observableOf('objectWithoutDependents')); + expect(store.dispatch).toHaveBeenCalledOnceWith(new AddDependentsObjectCacheAction('objectWithoutDependents', [requestUUID])); + }); + + it('should only dispatch once for the first value of either observable href', () => { + const testScheduler = new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected); + }); + + testScheduler.run(({ cold: tsCold, flush }) => { + const href$ = tsCold('--y-n-n', { + y: selfLink, + n: 'NOPE' + }); + const dependsOnHref$ = tsCold('-y-n-n', { + y: 'objectWithoutDependents', + n: 'NOPE' + }); + + service.addDependency(href$, dependsOnHref$); + flush(); + + expect(store.dispatch).toHaveBeenCalledOnceWith(new AddDependentsObjectCacheAction('objectWithoutDependents', [requestUUID])); + }); + }); + + it('should not dispatch if either of the hrefs emits undefined', () => { + const testScheduler = new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected); + }); + + testScheduler.run(({ cold: tsCold, flush }) => { + const undefined$ = tsCold('--u'); + + service.addDependency(selfLink, undefined$); + service.addDependency(undefined$, 'objectWithoutDependents'); + service.addDependency(undefined$, undefined$); + flush(); + + expect(store.dispatch).not.toHaveBeenCalled(); + }); + }); + }); + + describe('removeDependents', () => { + it('should dispatch a REMOVE_DEPENDENTS action', () => { + service.removeDependents('objectWithDependents'); + expect(store.dispatch).toHaveBeenCalledOnceWith(new RemoveDependentsObjectCacheAction('objectWithDependents')); + }); + + it('should resolve alt links', () => { + service.removeDependents('objectWithDependentsAlt'); + expect(store.dispatch).toHaveBeenCalledOnceWith(new RemoveDependentsObjectCacheAction('objectWithDependents')); + }); + + it('should not dispatch if the href cannot be resolved to a cached self link', () => { + service.removeDependents('unknown'); + expect(store.dispatch).not.toHaveBeenCalled(); + }); + }); + }); }); diff --git a/src/app/core/cache/object-cache.service.ts b/src/app/core/cache/object-cache.service.ts index cdf87e5c1a..9ca0216210 100644 --- a/src/app/core/cache/object-cache.service.ts +++ b/src/app/core/cache/object-cache.service.ts @@ -4,23 +4,15 @@ import { applyPatch, Operation } from 'fast-json-patch'; import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs'; import { distinctUntilChanged, filter, map, mergeMap, switchMap, take } from 'rxjs/operators'; -import { hasValue, isNotEmpty, isEmpty } from '../../shared/empty.util'; +import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util'; import { CoreState } from '../core-state.model'; import { coreSelector } from '../core.selectors'; import { RestRequestMethod } from '../data/rest-request-method'; -import { - selfLinkFromAlternativeLinkSelector, - selfLinkFromUuidSelector -} from '../index/index.selectors'; +import { selfLinkFromAlternativeLinkSelector, selfLinkFromUuidSelector } from '../index/index.selectors'; import { GenericConstructor } from '../shared/generic-constructor'; import { getClassForType } from './builders/build-decorators'; import { LinkService } from './builders/link.service'; -import { - AddPatchObjectCacheAction, - AddToObjectCacheAction, - ApplyPatchObjectCacheAction, - RemoveFromObjectCacheAction -} from './object-cache.actions'; +import { AddDependentsObjectCacheAction, AddPatchObjectCacheAction, AddToObjectCacheAction, ApplyPatchObjectCacheAction, RemoveDependentsObjectCacheAction, RemoveFromObjectCacheAction } from './object-cache.actions'; import { ObjectCacheEntry, ObjectCacheState } from './object-cache.reducer'; import { AddToSSBAction } from './server-sync-buffer.actions'; @@ -339,4 +331,97 @@ export class ObjectCacheService { this.store.dispatch(new ApplyPatchObjectCacheAction(selfLink)); } + /** + * Add a new dependency between two cached objects. + * When {@link dependsOnHref$} is invalidated, {@link href$} will be invalidated as well. + * + * This method should be called _after_ requests have been sent; + * it will only work if both objects are already present in the cache. + * + * If either object is undefined, the dependency will not be added + * + * @param href$ the href of an object to add a dependency to + * @param dependsOnHref$ the href of the new dependency + */ + addDependency(href$: string | Observable, dependsOnHref$: string | Observable) { + if (hasNoValue(href$) || hasNoValue(dependsOnHref$)) { + return; + } + + if (typeof href$ === 'string') { + href$ = observableOf(href$); + } + if (typeof dependsOnHref$ === 'string') { + dependsOnHref$ = observableOf(dependsOnHref$); + } + + observableCombineLatest([ + href$, + dependsOnHref$.pipe( + switchMap(dependsOnHref => this.resolveSelfLink(dependsOnHref)) + ), + ]).pipe( + switchMap(([href, dependsOnSelfLink]: [string, string]) => { + const dependsOnSelfLink$ = observableOf(dependsOnSelfLink); + + return observableCombineLatest([ + dependsOnSelfLink$, + dependsOnSelfLink$.pipe( + switchMap(selfLink => this.getBySelfLink(selfLink)), + map(oce => oce?.dependentRequestUUIDs || []), + ), + this.getByHref(href).pipe( + // only add the latest request to keep dependency index from growing indefinitely + map((entry: ObjectCacheEntry) => entry?.requestUUIDs?.[0]), + ) + ]); + }), + take(1), + ).subscribe(([dependsOnSelfLink, currentDependents, newDependent]: [string, string[], string]) => { + // don't dispatch if either href is invalid or if the new dependency already exists + if (hasValue(dependsOnSelfLink) && hasValue(newDependent) && !currentDependents.includes(newDependent)) { + this.store.dispatch(new AddDependentsObjectCacheAction(dependsOnSelfLink, [newDependent])); + } + }); + } + + /** + * Clear all dependent requests associated with a cache entry. + * + * @href the href of a cached object + */ + removeDependents(href: string) { + this.resolveSelfLink(href).pipe( + take(1), + ).subscribe((selfLink: string) => { + if (hasValue(selfLink)) { + this.store.dispatch(new RemoveDependentsObjectCacheAction(selfLink)); + } + }); + } + + + /** + * Resolve the self link of an existing cached object from an arbitrary href + * + * @param href any href + * @return an observable of the self link corresponding to the given href. + * Will emit the given href if it was a self link, another href + * if the given href was an alt link, or undefined if there is no + * cached object for this href. + */ + private resolveSelfLink(href: string): Observable { + return this.getBySelfLink(href).pipe( + switchMap((oce: ObjectCacheEntry) => { + if (isNotEmpty(oce)) { + return [href]; + } else { + return this.store.pipe( + select(selfLinkFromAlternativeLinkSelector(href)), + ); + } + }), + ); + } + } diff --git a/src/app/core/data/base/base-data.service.spec.ts b/src/app/core/data/base/base-data.service.spec.ts index cd8b8c3e32..17532f477a 100644 --- a/src/app/core/data/base/base-data.service.spec.ts +++ b/src/app/core/data/base/base-data.service.spec.ts @@ -10,7 +10,7 @@ import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.s import { HALEndpointService } from '../../shared/hal-endpoint.service'; import { ObjectCacheService } from '../../cache/object-cache.service'; import { FindListOptions } from '../find-list-options.model'; -import { Observable, of as observableOf } from 'rxjs'; +import { Observable, of as observableOf, combineLatest as observableCombineLatest } from 'rxjs'; import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; import { HALEndpointServiceStub } from '../../../shared/testing/hal-endpoint-service.stub'; import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; @@ -20,6 +20,7 @@ import { RemoteData } from '../remote-data'; import { RequestEntryState } from '../request-entry-state.model'; import { fakeAsync, tick } from '@angular/core/testing'; import { BaseDataService } from './base-data.service'; +import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; const endpoint = 'https://rest.api/core'; @@ -65,7 +66,13 @@ describe('BaseDataService', () => { }, getByHref: () => { /* empty */ - } + }, + addDependency: () => { + /* empty */ + }, + removeDependents: () => { + /* empty */ + }, } as any; selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7'; linksToFollow = [ @@ -558,7 +565,8 @@ describe('BaseDataService', () => { beforeEach(() => { getByHrefSpy = spyOn(objectCache, 'getByHref').and.returnValue(observableOf({ - requestUUIDs: ['request1', 'request2', 'request3'] + requestUUIDs: ['request1', 'request2', 'request3'], + dependentRequestUUIDs: ['request4', 'request5'] })); }); @@ -570,6 +578,8 @@ describe('BaseDataService', () => { expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request1'); expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request2'); expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request3'); + expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request4'); + expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request5'); done(); }); }); @@ -582,6 +592,8 @@ describe('BaseDataService', () => { expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request1'); expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request2'); expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request3'); + expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request4'); + expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request5'); })); it('should return an Observable that only emits true once all requests are stale', () => { @@ -591,9 +603,13 @@ describe('BaseDataService', () => { case 'request1': return cold('--(t|)', BOOLEAN); case 'request2': - return cold('----(t|)', BOOLEAN); - case 'request3': return cold('------(t|)', BOOLEAN); + case 'request3': + return cold('---(t|)', BOOLEAN); + case 'request4': + return cold('-(t|)', BOOLEAN); + case 'request5': + return cold('----(t|)', BOOLEAN); } }); @@ -607,9 +623,9 @@ describe('BaseDataService', () => { it('should only fire for the current state of the object (instead of tracking it)', () => { testScheduler.run(({ cold, flush }) => { getByHrefSpy.and.returnValue(cold('a---b---c---', { - a: { requestUUIDs: ['request1'] }, // this is the state at the moment we're invalidating the cache - b: { requestUUIDs: ['request2'] }, // we shouldn't keep tracking the state - c: { requestUUIDs: ['request3'] }, // because we may invalidate when we shouldn't + a: { requestUUIDs: ['request1'], dependentRequestUUIDs: [] }, // this is the state at the moment we're invalidating the cache + b: { requestUUIDs: ['request2'], dependentRequestUUIDs: [] }, // we shouldn't keep tracking the state + c: { requestUUIDs: ['request3'], dependentRequestUUIDs: [] }, // because we may invalidate when we shouldn't })); service.invalidateByHref('some-href'); @@ -624,4 +640,42 @@ describe('BaseDataService', () => { }); }); }); + + describe('addDependency', () => { + let addDependencySpy; + + beforeEach(() => { + addDependencySpy = spyOn(objectCache, 'addDependency'); + }); + + it('should call objectCache.addDependency with the object\'s self link', () => { + addDependencySpy.and.callFake((href$: Observable, dependsOn$: Observable) => { + observableCombineLatest([href$, dependsOn$]).subscribe(([href, dependsOn]) => { + expect(href).toBe('object-href'); + expect(dependsOn).toBe('dependsOnHref'); + }); + }); + + (service as any).addDependency( + createSuccessfulRemoteDataObject$({ _links: { self: { href: 'object-href' } } }), + observableOf('dependsOnHref') + ); + expect(addDependencySpy).toHaveBeenCalled(); + }); + + it('should call objectCache.addDependency without an href if request failed', () => { + addDependencySpy.and.callFake((href$: Observable, dependsOn$: Observable) => { + observableCombineLatest([href$, dependsOn$]).subscribe(([href, dependsOn]) => { + expect(href).toBe(undefined); + expect(dependsOn).toBe('dependsOnHref'); + }); + }); + + (service as any).addDependency( + createFailedRemoteDataObject$('something went wrong'), + observableOf('dependsOnHref') + ); + expect(addDependencySpy).toHaveBeenCalled(); + }); + }); }); diff --git a/src/app/core/data/base/base-data.service.ts b/src/app/core/data/base/base-data.service.ts index d8caa6c1c8..85603580a4 100644 --- a/src/app/core/data/base/base-data.service.ts +++ b/src/app/core/data/base/base-data.service.ts @@ -23,7 +23,9 @@ import { PaginatedList } from '../paginated-list.model'; import { ObjectCacheEntry } from '../../cache/object-cache.reducer'; import { ObjectCacheService } from '../../cache/object-cache.service'; import { HALDataService } from './hal-data-service.interface'; +import { getFirstCompletedRemoteData } from '../../shared/operators'; +export const EMBED_SEPARATOR = '%2F'; /** * Common functionality for data services. * Specific functionality that not all services would need @@ -201,7 +203,7 @@ export class BaseDataService implements HALDataServic let nestEmbed = embedString; linksToFollow.forEach((linkToFollow: FollowLinkConfig) => { if (hasValue(linkToFollow) && linkToFollow.shouldEmbed) { - nestEmbed = nestEmbed + '/' + String(linkToFollow.name); + nestEmbed = nestEmbed + EMBED_SEPARATOR + String(linkToFollow.name); // Add the nested embeds size if given in the FollowLinkConfig.FindListOptions if (hasValue(linkToFollow.findListOptions) && hasValue(linkToFollow.findListOptions.elementsPerPage)) { const nestedEmbedSize = 'embed.size=' + nestEmbed.split('=')[1] + '=' + linkToFollow.findListOptions.elementsPerPage; @@ -352,19 +354,55 @@ export class BaseDataService implements HALDataServic } /** - * Invalidate a cached object by its href - * @param href the href to invalidate + * Shorthand method to add a dependency to a cached object + * ``` + * const out$ = this.findByHref(...); // or another method that sends a request + * this.addDependency(out$, dependsOnHref); + * ``` + * When {@link dependsOnHref$} is invalidated, {@link object$} will be invalidated as well. + * + * + * @param object$ the cached object + * @param dependsOnHref$ the href of the object it should depend on */ - public invalidateByHref(href: string): Observable { + protected addDependency(object$: Observable>>, dependsOnHref$: string | Observable) { + this.objectCache.addDependency( + object$.pipe( + getFirstCompletedRemoteData(), + switchMap((rd: RemoteData) => { + if (rd.hasSucceeded) { + return [rd.payload._links.self.href]; + } else { + // undefined href will be skipped in objectCache.addDependency + return [undefined]; + } + }), + ), + dependsOnHref$ + ); + } + + /** + * Invalidate an existing DSpaceObject by marking all requests it is included in as stale + * @param href The self link of the object to be invalidated + * @return An Observable that will emit `true` once all requests are stale + */ + invalidateByHref(href: string): Observable { const done$ = new AsyncSubject(); this.objectCache.getByHref(href).pipe( take(1), - switchMap((oce: ObjectCacheEntry) => observableFrom(oce.requestUUIDs).pipe( - mergeMap((requestUUID: string) => this.requestService.setStaleByUUID(requestUUID)), - toArray(), - )), + switchMap((oce: ObjectCacheEntry) => { + return observableFrom([ + ...oce.requestUUIDs, + ...oce.dependentRequestUUIDs + ]).pipe( + mergeMap((requestUUID: string) => this.requestService.setStaleByUUID(requestUUID)), + toArray(), + ); + }), ).subscribe(() => { + this.objectCache.removeDependents(href); done$.next(true); done$.complete(); }); diff --git a/src/app/core/data/base/find-all-data.spec.ts b/src/app/core/data/base/find-all-data.spec.ts index 866c3279dc..6a73e032d0 100644 --- a/src/app/core/data/base/find-all-data.spec.ts +++ b/src/app/core/data/base/find-all-data.spec.ts @@ -22,6 +22,7 @@ import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.s import { HALEndpointService } from '../../shared/hal-endpoint.service'; import { ObjectCacheService } from '../../cache/object-cache.service'; import { Observable, of as observableOf } from 'rxjs'; +import { EMBED_SEPARATOR } from './base-data.service'; /** * Tests whether calls to `FindAllData` methods are correctly patched through in a concrete data service that implements it @@ -276,7 +277,7 @@ describe('FindAllDataImpl', () => { }); it('should include nested linksToFollow 3lvl', () => { - const expected = `${endpoint}?embed=owningCollection/itemtemplate/relationships`; + const expected = `${endpoint}?embed=owningCollection${EMBED_SEPARATOR}itemtemplate${EMBED_SEPARATOR}relationships`; (service as any).getFindAllHref({}, null, followLink('owningCollection', {}, followLink('itemtemplate', {}, followLink('relationships')))).subscribe((value) => { expect(value).toBe(expected); @@ -284,7 +285,7 @@ describe('FindAllDataImpl', () => { }); it('should include nested linksToFollow 2lvl and nested embed\'s size', () => { - const expected = `${endpoint}?embed.size=owningCollection/itemtemplate=4&embed=owningCollection/itemtemplate`; + const expected = `${endpoint}?embed.size=owningCollection${EMBED_SEPARATOR}itemtemplate=4&embed=owningCollection${EMBED_SEPARATOR}itemtemplate`; const config: FindListOptions = Object.assign(new FindListOptions(), { elementsPerPage: 4, }); diff --git a/src/app/core/data/base/identifiable-data.service.spec.ts b/src/app/core/data/base/identifiable-data.service.spec.ts index d08f1141fc..11af83ff9f 100644 --- a/src/app/core/data/base/identifiable-data.service.spec.ts +++ b/src/app/core/data/base/identifiable-data.service.spec.ts @@ -19,6 +19,7 @@ import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.s import { HALEndpointService } from '../../shared/hal-endpoint.service'; import { ObjectCacheService } from '../../cache/object-cache.service'; import { IdentifiableDataService } from './identifiable-data.service'; +import { EMBED_SEPARATOR } from './base-data.service'; const endpoint = 'https://rest.api/core'; @@ -137,7 +138,7 @@ describe('IdentifiableDataService', () => { }); it('should include nested linksToFollow 3lvl', () => { - const expected = `${endpointMock}/${resourceIdMock}?embed=owningCollection/itemtemplate/relationships`; + const expected = `${endpointMock}/${resourceIdMock}?embed=owningCollection${EMBED_SEPARATOR}itemtemplate${EMBED_SEPARATOR}relationships`; const result = (service as any).getIDHref(endpointMock, resourceIdMock, followLink('owningCollection', {}, followLink('itemtemplate', {}, followLink('relationships')))); expect(result).toEqual(expected); }); diff --git a/src/app/core/data/base/patch-data.spec.ts b/src/app/core/data/base/patch-data.spec.ts index 53f2083dab..a55b1229b8 100644 --- a/src/app/core/data/base/patch-data.spec.ts +++ b/src/app/core/data/base/patch-data.spec.ts @@ -178,7 +178,12 @@ describe('PatchDataImpl', () => { describe('patch', () => { const dso = { - uuid: 'dso-uuid' + uuid: 'dso-uuid', + _links: { + self: { + href: 'dso-href', + } + } }; const operations = [ Object.assign({ @@ -188,14 +193,23 @@ describe('PatchDataImpl', () => { }) as Operation ]; - beforeEach((done) => { - service.patch(dso, operations).subscribe(() => { - done(); - }); + it('should send a PatchRequest', () => { + service.patch(dso, operations); + expect(requestService.send).toHaveBeenCalledWith(jasmine.any(PatchRequest)); }); - it('should send a PatchRequest', () => { - expect(requestService.send).toHaveBeenCalledWith(jasmine.any(PatchRequest)); + it('should invalidate the cached object if successfully patched', () => { + spyOn(rdbService, 'buildFromRequestUUIDAndAwait'); + spyOn(service, 'invalidateByHref'); + + service.patch(dso, operations); + + expect(rdbService.buildFromRequestUUIDAndAwait).toHaveBeenCalled(); + expect((rdbService.buildFromRequestUUIDAndAwait as jasmine.Spy).calls.argsFor(0)[0]).toBe(requestService.generateRequestId()); + const callback = (rdbService.buildFromRequestUUIDAndAwait as jasmine.Spy).calls.argsFor(0)[1]; + callback(); + + expect(service.invalidateByHref).toHaveBeenCalledWith('dso-href'); }); }); diff --git a/src/app/core/data/base/patch-data.ts b/src/app/core/data/base/patch-data.ts index 732f71636e..e30c394a34 100644 --- a/src/app/core/data/base/patch-data.ts +++ b/src/app/core/data/base/patch-data.ts @@ -101,7 +101,7 @@ export class PatchDataImpl extends IdentifiableDataSe this.requestService.send(request); }); - return this.rdbService.buildFromRequestUUID(requestId); + return this.rdbService.buildFromRequestUUIDAndAwait(requestId, () => this.invalidateByHref(object._links.self.href)); } /** diff --git a/src/app/core/data/dso-redirect.service.spec.ts b/src/app/core/data/dso-redirect.service.spec.ts index 6bfd8dbd2e..ca064b5608 100644 --- a/src/app/core/data/dso-redirect.service.spec.ts +++ b/src/app/core/data/dso-redirect.service.spec.ts @@ -9,6 +9,7 @@ import { GetRequest, IdentifierType } from './request.models'; import { RequestService } from './request.service'; import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; import { Item } from '../shared/item.model'; +import { EMBED_SEPARATOR } from './base/base-data.service'; describe('DsoRedirectService', () => { let scheduler: TestScheduler; @@ -174,7 +175,7 @@ describe('DsoRedirectService', () => { }); it('should include nested linksToFollow 3lvl', () => { - const expected = `${requestUUIDURL}&embed=owningCollection/itemtemplate/relationships`; + const expected = `${requestUUIDURL}&embed=owningCollection${EMBED_SEPARATOR}itemtemplate${EMBED_SEPARATOR}relationships`; const result = (service as any).dataService.getIDHref( pidLink, dsoUUID, diff --git a/src/app/core/data/feature-authorization/authorization-data.service.spec.ts b/src/app/core/data/feature-authorization/authorization-data.service.spec.ts index ff9b542e44..ae44d590a4 100644 --- a/src/app/core/data/feature-authorization/authorization-data.service.spec.ts +++ b/src/app/core/data/feature-authorization/authorization-data.service.spec.ts @@ -2,7 +2,7 @@ import { AuthorizationDataService } from './authorization-data.service'; import { SiteDataService } from '../site-data.service'; import { Site } from '../../shared/site.model'; import { EPerson } from '../../eperson/models/eperson.model'; -import { of as observableOf } from 'rxjs'; +import { of as observableOf, combineLatest as observableCombineLatest, Observable } from 'rxjs'; import { FeatureID } from './feature-id'; import { hasValue } from '../../../shared/empty.util'; import { RequestParam } from '../../cache/models/request-param.model'; @@ -12,10 +12,12 @@ import { createPaginatedList } from '../../../shared/testing/utils.test'; import { Feature } from '../../shared/feature.model'; import { FindListOptions } from '../find-list-options.model'; import { testSearchDataImplementation } from '../base/search-data.spec'; +import { getMockObjectCacheService } from '../../../shared/mocks/object-cache.service.mock'; describe('AuthorizationDataService', () => { let service: AuthorizationDataService; let siteService: SiteDataService; + let objectCache; let site: Site; let ePerson: EPerson; @@ -38,7 +40,8 @@ describe('AuthorizationDataService', () => { siteService = jasmine.createSpyObj('siteService', { find: observableOf(site), }); - service = new AuthorizationDataService(requestService, undefined, undefined, undefined, siteService); + objectCache = getMockObjectCacheService(); + service = new AuthorizationDataService(requestService, undefined, objectCache, undefined, siteService); } beforeEach(() => { @@ -110,6 +113,43 @@ describe('AuthorizationDataService', () => { expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(objectUrl, ePersonUuid, FeatureID.LoginOnBehalfOf), true, true); }); }); + + describe('dependencies', () => { + let addDependencySpy; + + beforeEach(() => { + (service.searchBy as any).and.returnValue(observableOf('searchBy RD$')); + addDependencySpy = spyOn(service as any, 'addDependency'); + }); + + it('should add a dependency on the objectUrl', (done) => { + addDependencySpy.and.callFake((href$: Observable, dependsOn$: Observable) => { + observableCombineLatest([href$, dependsOn$]).subscribe(([href, dependsOn]) => { + expect(href).toBe('searchBy RD$'); + expect(dependsOn).toBe('object-href'); + }); + }); + + service.searchByObject(FeatureID.AdministratorOf, 'object-href').subscribe(() => { + expect(addDependencySpy).toHaveBeenCalled(); + done(); + }); + }); + + it('should add a dependency on the Site object if no objectUrl is given', (done) => { + addDependencySpy.and.callFake((object$: Observable, dependsOn$: Observable) => { + observableCombineLatest([object$, dependsOn$]).subscribe(([object, dependsOn]) => { + expect(object).toBe('searchBy RD$'); + expect(dependsOn).toBe('test-site-href'); + }); + }); + + service.searchByObject(FeatureID.AdministratorOf).subscribe(() => { + expect(addDependencySpy).toHaveBeenCalled(); + done(); + }); + }); + }); }); describe('isAuthorized', () => { diff --git a/src/app/core/data/feature-authorization/authorization-data.service.ts b/src/app/core/data/feature-authorization/authorization-data.service.ts index a9aded4bfe..c43d335234 100644 --- a/src/app/core/data/feature-authorization/authorization-data.service.ts +++ b/src/app/core/data/feature-authorization/authorization-data.service.ts @@ -11,10 +11,10 @@ import { followLink, FollowLinkConfig } from '../../../shared/utils/follow-link- import { RemoteData } from '../remote-data'; import { PaginatedList } from '../paginated-list.model'; import { catchError, map, switchMap } from 'rxjs/operators'; -import { hasValue, isNotEmpty } from '../../../shared/empty.util'; +import { hasNoValue, hasValue, isNotEmpty } from '../../../shared/empty.util'; import { RequestParam } from '../../cache/models/request-param.model'; import { AuthorizationSearchParams } from './authorization-search-params'; -import { addSiteObjectUrlIfEmpty, oneAuthorizationMatchesFeature } from './authorization-utils'; +import { oneAuthorizationMatchesFeature } from './authorization-utils'; import { FeatureID } from './feature-id'; import { getFirstCompletedRemoteData } from '../../shared/operators'; import { FindListOptions } from '../find-list-options.model'; @@ -96,12 +96,28 @@ export class AuthorizationDataService extends BaseDataService imp * {@link HALLink}s should be automatically resolved */ searchByObject(featureId?: FeatureID, objectUrl?: string, ePersonUuid?: string, options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { - return observableOf(new AuthorizationSearchParams(objectUrl, ePersonUuid, featureId)).pipe( - addSiteObjectUrlIfEmpty(this.siteService), + const objectUrl$ = observableOf(objectUrl).pipe( + switchMap((url) => { + if (hasNoValue(url)) { + return this.siteService.find().pipe( + map((site) => site.self) + ); + } else { + return observableOf(url); + } + }), + ); + + const out$ = objectUrl$.pipe( + map((url: string) => new AuthorizationSearchParams(url, ePersonUuid, featureId)), switchMap((params: AuthorizationSearchParams) => { return this.searchBy(this.searchByObjectPath, this.createSearchOptions(params.objectUrl, options, params.ePersonUuid, params.featureId), useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); }) ); + + this.addDependency(out$, objectUrl$); + + return out$; } /** diff --git a/src/app/core/data/processes/script-data.service.ts b/src/app/core/data/processes/script-data.service.ts index ed228612ef..d9c92cb1d2 100644 --- a/src/app/core/data/processes/script-data.service.ts +++ b/src/app/core/data/processes/script-data.service.ts @@ -24,6 +24,8 @@ import { dataService } from '../base/data-service.decorator'; export const METADATA_IMPORT_SCRIPT_NAME = 'metadata-import'; export const METADATA_EXPORT_SCRIPT_NAME = 'metadata-export'; +export const BATCH_IMPORT_SCRIPT_NAME = 'import'; +export const BATCH_EXPORT_SCRIPT_NAME = 'export'; @Injectable() @dataService(SCRIPT) diff --git a/src/app/core/eperson/eperson-data.service.spec.ts b/src/app/core/eperson/eperson-data.service.spec.ts index ce0b1f3ee4..b4b939eebf 100644 --- a/src/app/core/eperson/eperson-data.service.spec.ts +++ b/src/app/core/eperson/eperson-data.service.spec.ts @@ -307,7 +307,7 @@ describe('EPersonDataService', () => { it('should sent a patch request with an uuid, token and new password to the epersons endpoint', () => { service.patchPasswordWithToken('test-uuid', 'test-token', 'test-password'); - const operation = Object.assign({ op: 'add', path: '/password', value: 'test-password' }); + const operation = Object.assign({ op: 'add', path: '/password', value: { new_password: 'test-password' } }); const expected = new PatchRequest(requestService.generateRequestId(), epersonsEndpoint + '/test-uuid?token=test-token', [operation]); expect(requestService.send).toHaveBeenCalledWith(expected); diff --git a/src/app/core/eperson/eperson-data.service.ts b/src/app/core/eperson/eperson-data.service.ts index 8f9312d732..d30030365c 100644 --- a/src/app/core/eperson/eperson-data.service.ts +++ b/src/app/core/eperson/eperson-data.service.ts @@ -3,7 +3,10 @@ import { createSelector, select, Store } from '@ngrx/store'; import { Operation } from 'fast-json-patch'; import { Observable } from 'rxjs'; import { find, map, take } from 'rxjs/operators'; -import { EPeopleRegistryCancelEPersonAction, EPeopleRegistryEditEPersonAction } from '../../access-control/epeople-registry/epeople-registry.actions'; +import { + EPeopleRegistryCancelEPersonAction, + EPeopleRegistryEditEPersonAction +} from '../../access-control/epeople-registry/epeople-registry.actions'; import { EPeopleRegistryState } from '../../access-control/epeople-registry/epeople-registry.reducers'; import { AppState } from '../../app.reducer'; import { hasNoValue, hasValue } from '../../shared/empty.util'; @@ -318,7 +321,7 @@ export class EPersonDataService extends IdentifiableDataService impleme patchPasswordWithToken(uuid: string, token: string, password: string): Observable> { const requestId = this.requestService.generateRequestId(); - const operation = Object.assign({ op: 'add', path: '/password', value: password }); + const operation = Object.assign({ op: 'add', path: '/password', value: { 'new_password': password } }); const hrefObs = this.halService.getEndpoint(this.linkPath).pipe( map((endpoint: string) => this.getIDHref(endpoint, uuid)), diff --git a/src/app/core/eperson/group-data.service.spec.ts b/src/app/core/eperson/group-data.service.spec.ts index ed0803ce6e..6fdc302cff 100644 --- a/src/app/core/eperson/group-data.service.spec.ts +++ b/src/app/core/eperson/group-data.service.spec.ts @@ -26,10 +26,9 @@ import { EPersonMock, EPersonMock2 } from '../../shared/testing/eperson.mock'; import { createPaginatedList, createRequestEntry$ } from '../../shared/testing/utils.test'; import { CoreState } from '../core-state.model'; import { FindListOptions } from '../data/find-list-options.model'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { getMockLinkService } from '../../shared/mocks/link-service.mock'; import { of as observableOf } from 'rxjs'; import { ObjectCacheEntry } from '../cache/object-cache.reducer'; +import { getMockObjectCacheService } from '../../shared/mocks/object-cache.service.mock'; describe('GroupDataService', () => { let service: GroupDataService; @@ -42,7 +41,7 @@ describe('GroupDataService', () => { let groups$; let halService; let rdbService; - let objectCache: ObjectCacheService; + let objectCache; function init() { restEndpointURL = 'https://dspace.4science.it/dspace-spring-rest/api/eperson'; groupsEndpoint = `${restEndpointURL}/groups`; @@ -50,7 +49,7 @@ describe('GroupDataService', () => { groups$ = createSuccessfulRemoteDataObject$(createPaginatedList(groups)); rdbService = getMockRemoteDataBuildServiceHrefMap(undefined, { 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups': groups$ }); halService = new HALEndpointServiceStub(restEndpointURL); - objectCache = new ObjectCacheService(store, getMockLinkService()); + objectCache = getMockObjectCacheService(); TestBed.configureTestingModule({ imports: [ CommonModule, @@ -114,8 +113,9 @@ describe('GroupDataService', () => { describe('addSubGroupToGroup', () => { beforeEach(() => { - spyOn(objectCache, 'getByHref').and.returnValue(observableOf({ - requestUUIDs: ['request1', 'request2'] + objectCache.getByHref.and.returnValue(observableOf({ + requestUUIDs: ['request1', 'request2'], + dependentRequestUUIDs: [], } as ObjectCacheEntry)); spyOn((service as any).deleteData, 'invalidateByHref'); service.addSubGroupToGroup(GroupMock, GroupMock2).subscribe(); @@ -143,8 +143,9 @@ describe('GroupDataService', () => { describe('deleteSubGroupFromGroup', () => { beforeEach(() => { - spyOn(objectCache, 'getByHref').and.returnValue(observableOf({ - requestUUIDs: ['request1', 'request2'] + objectCache.getByHref.and.returnValue(observableOf({ + requestUUIDs: ['request1', 'request2'], + dependentRequestUUIDs: [], } as ObjectCacheEntry)); spyOn((service as any).deleteData, 'invalidateByHref'); service.deleteSubGroupFromGroup(GroupMock, GroupMock2).subscribe(); @@ -168,8 +169,9 @@ describe('GroupDataService', () => { describe('addMemberToGroup', () => { beforeEach(() => { - spyOn(objectCache, 'getByHref').and.returnValue(observableOf({ - requestUUIDs: ['request1', 'request2'] + objectCache.getByHref.and.returnValue(observableOf({ + requestUUIDs: ['request1', 'request2'], + dependentRequestUUIDs: [], } as ObjectCacheEntry)); spyOn((service as any).deleteData, 'invalidateByHref'); service.addMemberToGroup(GroupMock, EPersonMock2).subscribe(); @@ -198,8 +200,9 @@ describe('GroupDataService', () => { describe('deleteMemberFromGroup', () => { beforeEach(() => { - spyOn(objectCache, 'getByHref').and.returnValue(observableOf({ - requestUUIDs: ['request1', 'request2'] + objectCache.getByHref.and.returnValue(observableOf({ + requestUUIDs: ['request1', 'request2'], + dependentRequestUUIDs: [], } as ObjectCacheEntry)); spyOn((service as any).deleteData, 'invalidateByHref'); service.deleteMemberFromGroup(GroupMock, EPersonMock).subscribe(); diff --git a/src/app/core/eperson/models/workspaceitem.resource-type.ts b/src/app/core/eperson/models/workspaceitem.resource-type.ts new file mode 100644 index 0000000000..e141d45a9f --- /dev/null +++ b/src/app/core/eperson/models/workspaceitem.resource-type.ts @@ -0,0 +1,3 @@ +import { ResourceType } from '../../shared/resource-type'; + +export const WORKSPACEITEM = new ResourceType('workspaceitem'); diff --git a/src/app/core/services/browser-hard-redirect.service.spec.ts b/src/app/core/services/browser-hard-redirect.service.spec.ts index b9745906c3..1d8666d259 100644 --- a/src/app/core/services/browser-hard-redirect.service.spec.ts +++ b/src/app/core/services/browser-hard-redirect.service.spec.ts @@ -2,17 +2,25 @@ import { TestBed } from '@angular/core/testing'; import { BrowserHardRedirectService } from './browser-hard-redirect.service'; describe('BrowserHardRedirectService', () => { - const origin = 'https://test-host.com:4000'; - const mockLocation = { - href: undefined, - pathname: '/pathname', - search: '/search', - origin - } as Location; - - const service: BrowserHardRedirectService = new BrowserHardRedirectService(mockLocation); + let origin: string; + let mockLocation: Location; + let service: BrowserHardRedirectService; beforeEach(() => { + origin = 'https://test-host.com:4000'; + mockLocation = { + href: undefined, + pathname: '/pathname', + search: '/search', + origin, + replace: (url: string) => { + mockLocation.href = url; + } + } as Location; + spyOn(mockLocation, 'replace'); + + service = new BrowserHardRedirectService(mockLocation); + TestBed.configureTestingModule({}); }); @@ -28,8 +36,8 @@ describe('BrowserHardRedirectService', () => { service.redirect(redirect); }); - it('should update the location', () => { - expect(mockLocation.href).toEqual(redirect); + it('should call location.replace with the new url', () => { + expect(mockLocation.replace).toHaveBeenCalledWith(redirect); }); }); diff --git a/src/app/core/services/browser-hard-redirect.service.ts b/src/app/core/services/browser-hard-redirect.service.ts index eeb9006039..4ef9548899 100644 --- a/src/app/core/services/browser-hard-redirect.service.ts +++ b/src/app/core/services/browser-hard-redirect.service.ts @@ -24,7 +24,7 @@ export class BrowserHardRedirectService extends HardRedirectService { * @param url */ redirect(url: string) { - this.location.href = url; + this.location.replace(url); } /** diff --git a/src/app/core/submission/models/workspaceitem.model.ts b/src/app/core/submission/models/workspaceitem.model.ts index 0bfacdb475..62f21cb2f3 100644 --- a/src/app/core/submission/models/workspaceitem.model.ts +++ b/src/app/core/submission/models/workspaceitem.model.ts @@ -2,7 +2,7 @@ import { deserializeAs, inheritSerialization } from 'cerialize'; import { inheritLinkAnnotations, typedObject } from '../../cache/builders/build-decorators'; import { IDToUUIDSerializer } from '../../cache/id-to-uuid-serializer'; import { SubmissionObject } from './submission-object.model'; -import { ResourceType } from '../../shared/resource-type'; +import { WORKSPACEITEM } from '../../eperson/models/workspaceitem.resource-type'; /** * A model class for a WorkspaceItem. @@ -11,7 +11,7 @@ import { ResourceType } from '../../shared/resource-type'; @inheritSerialization(SubmissionObject) @inheritLinkAnnotations(SubmissionObject) export class WorkspaceItem extends SubmissionObject { - static type = new ResourceType('workspaceitem'); + static type = WORKSPACEITEM; /** * The universally unique identifier of this WorkspaceItem diff --git a/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal-issue/journal-issue-search-result-list-element.component.html b/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal-issue/journal-issue-search-result-list-element.component.html index d93639a5d9..36b7e98c51 100644 --- a/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal-issue/journal-issue-search-result-list-element.component.html +++ b/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal-issue/journal-issue-search-result-list-element.component.html @@ -6,6 +6,10 @@ + + + +
diff --git a/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal-volume/journal-volume-search-result-list-element.component.html b/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal-volume/journal-volume-search-result-list-element.component.html index 2226d03649..46683d7cdc 100644 --- a/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal-volume/journal-volume-search-result-list-element.component.html +++ b/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal-volume/journal-volume-search-result-list-element.component.html @@ -6,6 +6,10 @@ + + + +
diff --git a/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal/journal-search-result-list-element.component.html b/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal/journal-search-result-list-element.component.html index 3cafccf0b9..bf09305c86 100644 --- a/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal/journal-search-result-list-element.component.html +++ b/src/app/entity-groups/journal-entities/item-list-elements/search-result-list-elements/journal/journal-search-result-list-element.component.html @@ -5,6 +5,10 @@ + + + +
diff --git a/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/org-unit/org-unit-search-result-list-element.component.html b/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/org-unit/org-unit-search-result-list-element.component.html index fc14a99caf..0bba83a209 100644 --- a/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/org-unit/org-unit-search-result-list-element.component.html +++ b/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/org-unit/org-unit-search-result-list-element.component.html @@ -1,33 +1,40 @@
- -
- - - - - +
+ + + + + + + + +
+
+ + + + + + [innerHTML]="firstMetadataValue('dc.description')"> - -
+
+
diff --git a/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/org-unit/org-unit-search-result-list-element.component.spec.ts b/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/org-unit/org-unit-search-result-list-element.component.spec.ts index 974c418cdc..9609a9582a 100644 --- a/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/org-unit/org-unit-search-result-list-element.component.spec.ts +++ b/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/org-unit/org-unit-search-result-list-element.component.spec.ts @@ -10,6 +10,8 @@ import { ItemSearchResult } from '../../../../../shared/object-collection/shared import { DSONameService } from '../../../../../core/breadcrumbs/dso-name.service'; import { DSONameServiceMock } from '../../../../../shared/mocks/dso-name.service.mock'; import { APP_CONFIG } from '../../../../../../config/app-config.interface'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { TranslateLoaderMock } from '../../../../../shared/mocks/translate-loader.mock'; let orgUnitListElementComponent: OrgUnitSearchResultListElementComponent; let fixture: ComponentFixture; @@ -66,6 +68,13 @@ const enviromentNoThumbs = { describe('OrgUnitSearchResultListElementComponent', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + } + )], declarations: [ OrgUnitSearchResultListElementComponent , TruncatePipe], providers: [ { provide: TruncatableService, useValue: {} }, @@ -129,6 +138,13 @@ describe('OrgUnitSearchResultListElementComponent', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + } + )], declarations: [OrgUnitSearchResultListElementComponent, TruncatePipe], providers: [ {provide: TruncatableService, useValue: {}}, diff --git a/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/person/person-search-result-list-element.component.html b/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/person/person-search-result-list-element.component.html index 979c8711d6..4332b7a553 100644 --- a/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/person/person-search-result-list-element.component.html +++ b/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/person/person-search-result-list-element.component.html @@ -9,6 +9,13 @@ [placeholder]="'thumbnail.person.placeholder'"> + + + +
@@ -16,10 +23,10 @@ + [innerHTML]="dsoTitle || ('person.listelement.no-title' | translate)"> + [innerHTML]="dsoTitle || ('person.listelement.no-title' | translate)"> ; @@ -66,6 +68,13 @@ const enviromentNoThumbs = { describe('PersonSearchResultListElementComponent', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + } + )], declarations: [PersonSearchResultListElementComponent, TruncatePipe], providers: [ { provide: TruncatableService, useValue: {} }, @@ -129,6 +138,13 @@ describe('PersonSearchResultListElementComponent', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + } + )], declarations: [PersonSearchResultListElementComponent, TruncatePipe], providers: [ {provide: TruncatableService, useValue: {}}, diff --git a/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/project/project-search-result-list-element.component.html b/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/project/project-search-result-list-element.component.html index 3cfc6eaeb4..8883a9c80d 100644 --- a/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/project/project-search-result-list-element.component.html +++ b/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/project/project-search-result-list-element.component.html @@ -9,6 +9,13 @@ [placeholder]="'thumbnail.project.placeholder'"> + + + +
diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.html b/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.html index 7d1ab508b7..6872b3f609 100644 --- a/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.html +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.html @@ -8,6 +8,13 @@ [placeholder]="'thumbnail.person.placeholder'"> + + + +
diff --git a/src/app/header/header.component.html b/src/app/header/header.component.html index e5d5f38971..e2ee33c760 100644 --- a/src/app/header/header.component.html +++ b/src/app/header/header.component.html @@ -6,7 +6,7 @@