Merge remote-tracking branch 'origin/main' into w2p-94060_Issue-1720_MyDSpace-support-entities

This commit is contained in:
lotte
2022-10-03 14:43:24 +02:00
147 changed files with 7512 additions and 2668 deletions

View File

@@ -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

View File

@@ -238,7 +238,6 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
this.epersonService.deleteEPerson(ePerson).pipe(getFirstCompletedRemoteData()).subscribe((restResponse: RemoteData<NoContent>) => {
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);
}

View File

@@ -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);
}));
})

View File

@@ -0,0 +1,35 @@
<div class="container">
<h2 id="header">{{'admin.batch-import.page.header' | translate}}</h2>
<p>{{'admin.batch-import.page.help' | translate}}</p>
<p *ngIf="dso">
selected collection: <b>{{getDspaceObjectName()}}</b>&nbsp;
<a href="javascript:void(0)" (click)="removeDspaceObject()">{{'admin.batch-import.page.remove' | translate}}</a>
</p>
<p>
<button class="btn btn-primary" (click)="this.selectCollection();">{{'admin.metadata-import.page.button.select-collection' | translate}}</button>
</p>
<div class="form-group">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="validateOnly" [(ngModel)]="validateOnly">
<label class="form-check-label" for="validateOnly">
{{'admin.metadata-import.page.validateOnly' | translate}}
</label>
</div>
<small id="validateOnlyHelpBlock" class="form-text text-muted">
{{'admin.batch-import.page.validateOnly.hint' | translate}}
</small>
</div>
<ds-file-dropzone-no-uploader
(onFileAdded)="setFile($event)"
[dropMessageLabel]="'admin.batch-import.page.dropMsg'"
[dropMessageLabelReplacement]="'admin.batch-import.page.dropMsgReplace'">
</ds-file-dropzone-no-uploader>
<div class="space-children-mr">
<button class="btn btn-secondary" id="backButton"
(click)="this.onReturn();">{{'admin.metadata-import.page.button.return' | translate}}</button>
<button class="btn btn-primary" id="proceedButton"
(click)="this.importMetadata();">{{'admin.metadata-import.page.button.proceed' | translate}}</button>
</div>
</div>

View File

@@ -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<BatchImportPageComponent>;
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();
});
});
});
});

View File

@@ -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<Process>) => {
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;
}
}

View File

@@ -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: [

View File

@@ -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 {

View File

@@ -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() },

View File

@@ -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 {
/**

View File

@@ -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<BrowseByDatePageComponent> {
protected getComponentName(): string {
return 'BrowseByDatePageComponent';
}
protected importThemedComponent(themeName: string): Promise<any> {
return import(`../../../themes/${themeName}/app/browse-by/browse-by-date-page/browse-by-date-page.component`);
}
protected importUnthemedComponent(): Promise<any> {
return import(`./browse-by-date-page.component`);
}
}

View File

@@ -6,10 +6,10 @@
<ds-comcol-page-header [name]="parentContext.name">
</ds-comcol-page-header>
<!-- Handle -->
<ds-comcol-page-handle
<ds-themed-comcol-page-handle
[content]="parentContext.handle"
[title]="parentContext.type+'.page.handle'" >
</ds-comcol-page-handle>
</ds-themed-comcol-page-handle>
<!-- Introductory text -->
<ds-comcol-page-content [content]="parentContext.introductoryText" [hasInnerHtml]="true">
</ds-comcol-page-content>

View File

@@ -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)
*/

View File

@@ -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<BrowseByMetadataPageComponent> {
protected getComponentName(): string {
return 'BrowseByMetadataPageComponent';
}
protected importThemedComponent(themeName: string): Promise<any> {
return import(`../../../themes/${themeName}/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component`);
}
protected importUnthemedComponent(): Promise<any> {
return import(`./browse-by-metadata-page.component`);
}
}

View File

@@ -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,

View File

@@ -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<BrowseByTitlePageComponent> {
protected getComponentName(): string {
return 'BrowseByTitlePageComponent';
}
protected importThemedComponent(themeName: string): Promise<any> {
return import(`../../../themes/${themeName}/app/browse-by/browse-by-title-page/browse-by-title-page.component`);
}
protected importUnthemedComponent(): Promise<any> {
return import(`./browse-by-title-page.component`);
}
}

View File

@@ -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({

View File

@@ -17,10 +17,10 @@
</ds-comcol-page-logo>
<!-- Handle -->
<ds-comcol-page-handle
<ds-themed-comcol-page-handle
[content]="collection.handle"
[title]="'collection.page.handle'" >
</ds-comcol-page-handle>
</ds-themed-comcol-page-handle>
<!-- Introductory text -->
<ds-comcol-page-content
[content]="collection.introductoryText"

View File

@@ -10,8 +10,8 @@
<ds-comcol-page-logo *ngIf="logoRD$" [logo]="(logoRD$ | async)?.payload" [alternateText]="'Community Logo'">
</ds-comcol-page-logo>
<!-- Handle -->
<ds-comcol-page-handle [content]="communityPayload.handle" [title]="'community.page.handle'">
</ds-comcol-page-handle>
<ds-themed-comcol-page-handle [content]="communityPayload.handle" [title]="'community.page.handle'">
</ds-themed-comcol-page-handle>
<!-- Introductory text -->
<ds-comcol-page-content [content]="communityPayload.introductoryText" [hasInnerHtml]="true">
</ds-comcol-page-content>

View File

@@ -1,5 +1,5 @@
<ds-comcol-role
*ngFor="let comcolRole of getComcolRoles$() | async"
*ngFor="let comcolRole of comcolRoles$ | async"
[dso]="community$ | async"
[comcolRole]="comcolRole"
>

View File

@@ -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();
});
});

View File

@@ -19,28 +19,14 @@ export class CommunityRolesComponent implements OnInit {
dsoRD$: Observable<RemoteData<Community>>;
/**
* The community to manage, as an observable.
* The different roles for the community, as an observable.
*/
get community$(): Observable<Community> {
return this.dsoRD$.pipe(
getFirstSucceededRemoteData(),
getRemoteDataPayload(),
);
}
comcolRoles$: Observable<HALLink[]>;
/**
* The different roles for the community.
* The community to manage, as an observable.
*/
getComcolRoles$(): Observable<HALLink[]> {
return this.community$.pipe(
map((community) => [
{
name: 'community-admin',
href: community._links.adminGroup.href,
},
]),
);
}
community$: Observable<Community>;
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,
},
]),
);
}
}

View File

@@ -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', () => {

View File

@@ -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', () => {

View File

@@ -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;

View File

@@ -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([]);
});
});

View File

@@ -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;
}

View File

@@ -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();
});
});
});
});

View File

@@ -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<string>, dependsOnHref$: string | Observable<string>) {
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<string> {
return this.getBySelfLink(href).pipe(
switchMap((oce: ObjectCacheEntry) => {
if (isNotEmpty(oce)) {
return [href];
} else {
return this.store.pipe(
select(selfLinkFromAlternativeLinkSelector(href)),
);
}
}),
);
}
}

View File

@@ -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<string>, dependsOn$: Observable<string>) => {
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<string>, dependsOn$: Observable<string>) => {
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();
});
});
});

View File

@@ -23,6 +23,7 @@ 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';
/**
@@ -353,19 +354,55 @@ export class BaseDataService<T extends CacheableObject> 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<boolean> {
protected addDependency(object$: Observable<RemoteData<T | PaginatedList<T>>>, dependsOnHref$: string | Observable<string>) {
this.objectCache.addDependency(
object$.pipe(
getFirstCompletedRemoteData(),
switchMap((rd: RemoteData<T>) => {
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<boolean> {
const done$ = new AsyncSubject<boolean>();
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();
});

View File

@@ -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');
});
});

View File

@@ -101,7 +101,7 @@ export class PatchDataImpl<T extends CacheableObject> extends IdentifiableDataSe
this.requestService.send(request);
});
return this.rdbService.buildFromRequestUUID(requestId);
return this.rdbService.buildFromRequestUUIDAndAwait(requestId, () => this.invalidateByHref(object._links.self.href));
}
/**

View File

@@ -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<string>, dependsOn$: Observable<string>) => {
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<any>, dependsOn$: Observable<string>) => {
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', () => {

View File

@@ -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<Authorization> imp
* {@link HALLink}s should be automatically resolved
*/
searchByObject(featureId?: FeatureID, objectUrl?: string, ePersonUuid?: string, options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<Authorization>[]): Observable<RemoteData<PaginatedList<Authorization>>> {
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$;
}
/**

View File

@@ -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)

View File

@@ -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);

View File

@@ -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<EPerson> impleme
patchPasswordWithToken(uuid: string, token: string, password: string): Observable<RemoteData<EPerson>> {
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)),

View File

@@ -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();

View File

@@ -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);
});
});

View File

@@ -24,7 +24,7 @@ export class BrowserHardRedirectService extends HardRedirectService {
* @param url
*/
redirect(url: string) {
this.location.href = url;
this.location.replace(url);
}
/**

View File

@@ -6,7 +6,7 @@
</a>
<nav role="navigation" [attr.aria-label]="'nav.user.description' | translate" class="navbar navbar-light navbar-expand-md flex-shrink-0 px-0">
<ds-search-navbar></ds-search-navbar>
<ds-themed-search-navbar></ds-themed-search-navbar>
<ds-lang-switch></ds-lang-switch>
<ds-themed-auth-nav-menu></ds-themed-auth-nav-menu>
<ds-impersonate-navbar></ds-impersonate-navbar>

View File

@@ -10,6 +10,9 @@ import { StatisticsModule } from '../statistics/statistics.module';
import { ThemedHomeNewsComponent } from './home-news/themed-home-news.component';
import { ThemedHomePageComponent } from './themed-home-page.component';
import { RecentItemListComponent } from './recent-item-list/recent-item-list.component';
import { JournalEntitiesModule } from '../entity-groups/journal-entities/journal-entities.module';
import { ResearchEntitiesModule } from '../entity-groups/research-entities/research-entities.module';
const DECLARATIONS = [
HomePageComponent,
ThemedHomePageComponent,
@@ -22,7 +25,9 @@ const DECLARATIONS = [
@NgModule({
imports: [
CommonModule,
SharedModule,
SharedModule.withEntryComponents(),
JournalEntitiesModule.withEntryComponents(),
ResearchEntitiesModule.withEntryComponents(),
HomePageRoutingModule,
StatisticsModule.forRoot()
],

View File

@@ -1,5 +1,5 @@
<ng-container *ngVar="(itemRD$ | async) as itemRD">
<div class="mt-4" *ngIf="itemRD?.hasSucceeded && itemRD?.payload?.page.length > 0" @fadeIn>
<div class="mt-4" [ngClass]="placeholderFontClass" *ngIf="itemRD?.hasSucceeded && itemRD?.payload?.page.length > 0" @fadeIn>
<div class="d-flex flex-row border-bottom mb-4 pb-4 ng-tns-c416-2"></div>
<h2> {{'home.recent-submissions.head' | translate}}</h2>
<div class="my-4" *ngFor="let item of itemRD?.payload?.page">
@@ -12,4 +12,4 @@
<ds-error *ngIf="itemRD?.hasFailed" message="{{'error.recent-submissions' | translate}}"></ds-error>
<ds-loading *ngIf="!itemRD || itemRD.isLoading" message="{{'loading.recent-submissions' | translate}}">
</ds-loading>
</ng-container>
</ng-container>

View File

@@ -10,8 +10,11 @@ import { SearchConfigurationService } from '../../core/shared/search/search-conf
import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model';
import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model';
import { ViewMode } from 'src/app/core/shared/view-mode.model';
import { of as observableOf } from 'rxjs';
import { APP_CONFIG } from '../../../config/app-config.interface';
import { environment } from '../../../environments/environment';
import { PLATFORM_ID } from '@angular/core';
describe('RecentItemListComponent', () => {
let component: RecentItemListComponent;
let fixture: ComponentFixture<RecentItemListComponent>;
@@ -42,6 +45,8 @@ describe('RecentItemListComponent', () => {
{ provide: SearchService, useValue: searchServiceStub },
{ provide: PaginationService, useValue: paginationService },
{ provide: SearchConfigurationService, useValue: searchConfigServiceStub },
{ provide: APP_CONFIG, useValue: environment },
{ provide: PLATFORM_ID, useValue: 'browser' },
],
})
.compileComponents();

View File

@@ -1,4 +1,4 @@
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { ChangeDetectionStrategy, Component, ElementRef, Inject, OnInit, PLATFORM_ID } from '@angular/core';
import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model';
import { fadeIn, fadeInOut } from '../../shared/animations/fade';
import { RemoteData } from '../../core/data/remote-data';
@@ -11,12 +11,13 @@ import { SortDirection, SortOptions } from '../../core/cache/models/sort-options
import { environment } from '../../../environments/environment';
import { ViewMode } from '../../core/shared/view-mode.model';
import { SearchConfigurationService } from '../../core/shared/search/search-configuration.service';
import {
toDSpaceObjectListRD
} from '../../core/shared/operators';
import {
Observable,
} from 'rxjs';
import { toDSpaceObjectListRD } from '../../core/shared/operators';
import { Observable } from 'rxjs';
import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { APP_CONFIG, AppConfig } from '../../../config/app-config.interface';
import { isPlatformBrowser } from '@angular/common';
import { setPlaceHolderAttributes } from '../../shared/utils/object-list-utils';
@Component({
selector: 'ds-recent-item-list',
templateUrl: './recent-item-list.component.html',
@@ -31,14 +32,22 @@ export class RecentItemListComponent implements OnInit {
itemRD$: Observable<RemoteData<PaginatedList<Item>>>;
paginationConfig: PaginationComponentOptions;
sortConfig: SortOptions;
/**
* The view-mode we're currently on
* @type {ViewMode}
*/
viewMode = ViewMode.ListElement;
constructor(private searchService: SearchService,
private _placeholderFontClass: string;
constructor(
private searchService: SearchService,
private paginationService: PaginationService,
public searchConfigurationService: SearchConfigurationService
public searchConfigurationService: SearchConfigurationService,
protected elementRef: ElementRef,
@Inject(APP_CONFIG) private appConfig: AppConfig,
@Inject(PLATFORM_ID) private platformId: Object,
) {
this.paginationConfig = Object.assign(new PaginationComponentOptions(), {
@@ -50,16 +59,29 @@ export class RecentItemListComponent implements OnInit {
this.sortConfig = new SortOptions(environment.homePage.recentSubmissions.sortField, SortDirection.DESC);
}
ngOnInit(): void {
const linksToFollow: FollowLinkConfig<Item>[] = [];
if (this.appConfig.browseBy.showThumbnails) {
linksToFollow.push(followLink('thumbnail'));
}
this.itemRD$ = this.searchService.search(
new PaginatedSearchOptions({
pagination: this.paginationConfig,
sort: this.sortConfig,
}),
).pipe(toDSpaceObjectListRD()) as Observable<RemoteData<PaginatedList<Item>>>;
undefined,
undefined,
undefined,
...linksToFollow,
).pipe(
toDSpaceObjectListRD()
) as Observable<RemoteData<PaginatedList<Item>>>;
}
ngOnDestroy(): void {
this.paginationService.clearPagination(this.paginationConfig.id);
}
onLoadMore(): void {
this.paginationService.updateRouteWithUrl(this.searchConfigurationService.paginationID, ['search'], {
sortField: environment.homePage.recentSubmissions.sortField,
@@ -68,5 +90,17 @@ export class RecentItemListComponent implements OnInit {
});
}
get placeholderFontClass(): string {
if (this._placeholderFontClass === undefined) {
if (isPlatformBrowser(this.platformId)) {
const width = this.elementRef.nativeElement.offsetWidth;
this._placeholderFontClass = setPlaceHolderAttributes(width);
} else {
this._placeholderFontClass = 'hide-placeholder-text';
}
}
return this._placeholderFontClass;
}
}

View File

@@ -175,14 +175,18 @@ describe('TopLevelCommunityList Component', () => {
});
it('should display a list of top-communities', () => {
const subComList = fixture.debugElement.queryAll(By.css('li'));
expect(subComList.length).toEqual(5);
expect(subComList[0].nativeElement.textContent).toContain('TopCommunity 1');
expect(subComList[1].nativeElement.textContent).toContain('TopCommunity 2');
expect(subComList[2].nativeElement.textContent).toContain('TopCommunity 3');
expect(subComList[3].nativeElement.textContent).toContain('TopCommunity 4');
expect(subComList[4].nativeElement.textContent).toContain('TopCommunity 5');
it('should display a list of top-communities', () => {
waitForAsync(() => {
const subComList = fixture.debugElement.queryAll(By.css('li'));
expect(subComList.length).toEqual(5);
expect(subComList[0].nativeElement.textContent).toContain('TopCommunity 1');
expect(subComList[1].nativeElement.textContent).toContain('TopCommunity 2');
expect(subComList[2].nativeElement.textContent).toContain('TopCommunity 3');
expect(subComList[3].nativeElement.textContent).toContain('TopCommunity 4');
expect(subComList[4].nativeElement.textContent).toContain('TopCommunity 5');
});
});
});

View File

@@ -12,6 +12,7 @@ import { createPaginatedList } from '../../shared/testing/utils.test';
import { of as observableOf } from 'rxjs';
import { MiradorViewerService } from './mirador-viewer.service';
import { HostWindowService } from '../../shared/host-window.service';
import { BundleDataService } from '../../core/data/bundle-data.service';
function getItem(metadata: MetadataMap) {
@@ -46,6 +47,7 @@ describe('MiradorViewerComponent with search', () => {
declarations: [MiradorViewerComponent],
providers: [
{ provide: BitstreamDataService, useValue: {} },
{ provide: BundleDataService, useValue: {} },
{ provide: HostWindowService, useValue: mockHostWindowService }
],
schemas: [NO_ERRORS_SCHEMA]
@@ -108,6 +110,7 @@ describe('MiradorViewerComponent with multiple images', () => {
declarations: [MiradorViewerComponent],
providers: [
{ provide: BitstreamDataService, useValue: {} },
{ provide: BundleDataService, useValue: {} },
{ provide: HostWindowService, useValue: mockHostWindowService }
],
schemas: [NO_ERRORS_SCHEMA]
@@ -167,6 +170,7 @@ describe('MiradorViewerComponent with a single image', () => {
declarations: [MiradorViewerComponent],
providers: [
{ provide: BitstreamDataService, useValue: {} },
{ provide: BundleDataService, useValue: {} },
{ provide: HostWindowService, useValue: mockHostWindowService }
],
schemas: [NO_ERRORS_SCHEMA]
@@ -225,6 +229,7 @@ describe('MiradorViewerComponent in development mode', () => {
set: {
providers: [
{ provide: MiradorViewerService, useValue: viewerService },
{ provide: BundleDataService, useValue: {} },
{ provide: HostWindowService, useValue: mockHostWindowService }
]
}

View File

@@ -8,6 +8,7 @@ import { map, take } from 'rxjs/operators';
import { isPlatformBrowser } from '@angular/common';
import { MiradorViewerService } from './mirador-viewer.service';
import { HostWindowService, WidthCategory } from '../../shared/host-window.service';
import { BundleDataService } from '../../core/data/bundle-data.service';
@Component({
selector: 'ds-mirador-viewer',
@@ -55,6 +56,7 @@ export class MiradorViewerComponent implements OnInit {
constructor(private sanitizer: DomSanitizer,
private viewerService: MiradorViewerService,
private bitstreamDataService: BitstreamDataService,
private bundleDataService: BundleDataService,
private hostWindowService: HostWindowService,
@Inject(PLATFORM_ID) private platformId: any) {
}
@@ -107,10 +109,10 @@ export class MiradorViewerComponent implements OnInit {
this.notMobile = !(category === WidthCategory.XS || category === WidthCategory.SM);
});
// We need to set the multi property to true if the
// item is searchable or when the ORIGINAL bundle contains more
// than 1 image. (The multi property determines whether the
// Mirador side thumbnail navigation panel is shown.)
// Set the multi property. The default mirador configuration adds a right
// thumbnail navigation panel to the viewer when multi is 'true'.
// Set the multi property to 'true' if the item is searchable.
if (this.searchable) {
this.multi = true;
const observable = of('');
@@ -120,8 +122,12 @@ export class MiradorViewerComponent implements OnInit {
})
);
} else {
// Sets the multi value based on the image count.
this.iframeViewerUrl = this.viewerService.getImageCount(this.object, this.bitstreamDataService).pipe(
// Set the multi property based on the image count in IIIF-eligible bundles.
// Any count greater than 1 sets the value to 'true'.
this.iframeViewerUrl = this.viewerService.getImageCount(
this.object,
this.bitstreamDataService,
this.bundleDataService).pipe(
map(c => {
if (c > 1) {
this.multi = true;

View File

@@ -1,14 +1,18 @@
import { Injectable, isDevMode } from '@angular/core';
import { Observable } from 'rxjs';
import { Item } from '../../core/shared/item.model';
import { getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload } from '../../core/shared/operators';
import { last, map, switchMap } from 'rxjs/operators';
import {
getFirstCompletedRemoteData,
} from '../../core/shared/operators';
import { filter, last, map, mergeMap, switchMap } from 'rxjs/operators';
import { RemoteData } from '../../core/data/remote-data';
import { PaginatedList } from '../../core/data/paginated-list.model';
import { Bitstream } from '../../core/shared/bitstream.model';
import { BitstreamFormat } from '../../core/shared/bitstream-format.model';
import { BitstreamDataService } from '../../core/data/bitstream-data.service';
import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { Bundle } from '../../core/shared/bundle.model';
import { BundleDataService } from '../../core/data/bundle-data.service';
@Injectable()
export class MiradorViewerService {
@@ -26,32 +30,64 @@ export class MiradorViewerService {
}
/**
* Returns observable of the number of images in the ORIGINAL bundle
* Returns observable of the number of images found in eligible IIIF bundles. Checks
* the mimetype of the first 5 bitstreams in each bundle.
* @param item
* @param bitstreamDataService
* @param bundleDataService
* @returns the total image count
*/
getImageCount(item: Item, bitstreamDataService: BitstreamDataService): Observable<number> {
let count = 0;
return bitstreamDataService.findAllByItemAndBundleName(item, 'ORIGINAL', {
currentPage: 1,
elementsPerPage: 10
}, true, true, ...this.LINKS_TO_FOLLOW)
.pipe(
getFirstCompletedRemoteData(),
map((bitstreamsRD: RemoteData<PaginatedList<Bitstream>>) => bitstreamsRD.payload),
map((paginatedList: PaginatedList<Bitstream>) => paginatedList.page),
switchMap((bitstreams: Bitstream[]) => bitstreams),
switchMap((bitstream: Bitstream) => bitstream.format.pipe(
getFirstSucceededRemoteDataPayload(),
map((format: BitstreamFormat) => format)
)),
map((format: BitstreamFormat) => {
if (format.mimetype.includes('image')) {
count++;
}
return count;
}),
last()
getImageCount(item: Item, bitstreamDataService: BitstreamDataService, bundleDataService: BundleDataService):
Observable<number> {
let count = 0;
return bundleDataService.findAllByItem(item).pipe(
getFirstCompletedRemoteData(),
map((bundlesRD: RemoteData<PaginatedList<Bundle>>) => {
return bundlesRD.payload;
}),
map((paginatedList: PaginatedList<Bundle>) => paginatedList.page),
switchMap((bundles: Bundle[]) => bundles),
filter((b: Bundle) => this.isIiifBundle(b.name)),
mergeMap((bundle: Bundle) => {
return bitstreamDataService.findAllByItemAndBundleName(item, bundle.name, {
currentPage: 1,
elementsPerPage: 5
}, true, true, ...this.LINKS_TO_FOLLOW).pipe(
getFirstCompletedRemoteData(),
map((bitstreamsRD: RemoteData<PaginatedList<Bitstream>>) => {
return bitstreamsRD.payload;
}),
map((paginatedList: PaginatedList<Bitstream>) => paginatedList.page),
switchMap((bitstreams: Bitstream[]) => bitstreams),
switchMap((bitstream: Bitstream) => bitstream.format.pipe(
getFirstCompletedRemoteData(),
map((formatRD: RemoteData<BitstreamFormat>) => {
return formatRD.payload;
}),
map((format: BitstreamFormat) => {
if (format.mimetype.includes('image')) {
count++;
}
return count;
}),
)
)
);
}),
last()
);
}
isIiifBundle(bundleName: string): boolean {
return !(
bundleName === 'OtherContent' ||
bundleName === 'LICENSE' ||
bundleName === 'THUMBNAIL' ||
bundleName === 'TEXT' ||
bundleName === 'METADATA' ||
bundleName === 'CC-LICENSE' ||
bundleName === 'BRANDED_PREVIEW'
);
}
}

View File

@@ -259,9 +259,15 @@ describe('MenuResolver', () => {
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'import', visible: true,
}));
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'import_batch', parentID: 'import', visible: true,
}));
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'export', visible: true,
}));
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.ADMIN, jasmine.objectContaining({
id: 'export_batch', parentID: 'export', visible: true,
}));
});
});

View File

@@ -44,6 +44,9 @@ import {
METADATA_IMPORT_SCRIPT_NAME,
ScriptDataService
} from './core/data/processes/script-data.service';
import {
ExportBatchSelectorComponent
} from './shared/dso-selector/modal-wrappers/export-batch-selector/export-batch-selector.component';
/**
* Creates all of the app's menus
@@ -440,6 +443,20 @@ export class MenuResolver implements Resolve<boolean> {
} as OnClickMenuItemModel,
shouldPersistOnRouteChange: true
});
this.menuService.addSection(MenuID.ADMIN, {
id: 'export_batch',
parentID: 'export',
active: false,
visible: true,
model: {
type: MenuItemType.ONCLICK,
text: 'menu.section.export_batch',
function: () => {
this.modalService.open(ExportBatchSelectorComponent);
}
} as OnClickMenuItemModel,
shouldPersistOnRouteChange: true
});
});
}
@@ -448,20 +465,7 @@ export class MenuResolver implements Resolve<boolean> {
* the import scripts exist and the current user is allowed to execute them
*/
createImportMenuSections() {
const menuList = [
// TODO: enable this menu item once the feature has been implemented
// {
// id: 'import_batch',
// parentID: 'import',
// active: false,
// visible: true,
// model: {
// type: MenuItemType.LINK,
// text: 'menu.section.import_batch',
// link: ''
// } as LinkMenuItemModel,
// }
];
const menuList = [];
menuList.forEach((menuSection) => this.menuService.addSection(MenuID.ADMIN, menuSection));
observableCombineLatest([
@@ -498,6 +502,18 @@ export class MenuResolver implements Resolve<boolean> {
} as LinkMenuItemModel,
shouldPersistOnRouteChange: true
});
this.menuService.addSection(MenuID.ADMIN, {
id: 'import_batch',
parentID: 'import',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.import_batch',
link: '/admin/batch-import'
} as LinkMenuItemModel,
shouldPersistOnRouteChange: true
});
});
}

View File

@@ -8,15 +8,14 @@ import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { AuthService } from '../../core/auth/auth.service';
import { DSpaceObject } from '../../core/shared/dspace-object.model';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { NotificationOptions } from '../../shared/notifications/models/notification-options.model';
import { UploaderOptions } from '../../shared/uploader/uploader-options.model';
import { HALEndpointService } from '../../core/shared/hal-endpoint.service';
import { NotificationType } from '../../shared/notifications/models/notification-type';
import { hasValue } from '../../shared/empty.util';
import { SearchResult } from '../../shared/search/models/search-result.model';
import { CollectionSelectorComponent } from '../collection-selector/collection-selector.component';
import { UploaderComponent } from '../../shared/uploader/uploader.component';
import { UploaderError } from '../../shared/uploader/uploader-error.model';
import { Router } from '@angular/router';
/**
* This component represents the whole mydspace page header
@@ -56,13 +55,15 @@ export class MyDSpaceNewSubmissionComponent implements OnDestroy, OnInit {
* @param {NotificationsService} notificationsService
* @param {TranslateService} translate
* @param {NgbModal} modalService
* @param {Router} router
*/
constructor(private authService: AuthService,
private changeDetectorRef: ChangeDetectorRef,
private halService: HALEndpointService,
private notificationsService: NotificationsService,
private translate: TranslateService,
private modalService: NgbModal) {
private modalService: NgbModal,
private router: Router) {
}
/**
@@ -87,16 +88,9 @@ export class MyDSpaceNewSubmissionComponent implements OnDestroy, OnInit {
this.uploadEnd.emit(workspaceitems);
if (workspaceitems.length === 1) {
const options = new NotificationOptions();
options.timeOut = 0;
const link = '/workspaceitems/' + workspaceitems[0].id + '/edit';
this.notificationsService.notificationWithAnchor(
NotificationType.Success,
options,
link,
'mydspace.general.text-here',
'mydspace.upload.upload-successful',
'here');
// To avoid confusion and ambiguity, redirect the user on the publication page.
this.router.navigateByUrl(link);
} else if (workspaceitems.length > 1) {
this.notificationsService.success(null, this.translate.get('mydspace.upload.upload-multiple-successful', {qty: workspaceitems.length}));
}

View File

@@ -4,7 +4,7 @@ nav.navbar {
}
/** Mobile menu styling **/
@media screen and (max-width: map-get($grid-breakpoints, md)) {
@media screen and (max-width: map-get($grid-breakpoints, md)-0.02) {
.navbar {
width: 100vw;
background-color: var(--bs-white);
@@ -26,7 +26,7 @@ nav.navbar {
/* TODO remove when https://github.com/twbs/bootstrap/issues/24726 is fixed */
.navbar-expand-md.navbar-container {
@media screen and (max-width: map-get($grid-breakpoints, md)) {
@media screen and (max-width: map-get($grid-breakpoints, md)-0.02) {
> .container {
padding: 0 var(--bs-spacer);
}

View File

@@ -74,6 +74,19 @@ describe('ProfilePageSecurityFormComponent', () => {
expect(component.passwordValue.emit).toHaveBeenCalledWith('new-password');
}));
it('should emit the value on password change with current password for profile-page', fakeAsync(() => {
spyOn(component.passwordValue, 'emit');
spyOn(component.currentPasswordValue, 'emit');
component.FORM_PREFIX = 'profile.security.form.';
component.ngOnInit();
component.formGroup.patchValue({password: 'new-password'});
component.formGroup.patchValue({'current-password': 'current-password'});
tick(300);
expect(component.passwordValue.emit).toHaveBeenCalledWith('new-password');
expect(component.currentPasswordValue.emit).toHaveBeenCalledWith('current-password');
}));
});
});
});

View File

@@ -27,6 +27,10 @@ export class ProfilePageSecurityFormComponent implements OnInit {
* Emits the value of the password
*/
@Output() passwordValue = new EventEmitter<string>();
/**
* Emits the value of the current-password
*/
@Output() currentPasswordValue = new EventEmitter<string>();
/**
* The form's input models
@@ -70,6 +74,14 @@ export class ProfilePageSecurityFormComponent implements OnInit {
}
ngOnInit(): void {
if (this.FORM_PREFIX === 'profile.security.form.') {
this.formModel.unshift(new DynamicInputModel({
id: 'current-password',
name: 'current-password',
inputType: 'password',
required: true
}));
}
if (this.passwordCanBeEmpty) {
this.formGroup = this.formService.createFormGroup(this.formModel,
{ validators: [this.checkPasswordsEqual] });
@@ -94,6 +106,9 @@ export class ProfilePageSecurityFormComponent implements OnInit {
debounceTime(300),
).subscribe((valueChange) => {
this.passwordValue.emit(valueChange.password);
if (this.FORM_PREFIX === 'profile.security.form.') {
this.currentPasswordValue.emit(valueChange['current-password']);
}
}));
}

View File

@@ -24,6 +24,7 @@
[FORM_PREFIX]="'profile.security.form.'"
(isInvalid)="setInvalid($event)"
(passwordValue)="setPasswordValue($event)"
(currentPasswordValue)="setCurrentPasswordValue($event)"
></ds-profile-page-security-form>
</div>
</div>

View File

@@ -180,7 +180,7 @@ describe('ProfilePageComponent', () => {
beforeEach(() => {
component.setPasswordValue('');
component.setCurrentPasswordValue('current-password');
result = component.updateSecurity();
});
@@ -199,6 +199,7 @@ describe('ProfilePageComponent', () => {
beforeEach(() => {
component.setPasswordValue('test');
component.setInvalid(true);
component.setCurrentPasswordValue('current-password');
result = component.updateSecurity();
});
@@ -215,8 +216,11 @@ describe('ProfilePageComponent', () => {
beforeEach(() => {
component.setPasswordValue('testest');
component.setInvalid(false);
component.setCurrentPasswordValue('current-password');
operations = [{ op: 'add', path: '/password', value: 'testest' }];
operations = [
{ 'op': 'add', 'path': '/password', 'value': { 'new_password': 'testest', 'current_password': 'current-password' } }
];
result = component.updateSecurity();
});
@@ -228,6 +232,28 @@ describe('ProfilePageComponent', () => {
expect(epersonService.patch).toHaveBeenCalledWith(user, operations);
});
});
describe('when password is filled in, and is valid but return 403', () => {
let result;
let operations;
it('should return call epersonService.patch', (done) => {
epersonService.patch.and.returnValue(observableOf(Object.assign(new RestResponse(false, 403, 'Error'))));
component.setPasswordValue('testest');
component.setInvalid(false);
component.setCurrentPasswordValue('current-password');
operations = [
{ 'op': 'add', 'path': '/password', 'value': {'new_password': 'testest', 'current_password': 'current-password' }}
];
result = component.updateSecurity();
epersonService.patch(user, operations).subscribe((response) => {
expect(response.statusCode).toEqual(403);
done();
});
expect(epersonService.patch).toHaveBeenCalledWith(user, operations);
expect(result).toEqual(true);
});
});
});
describe('canChangePassword$', () => {

View File

@@ -67,6 +67,10 @@ export class ProfilePageComponent implements OnInit {
* The password filled in, in the security form
*/
private password: string;
/**
* The current-password filled in, in the security form
*/
private currentPassword: string;
/**
* The authenticated user
@@ -138,15 +142,14 @@ export class ProfilePageComponent implements OnInit {
*/
updateSecurity() {
const passEntered = isNotEmpty(this.password);
if (this.invalidSecurity) {
this.notificationsService.error(this.translate.instant(this.PASSWORD_NOTIFICATIONS_PREFIX + 'error.general'));
}
if (!this.invalidSecurity && passEntered) {
const operation = {op: 'add', path: '/password', value: this.password} as Operation;
this.epersonService.patch(this.currentUser, [operation]).pipe(
getFirstCompletedRemoteData()
).subscribe((response: RemoteData<EPerson>) => {
const operations = [
{ 'op': 'add', 'path': '/password', 'value': { 'new_password': this.password, 'current_password': this.currentPassword } }
] as Operation[];
this.epersonService.patch(this.currentUser, operations).pipe(getFirstCompletedRemoteData()).subscribe((response: RemoteData<EPerson>) => {
if (response.hasSucceeded) {
this.notificationsService.success(
this.translate.instant(this.PASSWORD_NOTIFICATIONS_PREFIX + 'success.title'),
@@ -154,7 +157,8 @@ export class ProfilePageComponent implements OnInit {
);
} else {
this.notificationsService.error(
this.translate.instant(this.PASSWORD_NOTIFICATIONS_PREFIX + 'error.title'), response.errorMessage
this.translate.instant(this.PASSWORD_NOTIFICATIONS_PREFIX + 'error.title'),
this.translate.instant(this.PASSWORD_NOTIFICATIONS_PREFIX + 'error.change-failed')
);
}
});
@@ -170,6 +174,14 @@ export class ProfilePageComponent implements OnInit {
this.password = $event;
}
/**
* Set the current-password value based on the value emitted from the security form
* @param $event
*/
setCurrentPasswordValue($event: string) {
this.currentPassword = $event;
}
/**
* Submit of the security form that triggers the updateProfile method
*/

View File

@@ -41,6 +41,11 @@ export class CreateProfileComponent implements OnInit {
userInfoForm: FormGroup;
activeLangs: LangConfig[];
/**
* Prefix for the notification messages of this security form
*/
NOTIFICATIONS_PREFIX = 'register-page.create-profile.submit.';
constructor(
private translateService: TranslateService,
private ePersonDataService: EPersonDataService,
@@ -161,13 +166,12 @@ export class CreateProfileComponent implements OnInit {
getFirstCompletedRemoteData(),
).subscribe((rd: RemoteData<EPerson>) => {
if (rd.hasSucceeded) {
this.notificationsService.success(this.translateService.get('register-page.create-profile.submit.success.head'),
this.translateService.get('register-page.create-profile.submit.success.content'));
this.notificationsService.success(this.translateService.get(this.NOTIFICATIONS_PREFIX + 'success.head'),
this.translateService.get(this.NOTIFICATIONS_PREFIX + 'success.content'));
this.store.dispatch(new AuthenticateAction(this.email, this.password));
this.router.navigate(['/home']);
} else {
this.notificationsService.error(this.translateService.get('register-page.create-profile.submit.error.head'),
this.translateService.get('register-page.create-profile.submit.error.content'));
this.notificationsService.error(this.translateService.get(this.NOTIFICATIONS_PREFIX + 'error.head'), rd.errorMessage);
}
});
}

View File

@@ -9,7 +9,6 @@ import { TranslateLoaderMock } from '../shared/mocks/translate-loader.mock';
import { NativeWindowRef, NativeWindowService } from '../core/services/window.service';
import { MetadataService } from '../core/metadata/metadata.service';
import { MetadataServiceMock } from '../shared/mocks/metadata-service.mock';
import { Angulartics2GoogleAnalytics } from 'angulartics2';
import { AngularticsProviderMock } from '../shared/mocks/angulartics-provider.service.mock';
import { Angulartics2DSpace } from '../statistics/angulartics/dspace-provider';
import { AuthService } from '../core/auth/auth.service';
@@ -50,7 +49,6 @@ describe('RootComponent', () => {
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() },

View File

@@ -5,7 +5,6 @@ import { Router } from '@angular/router';
import { combineLatest as combineLatestObservable, Observable, of } from 'rxjs';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { Angulartics2GoogleAnalytics } from 'angulartics2';
import { MetadataService } from '../core/metadata/metadata.service';
import { HostWindowState } from '../shared/search/host-window.reducer';
@@ -51,7 +50,6 @@ export class RootComponent implements OnInit {
private translate: TranslateService,
private store: Store<HostWindowState>,
private metadata: MetadataService,
private angulartics2GoogleAnalytics: Angulartics2GoogleAnalytics,
private angulartics2DSpace: Angulartics2DSpace,
private authService: AuthService,
private router: Router,

View File

@@ -0,0 +1,24 @@
import { ThemedComponent } from '../shared/theme-support/themed.component';
import { SearchNavbarComponent } from './search-navbar.component';
import { Component } from '@angular/core';
@Component({
selector: 'ds-themed-search-navbar',
styleUrls: [],
templateUrl: '../shared/theme-support/themed.component.html',
})
export class ThemedSearchNavbarComponent extends ThemedComponent<SearchNavbarComponent> {
protected getComponentName(): string {
return 'SearchNavbarComponent';
}
protected importThemedComponent(themeName: string): Promise<any> {
return import(`../../themes/${themeName}/app/search-navbar/search-navbar.component`);
}
protected importUnthemedComponent(): Promise<any> {
return import(`./search-navbar.component`);
}
}

View File

@@ -198,11 +198,13 @@ describe('BrowseByComponent', () => {
});
it('should use the base component to render browse entries', () => {
const componentLoaders = fixture.debugElement.queryAll(By.directive(ListableObjectComponentLoaderComponent));
expect(componentLoaders.length).toEqual(browseEntries.length);
componentLoaders.forEach((componentLoader) => {
const browseEntry = componentLoader.query(By.css('ds-browse-entry-list-element'));
expect(browseEntry.componentInstance).toBeInstanceOf(BrowseEntryListElementComponent);
waitForAsync(() => {
const componentLoaders = fixture.debugElement.queryAll(By.directive(ListableObjectComponentLoaderComponent));
expect(componentLoaders.length).toEqual(browseEntries.length);
componentLoaders.forEach((componentLoader) => {
const browseEntry = componentLoader.query(By.css('ds-browse-entry-list-element'));
expect(browseEntry.componentInstance).toBeInstanceOf(BrowseEntryListElementComponent);
});
});
});
});
@@ -215,11 +217,13 @@ describe('BrowseByComponent', () => {
});
it('should use the themed component to render browse entries', () => {
const componentLoaders = fixture.debugElement.queryAll(By.directive(ListableObjectComponentLoaderComponent));
expect(componentLoaders.length).toEqual(browseEntries.length);
componentLoaders.forEach((componentLoader) => {
const browseEntry = componentLoader.query(By.css('ds-browse-entry-list-element'));
expect(browseEntry.componentInstance).toBeInstanceOf(MockThemedBrowseEntryListElementComponent);
waitForAsync(() => {
const componentLoaders = fixture.debugElement.queryAll(By.directive(ListableObjectComponentLoaderComponent));
expect(componentLoaders.length).toEqual(browseEntries.length);
componentLoaders.forEach((componentLoader) => {
const browseEntry = componentLoader.query(By.css('ds-browse-entry-list-element'));
expect(browseEntry.componentInstance).toBeInstanceOf(MockThemedBrowseEntryListElementComponent);
});
});
});
});

View File

@@ -0,0 +1,36 @@
import {Component, Input} from '@angular/core';
import { ThemedComponent } from '../../theme-support/themed.component';
import { ComcolPageHandleComponent } from './comcol-page-handle.component';
/**
* Themed wrapper for BreadcrumbsComponent
*/
@Component({
selector: 'ds-themed-comcol-page-handle',
styleUrls: [],
templateUrl: '../../theme-support/themed.component.html',
})
export class ThemedComcolPageHandleComponent extends ThemedComponent<ComcolPageHandleComponent> {
// Optional title
@Input() title: string;
// The value of "handle"
@Input() content: string;
inAndOutputNames: (keyof ComcolPageHandleComponent & keyof this)[] = ['title', 'content'];
protected getComponentName(): string {
return 'ComcolPageHandleComponent';
}
protected importThemedComponent(themeName: string): Promise<any> {
return import(`../../../../themes/${themeName}/app/shared/comcol/comcol-page-handle/comcol-page-handle.component`);
}
protected importUnthemedComponent(): Promise<any> {
return import(`./comcol-page-handle.component`);
}
}

View File

@@ -2,6 +2,8 @@ import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ComcolPageContentComponent } from './comcol-page-content/comcol-page-content.component';
import { ComcolPageHandleComponent } from './comcol-page-handle/comcol-page-handle.component';
import { ThemedComcolPageHandleComponent} from './comcol-page-handle/themed-comcol-page-handle.component';
import { ComcolPageHeaderComponent } from './comcol-page-header/comcol-page-header.component';
import { ComcolPageLogoComponent } from './comcol-page-logo/comcol-page-logo.component';
import { ComColFormComponent } from './comcol-forms/comcol-form/comcol-form.component';
@@ -26,6 +28,9 @@ const COMPONENTS = [
ComcolPageBrowseByComponent,
ThemedComcolPageBrowseByComponent,
ComcolRoleComponent,
ThemedComcolPageHandleComponent
];
@NgModule({

View File

@@ -10,10 +10,12 @@ import { AuthService } from '../../core/auth/auth.service';
import { CookieService } from '../../core/services/cookie.service';
import { getTestScheduler } from 'jasmine-marbles';
import { MetadataValue } from '../../core/shared/metadata.models';
import {clone, cloneDeep} from 'lodash';
import { clone, cloneDeep } from 'lodash';
import { ConfigurationDataService } from '../../core/data/configuration-data.service';
import {createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$} from '../remote-data.utils';
import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../remote-data.utils';
import { ConfigurationProperty } from '../../core/shared/configuration-property.model';
import { ANONYMOUS_STORAGE_NAME_KLARO } from './klaro-configuration';
import { TestScheduler } from 'rxjs/testing';
describe('BrowserKlaroService', () => {
const trackingIdProp = 'google.analytics.key';
@@ -29,7 +31,7 @@ describe('BrowserKlaroService', () => {
let configurationDataService: ConfigurationDataService;
const createConfigSuccessSpy = (...values: string[]) => jasmine.createSpyObj('configurationDataService', {
findByPropertyName: createSuccessfulRemoteDataObject$({
... new ConfigurationProperty(),
...new ConfigurationProperty(),
name: trackingIdProp,
values: values,
}),
@@ -42,7 +44,9 @@ describe('BrowserKlaroService', () => {
let findByPropertyName;
beforeEach(() => {
user = new EPerson();
user = Object.assign(new EPerson(), {
uuid: 'test-user'
});
translateService = getMockTranslateService();
ePersonService = jasmine.createSpyObj('ePersonService', {
@@ -104,7 +108,7 @@ describe('BrowserKlaroService', () => {
services: [{
name: appName,
purposes: [purpose]
},{
}, {
name: googleAnalytics,
purposes: [purpose]
}],
@@ -219,6 +223,40 @@ describe('BrowserKlaroService', () => {
});
});
describe('getSavedPreferences', () => {
let scheduler: TestScheduler;
beforeEach(() => {
scheduler = getTestScheduler();
});
describe('when no user is autheticated', () => {
beforeEach(() => {
spyOn(service as any, 'getUser$').and.returnValue(observableOf(undefined));
});
it('should return the cookie consents object', () => {
scheduler.schedule(() => service.getSavedPreferences().subscribe());
scheduler.flush();
expect(cookieService.get).toHaveBeenCalledWith(ANONYMOUS_STORAGE_NAME_KLARO);
});
});
describe('when user is autheticated', () => {
beforeEach(() => {
spyOn(service as any, 'getUser$').and.returnValue(observableOf(user));
});
it('should return the cookie consents object', () => {
scheduler.schedule(() => service.getSavedPreferences().subscribe());
scheduler.flush();
expect(cookieService.get).toHaveBeenCalledWith('klaro-' + user.uuid);
});
});
});
describe('setSettingsForUser when there are changes', () => {
const cookieConsent = { test: 'testt' };
const cookieConsentString = '{test: \'testt\'}';
@@ -271,40 +309,40 @@ describe('BrowserKlaroService', () => {
});
it('should not filter googleAnalytics when servicesToHide are empty', () => {
const filteredConfig = (service as any).filterConfigServices([]);
expect(filteredConfig).toContain(jasmine.objectContaining({name: googleAnalytics}));
expect(filteredConfig).toContain(jasmine.objectContaining({ name: googleAnalytics }));
});
it('should filter services using names passed as servicesToHide', () => {
const filteredConfig = (service as any).filterConfigServices([googleAnalytics]);
expect(filteredConfig).not.toContain(jasmine.objectContaining({name: googleAnalytics}));
expect(filteredConfig).not.toContain(jasmine.objectContaining({ name: googleAnalytics }));
});
it('should have been initialized with googleAnalytics', () => {
service.initialize();
expect(service.klaroConfig.services).toContain(jasmine.objectContaining({name: googleAnalytics}));
expect(service.klaroConfig.services).toContain(jasmine.objectContaining({ name: googleAnalytics }));
});
it('should filter googleAnalytics when empty configuration is retrieved', () => {
configurationDataService.findByPropertyName = jasmine.createSpy().withArgs(GOOGLE_ANALYTICS_KEY).and.returnValue(
createSuccessfulRemoteDataObject$({
... new ConfigurationProperty(),
...new ConfigurationProperty(),
name: googleAnalytics,
values: [],
}));
service.initialize();
expect(service.klaroConfig.services).not.toContain(jasmine.objectContaining({name: googleAnalytics}));
expect(service.klaroConfig.services).not.toContain(jasmine.objectContaining({ name: googleAnalytics }));
});
it('should filter googleAnalytics when an error occurs', () => {
configurationDataService.findByPropertyName = jasmine.createSpy().withArgs(GOOGLE_ANALYTICS_KEY).and.returnValue(
createFailedRemoteDataObject$('Erro while loading GA')
);
service.initialize();
expect(service.klaroConfig.services).not.toContain(jasmine.objectContaining({name: googleAnalytics}));
expect(service.klaroConfig.services).not.toContain(jasmine.objectContaining({ name: googleAnalytics }));
});
it('should filter googleAnalytics when an invalid payload is retrieved', () => {
configurationDataService.findByPropertyName = jasmine.createSpy().withArgs(GOOGLE_ANALYTICS_KEY).and.returnValue(
createSuccessfulRemoteDataObject$(null)
);
service.initialize();
expect(service.klaroConfig.services).not.toContain(jasmine.objectContaining({name: googleAnalytics}));
expect(service.klaroConfig.services).not.toContain(jasmine.objectContaining({ name: googleAnalytics }));
});
});
});

View File

@@ -13,7 +13,7 @@ import { EPersonDataService } from '../../core/eperson/eperson-data.service';
import { cloneDeep, debounce } from 'lodash';
import { ANONYMOUS_STORAGE_NAME_KLARO, klaroConfiguration } from './klaro-configuration';
import { Operation } from 'fast-json-patch';
import { getFirstCompletedRemoteData} from '../../core/shared/operators';
import { getFirstCompletedRemoteData } from '../../core/shared/operators';
import { ConfigurationDataService } from '../../core/data/configuration-data.service';
/**
@@ -121,6 +121,23 @@ export class BrowserKlaroService extends KlaroService {
});
}
/**
* Return saved preferences stored in the klaro cookie
*/
getSavedPreferences(): Observable<any> {
return this.getUser$().pipe(
map((user: EPerson) => {
let storageName;
if (isEmpty(user)) {
storageName = ANONYMOUS_STORAGE_NAME_KLARO;
} else {
storageName = this.getStorageName(user.uuid);
}
return this.cookieService.get(storageName);
})
);
}
/**
* Initialize configuration for the logged in user
* @param user The authenticated user

View File

@@ -12,6 +12,8 @@ export const HAS_AGREED_END_USER = 'dsHasAgreedEndUser';
*/
export const ANONYMOUS_STORAGE_NAME_KLARO = 'klaro-anonymous';
export const GOOGLE_ANALYTICS_KLARO_KEY = 'google-analytics';
/**
* Klaro configuration
* For more information see https://kiprotect.com/docs/klaro/annotated-config
@@ -113,7 +115,7 @@ export const klaroConfiguration: any = {
]
},
{
name: 'google-analytics',
name: GOOGLE_ANALYTICS_KLARO_KEY,
purposes: ['statistical'],
required: false,
cookies: [

View File

@@ -1,5 +1,7 @@
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
/**
* Abstract class representing a service for handling Klaro consent preferences and UI
*/
@@ -11,7 +13,12 @@ export abstract class KlaroService {
abstract initialize();
/**
* Shows a the dialog with the current consent preferences
* Shows a dialog with the current consent preferences
*/
abstract showSettings();
/**
* Return saved preferences stored in the klaro cookie
*/
abstract getSavedPreferences(): Observable<any>;
}

View File

@@ -10,7 +10,9 @@ export enum SelectorActionType {
CREATE = 'create',
EDIT = 'edit',
EXPORT_METADATA = 'export-metadata',
SET_SCOPE = 'set-scope'
IMPORT_BATCH = 'import-batch',
SET_SCOPE = 'set-scope',
EXPORT_BATCH = 'export-batch'
}
/**

View File

@@ -0,0 +1,210 @@
import { of as observableOf } from 'rxjs';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { DebugElement, NgModule, NO_ERRORS_SCHEMA } from '@angular/core';
import { NgbActiveModal, NgbModal, NgbModalModule } from '@ng-bootstrap/ng-bootstrap';
import { ActivatedRoute, Router } from '@angular/router';
import { BATCH_EXPORT_SCRIPT_NAME, ScriptDataService } from '../../../../core/data/processes/script-data.service';
import { Collection } from '../../../../core/shared/collection.model';
import { Item } from '../../../../core/shared/item.model';
import { ProcessParameter } from '../../../../process-page/processes/process-parameter.model';
import { ConfirmationModalComponent } from '../../../confirmation-modal/confirmation-modal.component';
import { TranslateLoaderMock } from '../../../mocks/translate-loader.mock';
import { NotificationsService } from '../../../notifications/notifications.service';
import { NotificationsServiceStub } from '../../../testing/notifications-service.stub';
import {
createFailedRemoteDataObject$,
createSuccessfulRemoteDataObject,
createSuccessfulRemoteDataObject$
} from '../../../remote-data.utils';
import { ExportBatchSelectorComponent } from './export-batch-selector.component';
import { AuthorizationDataService } from '../../../../core/data/feature-authorization/authorization-data.service';
// No way to add entryComponents yet to testbed; alternative implemented; source: https://stackoverflow.com/questions/41689468/how-to-shallow-test-a-component-with-an-entrycomponents
@NgModule({
imports: [NgbModalModule,
TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useClass: TranslateLoaderMock
}
}),
],
exports: [],
declarations: [ConfirmationModalComponent],
providers: []
})
class ModelTestModule {
}
describe('ExportBatchSelectorComponent', () => {
let component: ExportBatchSelectorComponent;
let fixture: ComponentFixture<ExportBatchSelectorComponent>;
let debugElement: DebugElement;
let modalRef;
let router;
let notificationService: NotificationsServiceStub;
let scriptService;
let authorizationDataService;
const mockItem = Object.assign(new Item(), {
id: 'fake-id',
uuid: 'fake-id',
handle: 'fake/handle',
lastModified: '2018'
});
const mockCollection: Collection = Object.assign(new Collection(), {
id: 'test-collection-1-1',
uuid: 'test-collection-1-1',
name: 'test-collection-1',
metadata: {
'dc.identifier.uri': [
{
language: null,
value: 'fake/test-collection-1'
}
]
}
});
const itemRD = createSuccessfulRemoteDataObject(mockItem);
const modalStub = jasmine.createSpyObj('modalStub', ['close']);
beforeEach(waitForAsync(() => {
notificationService = new NotificationsServiceStub();
router = jasmine.createSpyObj('router', {
navigateByUrl: jasmine.createSpy('navigateByUrl')
});
scriptService = jasmine.createSpyObj('scriptService',
{
invoke: createSuccessfulRemoteDataObject$({ processId: '45' })
}
);
authorizationDataService = jasmine.createSpyObj('authorizationDataService', {
isAuthorized: observableOf(true)
});
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), ModelTestModule],
declarations: [ExportBatchSelectorComponent],
providers: [
{ provide: NgbActiveModal, useValue: modalStub },
{ provide: NotificationsService, useValue: notificationService },
{ provide: ScriptDataService, useValue: scriptService },
{ provide: AuthorizationDataService, useValue: authorizationDataService },
{
provide: ActivatedRoute,
useValue: {
root: {
snapshot: {
data: {
dso: itemRD,
},
},
}
},
},
{
provide: Router, useValue: router
}
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ExportBatchSelectorComponent);
component = fixture.componentInstance;
debugElement = fixture.debugElement;
const modalService = TestBed.inject(NgbModal);
modalRef = modalService.open(ConfirmationModalComponent);
modalRef.componentInstance.response = observableOf(true);
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('if item is selected', () => {
let scriptRequestSucceeded;
beforeEach((done) => {
component.navigate(mockItem).subscribe((succeeded: boolean) => {
scriptRequestSucceeded = succeeded;
done();
});
});
it('should not invoke batch-export script', () => {
expect(scriptService.invoke).not.toHaveBeenCalled();
});
});
describe('if collection is selected and is admin', () => {
let scriptRequestSucceeded;
beforeEach((done) => {
spyOn((component as any).modalService, 'open').and.returnValue(modalRef);
component.navigate(mockCollection).subscribe((succeeded: boolean) => {
scriptRequestSucceeded = succeeded;
done();
});
});
it('should invoke the batch-export script with option --id uuid option', () => {
const parameterValues: ProcessParameter[] = [
Object.assign(new ProcessParameter(), { name: '--id', value: mockCollection.uuid }),
Object.assign(new ProcessParameter(), { name: '--type', value: 'COLLECTION' }),
];
expect(scriptService.invoke).toHaveBeenCalledWith(BATCH_EXPORT_SCRIPT_NAME, parameterValues, []);
});
it('success notification is shown', () => {
expect(scriptRequestSucceeded).toBeTrue();
expect(notificationService.success).toHaveBeenCalled();
});
it('redirected to process page', () => {
expect(router.navigateByUrl).toHaveBeenCalledWith('/processes/45');
});
});
describe('if collection is selected and is not admin', () => {
let scriptRequestSucceeded;
beforeEach((done) => {
(authorizationDataService.isAuthorized as jasmine.Spy).and.returnValue(observableOf(false));
spyOn((component as any).modalService, 'open').and.returnValue(modalRef);
component.navigate(mockCollection).subscribe((succeeded: boolean) => {
scriptRequestSucceeded = succeeded;
done();
});
});
it('should invoke the Batch-export script with option --id uuid without option', () => {
const parameterValues: ProcessParameter[] = [
Object.assign(new ProcessParameter(), { name: '--id', value: mockCollection.uuid }),
Object.assign(new ProcessParameter(), { name: '--type', value: 'COLLECTION' })
];
expect(scriptService.invoke).toHaveBeenCalledWith(BATCH_EXPORT_SCRIPT_NAME, parameterValues, []);
});
it('success notification is shown', () => {
expect(scriptRequestSucceeded).toBeTrue();
expect(notificationService.success).toHaveBeenCalled();
});
it('redirected to process page', () => {
expect(router.navigateByUrl).toHaveBeenCalledWith('/processes/45');
});
});
describe('if collection is selected; but script invoke fails', () => {
let scriptRequestSucceeded;
beforeEach((done) => {
spyOn((component as any).modalService, 'open').and.returnValue(modalRef);
jasmine.getEnv().allowRespy(true);
spyOn(scriptService, 'invoke').and.returnValue(createFailedRemoteDataObject$('Error', 500));
component.navigate(mockCollection).subscribe((succeeded: boolean) => {
scriptRequestSucceeded = succeeded;
done();
});
});
it('error notification is shown', () => {
expect(scriptRequestSucceeded).toBeFalse();
expect(notificationService.error).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,111 @@
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { Observable, of as observableOf } from 'rxjs';
import { map, switchMap } from 'rxjs/operators';
import { BATCH_EXPORT_SCRIPT_NAME, ScriptDataService } from '../../../../core/data/processes/script-data.service';
import { Collection } from '../../../../core/shared/collection.model';
import { DSpaceObjectType } from '../../../../core/shared/dspace-object-type.model';
import { DSpaceObject } from '../../../../core/shared/dspace-object.model';
import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { ProcessParameter } from '../../../../process-page/processes/process-parameter.model';
import { ConfirmationModalComponent } from '../../../confirmation-modal/confirmation-modal.component';
import { isNotEmpty } from '../../../empty.util';
import { NotificationsService } from '../../../notifications/notifications.service';
import { createSuccessfulRemoteDataObject } from '../../../remote-data.utils';
import { DSOSelectorModalWrapperComponent, SelectorActionType } from '../dso-selector-modal-wrapper.component';
import { getFirstCompletedRemoteData } from '../../../../core/shared/operators';
import { Process } from '../../../../process-page/processes/process.model';
import { RemoteData } from '../../../../core/data/remote-data';
import { getProcessDetailRoute } from '../../../../process-page/process-page-routing.paths';
import { AuthorizationDataService } from '../../../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../../../core/data/feature-authorization/feature-id';
/**
* Component to wrap a list of existing dso's inside a modal
* Used to choose a dso from to export metadata of
*/
@Component({
selector: 'ds-export-metadata-selector',
templateUrl: '../dso-selector-modal-wrapper.component.html',
})
export class ExportBatchSelectorComponent extends DSOSelectorModalWrapperComponent implements OnInit {
objectType = DSpaceObjectType.DSPACEOBJECT;
selectorTypes = [DSpaceObjectType.COLLECTION];
action = SelectorActionType.EXPORT_BATCH;
constructor(protected activeModal: NgbActiveModal, protected route: ActivatedRoute, private router: Router,
protected notificationsService: NotificationsService, protected translationService: TranslateService,
protected scriptDataService: ScriptDataService,
protected authorizationDataService: AuthorizationDataService,
private modalService: NgbModal) {
super(activeModal, route);
}
/**
* If the dso is a collection or community: start export-metadata script & navigate to process if successful
* Otherwise show error message
*/
navigate(dso: DSpaceObject): Observable<boolean> {
if (dso instanceof Collection) {
const modalRef = this.modalService.open(ConfirmationModalComponent);
modalRef.componentInstance.dso = dso;
modalRef.componentInstance.headerLabel = 'confirmation-modal.export-batch.header';
modalRef.componentInstance.infoLabel = 'confirmation-modal.export-batch.info';
modalRef.componentInstance.cancelLabel = 'confirmation-modal.export-batch.cancel';
modalRef.componentInstance.confirmLabel = 'confirmation-modal.export-batch.confirm';
modalRef.componentInstance.confirmIcon = 'fas fa-file-export';
const resp$ = modalRef.componentInstance.response.pipe(switchMap((confirm: boolean) => {
if (confirm) {
const startScriptSucceeded$ = this.startScriptNotifyAndRedirect(dso);
return startScriptSucceeded$.pipe(
switchMap((r: boolean) => {
return observableOf(r);
})
);
} else {
const modalRefExport = this.modalService.open(ExportBatchSelectorComponent);
modalRefExport.componentInstance.dsoRD = createSuccessfulRemoteDataObject(dso);
}
}));
resp$.subscribe();
return resp$;
} else {
return observableOf(false);
}
}
/**
* Start export-metadata script of dso & navigate to process if successful
* Otherwise show error message
* @param dso Dso to export
*/
private startScriptNotifyAndRedirect(dso: DSpaceObject): Observable<boolean> {
const parameterValues: ProcessParameter[] = [
Object.assign(new ProcessParameter(), { name: '--id', value: dso.uuid }),
Object.assign(new ProcessParameter(), { name: '--type', value: 'COLLECTION' })
];
return this.authorizationDataService.isAuthorized(FeatureID.AdministratorOf).pipe(
switchMap(() => {
return this.scriptDataService.invoke(BATCH_EXPORT_SCRIPT_NAME, parameterValues, []);
}),
getFirstCompletedRemoteData(),
map((rd: RemoteData<Process>) => {
if (rd.hasSucceeded) {
const title = this.translationService.get('process.new.notification.success.title');
const content = this.translationService.get('process.new.notification.success.content');
this.notificationsService.success(title, content);
if (isNotEmpty(rd.payload)) {
this.router.navigateByUrl(getProcessDetailRoute(rd.payload.processId));
}
return true;
} else {
const title = this.translationService.get('process.new.notification.error.title');
const content = this.translationService.get('process.new.notification.error.content');
this.notificationsService.error(title, content);
return false;
}
})
);
}
}

View File

@@ -0,0 +1,77 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { TranslateModule } from '@ngx-translate/core';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { Collection } from '../../../../core/shared/collection.model';
import { Item } from '../../../../core/shared/item.model';
import { ImportBatchSelectorComponent } from './import-batch-selector.component';
describe('ImportBatchSelectorComponent', () => {
let component: ImportBatchSelectorComponent;
let fixture: ComponentFixture<ImportBatchSelectorComponent>;
const mockItem = Object.assign(new Item(), {
id: 'fake-id',
uuid: 'fake-id',
handle: 'fake/handle',
lastModified: '2018'
});
const mockCollection: Collection = Object.assign(new Collection(), {
id: 'test-collection-1-1',
uuid: 'test-collection-1-1',
name: 'test-collection-1',
metadata: {
'dc.identifier.uri': [
{
language: null,
value: 'fake/test-collection-1'
}
]
}
});
const modalStub = jasmine.createSpyObj('modalStub', ['close']);
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])],
declarations: [ImportBatchSelectorComponent],
providers: [
{ provide: NgbActiveModal, useValue: modalStub },
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ImportBatchSelectorComponent);
component = fixture.componentInstance;
spyOn(component.response, 'emit');
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('if item is selected', () => {
beforeEach((done) => {
component.navigate(mockItem).subscribe(() => {
done();
});
});
it('should emit null value', () => {
expect(component.response.emit).toHaveBeenCalledWith(null);
});
});
describe('if collection is selected', () => {
beforeEach((done) => {
component.navigate(mockCollection).subscribe(() => {
done();
});
});
it('should emit collection value', () => {
expect(component.response.emit).toHaveBeenCalledWith(mockCollection);
});
});
});

View File

@@ -0,0 +1,44 @@
import { Component, EventEmitter, OnInit, Output } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Collection } from '../../../../core/shared/collection.model';
import { DSpaceObjectType } from '../../../../core/shared/dspace-object-type.model';
import { DSpaceObject } from '../../../../core/shared/dspace-object.model';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { DSOSelectorModalWrapperComponent, SelectorActionType } from '../dso-selector-modal-wrapper.component';
import { Observable, of } from 'rxjs';
/**
* Component to wrap a list of existing dso's inside a modal
* Used to choose a dso from to import metadata of
*/
@Component({
selector: 'ds-import-batch-selector',
templateUrl: '../dso-selector-modal-wrapper.component.html',
})
export class ImportBatchSelectorComponent extends DSOSelectorModalWrapperComponent implements OnInit {
objectType = DSpaceObjectType.DSPACEOBJECT;
selectorTypes = [DSpaceObjectType.COLLECTION];
action = SelectorActionType.IMPORT_BATCH;
/**
* An event fired when the modal is closed
*/
@Output()
response = new EventEmitter<DSpaceObject>();
constructor(protected activeModal: NgbActiveModal,
protected route: ActivatedRoute) {
super(activeModal, route);
}
/**
* If the dso is a collection:
*/
navigate(dso: DSpaceObject): Observable<null> {
if (dso instanceof Collection) {
this.response.emit(dso);
return of(null);
}
this.response.emit(null);
return of(null);
}
}

View File

@@ -13,6 +13,8 @@ export function getMockObjectCacheService(): ObjectCacheService {
'hasByUUID',
'hasByHref',
'getRequestUUIDBySelfLink',
'addDependency',
'removeDependents',
]);
}

View File

@@ -1,4 +1,5 @@
<ds-object-list [ngClass]="placeholderFontClass" [config]="config"
<ds-themed-object-list [ngClass]="placeholderFontClass"
[config]="config"
[sortConfig]="sortConfig"
[objects]="objects"
[hasBorder]="hasBorder"
@@ -23,7 +24,7 @@
(prev)="goPrev()"
(next)="goNext()"
*ngIf="(currentMode$ | async) === viewModeEnum.ListElement">
</ds-object-list>
</ds-themed-object-list>
<ds-object-grid [config]="config"
[sortConfig]="sortConfig"

View File

@@ -0,0 +1,209 @@
import {Component, EventEmitter, Input, Output} from '@angular/core';
import { ObjectListComponent } from './object-list.component';
import { ThemedComponent } from '../theme-support/themed.component';
import {ViewMode} from '../../core/shared/view-mode.model';
import {PaginationComponentOptions} from '../pagination/pagination-component-options.model';
import {SortDirection, SortOptions} from '../../core/cache/models/sort-options.model';
import {CollectionElementLinkType} from '../object-collection/collection-element-link.type';
import {Context} from '../../core/shared/context.model';
import {RemoteData} from '../../core/data/remote-data';
import {PaginatedList} from '../../core/data/paginated-list.model';
import {ListableObject} from '../object-collection/shared/listable-object.model';
/**
* Themed wrapper for ObjectListComponent
*/
@Component({
selector: 'ds-themed-object-list',
styleUrls: [],
templateUrl: '../theme-support/themed.component.html',
})
export class ThemedObjectListComponent extends ThemedComponent<ObjectListComponent> {
/**
* The view mode of the this component
*/
viewMode = ViewMode.ListElement;
/**
* The current pagination configuration
*/
@Input() config: PaginationComponentOptions;
/**
* The current sort configuration
*/
@Input() sortConfig: SortOptions;
/**
* Whether or not the list elements have a border
*/
@Input() hasBorder = false;
/**
* The whether or not the gear is hidden
*/
@Input() hideGear = false;
/**
* Whether or not the pager is visible when there is only a single page of results
*/
@Input() hidePagerWhenSinglePage = true;
@Input() selectable = false;
@Input() selectionConfig: { repeatable: boolean, listId: string };
/**
* The link type of the listable elements
*/
@Input() linkType: CollectionElementLinkType;
/**
* The context of the listable elements
*/
@Input() context: Context;
/**
* Option for hiding the pagination detail
*/
@Input() hidePaginationDetail = false;
/**
* Whether or not to add an import button to the object
*/
@Input() importable = false;
/**
* Config used for the import button
*/
@Input() importConfig: { importLabel: string };
/**
* Whether or not the pagination should be rendered as simple previous and next buttons instead of the normal pagination
*/
@Input() showPaginator = true;
/**
* Emit when one of the listed object has changed.
*/
@Output() contentChange = new EventEmitter<any>();
/**
* If showPaginator is set to true, emit when the previous button is clicked
*/
@Output() prev = new EventEmitter<boolean>();
/**
* If showPaginator is set to true, emit when the next button is clicked
*/
@Output() next = new EventEmitter<boolean>();
/**
* The current listable objects
*/
private _objects: RemoteData<PaginatedList<ListableObject>>;
/**
* Setter for the objects
* @param objects The new objects
*/
@Input() set objects(objects: RemoteData<PaginatedList<ListableObject>>) {
this._objects = objects;
}
/**
* Getter to return the current objects
*/
get objects() {
return this._objects;
}
/**
* An event fired when the page is changed.
* Event's payload equals to the newly selected page.
*/
@Output() change: EventEmitter<{
pagination: PaginationComponentOptions,
sort: SortOptions
}> = new EventEmitter<{
pagination: PaginationComponentOptions,
sort: SortOptions
}>();
/**
* An event fired when the page is changed.
* Event's payload equals to the newly selected page.
*/
@Output() pageChange: EventEmitter<number> = new EventEmitter<number>();
/**
* An event fired when the page wsize is changed.
* Event's payload equals to the newly selected page size.
*/
@Output() pageSizeChange: EventEmitter<number> = new EventEmitter<number>();
/**
* An event fired when the sort direction is changed.
* Event's payload equals to the newly selected sort direction.
*/
@Output() sortDirectionChange: EventEmitter<SortDirection> = new EventEmitter<SortDirection>();
/**
* An event fired when on of the pagination parameters changes
*/
@Output() paginationChange: EventEmitter<any> = new EventEmitter<any>();
@Output() deselectObject: EventEmitter<ListableObject> = new EventEmitter<ListableObject>();
@Output() selectObject: EventEmitter<ListableObject> = new EventEmitter<ListableObject>();
/**
* Send an import event to the parent component
*/
@Output() importObject: EventEmitter<ListableObject> = new EventEmitter<ListableObject>();
/**
* An event fired when the sort field is changed.
* Event's payload equals to the newly selected sort field.
*/
@Output() sortFieldChange: EventEmitter<string> = new EventEmitter<string>();
inAndOutputNames: (keyof ObjectListComponent & keyof this)[] = [
'config',
'sortConfig',
'hasBorder',
'hideGear',
'hidePagerWhenSinglePage',
'selectable',
'selectionConfig',
'linkType',
'context',
'hidePaginationDetail',
'importable',
'importConfig',
'showPaginator',
'contentChange',
'prev',
'next',
'objects',
'change',
'pageChange',
'pageSizeChange',
'sortDirectionChange',
'paginationChange',
'deselectObject',
'selectObject',
'importObject',
'sortFieldChange',
];
protected getComponentName(): string {
return 'ObjectListComponent';
}
protected importThemedComponent(themeName: string): Promise<any> {
return import(`../../../themes/${themeName}/app/shared/object-list/object-list.component`);
}
protected importUnthemedComponent(): Promise<any> {
return import('./object-list.component');
}
}

View File

@@ -0,0 +1,33 @@
import { Component, Input } from '@angular/core';
import { ThemedComponent } from '../../theme-support/themed.component';
import { SearchSettingsComponent } from './search-settings.component';
import { SortOptions } from '../../../core/cache/models/sort-options.model';
/**
* Themed wrapper for SearchSettingsComponent
*/
@Component({
selector: 'ds-themed-search-settings',
styleUrls: [],
templateUrl: '../../theme-support/themed.component.html',
})
export class ThemedSearchSettingsComponent extends ThemedComponent<SearchSettingsComponent> {
@Input() currentSortOption: SortOptions;
@Input() sortOptionsList: SortOptions[];
protected inAndOutputNames: (keyof SearchSettingsComponent & keyof this)[] = [
'currentSortOption', 'sortOptionsList'];
protected getComponentName(): string {
return 'SearchSettingsComponent';
}
protected importThemedComponent(themeName: string): Promise<any> {
return import(`../../../../themes/${themeName}/app/shared/search/search-settings/search-settings.component`);
}
protected importUnthemedComponent(): Promise<any> {
return import('./search-settings.component');
}
}

View File

@@ -21,7 +21,8 @@
[currentConfiguration]="configuration"
[refreshFilters]="refreshFilters"
[inPlaceSearch]="inPlaceSearch"></ds-search-filters>
<ds-search-settings [currentSortOption]="currentSortOption" [sortOptionsList]="sortOptionsList"></ds-search-settings>
<ds-themed-search-settings [currentSortOption]="currentSortOption"
[sortOptionsList]="sortOptionsList"></ds-themed-search-settings>
</div>
</div>
</div>

View File

@@ -30,6 +30,7 @@ import { SearchResultsComponent } from './search-results/search-results.componen
import { SearchComponent } from './search.component';
import { ThemedSearchComponent } from './themed-search.component';
import { ThemedSearchResultsComponent } from './search-results/themed-search-results.component';
import { ThemedSearchSettingsComponent } from './search-settings/themed-search-settings.component';
const COMPONENTS = [
SearchComponent,
@@ -55,6 +56,7 @@ const COMPONENTS = [
ConfigurationSearchPageComponent,
ThemedConfigurationSearchPageComponent,
ThemedSearchResultsComponent,
ThemedSearchSettingsComponent,
];
const ENTRY_COMPONENTS = [

View File

@@ -24,6 +24,12 @@ import { ConfirmationModalComponent } from './confirmation-modal/confirmation-mo
import {
ExportMetadataSelectorComponent
} from './dso-selector/modal-wrappers/export-metadata-selector/export-metadata-selector.component';
import {
ExportBatchSelectorComponent
} from './dso-selector/modal-wrappers/export-batch-selector/export-batch-selector.component';
import {
ImportBatchSelectorComponent
} from './dso-selector/modal-wrappers/import-batch-selector/import-batch-selector.component';
import { FileDropzoneNoUploaderComponent } from './file-dropzone-no-uploader/file-dropzone-no-uploader.component';
import { ItemListElementComponent } from './object-list/item-list-element/item-types/item/item-list-element.component';
import { EnumKeysPipe } from './utils/enum-keys-pipe';
@@ -39,6 +45,7 @@ import {
SearchResultListElementComponent
} from './object-list/search-result-list-element/search-result-list-element.component';
import { ObjectListComponent } from './object-list/object-list.component';
import { ThemedObjectListComponent } from './object-list/themed-object-list.component';
import {
CollectionGridElementComponent
} from './object-grid/collection-grid-element/collection-grid-element.component';
@@ -290,6 +297,7 @@ import { LinkMenuItemComponent } from './menu/menu-item/link-menu-item.component
import { OnClickMenuItemComponent } from './menu/menu-item/onclick-menu-item.component';
import { TextMenuItemComponent } from './menu/menu-item/text-menu-item.component';
import { SearchNavbarComponent } from '../search-navbar/search-navbar.component';
import { ThemedSearchNavbarComponent } from '../search-navbar/themed-search-navbar.component';
import {
ItemVersionsSummaryModalComponent
} from './item/item-versions/item-versions-summary-modal/item-versions-summary-modal.component';
@@ -378,6 +386,7 @@ const COMPONENTS = [
LogOutComponent,
NumberPickerComponent,
ObjectListComponent,
ThemedObjectListComponent,
ObjectDetailComponent,
ObjectGridComponent,
AbstractListableElementComponent,
@@ -473,6 +482,8 @@ const COMPONENTS = [
CollectionDropdownComponent,
EntityDropdownComponent,
ExportMetadataSelectorComponent,
ImportBatchSelectorComponent,
ExportBatchSelectorComponent,
ConfirmationModalComponent,
VocabularyTreeviewComponent,
AuthorizedCollectionSelectorComponent,
@@ -498,6 +509,7 @@ const COMPONENTS = [
SearchNavbarComponent,
ScopeSelectorModalComponent,
ItemPageTitleFieldComponent,
ThemedSearchNavbarComponent,
];
const ENTRY_COMPONENTS = [
@@ -551,6 +563,8 @@ const ENTRY_COMPONENTS = [
BitstreamRequestACopyPageComponent,
CurationFormComponent,
ExportMetadataSelectorComponent,
ImportBatchSelectorComponent,
ExportBatchSelectorComponent,
ConfirmationModalComponent,
VocabularyTreeviewComponent,
SidebarSearchListElementComponent,

View File

@@ -40,7 +40,7 @@
</button>
</div>
<span *ngIf="uploader.progress < 100 && !(uploader.progress === 0 && !uploader.options.autoUpload)" class="float-right mr-3">{{ uploader.progress }}%</span>
<span *ngIf="uploader.progress === 100" class="float-right mr-3">{{'uploader.processing' | translate}}...</span>
<span *ngIf="uploader.progress === 100" class="float-right mr-3">{{'uploader.processing' | translate}}</span>
</div>
<div class="ds-base-drop-zone-progress clearfix mt-2">
<div role="progressbar"

View File

@@ -1,20 +1,26 @@
import { Angulartics2GoogleAnalytics, Angulartics2GoogleTagManager } from 'angulartics2';
import { of } from 'rxjs';
import { GoogleAnalyticsService } from './google-analytics.service';
import { Angulartics2GoogleAnalytics } from 'angulartics2';
import { ConfigurationDataService } from '../core/data/configuration-data.service';
import {
createFailedRemoteDataObject$,
createSuccessfulRemoteDataObject$
} from '../shared/remote-data.utils';
import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils';
import { ConfigurationProperty } from '../core/shared/configuration-property.model';
import { KlaroService } from '../shared/cookies/klaro.service';
import { GOOGLE_ANALYTICS_KLARO_KEY } from '../shared/cookies/klaro-configuration';
describe('GoogleAnalyticsService', () => {
const trackingIdProp = 'google.analytics.key';
const trackingIdTestValue = 'mock-tracking-id';
const trackingIdV4TestValue = 'G-mock-tracking-id';
const trackingIdV3TestValue = 'UA-mock-tracking-id';
const innerHTMLTestValue = 'mock-script-inner-html';
const srcTestValue = 'mock-script-src';
let service: GoogleAnalyticsService;
let angularticsSpy: Angulartics2GoogleAnalytics;
let googleAnalyticsSpy: Angulartics2GoogleAnalytics;
let googleTagManagerSpy: Angulartics2GoogleTagManager;
let configSpy: ConfigurationDataService;
let klaroServiceSpy: jasmine.SpyObj<KlaroService>;
let scriptElementMock: any;
let srcSpy: any;
let innerHTMLSpy: any;
let bodyElementSpy: HTMLBodyElement;
let documentSpy: Document;
@@ -28,18 +34,28 @@ describe('GoogleAnalyticsService', () => {
});
beforeEach(() => {
angularticsSpy = jasmine.createSpyObj('angulartics2GoogleAnalytics', [
googleAnalyticsSpy = jasmine.createSpyObj('Angulartics2GoogleAnalytics', [
'startTracking',
]);
googleTagManagerSpy = jasmine.createSpyObj('Angulartics2GoogleTagManager', [
'startTracking',
]);
configSpy = createConfigSuccessSpy(trackingIdTestValue);
klaroServiceSpy = jasmine.createSpyObj('KlaroService', {
'getSavedPreferences': jasmine.createSpy('getSavedPreferences')
});
configSpy = createConfigSuccessSpy(trackingIdV4TestValue);
scriptElementMock = {
set src(newVal) { /* noop */ },
get src() { return innerHTMLTestValue; },
set innerHTML(newVal) { /* noop */ },
get innerHTML() { return innerHTMLTestValue; }
get innerHTML() { return srcTestValue; }
};
innerHTMLSpy = spyOnProperty(scriptElementMock, 'innerHTML', 'set');
srcSpy = spyOnProperty(scriptElementMock, 'src', 'set');
bodyElementSpy = jasmine.createSpyObj('body', {
appendChild: scriptElementMock,
@@ -51,7 +67,11 @@ describe('GoogleAnalyticsService', () => {
body: bodyElementSpy,
});
service = new GoogleAnalyticsService(angularticsSpy, configSpy, documentSpy);
klaroServiceSpy.getSavedPreferences.and.returnValue(of({
GOOGLE_ANALYTICS_KLARO_KEY: true
}));
service = new GoogleAnalyticsService(googleAnalyticsSpy, googleTagManagerSpy, klaroServiceSpy, configSpy, documentSpy );
});
it('should be created', () => {
@@ -71,7 +91,11 @@ describe('GoogleAnalyticsService', () => {
findByPropertyName: createFailedRemoteDataObject$(),
});
service = new GoogleAnalyticsService(angularticsSpy, configSpy, documentSpy);
klaroServiceSpy.getSavedPreferences.and.returnValue(of({
GOOGLE_ANALYTICS_KLARO_KEY: true
}));
service = new GoogleAnalyticsService(googleAnalyticsSpy, googleTagManagerSpy, klaroServiceSpy, configSpy, documentSpy);
});
it('should NOT add a script to the body', () => {
@@ -81,7 +105,8 @@ describe('GoogleAnalyticsService', () => {
it('should NOT start tracking', () => {
service.addTrackingIdToPage();
expect(angularticsSpy.startTracking).toHaveBeenCalledTimes(0);
expect(googleAnalyticsSpy.startTracking).toHaveBeenCalledTimes(0);
expect(googleTagManagerSpy.startTracking).toHaveBeenCalledTimes(0);
});
});
@@ -89,7 +114,10 @@ describe('GoogleAnalyticsService', () => {
describe('when the tracking id is empty', () => {
beforeEach(() => {
configSpy = createConfigSuccessSpy();
service = new GoogleAnalyticsService(angularticsSpy, configSpy, documentSpy);
klaroServiceSpy.getSavedPreferences.and.returnValue(of({
[GOOGLE_ANALYTICS_KLARO_KEY]: true
}));
service = new GoogleAnalyticsService(googleAnalyticsSpy, googleTagManagerSpy, klaroServiceSpy, configSpy, documentSpy);
});
it('should NOT add a script to the body', () => {
@@ -99,11 +127,99 @@ describe('GoogleAnalyticsService', () => {
it('should NOT start tracking', () => {
service.addTrackingIdToPage();
expect(angularticsSpy.startTracking).toHaveBeenCalledTimes(0);
expect(googleAnalyticsSpy.startTracking).toHaveBeenCalledTimes(0);
expect(googleTagManagerSpy.startTracking).toHaveBeenCalledTimes(0);
});
});
describe('when the tracking id is non-empty', () => {
describe('when google-analytics cookie preferences are not existing', () => {
beforeEach(() => {
configSpy = createConfigSuccessSpy(trackingIdV4TestValue);
klaroServiceSpy.getSavedPreferences.and.returnValue(of({}));
service = new GoogleAnalyticsService(googleAnalyticsSpy, googleTagManagerSpy, klaroServiceSpy, configSpy, documentSpy);
});
it('should NOT add a script to the body', () => {
service.addTrackingIdToPage();
expect(bodyElementSpy.appendChild).toHaveBeenCalledTimes(0);
});
it('should NOT start tracking', () => {
service.addTrackingIdToPage();
expect(googleAnalyticsSpy.startTracking).toHaveBeenCalledTimes(0);
expect(googleTagManagerSpy.startTracking).toHaveBeenCalledTimes(0);
});
});
describe('when google-analytics cookie preferences are set to false', () => {
beforeEach(() => {
configSpy = createConfigSuccessSpy(trackingIdV4TestValue);
klaroServiceSpy.getSavedPreferences.and.returnValue(of({
[GOOGLE_ANALYTICS_KLARO_KEY]: false
}));
service = new GoogleAnalyticsService(googleAnalyticsSpy, googleTagManagerSpy, klaroServiceSpy, configSpy, documentSpy);
});
it('should NOT add a script to the body', () => {
service.addTrackingIdToPage();
expect(bodyElementSpy.appendChild).toHaveBeenCalledTimes(0);
});
it('should NOT start tracking', () => {
service.addTrackingIdToPage();
expect(googleAnalyticsSpy.startTracking).toHaveBeenCalledTimes(0);
expect(googleTagManagerSpy.startTracking).toHaveBeenCalledTimes(0);
});
});
describe('when both google-analytics cookie and the tracking v4 id are non-empty', () => {
beforeEach(() => {
configSpy = createConfigSuccessSpy(trackingIdV4TestValue);
klaroServiceSpy.getSavedPreferences.and.returnValue(of({
[GOOGLE_ANALYTICS_KLARO_KEY]: true
}));
service = new GoogleAnalyticsService(googleAnalyticsSpy, googleTagManagerSpy, klaroServiceSpy, configSpy, documentSpy);
});
it('should create a script tag whose innerHTML contains the tracking id', () => {
service.addTrackingIdToPage();
expect(documentSpy.createElement).toHaveBeenCalledTimes(2);
expect(documentSpy.createElement).toHaveBeenCalledWith('script');
// sanity check
expect(documentSpy.createElement('script')).toBe(scriptElementMock);
expect(srcSpy).toHaveBeenCalledTimes(1);
expect(srcSpy.calls.argsFor(0)[0]).toContain(trackingIdV4TestValue);
expect(innerHTMLSpy).toHaveBeenCalledTimes(1);
expect(innerHTMLSpy.calls.argsFor(0)[0]).toContain(trackingIdV4TestValue);
});
it('should add a script to the body', () => {
service.addTrackingIdToPage();
expect(bodyElementSpy.appendChild).toHaveBeenCalledTimes(2);
});
it('should start tracking', () => {
service.addTrackingIdToPage();
expect(googleAnalyticsSpy.startTracking).not.toHaveBeenCalled();
expect(googleTagManagerSpy.startTracking).toHaveBeenCalledTimes(1);
});
});
describe('when both google-analytics cookie and the tracking id v3 are non-empty', () => {
beforeEach(() => {
configSpy = createConfigSuccessSpy(trackingIdV3TestValue);
klaroServiceSpy.getSavedPreferences.and.returnValue(of({
[GOOGLE_ANALYTICS_KLARO_KEY]: true
}));
service = new GoogleAnalyticsService(googleAnalyticsSpy, googleTagManagerSpy, klaroServiceSpy, configSpy, documentSpy);
});
it('should create a script tag whose innerHTML contains the tracking id', () => {
service.addTrackingIdToPage();
expect(documentSpy.createElement).toHaveBeenCalledTimes(1);
@@ -113,7 +229,7 @@ describe('GoogleAnalyticsService', () => {
expect(documentSpy.createElement('script')).toBe(scriptElementMock);
expect(innerHTMLSpy).toHaveBeenCalledTimes(1);
expect(innerHTMLSpy.calls.argsFor(0)[0]).toContain(trackingIdTestValue);
expect(innerHTMLSpy.calls.argsFor(0)[0]).toContain(trackingIdV3TestValue);
});
it('should add a script to the body', () => {
@@ -123,9 +239,12 @@ describe('GoogleAnalyticsService', () => {
it('should start tracking', () => {
service.addTrackingIdToPage();
expect(angularticsSpy.startTracking).toHaveBeenCalledTimes(1);
expect(googleAnalyticsSpy.startTracking).toHaveBeenCalledTimes(1);
expect(googleTagManagerSpy.startTracking).not.toHaveBeenCalled();
});
});
});
});
});

View File

@@ -1,9 +1,14 @@
import { DOCUMENT } from '@angular/common';
import { Inject, Injectable } from '@angular/core';
import { Angulartics2GoogleAnalytics } from 'angulartics2';
import { Angulartics2GoogleAnalytics, Angulartics2GoogleTagManager } from 'angulartics2';
import { combineLatest } from 'rxjs';
import { ConfigurationDataService } from '../core/data/configuration-data.service';
import { getFirstCompletedRemoteData } from '../core/shared/operators';
import { isEmpty } from '../shared/empty.util';
import { DOCUMENT } from '@angular/common';
import { KlaroService } from '../shared/cookies/klaro.service';
import { GOOGLE_ANALYTICS_KLARO_KEY } from '../shared/cookies/klaro-configuration';
/**
* Set up Google Analytics on the client side.
@@ -13,10 +18,13 @@ import { DOCUMENT } from '@angular/common';
export class GoogleAnalyticsService {
constructor(
private angulartics: Angulartics2GoogleAnalytics,
private googleAnalytics: Angulartics2GoogleAnalytics,
private googleTagManager: Angulartics2GoogleTagManager,
private klaroService: KlaroService,
private configService: ConfigurationDataService,
@Inject(DOCUMENT) private document: any,
) { }
) {
}
/**
* Call this method once when Angular initializes on the client side.
@@ -25,28 +33,61 @@ export class GoogleAnalyticsService {
* page and starts tracking.
*/
addTrackingIdToPage(): void {
this.configService.findByPropertyName('google.analytics.key').pipe(
const googleKey$ = this.configService.findByPropertyName('google.analytics.key').pipe(
getFirstCompletedRemoteData(),
).subscribe((remoteData) => {
// make sure we got a success response from the backend
if (!remoteData.hasSucceeded) { return; }
);
const preferences$ = this.klaroService.getSavedPreferences();
const trackingId = remoteData.payload.values[0];
combineLatest([preferences$, googleKey$])
.subscribe(([preferences, remoteData]) => {
// make sure user has accepted Google Analytics consents
if (isEmpty(preferences) || isEmpty(preferences[GOOGLE_ANALYTICS_KLARO_KEY]) || !preferences[GOOGLE_ANALYTICS_KLARO_KEY]) {
return;
}
// make sure we received a tracking id
if (isEmpty(trackingId)) { return; }
// make sure we got a success response from the backend
if (!remoteData.hasSucceeded) {
return;
}
// add trackingId snippet to page
const keyScript = this.document.createElement('script');
keyScript.innerHTML = `(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
const trackingId = remoteData.payload.values[0];
// make sure we received a tracking id
if (isEmpty(trackingId)) {
return;
}
if (this.isGTagVersion(trackingId)) {
// add GTag snippet to page
const keyScript = this.document.createElement('script');
keyScript.src = `https://www.googletagmanager.com/gtag/js?id=${trackingId}`;
this.document.body.appendChild(keyScript);
const libScript = this.document.createElement('script');
libScript.innerHTML = `window.dataLayer = window.dataLayer || [];function gtag(){window.dataLayer.push(arguments);}
gtag('js', new Date());gtag('config', '${trackingId}');`;
this.document.body.appendChild(libScript);
// start tracking
this.googleTagManager.startTracking();
} else {
// add trackingId snippet to page
const keyScript = this.document.createElement('script');
keyScript.innerHTML = `(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
})(window,document,'script','https://www.google-analytics.com/analytics.js','ga');
ga('create', '${trackingId}', 'auto');`;
this.document.body.appendChild(keyScript);
this.document.body.appendChild(keyScript);
// start tracking
this.angulartics.startTracking();
});
// start tracking
this.googleAnalytics.startTracking();
}
});
}
private isGTagVersion(trackingId: string) {
return trackingId && trackingId.startsWith('G-');
}
}

View File

@@ -127,19 +127,18 @@ describe('ThumbnailComponent', () => {
});
const errorHandler = () => {
let fallbackSpy;
let setSrcSpy;
beforeEach(() => {
fallbackSpy = spyOn(comp, 'showFallback').and.callThrough();
// disconnect error handler to be sure it's only called once
const img = fixture.debugElement.query(By.css('img.thumbnail-content'));
img.nativeNode.onerror = null;
comp.ngOnChanges();
setSrcSpy = spyOn(comp, 'setSrc').and.callThrough();
});
describe('retry with authentication token', () => {
beforeEach(() => {
// disconnect error handler to be sure it's only called once
const img = fixture.debugElement.query(By.css('img.thumbnail-content'));
img.nativeNode.onerror = null;
});
it('should remember that it already retried once', () => {
expect(comp.retriedWithToken).toBeFalse();
comp.errorHandler();
@@ -153,7 +152,7 @@ describe('ThumbnailComponent', () => {
it('should fall back to default', () => {
comp.errorHandler();
expect(fallbackSpy).toHaveBeenCalled();
expect(setSrcSpy).toHaveBeenCalledWith(comp.defaultImage);
});
});
@@ -172,11 +171,9 @@ describe('ThumbnailComponent', () => {
if ((comp.thumbnail as RemoteData<Bitstream>)?.hasFailed) {
// If we failed to retrieve the Bitstream in the first place, fall back to the default
expect(comp.src$.getValue()).toBe(null);
expect(fallbackSpy).toHaveBeenCalled();
expect(setSrcSpy).toHaveBeenCalledWith(comp.defaultImage);
} else {
expect(comp.src$.getValue()).toBe(CONTENT + '?authentication-token=fake');
expect(fallbackSpy).not.toHaveBeenCalled();
expect(setSrcSpy).toHaveBeenCalledWith(CONTENT + '?authentication-token=fake');
}
});
});
@@ -189,8 +186,7 @@ describe('ThumbnailComponent', () => {
it('should fall back to default', () => {
comp.errorHandler();
expect(comp.src$.getValue()).toBe(null);
expect(fallbackSpy).toHaveBeenCalled();
expect(setSrcSpy).toHaveBeenCalledWith(comp.defaultImage);
// We don't need to check authorization if we failed to retrieve the Bitstreamin the first place
if (!(comp.thumbnail as RemoteData<Bitstream>)?.hasFailed) {
@@ -210,7 +206,7 @@ describe('ThumbnailComponent', () => {
comp.errorHandler();
expect(authService.isAuthenticated).not.toHaveBeenCalled();
expect(fileService.retrieveFileDownloadLink).not.toHaveBeenCalled();
expect(fallbackSpy).toHaveBeenCalled();
expect(setSrcSpy).toHaveBeenCalledWith(comp.defaultImage);
});
});
};
@@ -263,21 +259,23 @@ describe('ThumbnailComponent', () => {
comp.thumbnail = thumbnail;
});
it('should display an image', () => {
comp.ngOnChanges();
fixture.detectChanges();
const image: HTMLElement = fixture.debugElement.query(By.css('img')).nativeElement;
expect(image.getAttribute('src')).toBe(thumbnail._links.content.href);
describe('if content can be loaded', () => {
it('should display an image', () => {
comp.ngOnChanges();
fixture.detectChanges();
const image: HTMLElement = fixture.debugElement.query(By.css('img')).nativeElement;
expect(image.getAttribute('src')).toBe(thumbnail._links.content.href);
});
it('should include the alt text', () => {
comp.ngOnChanges();
fixture.detectChanges();
const image: HTMLElement = fixture.debugElement.query(By.css('img')).nativeElement;
expect(image.getAttribute('alt')).toBe('TRANSLATED ' + comp.alt);
});
});
it('should include the alt text', () => {
comp.ngOnChanges();
fixture.detectChanges();
const image: HTMLElement = fixture.debugElement.query(By.css('img')).nativeElement;
expect(image.getAttribute('alt')).toBe('TRANSLATED ' + comp.alt);
});
describe('when there is no thumbnail', () => {
describe('if content can\'t be loaded', () => {
errorHandler();
});
});
@@ -296,36 +294,42 @@ describe('ThumbnailComponent', () => {
};
});
describe('when there is a thumbnail', () => {
describe('if RemoteData succeeded', () => {
beforeEach(() => {
comp.thumbnail = createSuccessfulRemoteDataObject(thumbnail);
});
it('should display an image', () => {
comp.ngOnChanges();
fixture.detectChanges();
const image: HTMLElement = de.query(By.css('img')).nativeElement;
expect(image.getAttribute('src')).toBe(thumbnail._links.content.href);
describe('if content can be loaded', () => {
it('should display an image', () => {
comp.ngOnChanges();
fixture.detectChanges();
const image: HTMLElement = de.query(By.css('img')).nativeElement;
expect(image.getAttribute('src')).toBe(thumbnail._links.content.href);
});
it('should display the alt text', () => {
comp.ngOnChanges();
fixture.detectChanges();
const image: HTMLElement = de.query(By.css('img')).nativeElement;
expect(image.getAttribute('alt')).toBe('TRANSLATED ' + comp.alt);
});
});
it('should display the alt text', () => {
comp.ngOnChanges();
fixture.detectChanges();
const image: HTMLElement = de.query(By.css('img')).nativeElement;
expect(image.getAttribute('alt')).toBe('TRANSLATED ' + comp.alt);
});
describe('but it can\'t be loaded', () => {
describe('if content can\'t be loaded', () => {
errorHandler();
});
});
describe('when there is no thumbnail', () => {
describe('if RemoteData failed', () => {
beforeEach(() => {
comp.thumbnail = createFailedRemoteDataObject();
});
errorHandler();
it('should show the default image', () => {
comp.defaultImage = 'default/image.jpg';
comp.ngOnChanges();
expect(comp.src$.getValue()).toBe('default/image.jpg');
});
});
});
});

View File

@@ -12,7 +12,7 @@ import { FileService } from '../core/shared/file.service';
/**
* This component renders a given Bitstream as a thumbnail.
* One input parameter of type Bitstream is expected.
* If no Bitstream is provided, a HTML placeholder will be rendered instead.
* If no Bitstream is provided, an HTML placeholder will be rendered instead.
*/
@Component({
selector: 'ds-thumbnail',
@@ -75,11 +75,11 @@ export class ThumbnailComponent implements OnChanges {
return;
}
const thumbnail = this.bitstream;
if (hasValue(thumbnail?._links?.content?.href)) {
this.setSrc(thumbnail?._links?.content?.href);
const src = this.contentHref;
if (hasValue(src)) {
this.setSrc(src);
} else {
this.showFallback();
this.setSrc(this.defaultImage);
}
}
@@ -95,22 +95,33 @@ export class ThumbnailComponent implements OnChanges {
}
}
private get contentHref(): string | undefined {
if (this.thumbnail instanceof Bitstream) {
return this.thumbnail?._links?.content?.href;
} else if (this.thumbnail instanceof RemoteData) {
return this.thumbnail?.payload?._links?.content?.href;
}
}
/**
* Handle image download errors.
* If the image can't be loaded, try re-requesting it with an authorization token in case it's a restricted Bitstream
* Otherwise, fall back to the default image or a HTML placeholder
*/
errorHandler() {
if (!this.retriedWithToken && hasValue(this.thumbnail)) {
const src = this.src$.getValue();
const thumbnail = this.bitstream;
const thumbnailSrc = thumbnail?._links?.content?.href;
if (!this.retriedWithToken && hasValue(thumbnailSrc) && src === thumbnailSrc) {
// the thumbnail may have failed to load because it's restricted
// → retry with an authorization token
// only do this once; fall back to the default if it still fails
this.retriedWithToken = true;
const thumbnail = this.bitstream;
this.auth.isAuthenticated().pipe(
switchMap((isLoggedIn) => {
if (isLoggedIn && hasValue(thumbnail)) {
if (isLoggedIn) {
return this.authorizationService.isAuthorized(FeatureID.CanDownload, thumbnail.self);
} else {
return observableOf(false);
@@ -118,7 +129,7 @@ export class ThumbnailComponent implements OnChanges {
}),
switchMap((isAuthorized) => {
if (isAuthorized) {
return this.fileService.retrieveFileDownloadLink(thumbnail._links.content.href);
return this.fileService.retrieveFileDownloadLink(thumbnailSrc);
} else {
return observableOf(null);
}
@@ -130,27 +141,17 @@ export class ThumbnailComponent implements OnChanges {
// Otherwise, fall back to the default image right now
this.setSrc(url);
} else {
this.showFallback();
this.setSrc(this.defaultImage);
}
});
} else {
this.showFallback();
}
}
/**
* To be called when the requested thumbnail could not be found
* - If the current src is not the default image, try that first
* - If this was already the case and the default image could not be found either,
* show an HTML placecholder by setting src to null
*
* Also stops the loading animation.
*/
showFallback() {
if (this.src$.getValue() !== this.defaultImage) {
this.setSrc(this.defaultImage);
} else {
this.setSrc(null);
if (src !== this.defaultImage) {
// we failed to get thumbnail (possibly retried with a token but failed again)
this.setSrc(this.defaultImage);
} else {
// we have failed to retrieve the default image, fall back to the placeholder
this.setSrc(null);
}
}
}

View File

@@ -6651,9 +6651,9 @@
// TODO New key - Add a translation
"uploader.or": ", or ",
// "uploader.processing": "Processing",
// "uploader.processing": "Processing uploaded file(s)... (it's now safe to close this page)",
// TODO New key - Add a translation
"uploader.processing": "Processing",
"uploader.processing": "Processing uploaded file(s)... (it's now safe to close this page)",
// "uploader.queue-length": "Queue length",
// TODO New key - Add a translation

View File

@@ -6026,7 +6026,8 @@
// "uploader.or": ", or ",
"uploader.or": "অথবা",
// "uploader.processing": "Processing",
// "uploader.processing": "Processing uploaded file(s)... (it's now safe to close this page)",
// TODO Source message changed - Revise the translation
"uploader.processing": "প্রক্রিয়াকরণ",
// "uploader.queue-length": "Queue length",

View File

@@ -6518,9 +6518,9 @@
// TODO New key - Add a translation
"uploader.or": ", or ",
// "uploader.processing": "Processing",
// "uploader.processing": "Processing uploaded file(s)... (it's now safe to close this page)",
// TODO New key - Add a translation
"uploader.processing": "Processing",
"uploader.processing": "Processing uploaded file(s)... (it's now safe to close this page)",
// "uploader.queue-length": "Queue length",
// TODO New key - Add a translation

View File

@@ -5381,7 +5381,8 @@
// "uploader.or": ", or ",
"uploader.or": ", oder",
// "uploader.processing": "Processing",
// "uploader.processing": "Processing uploaded file(s)... (it's now safe to close this page)",
// TODO Source message changed - Revise the translation
"uploader.processing": "In Arbeit...",
// "uploader.queue-length": "Queue length",

View File

@@ -2203,6 +2203,8 @@
"uploader.delete.btn-title": "Διαγραφή",
"uploader.drag-message": "Σύρετε και αποθέστε τα αρχεία σας εδώ",
"uploader.or": ", ή",
// "uploader.processing": "Processing uploaded file(s)... (it's now safe to close this page)",
// TODO Source message changed - Revise the translation
"uploader.processing": "Επεξεργασία",
"uploader.queue-length": "Μέγεθος ουράς",
"virtual-metadata.delete-item.info": "Επιλέξτε τους τύπους για τους οποίους θέλετε να αποθηκεύσετε τα εικονικά μεταδεδομένα ως πραγματικά μεταδεδομένα",

View File

@@ -542,28 +542,45 @@
"admin.metadata-import.breadcrumbs": "Import Metadata",
"admin.batch-import.breadcrumbs": "Import Batch",
"admin.metadata-import.title": "Import Metadata",
"admin.batch-import.title": "Import Batch",
"admin.metadata-import.page.header": "Import Metadata",
"admin.batch-import.page.header": "Import Batch",
"admin.metadata-import.page.help": "You can drop or browse CSV files that contain batch metadata operations on files here",
"admin.batch-import.page.help": "Select the Collection to import into. Then, drop or browse to a Simple Archive Format (SAF) zip file that includes the Items to import",
"admin.metadata-import.page.dropMsg": "Drop a metadata CSV to import",
"admin.batch-import.page.dropMsg": "Drop a batch ZIP to import",
"admin.metadata-import.page.dropMsgReplace": "Drop to replace the metadata CSV to import",
"admin.batch-import.page.dropMsgReplace": "Drop to replace the batch ZIP to import",
"admin.metadata-import.page.button.return": "Back",
"admin.metadata-import.page.button.proceed": "Proceed",
"admin.metadata-import.page.button.select-collection": "Select Collection",
"admin.metadata-import.page.error.addFile": "Select file first!",
"admin.batch-import.page.error.addFile": "Select Zip file first!",
"admin.metadata-import.page.validateOnly": "Validate Only",
"admin.metadata-import.page.validateOnly.hint": "When selected, the uploaded CSV will be validated. You will receive a report of detected changes, but no changes will be saved.",
"admin.batch-import.page.validateOnly.hint": "When selected, the uploaded ZIP will be validated. You will receive a report of detected changes, but no changes will be saved.",
"admin.batch-import.page.remove": "remove",
"auth.errors.invalid-user": "Invalid email address or password.",
@@ -1346,6 +1363,10 @@
"dso-selector.export-metadata.dspaceobject.head": "Export metadata from",
"dso-selector.export-batch.dspaceobject.head": "Export Batch (ZIP) from",
"dso-selector.import-batch.dspaceobject.head": "Import batch from",
"dso-selector.no-results": "No {{ type }} found",
"dso-selector.placeholder": "Search for a {{ type }}",
@@ -1374,6 +1395,14 @@
"confirmation-modal.export-metadata.confirm": "Export",
"confirmation-modal.export-batch.header": "Export batch (ZIP) for {{ dsoName }}",
"confirmation-modal.export-batch.info": "Are you sure you want to export batch (ZIP) for {{ dsoName }}",
"confirmation-modal.export-batch.cancel": "Cancel",
"confirmation-modal.export-batch.confirm": "Export",
"confirmation-modal.delete-eperson.header": "Delete EPerson \"{{ dsoName }}\"",
"confirmation-modal.delete-eperson.info": "Are you sure you want to delete EPerson \"{{ dsoName }}\"",
@@ -2617,6 +2646,7 @@
"menu.section.export_metadata": "Metadata",
"menu.section.export_batch": "Batch Export (ZIP)",
"menu.section.icon.access_control": "Access Control menu section",
@@ -2734,8 +2764,6 @@
"mydspace.description": "",
"mydspace.general.text-here": "here",
"mydspace.messages.controller-help": "Select this option to send a message to item's submitter.",
"mydspace.messages.description-placeholder": "Insert your message here...",
@@ -2812,8 +2840,6 @@
"mydspace.upload.upload-multiple-successful": "{{qty}} new workspace items created.",
"mydspace.upload.upload-successful": "New workspace item created. Click {{here}} for edit it.",
"mydspace.view-btn": "View",
@@ -3083,12 +3109,16 @@
"profile.security.form.label.passwordrepeat": "Retype to confirm",
"profile.security.form.label.current-password": "Current password",
"profile.security.form.notifications.success.content": "Your changes to the password were saved.",
"profile.security.form.notifications.success.title": "Password saved",
"profile.security.form.notifications.error.title": "Error changing passwords",
"profile.security.form.notifications.error.change-failed": "An error occurred while trying to change the password. Please check if the current password is correct.",
"profile.security.form.notifications.error.not-same": "The provided passwords are not the same.",
"profile.security.form.notifications.error.general": "Please fill required fields of security form.",
@@ -4445,7 +4475,7 @@
"uploader.or": ", or ",
"uploader.processing": "Processing",
"uploader.processing": "Processing uploaded file(s)... (it's now safe to close this page)",
"uploader.queue-length": "Queue length",

View File

@@ -6515,7 +6515,8 @@
// "uploader.or": ", or ",
"uploader.or": ", o ",
// "uploader.processing": "Processing",
// "uploader.processing": "Processing uploaded file(s)... (it's now safe to close this page)",
// TODO Source message changed - Revise the translation
"uploader.processing": "Procesando",
// "uploader.queue-length": "Queue length",

View File

@@ -5061,7 +5061,8 @@
// "uploader.or": ", or ",
"uploader.or": " tai",
// "uploader.processing": "Processing",
// "uploader.processing": "Processing uploaded file(s)... (it's now safe to close this page)",
// TODO Source message changed - Revise the translation
"uploader.processing": "Käsitellään",
// "uploader.queue-length": "Queue length",

View File

@@ -1215,6 +1215,17 @@
// "collection.edit.tabs.authorizations.title": "Collection Edit - Authorizations",
"collection.edit.tabs.authorizations.title": "Édition de collection - Autorisations",
//"collection.edit.item.authorizations.load-bundle-button": "Load more bundles",
"collection.edit.item.authorizations.load-bundle-button": "Charger plus de Bundles",
//"collection.edit.item.authorizations.load-more-button": "Load more",
"collection.edit.item.authorizations.load-more-button": "Charger plus",
//"collection.edit.item.authorizations.show-bitstreams-button": "Show bitstream policies for bundle",
"collection.edit.item.authorizations.show-bitstreams-button": "Afficher les politiques de Bitstream pour le Bundle",
// "collection.edit.tabs.metadata.head": "Edit Metadata",
"collection.edit.tabs.metadata.head": "Éditer les Métadonnées",
@@ -2909,6 +2920,13 @@
// "item.search.title": "Item Search",
"item.search.title": "Recherche d'Items",
// "item.truncatable-part.show-more": "Show more",
"item.truncatable-part.show-more": "Voir plus",
//"item.truncatable-part.show-less": "Collapse",
"item.truncatable-part.show-less": "Réduire",
// "item.page.abstract": "Abstract",
"item.page.abstract": "Résumé",
@@ -3572,6 +3590,9 @@
// "menu.section.processes": "Processes",
"menu.section.processes": "Processus",
// "menu.section.health": "Health",
"menu.section.health": "Santé du système",
// "menu.section.registries": "Registries",
"menu.section.registries": "Registres",
@@ -3627,9 +3648,6 @@
// "mydspace.description": "",
"mydspace.description": "",
// "mydspace.general.text-here": "here",
"mydspace.general.text-here": "ici",
// "mydspace.messages.controller-help": "Select this option to send a message to item's submitter.",
"mydspace.messages.controller-help": "Sélectionner cette option pour envoyer un message au déposant.",
@@ -3744,9 +3762,6 @@
// "mydspace.upload.upload-multiple-successful": "{{qty}} new workspace items created.",
"mydspace.upload.upload-multiple-successful": "{{qty}} nouveaux Items créés dans l'espace de travail.",
// "mydspace.upload.upload-successful": "New workspace item created. Click {{here}} for edit it.",
"mydspace.upload.upload-successful": "Nouvel item créé dans l'espace de travail. Cliquer {{here}} pour l'éditer.",
// "mydspace.view-btn": "View",
"mydspace.view-btn": "Afficher",
@@ -4808,6 +4823,9 @@
// "default.search.results.head": "Search Results",
"default.search.results.head": "Résultats de recherche",
// "default-relationships.search.results.head": "Search Results",
"default-relationships.search.results.head": "Résultats de recherche",
// "search.sidebar.close": "Back to results",
"search.sidebar.close": "Retour aux résultats",
@@ -5802,7 +5820,8 @@
// "uploader.or": ", or ",
"uploader.or": ", ou ",
// "uploader.processing": "Processing",
// "uploader.processing": "Processing uploaded file(s)... (it's now safe to close this page)",
// TODO Source message changed - Revise the translation
"uploader.processing": "En cours de traitement",
// "uploader.queue-length": "Queue length",

View File

@@ -5980,7 +5980,8 @@
// "uploader.or": ", or ",
"uploader.or": ", no ",
// "uploader.processing": "Processing",
// "uploader.processing": "Processing uploaded file(s)... (it's now safe to close this page)",
// TODO Source message changed - Revise the translation
"uploader.processing": "A' pròiseasadh",
// "uploader.queue-length": "Queue length",

View File

@@ -5072,7 +5072,8 @@
// TODO Source message changed - Revise the translation
"uploader.or": ", vagy",
// "uploader.processing": "Processing",
// "uploader.processing": "Processing uploaded file(s)... (it's now safe to close this page)",
// TODO Source message changed - Revise the translation
"uploader.processing": "Feldolgozás",
// "uploader.queue-length": "Queue length",

View File

@@ -6651,9 +6651,9 @@
// TODO New key - Add a translation
"uploader.or": ", or ",
// "uploader.processing": "Processing",
// "uploader.processing": "Processing uploaded file(s)... (it's now safe to close this page)",
// TODO New key - Add a translation
"uploader.processing": "Processing",
"uploader.processing": "Processing uploaded file(s)... (it's now safe to close this page)",
// "uploader.queue-length": "Queue length",
// TODO New key - Add a translation

View File

@@ -7135,7 +7135,8 @@
// "uploader.or": ", or ",
"uploader.or": ", немесе ",
// "uploader.processing": "Processing",
// "uploader.processing": "Processing uploaded file(s)... (it's now safe to close this page)",
// TODO Source message changed - Revise the translation
"uploader.processing": "Өңдеу",
// "uploader.queue-length": "Queue length",

Some files were not shown because too many files have changed in this diff Show More