Merge branch 'main' into w2p-85192_pr-bugfix-specify-view-mode-in-ds-browse-by

This commit is contained in:
bruno-atmire
2021-12-01 16:19:21 +01:00
committed by GitHub
391 changed files with 34011 additions and 22560 deletions

View File

@@ -52,15 +52,17 @@
<table id="groups" class="table table-striped table-hover table-bordered">
<thead>
<tr>
<th scope="col">{{messagePrefix + '.table.id' | translate}}</th>
<th scope="col">{{messagePrefix + '.table.name' | translate}}</th>
<th scope="col" class="align-middle">{{messagePrefix + '.table.id' | translate}}</th>
<th scope="col" class="align-middle">{{messagePrefix + '.table.name' | translate}}</th>
<th scope="col" class="align-middle">{{messagePrefix + '.table.collectionOrCommunity' | translate}}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let group of (groups | async)?.payload?.page">
<td>{{group.id}}</td>
<td><a (click)="groupsDataService.startEditingNewGroup(group)"
<td class="align-middle">{{group.id}}</td>
<td class="align-middle"><a (click)="groupsDataService.startEditingNewGroup(group)"
[routerLink]="[groupsDataService.getGroupEditPageRouterLink(group)]">{{group.name}}</a></td>
<td class="align-middle">{{(group.object | async)?.payload?.name}}</td>
</tr>
</tbody>
</table>

View File

@@ -28,6 +28,9 @@ import { createPaginatedList } from '../../../shared/testing/utils.test';
import { RequestService } from '../../../core/data/request.service';
import { PaginationService } from '../../../core/pagination/pagination.service';
import { PaginationServiceStub } from '../../../shared/testing/pagination-service.stub';
import { FormArray, FormControl, FormGroup,Validators, NG_VALIDATORS, NG_ASYNC_VALIDATORS } from '@angular/forms';
import { ValidateEmailNotTaken } from './validators/email-taken.validator';
describe('EPersonFormComponent', () => {
let component: EPersonFormComponent;
@@ -99,12 +102,78 @@ describe('EPersonFormComponent', () => {
}
});
return createSuccessfulRemoteDataObject$(ePerson);
},
getEPersonByEmail(email): Observable<RemoteData<EPerson>> {
return createSuccessfulRemoteDataObject$(null);
}
};
builderService = getMockFormBuilderService();
builderService = Object.assign(getMockFormBuilderService(),{
createFormGroup(formModel, options = null) {
const controls = {};
formModel.forEach( model => {
model.parent = parent;
const controlModel = model;
const controlState = { value: controlModel.value, disabled: controlModel.disabled };
const controlOptions = this.createAbstractControlOptions(controlModel.validators, controlModel.asyncValidators, controlModel.updateOn);
controls[model.id] = new FormControl(controlState, controlOptions);
});
return new FormGroup(controls, options);
},
createAbstractControlOptions(validatorsConfig = null, asyncValidatorsConfig = null, updateOn = null) {
return {
validators: validatorsConfig !== null ? this.getValidators(validatorsConfig) : null,
};
},
getValidators(validatorsConfig) {
return this.getValidatorFns(validatorsConfig);
},
getValidatorFns(validatorsConfig, validatorsToken = this._NG_VALIDATORS) {
let validatorFns = [];
if (this.isObject(validatorsConfig)) {
validatorFns = Object.keys(validatorsConfig).map(validatorConfigKey => {
const validatorConfigValue = validatorsConfig[validatorConfigKey];
if (this.isValidatorDescriptor(validatorConfigValue)) {
const descriptor = validatorConfigValue;
return this.getValidatorFn(descriptor.name, descriptor.args, validatorsToken);
}
return this.getValidatorFn(validatorConfigKey, validatorConfigValue, validatorsToken);
});
}
return validatorFns;
},
getValidatorFn(validatorName, validatorArgs = null, validatorsToken = this._NG_VALIDATORS) {
let validatorFn;
if (Validators.hasOwnProperty(validatorName)) { // Built-in Angular Validators
validatorFn = Validators[validatorName];
} else { // Custom Validators
if (this._DYNAMIC_VALIDATORS && this._DYNAMIC_VALIDATORS.has(validatorName)) {
validatorFn = this._DYNAMIC_VALIDATORS.get(validatorName);
} else if (validatorsToken) {
validatorFn = validatorsToken.find(validator => validator.name === validatorName);
}
}
if (validatorFn === undefined) { // throw when no validator could be resolved
throw new Error(`validator '${validatorName}' is not provided via NG_VALIDATORS, NG_ASYNC_VALIDATORS or DYNAMIC_FORM_VALIDATORS`);
}
if (validatorArgs !== null) {
return validatorFn(validatorArgs);
}
return validatorFn;
},
isValidatorDescriptor(value) {
if (this.isObject(value)) {
return value.hasOwnProperty('name') && value.hasOwnProperty('args');
}
return false;
},
isObject(value) {
return typeof value === 'object' && value !== null;
}
});
authService = new AuthServiceStub();
authorizationService = jasmine.createSpyObj('authorizationService', {
isAuthorized: observableOf(true)
isAuthorized: observableOf(true),
});
groupsDataService = jasmine.createSpyObj('groupsDataService', {
findAllByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])),
@@ -146,6 +215,131 @@ describe('EPersonFormComponent', () => {
expect(component).toBeDefined();
});
describe('check form validation', () => {
let firstName;
let lastName;
let email;
let canLogIn;
let requireCertificate;
let expected;
beforeEach(() => {
firstName = 'testName';
lastName = 'testLastName';
email = 'testEmail@test.com';
canLogIn = false;
requireCertificate = false;
expected = Object.assign(new EPerson(), {
metadata: {
'eperson.firstname': [
{
value: firstName
}
],
'eperson.lastname': [
{
value: lastName
},
],
},
email: email,
canLogIn: canLogIn,
requireCertificate: requireCertificate,
});
spyOn(component.submitForm, 'emit');
component.canLogIn.value = canLogIn;
component.requireCertificate.value = requireCertificate;
fixture.detectChanges();
component.initialisePage();
fixture.detectChanges();
});
describe('firstName, lastName and email should be required', () => {
it('form should be invalid because the firstName is required', waitForAsync(() => {
fixture.whenStable().then(() => {
expect(component.formGroup.controls.firstName.valid).toBeFalse();
expect(component.formGroup.controls.firstName.errors.required).toBeTrue();
});
}));
it('form should be invalid because the lastName is required', waitForAsync(() => {
fixture.whenStable().then(() => {
expect(component.formGroup.controls.lastName.valid).toBeFalse();
expect(component.formGroup.controls.lastName.errors.required).toBeTrue();
});
}));
it('form should be invalid because the email is required', waitForAsync(() => {
fixture.whenStable().then(() => {
expect(component.formGroup.controls.email.valid).toBeFalse();
expect(component.formGroup.controls.email.errors.required).toBeTrue();
});
}));
});
describe('after inserting information firstName,lastName and email not required', () => {
beforeEach(() => {
component.formGroup.controls.firstName.setValue('test');
component.formGroup.controls.lastName.setValue('test');
component.formGroup.controls.email.setValue('test@test.com');
fixture.detectChanges();
});
it('firstName should be valid because the firstName is set', waitForAsync(() => {
fixture.whenStable().then(() => {
expect(component.formGroup.controls.firstName.valid).toBeTrue();
expect(component.formGroup.controls.firstName.errors).toBeNull();
});
}));
it('lastName should be valid because the lastName is set', waitForAsync(() => {
fixture.whenStable().then(() => {
expect(component.formGroup.controls.lastName.valid).toBeTrue();
expect(component.formGroup.controls.lastName.errors).toBeNull();
});
}));
it('email should be valid because the email is set', waitForAsync(() => {
fixture.whenStable().then(() => {
expect(component.formGroup.controls.email.valid).toBeTrue();
expect(component.formGroup.controls.email.errors).toBeNull();
});
}));
});
describe('after inserting email wrong should show pattern validation error', () => {
beforeEach(() => {
component.formGroup.controls.email.setValue('test@test');
fixture.detectChanges();
});
it('email should not be valid because the email pattern', waitForAsync(() => {
fixture.whenStable().then(() => {
expect(component.formGroup.controls.email.valid).toBeFalse();
expect(component.formGroup.controls.email.errors.pattern).toBeTruthy();
});
}));
});
describe('after already utilized email', () => {
beforeEach(() => {
const ePersonServiceWithEperson = Object.assign(ePersonDataServiceStub,{
getEPersonByEmail(): Observable<RemoteData<EPerson>> {
return createSuccessfulRemoteDataObject$(EPersonMock);
}
});
component.formGroup.controls.email.setValue('test@test.com');
component.formGroup.controls.email.setAsyncValidators(ValidateEmailNotTaken.createValidator(ePersonServiceWithEperson));
fixture.detectChanges();
});
it('email should not be valid because email is already taken', waitForAsync(() => {
fixture.whenStable().then(() => {
expect(component.formGroup.controls.email.valid).toBeFalse();
expect(component.formGroup.controls.email.errors.emailTaken).toBeTruthy();
});
}));
});
});
describe('when submitting the form', () => {
let firstName;
let lastName;

View File

@@ -1,4 +1,4 @@
import { Component, EventEmitter, OnDestroy, OnInit, Output } from '@angular/core';
import { ChangeDetectorRef, Component, EventEmitter, OnDestroy, OnInit, Output } from '@angular/core';
import { FormGroup } from '@angular/forms';
import {
DynamicCheckboxModel,
@@ -8,7 +8,7 @@ import {
} from '@ng-dynamic-forms/core';
import { TranslateService } from '@ngx-translate/core';
import { combineLatest as observableCombineLatest, Observable, of as observableOf, Subscription } from 'rxjs';
import { switchMap, take } from 'rxjs/operators';
import { debounceTime, switchMap, take } from 'rxjs/operators';
import { PaginatedList } from '../../../core/data/paginated-list.model';
import { RemoteData } from '../../../core/data/remote-data';
import { EPersonDataService } from '../../../core/eperson/eperson-data.service';
@@ -32,10 +32,12 @@ import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { RequestService } from '../../../core/data/request.service';
import { NoContent } from '../../../core/shared/NoContent.model';
import { PaginationService } from '../../../core/pagination/pagination.service';
import { followLink } from '../../../shared/utils/follow-link-config.model';
import { ValidateEmailNotTaken } from './validators/email-taken.validator';
@Component({
selector: 'ds-eperson-form',
templateUrl: './eperson-form.component.html'
templateUrl: './eperson-form.component.html',
})
/**
* A form used for creating and editing EPeople
@@ -160,7 +162,13 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
*/
isImpersonated = false;
constructor(public epersonService: EPersonDataService,
/**
* Subscription to email field value change
*/
emailValueChangeSubscribe: Subscription;
constructor(protected changeDetectorRef: ChangeDetectorRef,
public epersonService: EPersonDataService,
public groupsDataService: GroupDataService,
private formBuilderService: FormBuilderService,
private translateService: TranslateService,
@@ -186,6 +194,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
* This method will initialise the page
*/
initialisePage() {
observableCombineLatest(
this.translateService.get(`${this.messagePrefix}.firstName`),
this.translateService.get(`${this.messagePrefix}.lastName`),
@@ -218,9 +227,13 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
name: 'email',
validators: {
required: null,
pattern: '^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,4}$'
pattern: '^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,4}$',
},
required: true,
errorMessages: {
emailTaken: 'error.validation.emailTaken',
pattern: 'error.validation.NotValidEmail'
},
hint: emailHint
});
this.canLogIn = new DynamicCheckboxModel(
@@ -259,11 +272,18 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
canLogIn: eperson != null ? eperson.canLogIn : true,
requireCertificate: eperson != null ? eperson.requireCertificate : false
});
if (eperson === null && !!this.formGroup.controls.email) {
this.formGroup.controls.email.setAsyncValidators(ValidateEmailNotTaken.createValidator(this.epersonService));
this.emailValueChangeSubscribe = this.email.valueChanges.pipe(debounceTime(300)).subscribe(() => {
this.changeDetectorRef.detectChanges();
});
}
}));
const activeEPerson$ = this.epersonService.getActiveEPerson();
this.groups = activeEPerson$.pipe(
this.groups = activeEPerson$.pipe(
switchMap((eperson) => {
return observableCombineLatest([observableOf(eperson), this.paginationService.getFindListOptions(this.config.id, {
currentPage: 1,
@@ -272,14 +292,20 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
}),
switchMap(([eperson, findListOptions]) => {
if (eperson != null) {
return this.groupsDataService.findAllByHref(eperson._links.groups.href, findListOptions);
return this.groupsDataService.findAllByHref(eperson._links.groups.href, findListOptions, true, true, followLink('object'));
}
return observableOf(undefined);
})
);
this.canImpersonate$ = activeEPerson$.pipe(
switchMap((eperson) => this.authorizationService.isAuthorized(FeatureID.LoginOnBehalfOf, hasValue(eperson) ? eperson.self : undefined))
switchMap((eperson) => {
if (hasValue(eperson)) {
return this.authorizationService.isAuthorized(FeatureID.LoginOnBehalfOf, eperson.self);
} else {
return observableOf(false);
}
})
);
this.canDelete$ = activeEPerson$.pipe(
switchMap((eperson) => this.authorizationService.isAuthorized(FeatureID.CanDelete, hasValue(eperson) ? eperson.self : undefined))
@@ -342,10 +368,10 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
getFirstCompletedRemoteData()
).subscribe((rd: RemoteData<EPerson>) => {
if (rd.hasSucceeded) {
this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.created.success', {name: ePersonToCreate.name}));
this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.created.success', { name: ePersonToCreate.name }));
this.submitForm.emit(ePersonToCreate);
} else {
this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.created.failure', {name: ePersonToCreate.name}));
this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.created.failure', { name: ePersonToCreate.name }));
this.cancelForm.emit();
}
});
@@ -381,10 +407,10 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
const response = this.epersonService.updateEPerson(editedEperson);
response.pipe(getFirstCompletedRemoteData()).subscribe((rd: RemoteData<EPerson>) => {
if (rd.hasSucceeded) {
this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.edited.success', {name: editedEperson.name}));
this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.edited.success', { name: editedEperson.name }));
this.submitForm.emit(editedEperson);
} else {
this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.edited.failure', {name: editedEperson.name}));
this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.edited.failure', { name: editedEperson.name }));
this.cancelForm.emit();
}
});
@@ -394,6 +420,87 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
}
}
/**
* Event triggered when the user changes page
* @param event
*/
onPageChange(event) {
this.updateGroups({
currentPage: event,
elementsPerPage: this.config.pageSize
});
}
/**
* Start impersonating the EPerson
*/
impersonate() {
this.authService.impersonate(this.epersonInitial.id);
this.isImpersonated = true;
}
/**
* Deletes the EPerson from the Repository. The EPerson will be the only that this form is showing.
* It'll either show a success or error message depending on whether the delete was successful or not.
*/
delete() {
this.epersonService.getActiveEPerson().pipe(take(1)).subscribe((eperson: EPerson) => {
const modalRef = this.modalService.open(ConfirmationModalComponent);
modalRef.componentInstance.dso = eperson;
modalRef.componentInstance.headerLabel = 'confirmation-modal.delete-eperson.header';
modalRef.componentInstance.infoLabel = 'confirmation-modal.delete-eperson.info';
modalRef.componentInstance.cancelLabel = 'confirmation-modal.delete-eperson.cancel';
modalRef.componentInstance.confirmLabel = 'confirmation-modal.delete-eperson.confirm';
modalRef.componentInstance.brandColor = 'danger';
modalRef.componentInstance.confirmIcon = 'fas fa-trash';
modalRef.componentInstance.response.pipe(take(1)).subscribe((confirm: boolean) => {
if (confirm) {
if (hasValue(eperson.id)) {
this.epersonService.deleteEPerson(eperson).pipe(getFirstCompletedRemoteData()).subscribe((restResponse: RemoteData<NoContent>) => {
if (restResponse.hasSucceeded) {
this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', { name: eperson.name }));
this.submitForm.emit();
} else {
this.notificationsService.error('Error occured when trying to delete EPerson with id: ' + eperson.id + ' with code: ' + restResponse.statusCode + ' and message: ' + restResponse.errorMessage);
}
this.cancelForm.emit();
});
}
}
});
});
}
/**
* Stop impersonating the EPerson
*/
stopImpersonating() {
this.authService.stopImpersonatingAndRefresh();
this.isImpersonated = false;
}
/**
* Cancel the current edit when component is destroyed & unsub all subscriptions
*/
ngOnDestroy(): void {
this.onCancel();
this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe());
this.paginationService.clearPagination(this.config.id);
if (hasValue(this.emailValueChangeSubscribe)) {
this.emailValueChangeSubscribe.unsubscribe();
}
}
/**
* This method will ensure that the page gets reset and that the cache is cleared
*/
reset() {
this.epersonService.getActiveEPerson().pipe(take(1)).subscribe((eperson: EPerson) => {
this.requestService.removeByHrefSubstring(eperson.self);
});
this.initialisePage();
}
/**
* Checks for the given ePerson if there is already an ePerson in the system with that email
* and shows notification if this is the case
@@ -416,17 +523,6 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
}));
}
/**
* Event triggered when the user changes page
* @param event
*/
onPageChange(event) {
this.updateGroups({
currentPage: event,
elementsPerPage: this.config.pageSize
});
}
/**
* Update the list of groups by fetching it from the rest api or cache
*/
@@ -435,71 +531,4 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
this.groups = this.groupsDataService.findAllByHref(eperson._links.groups.href, options);
}));
}
/**
* Start impersonating the EPerson
*/
impersonate() {
this.authService.impersonate(this.epersonInitial.id);
this.isImpersonated = true;
}
/**
* Deletes the EPerson from the Repository. The EPerson will be the only that this form is showing.
* It'll either show a success or error message depending on whether the delete was successful or not.
*/
delete() {
this.epersonService.getActiveEPerson().pipe(take(1)).subscribe((eperson: EPerson) => {
const modalRef = this.modalService.open(ConfirmationModalComponent);
modalRef.componentInstance.dso = eperson;
modalRef.componentInstance.headerLabel = 'confirmation-modal.delete-eperson.header';
modalRef.componentInstance.infoLabel = 'confirmation-modal.delete-eperson.info';
modalRef.componentInstance.cancelLabel = 'confirmation-modal.delete-eperson.cancel';
modalRef.componentInstance.confirmLabel = 'confirmation-modal.delete-eperson.confirm';
modalRef.componentInstance.brandColor = 'danger';
modalRef.componentInstance.confirmIcon = 'fas fa-trash';
modalRef.componentInstance.response.pipe(take(1)).subscribe((confirm: boolean) => {
if (confirm) {
if (hasValue(eperson.id)) {
this.epersonService.deleteEPerson(eperson).pipe(getFirstCompletedRemoteData()).subscribe((restResponse: RemoteData<NoContent>) => {
if (restResponse.hasSucceeded) {
this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', { name: eperson.name }));
this.submitForm.emit();
} else {
this.notificationsService.error('Error occured when trying to delete EPerson with id: ' + eperson.id + ' with code: ' + restResponse.statusCode + ' and message: ' + restResponse.errorMessage);
}
this.cancelForm.emit();
});
}}
});
});
}
/**
* Stop impersonating the EPerson
*/
stopImpersonating() {
this.authService.stopImpersonatingAndRefresh();
this.isImpersonated = false;
}
/**
* Cancel the current edit when component is destroyed & unsub all subscriptions
*/
ngOnDestroy(): void {
this.onCancel();
this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe());
this.paginationService.clearPagination(this.config.id);
}
/**
* This method will ensure that the page gets reset and that the cache is cleared
*/
reset() {
this.epersonService.getActiveEPerson().pipe(take(1)).subscribe((eperson: EPerson) => {
this.requestService.removeByHrefSubstring(eperson.self);
});
this.initialisePage();
}
}

View File

@@ -0,0 +1,25 @@
import { AbstractControl, ValidationErrors } from '@angular/forms';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { EPersonDataService } from '../../../../core/eperson/eperson-data.service';
import { getFirstSucceededRemoteData, } from '../../../../core/shared/operators';
export class ValidateEmailNotTaken {
/**
* This method will create the validator with the ePersonDataService requested from component
* @param ePersonDataService the service with DI in the component that this validator is being utilized.
*/
static createValidator(ePersonDataService: EPersonDataService) {
return (control: AbstractControl): Promise<ValidationErrors | null> | Observable<ValidationErrors | null> => {
return ePersonDataService.getEPersonByEmail(control.value)
.pipe(
getFirstSucceededRemoteData(),
map(res => {
return !!res.payload ? { emailTaken: true } : null;
})
);
};
}
}

View File

@@ -2,7 +2,7 @@ import { CommonModule } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { FormsModule, ReactiveFormsModule, FormArray, FormControl, FormGroup,Validators, NG_VALIDATORS, NG_ASYNC_VALIDATORS } from '@angular/forms';
import { BrowserModule } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
@@ -34,6 +34,7 @@ import { TranslateLoaderMock } from '../../../shared/testing/translate-loader.mo
import { RouterMock } from '../../../shared/mocks/router.mock';
import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub';
import { Operation } from 'fast-json-patch';
import { ValidateGroupExists } from './validators/group-exists.validator';
describe('GroupFormComponent', () => {
let component: GroupFormComponent;
@@ -117,7 +118,69 @@ describe('GroupFormComponent', () => {
return null;
}
};
builderService = getMockFormBuilderService();
builderService = Object.assign(getMockFormBuilderService(),{
createFormGroup(formModel, options = null) {
const controls = {};
formModel.forEach( model => {
model.parent = parent;
const controlModel = model;
const controlState = { value: controlModel.value, disabled: controlModel.disabled };
const controlOptions = this.createAbstractControlOptions(controlModel.validators, controlModel.asyncValidators, controlModel.updateOn);
controls[model.id] = new FormControl(controlState, controlOptions);
});
return new FormGroup(controls, options);
},
createAbstractControlOptions(validatorsConfig = null, asyncValidatorsConfig = null, updateOn = null) {
return {
validators: validatorsConfig !== null ? this.getValidators(validatorsConfig) : null,
};
},
getValidators(validatorsConfig) {
return this.getValidatorFns(validatorsConfig);
},
getValidatorFns(validatorsConfig, validatorsToken = this._NG_VALIDATORS) {
let validatorFns = [];
if (this.isObject(validatorsConfig)) {
validatorFns = Object.keys(validatorsConfig).map(validatorConfigKey => {
const validatorConfigValue = validatorsConfig[validatorConfigKey];
if (this.isValidatorDescriptor(validatorConfigValue)) {
const descriptor = validatorConfigValue;
return this.getValidatorFn(descriptor.name, descriptor.args, validatorsToken);
}
return this.getValidatorFn(validatorConfigKey, validatorConfigValue, validatorsToken);
});
}
return validatorFns;
},
getValidatorFn(validatorName, validatorArgs = null, validatorsToken = this._NG_VALIDATORS) {
let validatorFn;
if (Validators.hasOwnProperty(validatorName)) { // Built-in Angular Validators
validatorFn = Validators[validatorName];
} else { // Custom Validators
if (this._DYNAMIC_VALIDATORS && this._DYNAMIC_VALIDATORS.has(validatorName)) {
validatorFn = this._DYNAMIC_VALIDATORS.get(validatorName);
} else if (validatorsToken) {
validatorFn = validatorsToken.find(validator => validator.name === validatorName);
}
}
if (validatorFn === undefined) { // throw when no validator could be resolved
throw new Error(`validator '${validatorName}' is not provided via NG_VALIDATORS, NG_ASYNC_VALIDATORS or DYNAMIC_FORM_VALIDATORS`);
}
if (validatorArgs !== null) {
return validatorFn(validatorArgs);
}
return validatorFn;
},
isValidatorDescriptor(value) {
if (this.isObject(value)) {
return value.hasOwnProperty('name') && value.hasOwnProperty('args');
}
return false;
},
isObject(value) {
return typeof value === 'object' && value !== null;
}
});
translateService = getMockTranslateService();
router = new RouterMock();
notificationService = new NotificationsServiceStub();
@@ -217,4 +280,72 @@ describe('GroupFormComponent', () => {
});
});
describe('check form validation', () => {
let groupCommunity;
beforeEach(() => {
groupName = 'testName';
groupCommunity = 'testgroupCommunity';
groupDescription = 'testgroupDescription';
expected = Object.assign(new Group(), {
name: groupName,
metadata: {
'dc.description': [
{
value: groupDescription
}
],
},
});
spyOn(component.submitForm, 'emit');
fixture.detectChanges();
component.initialisePage();
fixture.detectChanges();
});
describe('groupName, groupCommunity and groupDescription should be required', () => {
it('form should be invalid because the groupName is required', waitForAsync(() => {
fixture.whenStable().then(() => {
expect(component.formGroup.controls.groupName.valid).toBeFalse();
expect(component.formGroup.controls.groupName.errors.required).toBeTrue();
});
}));
});
describe('after inserting information groupName,groupCommunity and groupDescription not required', () => {
beforeEach(() => {
component.formGroup.controls.groupName.setValue('test');
fixture.detectChanges();
});
it('groupName should be valid because the groupName is set', waitForAsync(() => {
fixture.whenStable().then(() => {
expect(component.formGroup.controls.groupName.valid).toBeTrue();
expect(component.formGroup.controls.groupName.errors).toBeNull();
});
}));
});
describe('after already utilized groupName', () => {
beforeEach(() => {
const groupsDataServiceStubWithGroup = Object.assign(groupsDataServiceStub,{
searchGroups(query: string): Observable<RemoteData<PaginatedList<Group>>> {
return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [expected]));
}
});
component.formGroup.controls.groupName.setValue('testName');
component.formGroup.controls.groupName.setAsyncValidators(ValidateGroupExists.createValidator(groupsDataServiceStubWithGroup));
fixture.detectChanges();
});
it('groupName should not be valid because groupName is already taken', waitForAsync(() => {
fixture.whenStable().then(() => {
expect(component.formGroup.controls.groupName.valid).toBeFalse();
expect(component.formGroup.controls.groupName.errors.groupExists).toBeTruthy();
});
}));
});
});
});

View File

@@ -1,4 +1,4 @@
import { Component, EventEmitter, HostListener, OnDestroy, OnInit, Output } from '@angular/core';
import { Component, EventEmitter, HostListener, OnDestroy, OnInit, Output, ChangeDetectorRef } from '@angular/core';
import { FormGroup } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
@@ -14,9 +14,9 @@ import {
combineLatest as observableCombineLatest,
Observable,
of as observableOf,
Subscription
Subscription,
} from 'rxjs';
import { catchError, map, switchMap, take } from 'rxjs/operators';
import { catchError, map, switchMap, take, filter, debounceTime } from 'rxjs/operators';
import { getCollectionEditRolesRoute } from '../../../collection-page/collection-page-routing-paths';
import { getCommunityEditRolesRoute } from '../../../community-page/community-page-routing-paths';
import { DSpaceObjectDataService } from '../../../core/data/dspace-object-data.service';
@@ -34,7 +34,8 @@ import { DSpaceObject } from '../../../core/shared/dspace-object.model';
import {
getRemoteDataPayload,
getFirstSucceededRemoteData,
getFirstCompletedRemoteData
getFirstCompletedRemoteData,
getFirstSucceededRemoteDataPayload
} from '../../../core/shared/operators';
import { AlertType } from '../../../shared/alert/aletr-type';
import { ConfirmationModalComponent } from '../../../shared/confirmation-modal/confirmation-modal.component';
@@ -44,6 +45,7 @@ import { NotificationsService } from '../../../shared/notifications/notification
import { followLink } from '../../../shared/utils/follow-link-config.model';
import { NoContent } from '../../../core/shared/NoContent.model';
import { Operation } from 'fast-json-patch';
import { ValidateGroupExists } from './validators/group-exists.validator';
@Component({
selector: 'ds-group-form',
@@ -65,6 +67,7 @@ export class GroupFormComponent implements OnInit, OnDestroy {
* Dynamic models for the inputs of form
*/
groupName: DynamicInputModel;
groupCommunity: DynamicInputModel;
groupDescription: DynamicTextAreaModel;
/**
@@ -124,17 +127,24 @@ export class GroupFormComponent implements OnInit, OnDestroy {
*/
public AlertTypeEnum = AlertType;
/**
* Subscription to email field value change
*/
groupNameValueChangeSubscribe: Subscription;
constructor(public groupDataService: GroupDataService,
private ePersonDataService: EPersonDataService,
private dSpaceObjectDataService: DSpaceObjectDataService,
private formBuilderService: FormBuilderService,
private translateService: TranslateService,
private notificationsService: NotificationsService,
private route: ActivatedRoute,
protected router: Router,
private authorizationService: AuthorizationDataService,
private modalService: NgbModal,
public requestService: RequestService) {
private ePersonDataService: EPersonDataService,
private dSpaceObjectDataService: DSpaceObjectDataService,
private formBuilderService: FormBuilderService,
private translateService: TranslateService,
private notificationsService: NotificationsService,
private route: ActivatedRoute,
protected router: Router,
private authorizationService: AuthorizationDataService,
private modalService: NgbModal,
public requestService: RequestService,
protected changeDetectorRef: ChangeDetectorRef) {
}
ngOnInit() {
@@ -160,8 +170,9 @@ export class GroupFormComponent implements OnInit, OnDestroy {
);
observableCombineLatest(
this.translateService.get(`${this.messagePrefix}.groupName`),
this.translateService.get(`${this.messagePrefix}.groupCommunity`),
this.translateService.get(`${this.messagePrefix}.groupDescription`)
).subscribe(([groupName, groupDescription]) => {
).subscribe(([groupName, groupCommunity, groupDescription]) => {
this.groupName = new DynamicInputModel({
id: 'groupName',
label: groupName,
@@ -171,6 +182,13 @@ export class GroupFormComponent implements OnInit, OnDestroy {
},
required: true,
});
this.groupCommunity = new DynamicInputModel({
id: 'groupCommunity',
label: groupCommunity,
name: 'groupCommunity',
required: false,
readOnly: true,
});
this.groupDescription = new DynamicTextAreaModel({
id: 'groupDescription',
label: groupDescription,
@@ -182,20 +200,51 @@ export class GroupFormComponent implements OnInit, OnDestroy {
this.groupDescription,
];
this.formGroup = this.formBuilderService.createFormGroup(this.formModel);
if (!!this.formGroup.controls.groupName) {
this.formGroup.controls.groupName.setAsyncValidators(ValidateGroupExists.createValidator(this.groupDataService));
this.groupNameValueChangeSubscribe = this.groupName.valueChanges.pipe(debounceTime(300)).subscribe(() => {
this.changeDetectorRef.detectChanges();
});
}
this.subs.push(
observableCombineLatest(
this.groupDataService.getActiveGroup(),
this.canEdit$
).subscribe(([activeGroup, canEdit]) => {
this.canEdit$,
this.groupDataService.getActiveGroup()
.pipe(filter((activeGroup) => hasValue(activeGroup)),switchMap((activeGroup) => this.getLinkedDSO(activeGroup).pipe(getFirstSucceededRemoteDataPayload())))
).subscribe(([activeGroup, canEdit, linkedObject]) => {
if (activeGroup != null) {
// Disable group name exists validator
this.formGroup.controls.groupName.clearAsyncValidators();
this.groupBeingEdited = activeGroup;
this.formGroup.patchValue({
groupName: activeGroup != null ? activeGroup.name : '',
groupDescription: activeGroup != null ? activeGroup.firstMetadataValue('dc.description') : '',
});
if (!canEdit || activeGroup.permanent) {
this.formGroup.disable();
if (linkedObject?.name) {
this.formBuilderService.insertFormGroupControl(1, this.formGroup, this.formModel, this.groupCommunity);
this.formGroup.patchValue({
groupName: activeGroup.name,
groupCommunity: linkedObject?.name ?? '',
groupDescription: activeGroup.firstMetadataValue('dc.description'),
});
} else {
this.formModel = [
this.groupName,
this.groupDescription,
];
this.formGroup.patchValue({
groupName: activeGroup.name,
groupDescription: activeGroup.firstMetadataValue('dc.description'),
});
}
setTimeout(() => {
if (!canEdit || activeGroup.permanent) {
this.formGroup.disable();
}
}, 200);
}
})
);
@@ -407,6 +456,11 @@ export class GroupFormComponent implements OnInit, OnDestroy {
ngOnDestroy(): void {
this.groupDataService.cancelEditGroup();
this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe());
if ( hasValue(this.groupNameValueChangeSubscribe) ) {
this.groupNameValueChangeSubscribe.unsubscribe();
}
}
/**
@@ -417,11 +471,7 @@ export class GroupFormComponent implements OnInit, OnDestroy {
if (hasValue(group) && hasValue(group._links.object.href)) {
return this.getLinkedDSO(group).pipe(
map((rd: RemoteData<DSpaceObject>) => {
if (hasValue(rd) && hasValue(rd.payload)) {
return true;
} else {
return false;
}
return hasValue(rd) && hasValue(rd.payload);
}),
catchError(() => observableOf(false)),
);

View File

@@ -38,17 +38,22 @@
<table id="epersonsSearch" class="table table-striped table-hover table-bordered">
<thead>
<tr>
<th scope="col">{{messagePrefix + '.table.id' | translate}}</th>
<th scope="col">{{messagePrefix + '.table.name' | translate}}</th>
<th>{{messagePrefix + '.table.edit' | translate}}</th>
<th scope="col" class="align-middle">{{messagePrefix + '.table.id' | translate}}</th>
<th scope="col" class="align-middle">{{messagePrefix + '.table.name' | translate}}</th>
<th scope="col" class="align-middle">{{messagePrefix + '.table.identity' | translate}}</th>
<th class="align-middle">{{messagePrefix + '.table.edit' | translate}}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let ePerson of (ePeopleSearchDtos | async)?.page">
<td>{{ePerson.eperson.id}}</td>
<td><a (click)="ePersonDataService.startEditingNewEPerson(ePerson.eperson)"
<td class="align-middle">{{ePerson.eperson.id}}</td>
<td class="align-middle"><a (click)="ePersonDataService.startEditingNewEPerson(ePerson.eperson)"
[routerLink]="[ePersonDataService.getEPeoplePageRouterLink()]">{{ePerson.eperson.name}}</a></td>
<td>
<td class="align-middle">
{{messagePrefix + '.table.email' | translate}}: {{ ePerson.eperson.email ? ePerson.eperson.email : '-' }}<br/>
{{messagePrefix + '.table.netid' | translate}}: {{ ePerson.eperson.netid ? ePerson.eperson.netid : '-' }}
</td>
<td class="align-middle">
<div class="btn-group edit-field">
<button *ngIf="(ePerson.memberOfGroup)"
(click)="deleteMemberFromGroup(ePerson)"
@@ -91,17 +96,22 @@
<table id="ePeopleMembersOfGroup" class="table table-striped table-hover table-bordered">
<thead>
<tr>
<th scope="col">{{messagePrefix + '.table.id' | translate}}</th>
<th scope="col">{{messagePrefix + '.table.name' | translate}}</th>
<th>{{messagePrefix + '.table.edit' | translate}}</th>
<th scope="col" class="align-middle">{{messagePrefix + '.table.id' | translate}}</th>
<th scope="col" class="align-middle">{{messagePrefix + '.table.name' | translate}}</th>
<th scope="col" class="align-middle">{{messagePrefix + '.table.identity' | translate}}</th>
<th class="align-middle">{{messagePrefix + '.table.edit' | translate}}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let ePerson of (ePeopleMembersOfGroupDtos | async)?.page">
<td>{{ePerson.eperson.id}}</td>
<td><a (click)="ePersonDataService.startEditingNewEPerson(ePerson.eperson)"
<td class="align-middle">{{ePerson.eperson.id}}</td>
<td class="align-middle"><a (click)="ePersonDataService.startEditingNewEPerson(ePerson.eperson)"
[routerLink]="[ePersonDataService.getEPeoplePageRouterLink()]">{{ePerson.eperson.name}}</a></td>
<td>
<td class="align-middle">
{{messagePrefix + '.table.email' | translate}}: {{ ePerson.eperson.email ? ePerson.eperson.email : '-' }}<br/>
{{messagePrefix + '.table.netid' | translate}}: {{ ePerson.eperson.netid ? ePerson.eperson.netid : '-' }}
</td>
<td class="align-middle">
<div class="btn-group edit-field">
<button (click)="deleteMemberFromGroup(ePerson)"
class="btn btn-outline-danger btn-sm"

View File

@@ -35,17 +35,19 @@
<table id="groupsSearch" class="table table-striped table-hover table-bordered">
<thead>
<tr>
<th scope="col">{{messagePrefix + '.table.id' | translate}}</th>
<th scope="col">{{messagePrefix + '.table.name' | translate}}</th>
<th>{{messagePrefix + '.table.edit' | translate}}</th>
<th scope="col" class="align-middle">{{messagePrefix + '.table.id' | translate}}</th>
<th scope="col" class="align-middle">{{messagePrefix + '.table.name' | translate}}</th>
<th scope="col" class="align-middle">{{messagePrefix + '.table.collectionOrCommunity' | translate}}</th>
<th class="align-middle">{{messagePrefix + '.table.edit' | translate}}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let group of (searchResults$ | async)?.payload?.page">
<td>{{group.id}}</td>
<td><a (click)="groupDataService.startEditingNewGroup(group)"
<td class="align-middle">{{group.id}}</td>
<td class="align-middle"><a (click)="groupDataService.startEditingNewGroup(group)"
[routerLink]="[groupDataService.getGroupEditPageRouterLink(group)]">{{group.name}}</a></td>
<td>
<td class="align-middle">{{(group.object | async)?.payload?.name}}</td>
<td class="align-middle">
<div class="btn-group edit-field">
<button *ngIf="(isSubgroupOfGroup(group) | async) && !(isActiveGroup(group) | async)"
(click)="deleteSubgroupFromGroup(group)"
@@ -88,17 +90,19 @@
<table id="subgroupsOfGroup" class="table table-striped table-hover table-bordered">
<thead>
<tr>
<th scope="col">{{messagePrefix + '.table.id' | translate}}</th>
<th scope="col">{{messagePrefix + '.table.name' | translate}}</th>
<th scope="col" class="align-middle">{{messagePrefix + '.table.id' | translate}}</th>
<th scope="col" class="align-middle">{{messagePrefix + '.table.name' | translate}}</th>
<th scope="col" class="align-middle">{{messagePrefix + '.table.collectionOrCommunity' | translate}}</th>
<th>{{messagePrefix + '.table.edit' | translate}}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let group of (subGroups$ | async)?.payload?.page">
<td>{{group.id}}</td>
<td><a (click)="groupDataService.startEditingNewGroup(group)"
<td class="align-middle">{{group.id}}</td>
<td class="align-middle"><a (click)="groupDataService.startEditingNewGroup(group)"
[routerLink]="[groupDataService.getGroupEditPageRouterLink(group)]">{{group.name}}</a></td>
<td>
<td class="align-middle">{{(group.object | async)?.payload?.name}}</td>
<td class="align-middle">
<div class="btn-group edit-field">
<button (click)="deleteSubgroupFromGroup(group)"
class="btn btn-outline-danger btn-sm deleteButton"

View File

@@ -17,6 +17,7 @@ import { NotificationsService } from '../../../../shared/notifications/notificat
import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model';
import { NoContent } from '../../../../core/shared/NoContent.model';
import { PaginationService } from '../../../../core/pagination/pagination.service';
import { followLink } from '../../../../shared/utils/follow-link-config.model';
/**
* Keys to keep track of specific subscriptions
@@ -117,7 +118,10 @@ export class SubgroupsListComponent implements OnInit, OnDestroy {
switchMap((config) => this.groupDataService.findAllByHref(this.groupBeingEdited._links.subgroups.href, {
currentPage: config.currentPage,
elementsPerPage: config.pageSize
}
},
true,
true,
followLink('object')
))
).subscribe((rd: RemoteData<PaginatedList<Group>>) => {
this.subGroups$.next(rd);
@@ -217,7 +221,8 @@ export class SubgroupsListComponent implements OnInit, OnDestroy {
switchMap((config) => this.groupDataService.searchGroups(this.currentSearchQuery, {
currentPage: config.currentPage,
elementsPerPage: config.pageSize
}))
}, true, true, followLink('object')
))
).subscribe((rd: RemoteData<PaginatedList<Group>>) => {
this.searchResults$.next(rd);
}));

View File

@@ -0,0 +1,33 @@
import { AbstractControl, ValidationErrors } from '@angular/forms';
import { Observable } from 'rxjs';
import { map} from 'rxjs/operators';
import { GroupDataService } from '../../../../core/eperson/group-data.service';
import { getFirstSucceededRemoteListPayload } from '../../../../core/shared/operators';
import { Group } from '../../../../core/eperson/models/group.model';
export class ValidateGroupExists {
/**
* This method will create the validator with the groupDataService requested from component
* @param groupDataService the service with DI in the component that this validator is being utilized.
* @return Observable<ValidationErrors | null>
*/
static createValidator(groupDataService: GroupDataService) {
return (control: AbstractControl): Promise<ValidationErrors | null> | Observable<ValidationErrors | null> => {
return groupDataService.searchGroups(control.value, {
currentPage: 1,
elementsPerPage: 100
})
.pipe(
getFirstSucceededRemoteListPayload(),
map( (groups: Group[]) => {
return groups.filter(group => group.name === control.value);
}),
map( (groups: Group[]) => {
return groups.length > 0 ? { groupExists: true } : null;
}),
);
};
}
}

View File

@@ -48,6 +48,7 @@
<tr>
<th scope="col">{{messagePrefix + 'table.id' | translate}}</th>
<th scope="col">{{messagePrefix + 'table.name' | translate}}</th>
<th scope="col">{{messagePrefix + 'table.collectionOrCommunity' | translate}}</th>
<th scope="col">{{messagePrefix + 'table.members' | translate}}</th>
<th>{{messagePrefix + 'table.edit' | translate}}</th>
</tr>
@@ -56,6 +57,7 @@
<tr *ngFor="let groupDto of (groupsDto$ | async)?.page">
<td>{{groupDto.group.id}}</td>
<td>{{groupDto.group.name}}</td>
<td>{{(groupDto.group.object | async)?.payload?.name}}</td>
<td>{{groupDto.epersons?.totalElements + groupDto.subgroups?.totalElements}}</td>
<td>
<div class="btn-group edit-field">

View File

@@ -152,6 +152,7 @@ describe('GroupRegistryComponent', () => {
return createSuccessfulRemoteDataObject$(undefined);
}
};
authorizationService = jasmine.createSpyObj('authorizationService', ['isAuthorized']);
setIsAuthorized(true, true);
paginationService = new PaginationServiceStub();
@@ -200,6 +201,13 @@ describe('GroupRegistryComponent', () => {
});
});
it('should display community/collection name if present', () => {
const collectionNamesFound = fixture.debugElement.queryAll(By.css('#groups tr td:nth-child(3)'));
expect(collectionNamesFound.length).toEqual(2);
expect(collectionNamesFound[0].nativeElement.textContent).toEqual('');
expect(collectionNamesFound[1].nativeElement.textContent).toEqual('testgroupid2objectName');
});
describe('edit buttons', () => {
describe('when the user is a general admin', () => {
beforeEach(fakeAsync(() => {
@@ -213,7 +221,7 @@ describe('GroupRegistryComponent', () => {
}));
it('should be active', () => {
const editButtonsFound = fixture.debugElement.queryAll(By.css('#groups tr td:nth-child(4) button.btn-edit'));
const editButtonsFound = fixture.debugElement.queryAll(By.css('#groups tr td:nth-child(5) button.btn-edit'));
expect(editButtonsFound.length).toEqual(2);
editButtonsFound.forEach((editButtonFound) => {
expect(editButtonFound.nativeElement.disabled).toBeFalse();
@@ -247,7 +255,7 @@ describe('GroupRegistryComponent', () => {
}));
it('should be active', () => {
const editButtonsFound = fixture.debugElement.queryAll(By.css('#groups tr td:nth-child(4) button.btn-edit'));
const editButtonsFound = fixture.debugElement.queryAll(By.css('#groups tr td:nth-child(5) button.btn-edit'));
expect(editButtonsFound.length).toEqual(2);
editButtonsFound.forEach((editButtonFound) => {
expect(editButtonFound.nativeElement.disabled).toBeFalse();
@@ -266,7 +274,7 @@ describe('GroupRegistryComponent', () => {
}));
it('should not be active', () => {
const editButtonsFound = fixture.debugElement.queryAll(By.css('#groups tr td:nth-child(4) button.btn-edit'));
const editButtonsFound = fixture.debugElement.queryAll(By.css('#groups tr td:nth-child(5) button.btn-edit'));
expect(editButtonsFound.length).toEqual(2);
editButtonsFound.forEach((editButtonFound) => {
expect(editButtonFound.nativeElement.disabled).toBeTrue();

View File

@@ -35,6 +35,7 @@ import { NotificationsService } from '../../shared/notifications/notifications.s
import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
import { NoContent } from '../../core/shared/NoContent.model';
import { PaginationService } from '../../core/pagination/pagination.service';
import { followLink } from '../../shared/utils/follow-link-config.model';
@Component({
selector: 'ds-groups-registry',
@@ -132,8 +133,8 @@ export class GroupsRegistryComponent implements OnInit, OnDestroy {
}
return this.groupService.searchGroups(this.currentSearchQuery.trim(), {
currentPage: paginationOptions.currentPage,
elementsPerPage: paginationOptions.pageSize
});
elementsPerPage: paginationOptions.pageSize,
}, true, true, followLink('object'));
}),
getAllSucceededRemoteData(),
getRemoteDataPayload(),

View File

@@ -1,10 +1,23 @@
<li class="sidebar-section">
<a href="javascript:void(0);" class="nav-item nav-link shortcut-icon" attr.aria-labelledby="sidebarName-{{section.id}}" [title]="('menu.section.icon.' + section.id) | translate" [routerLink]="itemModel.link">
<div class="sidebar-section">
<a class="nav-item nav-link d-flex flex-row flex-nowrap"
[ngClass]="{ disabled: !hasLink }"
[attr.aria-disabled]="!hasLink"
[attr.aria-labelledby]="'sidebarName-' + section.id"
[title]="('menu.section.icon.' + section.id) | translate"
[routerLink]="itemModel.link"
(keyup.space)="navigate($event)"
(keyup.enter)="navigate($event)"
href="javascript:void(0);"
>
<div class="shortcut-icon">
<i class="fas fa-{{section.icon}} fa-fw"></i>
</div>
<div class="sidebar-collapsible">
<div class="toggle">
<span id="sidebarName-{{section.id}}" class="section-header-text">
{{itemModel.text | translate}}
</span>
</div>
</div>
</a>
<div class="sidebar-collapsible">
<span id="sidebarName-{{section.id}}" class="section-header-text">
<a class="nav-item nav-link" tabindex="-1" [routerLink]="itemModel.link">{{itemModel.text | translate}}</a>
</span>
</div>
</li>
</div>

View File

@@ -5,12 +5,15 @@ import { MenuService } from '../../../shared/menu/menu.service';
import { rendersSectionForMenu } from '../../../shared/menu/menu-section.decorator';
import { LinkMenuItemModel } from '../../../shared/menu/menu-item/models/link.model';
import { MenuSection } from '../../../shared/menu/menu.reducer';
import { isNotEmpty } from '../../../shared/empty.util';
import { Router } from '@angular/router';
/**
* Represents a non-expandable section in the admin sidebar
*/
@Component({
selector: 'ds-admin-sidebar-section',
/* tslint:disable:component-selector */
selector: 'li[ds-admin-sidebar-section]',
templateUrl: './admin-sidebar-section.component.html',
styleUrls: ['./admin-sidebar-section.component.scss'],
@@ -23,12 +26,26 @@ export class AdminSidebarSectionComponent extends MenuSectionComponent implement
*/
menuID: MenuID = MenuID.ADMIN;
itemModel;
constructor(@Inject('sectionDataProvider') menuSection: MenuSection, protected menuService: MenuService, protected injector: Injector,) {
hasLink: boolean;
constructor(
@Inject('sectionDataProvider') menuSection: MenuSection,
protected menuService: MenuService,
protected injector: Injector,
protected router: Router,
) {
super(menuSection, menuService, injector);
this.itemModel = menuSection.model as LinkMenuItemModel;
}
ngOnInit(): void {
this.hasLink = isNotEmpty(this.itemModel?.link);
super.ngOnInit();
}
navigate(event: any): void {
event.preventDefault();
if (this.hasLink) {
this.router.navigate(this.itemModel.link);
}
}
}

View File

@@ -4,24 +4,26 @@
value: (!(sidebarExpanded | async) ? 'collapsed' : 'expanded'),
params: {sidebarWidth: (sidebarWidth | async)}
}" (@slideSidebar.done)="finishSlide($event)" (@slideSidebar.start)="startSlide($event)"
*ngIf="menuVisible | async" (mouseenter)="expandPreview($event)"
(mouseleave)="collapsePreview($event)"
*ngIf="menuVisible | async"
(mouseenter)="handleMouseEnter($event)"
(mouseleave)="handleMouseLeave($event)"
role="navigation" [attr.aria-label]="'menu.header.admin.description' |translate">
<div class="sidebar-top-level-items">
<ul class="navbar-nav">
<li class="admin-menu-header sidebar-section">
<a class="shortcut-icon navbar-brand mr-0" href="javascript:void(0);">
<span class="logo-wrapper">
<li class="admin-menu-header">
<div class="sidebar-section">
<div href="javascript:void(0);" class="nav-item d-flex flex-row flex-nowrap py-0">
<div class="shortcut-icon navbar-brand logo-wrapper">
<img class="admin-logo" src="assets/images/dspace-logo-mini.svg"
[alt]="('menu.header.image.logo') | translate">
</span>
</a>
<div class="sidebar-collapsible">
<a class="navbar-brand mr-0" href="javascript:void(0);">
<h4 class="section-header-text mb-0">{{'menu.header.admin' |
translate}}</h4>
</a>
</div>
<div class="sidebar-collapsible navbar-brand">
<div class="mr-0">
<h4 class="section-header-text mb-0">{{ 'menu.header.admin' | translate }}</h4>
</div>
</div>
</div>
</div>
</li>
<ng-container *ngFor="let section of (sections | async)">
@@ -32,22 +34,22 @@
</div>
<div class="navbar-nav">
<div class="sidebar-section" id="sidebar-collapse-toggle">
<a class="nav-item nav-link shortcut-icon"
<a class="nav-item nav-link sidebar-section d-flex flex-row flex-nowrap"
href="javascript:void(0);"
(click)="toggle($event)">
(click)="toggle($event)"
(keyup.space)="toggle($event)"
>
<div class="shortcut-icon">
<i *ngIf="(menuCollapsed | async)" class="fas fa-fw fa-angle-double-right"
[title]="'menu.section.icon.pin' | translate"></i>
<i *ngIf="!(menuCollapsed | async)" class="fas fa-fw fa-angle-double-left"
[title]="'menu.section.icon.unpin' | translate"></i>
</div>
<div class="sidebar-collapsible">
<span *ngIf="menuCollapsed | async" class="section-header-text">{{'menu.section.pin' | translate }}</span>
<span *ngIf="!(menuCollapsed | async)" class="section-header-text">{{'menu.section.unpin' | translate }}</span>
</div>
</a>
<div class="sidebar-collapsible">
<a class="nav-item nav-link sidebar-section"
href="javascript:void(0);"
(click)="toggle($event)">
<span *ngIf="menuCollapsed | async" class="section-header-text">{{'menu.section.pin' | translate }}</span>
<span *ngIf="!(menuCollapsed | async)" class="section-header-text">{{'menu.section.unpin' | translate }}</span>
</a>
</div>
</div>
</div>
</nav>

View File

@@ -25,6 +25,11 @@
.navbar-nav {
.admin-menu-header {
background-color: var(--ds-admin-sidebar-header-bg);
.sidebar-section {
background-color: inherit;
}
.logo-wrapper {
img {
height: 20px;
@@ -34,6 +39,10 @@
line-height: 1.5;
}
.navbar-brand {
margin-right: 0;
}
}
}
@@ -44,26 +53,64 @@
display: flex;
align-content: stretch;
background-color: var(--ds-admin-sidebar-bg);
overflow-x: visible;
.nav-item {
padding-top: var(--bs-spacer);
padding-bottom: var(--bs-spacer);
background-color: inherit;
&:focus-visible {
// since links fill the whole sidebar, we _inset_ the outline
outline-offset: -4px;
// replace padding with margins so it doesn't extend over the :focus-visible outline
// → can't remove the padding altogether; the icon needs to fill out
// the collapsed width of the sidebar for the slide animation to look decent.
.shortcut-icon {
padding-left: 0;
padding-right: 0;
margin-left: var(--ds-icon-padding);
margin-right: var(--ds-icon-padding);
}
.logo-wrapper {
margin-right: var(--bs-navbar-padding-x) !important;
}
.navbar-brand {
padding-top: 0;
padding-bottom: 0;
margin-top: var(--bs-navbar-brand-padding-y);
margin-bottom: var(--bs-navbar-brand-padding-y);
}
}
}
.shortcut-icon {
background-color: inherit;
padding-left: var(--ds-icon-padding);
padding-right: var(--ds-icon-padding);
}
.shortcut-icon, .icon-wrapper {
background-color: inherit;
z-index: var(--ds-icon-z-index);
align-self: baseline;
}
.sidebar-collapsible {
padding-left: 0;
padding-right: var(--bs-spacer);
width: var(--ds-sidebar-items-width);
position: relative;
a {
padding-right: var(--bs-spacer);
width: 100%;
.toggle {
width: 100%;
}
ul {
padding-top: var(--bs-spacer);
li a {
padding-left: var(--bs-spacer);
}
}
}
&.active > .sidebar-collapsible > .nav-link {
color: var(--bs-navbar-dark-active-color);
}

View File

@@ -113,25 +113,10 @@ describe('AdminSidebarComponent', () => {
});
});
describe('when the collapse icon is clicked', () => {
beforeEach(() => {
spyOn(menuService, 'toggleMenu');
const sidebarToggler = fixture.debugElement.query(By.css('#sidebar-collapse-toggle')).query(By.css('a.shortcut-icon'));
sidebarToggler.triggerEventHandler('click', {
preventDefault: () => {/**/
}
});
});
it('should call toggleMenu on the menuService', () => {
expect(menuService.toggleMenu).toHaveBeenCalled();
});
});
describe('when the collapse link is clicked', () => {
beforeEach(() => {
spyOn(menuService, 'toggleMenu');
const sidebarToggler = fixture.debugElement.query(By.css('#sidebar-collapse-toggle')).query(By.css('.sidebar-collapsible')).query(By.css('a'));
const sidebarToggler = fixture.debugElement.query(By.css('#sidebar-collapse-toggle > a'));
sidebarToggler.triggerEventHandler('click', {
preventDefault: () => {/**/
}

View File

@@ -1,7 +1,7 @@
import { Component, Injector, OnInit } from '@angular/core';
import { Component, HostListener, Injector, OnInit } from '@angular/core';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { combineLatest, combineLatest as observableCombineLatest, Observable } from 'rxjs';
import { first, map, take } from 'rxjs/operators';
import { combineLatest, combineLatest as observableCombineLatest, Observable, BehaviorSubject } from 'rxjs';
import { debounceTime, first, map, take, distinctUntilChanged, withLatestFrom } from 'rxjs/operators';
import { AuthService } from '../../core/auth/auth.service';
import { ScriptDataService } from '../../core/data/processes/script-data.service';
import { slideHorizontal, slideSidebar } from '../../shared/animations/slide';
@@ -60,6 +60,8 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
*/
sidebarExpanded: Observable<boolean>;
inFocus$: BehaviorSubject<boolean>;
constructor(protected menuService: MenuService,
protected injector: Injector,
private variableService: CSSVariableService,
@@ -69,6 +71,7 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
private scriptDataService: ScriptDataService,
) {
super(menuService, injector);
this.inFocus$ = new BehaviorSubject(false);
}
/**
@@ -89,10 +92,25 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
this.sidebarOpen = !collapsed;
this.sidebarClosed = collapsed;
});
this.sidebarExpanded = observableCombineLatest(this.menuCollapsed, this.menuPreviewCollapsed)
this.sidebarExpanded = combineLatest([this.menuCollapsed, this.menuPreviewCollapsed])
.pipe(
map(([collapsed, previewCollapsed]) => (!collapsed || !previewCollapsed))
);
this.inFocus$.pipe(
debounceTime(50),
distinctUntilChanged(), // disregard focusout in situations like --(focusout)-(focusin)--
withLatestFrom(
combineLatest([this.menuCollapsed, this.menuPreviewCollapsed])
),
).subscribe(([inFocus, [collapsed, previewCollapsed]]) => {
if (collapsed) {
if (inFocus && previewCollapsed) {
this.expandPreview(new Event('focusin → expand'));
} else if (!inFocus && !previewCollapsed) {
this.collapsePreview(new Event('focusout → collapse'));
}
}
});
}
/**
@@ -590,6 +608,32 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
});
}
@HostListener('focusin')
public handleFocusIn() {
this.inFocus$.next(true);
}
@HostListener('focusout')
public handleFocusOut() {
this.inFocus$.next(false);
}
public handleMouseEnter(event: any) {
if (!this.inFocus$.getValue()) {
this.expandPreview(event);
} else {
event.preventDefault();
}
}
public handleMouseLeave(event: any) {
if (!this.inFocus$.getValue()) {
this.collapsePreview(event);
} else {
event.preventDefault();
}
}
/**
* Method to change this.collapsed to false when the slide animation ends and is sliding open
* @param event The animation event

View File

@@ -1,27 +1,36 @@
<li class="sidebar-section" [ngClass]="{'expanded': (expanded | async)}"
<div class="sidebar-section" [ngClass]="{'expanded': (expanded | async)}"
[@bgColor]="{
value: ((expanded | async) ? 'endBackground' : 'startBackground'),
params: {endColor: (sidebarActiveBg | async)}}">
<div class="icon-wrapper">
<a class="nav-item nav-link shortcut-icon" attr.aria.labelledby="sidebarName-{{section.id}}" [title]="('menu.section.icon.' + section.id) | translate" (click)="toggleSection($event)" href="javascript:void(0);">
<i class="fas fa-{{section.icon}} fa-fw"></i>
</a>
<div class="nav-item nav-link d-flex flex-row flex-nowrap"
role="button" tabindex="0"
[attr.aria-labelledby]="'sidebarName-' + section.id"
[attr.aria-expanded]="expanded | async"
[title]="('menu.section.icon.' + section.id) | translate"
(click)="toggleSection($event)"
(keyup.space)="toggleSection($event)"
(keyup.enter)="toggleSection($event)"
>
<div class="shortcut-icon h-100">
<i class="fas fa-{{section.icon}} fa-fw"></i>
</div>
<div class="sidebar-collapsible">
<a class="nav-item nav-link" href="javascript:void(0);" tabindex="-1"
(click)="toggleSection($event)">
<span id="sidebarName-{{section.id}}" class="section-header-text">
<ng-container
*ngComponentOutlet="(sectionMap$ | async).get(section.id).component; injector: (sectionMap$ | async).get(section.id).injector;"></ng-container>
</span>
<i class="fas fa-chevron-right fa-pull-right"
[@rotate]="(expanded | async) ? 'expanded' : 'collapsed'" [title]="('menu.section.toggle.' + section.id) | translate"></i>
</a>
<ul class="sidebar-sub-level-items list-unstyled" @slide *ngIf="(expanded | async)">
<li *ngFor="let subSection of (subSections$ | async)">
<ng-container
*ngComponentOutlet="(sectionMap$ | async).get(subSection.id).component; injector: (sectionMap$ | async).get(subSection.id).injector;"></ng-container>
</li>
</ul>
<div class="toggle">
<span id="sidebarName-{{section.id}}" class="section-header-text">
<ng-container
*ngComponentOutlet="(sectionMap$ | async).get(section.id).component; injector: (sectionMap$ | async).get(section.id).injector;"></ng-container>
</span>
<i class="fas fa-chevron-right fa-pull-right"
[@rotate]="(expanded | async) ? 'expanded' : 'collapsed'"
[title]="('menu.section.toggle.' + section.id) | translate"
></i>
</div>
<ul class="sidebar-sub-level-items list-unstyled" @slide *ngIf="(expanded | async)">
<li *ngFor="let subSection of (subSections$ | async)">
<ng-container
*ngComponentOutlet="(sectionMap$ | async).get(subSection.id).component; injector: (sectionMap$ | async).get(subSection.id).injector;"></ng-container>
</li>
</ul>
</div>
</li>
</div>
</div>

View File

@@ -9,7 +9,7 @@
list-style: disc;
color: var(--bs-navbar-dark-color);
overflow: hidden;
margin-bottom: calc(-1 * var(--bs-spacer)); // the bottom-most nav-item is padded, no need for double spacing
}
.sidebar-collapsible {

View File

@@ -10,6 +10,8 @@ import { Component } from '@angular/core';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { By } from '@angular/platform-browser';
import { TranslateModule } from '@ngx-translate/core';
import { Router } from '@angular/router';
import { RouterStub } from '../../../shared/testing/router.stub';
describe('ExpandableAdminSidebarSectionComponent', () => {
let component: ExpandableAdminSidebarSectionComponent;
@@ -24,6 +26,7 @@ describe('ExpandableAdminSidebarSectionComponent', () => {
{ provide: 'sectionDataProvider', useValue: { icon: iconString } },
{ provide: MenuService, useValue: menuService },
{ provide: CSSVariableService, useClass: CSSVariableServiceStub },
{ provide: Router, useValue: new RouterStub() },
]
}).overrideComponent(ExpandableAdminSidebarSectionComponent, {
set: {
@@ -46,29 +49,14 @@ describe('ExpandableAdminSidebarSectionComponent', () => {
});
it('should set the right icon', () => {
const icon = fixture.debugElement.query(By.css('.icon-wrapper')).query(By.css('i.fas'));
const icon = fixture.debugElement.query(By.css('.shortcut-icon > i.fas'));
expect(icon.nativeElement.getAttribute('class')).toContain('fa-' + iconString);
});
describe('when the icon is clicked', () => {
beforeEach(() => {
spyOn(menuService, 'toggleActiveSection');
const sidebarToggler = fixture.debugElement.query(By.css('a.shortcut-icon'));
sidebarToggler.triggerEventHandler('click', {
preventDefault: () => {/**/
}
});
});
it('should call toggleActiveSection on the menuService', () => {
expect(menuService.toggleActiveSection).toHaveBeenCalled();
});
});
describe('when the header text is clicked', () => {
beforeEach(() => {
spyOn(menuService, 'toggleActiveSection');
const sidebarToggler = fixture.debugElement.query(By.css('.sidebar-collapsible')).query(By.css('a'));
const sidebarToggler = fixture.debugElement.query(By.css('.sidebar-section > div.nav-item'));
sidebarToggler.triggerEventHandler('click', {
preventDefault: () => {/**/
}

View File

@@ -9,12 +9,14 @@ import { MenuService } from '../../../shared/menu/menu.service';
import { combineLatest as combineLatestObservable, Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { rendersSectionForMenu } from '../../../shared/menu/menu-section.decorator';
import { Router } from '@angular/router';
/**
* Represents a expandable section in the sidebar
*/
@Component({
selector: 'ds-expandable-admin-sidebar-section',
/* tslint:disable:component-selector */
selector: 'li[ds-expandable-admin-sidebar-section]',
templateUrl: './expandable-admin-sidebar-section.component.html',
styleUrls: ['./expandable-admin-sidebar-section.component.scss'],
animations: [rotate, slide, bgColor]
@@ -48,9 +50,14 @@ export class ExpandableAdminSidebarSectionComponent extends AdminSidebarSectionC
*/
expanded: Observable<boolean>;
constructor(@Inject('sectionDataProvider') menuSection, protected menuService: MenuService,
private variableService: CSSVariableService, protected injector: Injector) {
super(menuSection, menuService, injector);
constructor(
@Inject('sectionDataProvider') menuSection,
protected menuService: MenuService,
private variableService: CSSVariableService,
protected injector: Injector,
protected router: Router,
) {
super(menuSection, menuService, injector, router);
}
/**

View File

@@ -4,7 +4,7 @@ import { Collection } from './core/shared/collection.model';
import { Item } from './core/shared/item.model';
import { getCommunityPageRoute } from './community-page/community-page-routing-paths';
import { getCollectionPageRoute } from './collection-page/collection-page-routing-paths';
import { getItemPageRoute } from './item-page/item-page-routing-paths';
import { getItemModuleRoute, getItemPageRoute } from './item-page/item-page-routing-paths';
import { hasValue } from './shared/empty.util';
import { URLCombiner } from './core/url-combiner/url-combiner';
@@ -22,6 +22,15 @@ export function getBitstreamModuleRoute() {
export function getBitstreamDownloadRoute(bitstream): string {
return new URLCombiner(getBitstreamModuleRoute(), bitstream.uuid, 'download').toString();
}
export function getBitstreamRequestACopyRoute(item, bitstream): { routerLink: string, queryParams: any } {
const url = new URLCombiner(getItemModuleRoute(), item.uuid, 'request-a-copy').toString();
return {
routerLink: url,
queryParams: {
bitstream: bitstream.uuid
}
};
}
export const ADMIN_MODULE_PATH = 'admin';
@@ -90,3 +99,8 @@ export const ACCESS_CONTROL_MODULE_PATH = 'access-control';
export function getAccessControlModuleRoute() {
return `/${ACCESS_CONTROL_MODULE_PATH}`;
}
export const REQUEST_COPY_MODULE_PATH = 'request-a-copy';
export function getRequestCopyModulePath() {
return `/${REQUEST_COPY_MODULE_PATH}`;
}

View File

@@ -14,7 +14,7 @@ import {
PROFILE_MODULE_PATH,
REGISTER_PATH,
WORKFLOW_ITEM_MODULE_PATH,
LEGACY_BITSTREAM_MODULE_PATH,
LEGACY_BITSTREAM_MODULE_PATH, REQUEST_COPY_MODULE_PATH,
} from './app-routing-paths';
import { COLLECTION_MODULE_PATH } from './collection-page/collection-page-routing-paths';
import { COMMUNITY_MODULE_PATH } from './community-page/community-page-routing-paths';
@@ -180,6 +180,11 @@ import { GroupAdministratorGuard } from './core/data/feature-authorization/featu
path: INFO_MODULE_PATH,
loadChildren: () => import('./info/info.module').then((m) => m.InfoModule),
},
{
path: REQUEST_COPY_MODULE_PATH,
loadChildren: () => import('./request-copy/request-copy.module').then((m) => m.RequestCopyModule),
canActivate: [AuthenticatedGuard, EndUserAgreementCurrentUserGuard]
},
{
path: FORBIDDEN_PATH,
component: ThemedForbiddenComponent

View File

@@ -1,4 +1,4 @@
import { delay, distinctUntilChanged, filter, take, withLatestFrom } from 'rxjs/operators';
import { distinctUntilChanged, filter, switchMap, take, withLatestFrom } from 'rxjs/operators';
import {
AfterViewInit,
ChangeDetectionStrategy,
@@ -9,7 +9,13 @@ import {
Optional,
PLATFORM_ID,
} from '@angular/core';
import { NavigationCancel, NavigationEnd, NavigationStart, Router } from '@angular/router';
import {
ActivatedRouteSnapshot,
NavigationCancel,
NavigationEnd,
NavigationStart, ResolveEnd,
Router,
} from '@angular/router';
import { BehaviorSubject, Observable, of } from 'rxjs';
import { select, Store } from '@ngrx/store';
@@ -71,6 +77,7 @@ export class AppComponent implements OnInit, AfterViewInit {
*/
isThemeLoading$: BehaviorSubject<boolean> = new BehaviorSubject(false);
isThemeCSSLoading$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
/**
* Whether or not the idle modal is is currently open
@@ -105,7 +112,7 @@ export class AppComponent implements OnInit, AfterViewInit {
this.themeService.getThemeName$().subscribe((themeName: string) => {
if (isPlatformBrowser(this.platformId)) {
// the theme css will never download server side, so this should only happen on the browser
this.isThemeLoading$.next(true);
this.isThemeCSSLoading$.next(true);
}
if (hasValue(themeName)) {
this.setThemeCss(themeName);
@@ -177,17 +184,33 @@ export class AppComponent implements OnInit, AfterViewInit {
}
ngAfterViewInit() {
this.router.events.pipe(
// This fixes an ExpressionChangedAfterItHasBeenCheckedError from being thrown while loading the component
// More information on this bug-fix: https://blog.angular-university.io/angular-debugging/
delay(0)
).subscribe((event) => {
let resolveEndFound = false;
this.router.events.subscribe((event) => {
if (event instanceof NavigationStart) {
resolveEndFound = false;
this.isRouteLoading$.next(true);
this.isThemeLoading$.next(true);
} else if (event instanceof ResolveEnd) {
resolveEndFound = true;
const activatedRouteSnapShot: ActivatedRouteSnapshot = event.state.root;
this.themeService.updateThemeOnRouteChange$(event.urlAfterRedirects, activatedRouteSnapShot).pipe(
switchMap((changed) => {
if (changed) {
return this.isThemeCSSLoading$;
} else {
return [false];
}
})
).subscribe((changed) => {
this.isThemeLoading$.next(changed);
});
} else if (
event instanceof NavigationEnd ||
event instanceof NavigationCancel
) {
if (!resolveEndFound) {
this.isThemeLoading$.next(false);
}
this.isRouteLoading$.next(false);
}
});
@@ -237,7 +260,7 @@ export class AppComponent implements OnInit, AfterViewInit {
});
}
// the fact that this callback is used, proves we're on the browser.
this.isThemeLoading$.next(false);
this.isThemeCSSLoading$.next(false);
};
head.appendChild(link);
}

View File

@@ -7,7 +7,11 @@ import { EffectsModule } from '@ngrx/effects';
import { RouterStateSerializer, StoreRouterConnectingModule } from '@ngrx/router-store';
import { MetaReducer, Store, StoreModule, USER_PROVIDED_META_REDUCERS } from '@ngrx/store';
import { StoreDevtoolsModule } from '@ngrx/store-devtools';
import { DYNAMIC_MATCHER_PROVIDERS } from '@ng-dynamic-forms/core';
import {
DYNAMIC_ERROR_MESSAGES_MATCHER,
DYNAMIC_MATCHER_PROVIDERS,
DynamicErrorMessagesMatcher
} from '@ng-dynamic-forms/core';
import { TranslateModule } from '@ngx-translate/core';
import { ScrollToModule } from '@nicky-lenaers/ngx-scroll-to';
@@ -52,6 +56,7 @@ import { IdleModalComponent } from './shared/idle-modal/idle-modal.component';
import { UUIDService } from './core/shared/uuid.service';
import { CookieService } from './core/services/cookie.service';
import { AbstractControl } from '@angular/forms';
export function getBase() {
return environment.ui.nameSpace;
@@ -61,6 +66,14 @@ export function getMetaReducers(): MetaReducer<AppState>[] {
return environment.debug ? [...appMetaReducers, ...debugMetaReducers] : appMetaReducers;
}
/**
* Condition for displaying error messages on email form field
*/
export const ValidateEmailErrorStateMatcher: DynamicErrorMessagesMatcher =
(control: AbstractControl, model: any, hasFocus: boolean) => {
return (control.touched && !hasFocus) || (control.errors?.emailTaken && hasFocus);
};
const IMPORTS = [
CommonModule,
SharedModule,
@@ -146,6 +159,10 @@ const PROVIDERS = [
multi: true,
deps: [ CookieService, UUIDService ]
},
{
provide: DYNAMIC_ERROR_MESSAGES_MATCHER,
useValue: ValidateEmailErrorStateMatcher
},
...DYNAMIC_MATCHER_PROVIDERS,
];

View File

@@ -8,7 +8,7 @@ import { By } from '@angular/platform-browser';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { TranslateLoaderMock } from '../shared/testing/translate-loader.mock';
import { RouterTestingModule } from '@angular/router/testing';
import { of as observableOf } from 'rxjs/internal/observable/of';
import { of as observableOf } from 'rxjs';
import { DebugElement } from '@angular/core';
describe('BreadcrumbsComponent', () => {

View File

@@ -1,7 +1,7 @@
import { Component } from '@angular/core';
import { Breadcrumb } from './breadcrumb/breadcrumb.model';
import { BreadcrumbsService } from './breadcrumbs.service';
import { Observable } from 'rxjs/internal/Observable';
import { Observable } from 'rxjs';
/**
* Component representing the breadcrumbs of a page

View File

@@ -1,18 +1,27 @@
import { Component, Input } from '@angular/core';
import { Component, Input, OnInit } from '@angular/core';
import { Observable } from 'rxjs';
import { TranslateService } from '@ngx-translate/core';
import {
DynamicFormControlModel,
DynamicFormOptionConfig,
DynamicFormService,
DynamicInputModel,
DynamicTextAreaModel
DynamicSelectModel
} from '@ng-dynamic-forms/core';
import { Collection } from '../../core/shared/collection.model';
import { ComColFormComponent } from '../../shared/comcol-forms/comcol-form/comcol-form.component';
import { TranslateService } from '@ngx-translate/core';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { CommunityDataService } from '../../core/data/community-data.service';
import { AuthService } from '../../core/auth/auth.service';
import { RequestService } from '../../core/data/request.service';
import { ObjectCacheService } from '../../core/cache/object-cache.service';
import { EntityTypeService } from '../../core/data/entity-type.service';
import { ItemType } from '../../core/shared/item-relationships/item-type.model';
import { MetadataValue } from '../../core/shared/metadata.models';
import { getFirstSucceededRemoteListPayload } from '../../core/shared/operators';
import { collectionFormEntityTypeSelectionConfig, collectionFormModels, } from './collection-form.models';
import { NONE_ENTITY_TYPE } from '../../core/shared/item-relationships/item-type.resource-type';
/**
* Form used for creating and editing collections
@@ -22,7 +31,7 @@ import { ObjectCacheService } from '../../core/cache/object-cache.service';
styleUrls: ['../../shared/comcol-forms/comcol-form/comcol-form.component.scss'],
templateUrl: '../../shared/comcol-forms/comcol-form/comcol-form.component.html'
})
export class CollectionFormComponent extends ComColFormComponent<Collection> {
export class CollectionFormComponent extends ComColFormComponent<Collection> implements OnInit {
/**
* @type {Collection} A new collection when a collection is being created, an existing Input collection when a collection is being edited
*/
@@ -34,46 +43,16 @@ export class CollectionFormComponent extends ComColFormComponent<Collection> {
type = Collection.type;
/**
* The dynamic form fields used for creating/editing a collection
* @type {(DynamicInputModel | DynamicTextAreaModel)[]}
* The dynamic form field used for entity type selection
* @type {DynamicSelectModel<string>}
*/
formModel: DynamicFormControlModel[] = [
new DynamicInputModel({
id: 'title',
name: 'dc.title',
required: true,
validators: {
required: null
},
errorMessages: {
required: 'Please enter a name for this title'
},
}),
new DynamicTextAreaModel({
id: 'description',
name: 'dc.description',
}),
new DynamicTextAreaModel({
id: 'abstract',
name: 'dc.description.abstract',
}),
new DynamicTextAreaModel({
id: 'rights',
name: 'dc.rights',
}),
new DynamicTextAreaModel({
id: 'tableofcontents',
name: 'dc.description.tableofcontents',
}),
new DynamicTextAreaModel({
id: 'license',
name: 'dc.rights.license',
}),
new DynamicTextAreaModel({
id: 'provenance',
name: 'dc.description.provenance',
}),
];
entityTypeSelection: DynamicSelectModel<string> = new DynamicSelectModel(collectionFormEntityTypeSelectionConfig);
/**
* The dynamic form fields used for creating/editing a collection
* @type {DynamicFormControlModel[]}
*/
formModel: DynamicFormControlModel[];
public constructor(protected formService: DynamicFormService,
protected translate: TranslateService,
@@ -81,7 +60,43 @@ export class CollectionFormComponent extends ComColFormComponent<Collection> {
protected authService: AuthService,
protected dsoService: CommunityDataService,
protected requestService: RequestService,
protected objectCache: ObjectCacheService) {
protected objectCache: ObjectCacheService,
protected entityTypeService: EntityTypeService) {
super(formService, translate, notificationsService, authService, requestService, objectCache);
}
ngOnInit() {
let currentRelationshipValue: MetadataValue[];
if (this.dso && this.dso.metadata) {
currentRelationshipValue = this.dso.metadata['dspace.entity.type'];
}
const entities$: Observable<ItemType[]> = this.entityTypeService.findAll({ elementsPerPage: 100, currentPage: 1 }).pipe(
getFirstSucceededRemoteListPayload()
);
// retrieve all entity types to populate the dropdowns selection
entities$.subscribe((entityTypes: ItemType[]) => {
entityTypes
.filter((type: ItemType) => type.label !== NONE_ENTITY_TYPE)
.forEach((type: ItemType, index: number) => {
this.entityTypeSelection.add({
disabled: false,
label: type.label,
value: type.label
} as DynamicFormOptionConfig<string>);
if (currentRelationshipValue && currentRelationshipValue.length > 0 && currentRelationshipValue[0].value === type.label) {
this.entityTypeSelection.select(index);
this.entityTypeSelection.disabled = true;
}
});
this.formModel = [...collectionFormModels, this.entityTypeSelection];
super.ngOnInit();
});
}
}

View File

@@ -0,0 +1,46 @@
import { DynamicFormControlModel, DynamicInputModel, DynamicTextAreaModel } from '@ng-dynamic-forms/core';
import { DynamicSelectModelConfig } from '@ng-dynamic-forms/core/lib/model/select/dynamic-select.model';
export const collectionFormEntityTypeSelectionConfig: DynamicSelectModelConfig<string> = {
id: 'entityType',
name: 'dspace.entity.type',
disabled: false
};
/**
* The dynamic form fields used for creating/editing a collection
* @type {(DynamicInputModel | DynamicTextAreaModel)[]}
*/
export const collectionFormModels: DynamicFormControlModel[] = [
new DynamicInputModel({
id: 'title',
name: 'dc.title',
required: true,
validators: {
required: null
},
errorMessages: {
required: 'Please enter a name for this title'
},
}),
new DynamicTextAreaModel({
id: 'description',
name: 'dc.description',
}),
new DynamicTextAreaModel({
id: 'abstract',
name: 'dc.description.abstract',
}),
new DynamicTextAreaModel({
id: 'rights',
name: 'dc.rights',
}),
new DynamicTextAreaModel({
id: 'tableofcontents',
name: 'dc.description.tableofcontents',
}),
new DynamicTextAreaModel({
id: 'license',
name: 'dc.rights.license',
})
];

View File

@@ -27,7 +27,7 @@ import { ItemSelectComponent } from '../../shared/object-select/item-select/item
import { ObjectSelectService } from '../../shared/object-select/object-select.service';
import { ObjectSelectServiceStub } from '../../shared/testing/object-select-service.stub';
import { VarDirective } from '../../shared/utils/var.directive';
import { of as observableOf } from 'rxjs/internal/observable/of';
import { of as observableOf } from 'rxjs';
import { RouteService } from '../../core/services/route.service';
import { ErrorComponent } from '../../shared/error/error.component';
import { LoadingComponent } from '../../shared/loading/loading.component';

View File

@@ -0,0 +1,54 @@
<div *ngVar="(contentSource$ |async) as contentSource">
<div class="container-fluid" *ngIf="shouldShow">
<h4>{{ 'collection.source.controls.head' | translate }}</h4>
<div>
<span class="font-weight-bold">{{'collection.source.controls.harvest.status' | translate}}</span>
<span>{{contentSource?.harvestStatus}}</span>
</div>
<div>
<span class="font-weight-bold">{{'collection.source.controls.harvest.start' | translate}}</span>
<span>{{contentSource?.harvestStartTime ? contentSource?.harvestStartTime : 'collection.source.controls.harvest.no-information'|translate }}</span>
</div>
<div>
<span class="font-weight-bold">{{'collection.source.controls.harvest.last' | translate}}</span>
<span>{{contentSource?.message ? contentSource?.message : 'collection.source.controls.harvest.no-information'|translate }}</span>
</div>
<div>
<span class="font-weight-bold">{{'collection.source.controls.harvest.message' | translate}}</span>
<span>{{contentSource?.lastHarvested ? contentSource?.lastHarvested : 'collection.source.controls.harvest.no-information'|translate }}</span>
</div>
<button *ngIf="!(testConfigRunning$ |async)" class="btn btn-secondary"
[disabled]="!(isEnabled)"
(click)="testConfiguration(contentSource)">
<span>{{'collection.source.controls.test.submit' | translate}}</span>
</button>
<button *ngIf="(testConfigRunning$ |async)" class="btn btn-secondary"
[disabled]="true">
<span class="spinner-border spinner-border-sm spinner-button" role="status" aria-hidden="true"></span>
<span>{{'collection.source.controls.test.running' | translate}}</span>
</button>
<button *ngIf="!(importRunning$ |async)" class="btn btn-primary"
[disabled]="!(isEnabled)"
(click)="importNow()">
<span class="d-none d-sm-inline">{{'collection.source.controls.import.submit' | translate}}</span>
</button>
<button *ngIf="(importRunning$ |async)" class="btn btn-primary"
[disabled]="true">
<span class="spinner-border spinner-border-sm spinner-button" role="status" aria-hidden="true"></span>
<span class="d-none d-sm-inline">{{'collection.source.controls.import.running' | translate}}</span>
</button>
<button *ngIf="!(reImportRunning$ |async)" class="btn btn-primary"
[disabled]="!(isEnabled)"
(click)="resetAndReimport()">
<span class="d-none d-sm-inline">&nbsp;{{'collection.source.controls.reset.submit' | translate}}</span>
</button>
<button *ngIf="(reImportRunning$ |async)" class="btn btn-primary"
[disabled]="true">
<span class="spinner-border spinner-border-sm spinner-button" role="status" aria-hidden="true"></span>
<span class="d-none d-sm-inline">&nbsp;{{'collection.source.controls.reset.running' | translate}}</span>
</button>
</div>
</div>

View File

@@ -0,0 +1,3 @@
.spinner-button {
margin-bottom: calc((var(--bs-line-height-base) * 1rem - var(--bs-font-size-base)) / 2);
}

View File

@@ -0,0 +1,232 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { ContentSource } from '../../../../core/shared/content-source.model';
import { Collection } from '../../../../core/shared/collection.model';
import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils';
import { TranslateModule } from '@ngx-translate/core';
import { RouterTestingModule } from '@angular/router/testing';
import { NotificationsService } from '../../../../shared/notifications/notifications.service';
import { CollectionDataService } from '../../../../core/data/collection-data.service';
import { RequestService } from '../../../../core/data/request.service';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { ProcessDataService } from '../../../../core/data/processes/process-data.service';
import { ScriptDataService } from '../../../../core/data/processes/script-data.service';
import { HttpClient } from '@angular/common/http';
import { BitstreamDataService } from '../../../../core/data/bitstream-data.service';
import { NotificationsServiceStub } from '../../../../shared/testing/notifications-service.stub';
import { Process } from '../../../../process-page/processes/process.model';
import { of as observableOf } from 'rxjs';
import { CollectionSourceControlsComponent } from './collection-source-controls.component';
import { Bitstream } from '../../../../core/shared/bitstream.model';
import { getTestScheduler } from 'jasmine-marbles';
import { TestScheduler } from 'rxjs/testing';
import { By } from '@angular/platform-browser';
import { VarDirective } from '../../../../shared/utils/var.directive';
import { ContentSourceSetSerializer } from '../../../../core/shared/content-source-set-serializer';
describe('CollectionSourceControlsComponent', () => {
let comp: CollectionSourceControlsComponent;
let fixture: ComponentFixture<CollectionSourceControlsComponent>;
const uuid = '29481ed7-ae6b-409a-8c51-34dd347a0ce4';
let contentSource: ContentSource;
let collection: Collection;
let process: Process;
let bitstream: Bitstream;
let scriptDataService: ScriptDataService;
let processDataService: ProcessDataService;
let requestService: RequestService;
let notificationsService;
let collectionService: CollectionDataService;
let httpClient: HttpClient;
let bitstreamService: BitstreamDataService;
let scheduler: TestScheduler;
beforeEach(waitForAsync(() => {
scheduler = getTestScheduler();
contentSource = Object.assign(new ContentSource(), {
uuid: uuid,
metadataConfigs: [
{
id: 'dc',
label: 'Simple Dublin Core',
nameSpace: 'http://www.openarchives.org/OAI/2.0/oai_dc/'
},
{
id: 'qdc',
label: 'Qualified Dublin Core',
nameSpace: 'http://purl.org/dc/terms/'
},
{
id: 'dim',
label: 'DSpace Intermediate Metadata',
nameSpace: 'http://www.dspace.org/xmlns/dspace/dim'
}
],
oaiSource: 'oai-harvest-source',
oaiSetId: 'oai-set-id',
_links: {self: {href: 'contentsource-selflink'}}
});
process = Object.assign(new Process(), {
processId: 'process-id', processStatus: 'COMPLETED',
_links: {output: {href: 'output-href'}}
});
bitstream = Object.assign(new Bitstream(), {_links: {content: {href: 'content-href'}}});
collection = Object.assign(new Collection(), {
uuid: 'fake-collection-id',
_links: {self: {href: 'collection-selflink'}}
});
notificationsService = new NotificationsServiceStub();
collectionService = jasmine.createSpyObj('collectionService', {
getContentSource: createSuccessfulRemoteDataObject$(contentSource),
findByHref: createSuccessfulRemoteDataObject$(collection)
});
scriptDataService = jasmine.createSpyObj('scriptDataService', {
invoke: createSuccessfulRemoteDataObject$(process),
});
processDataService = jasmine.createSpyObj('processDataService', {
findById: createSuccessfulRemoteDataObject$(process),
});
bitstreamService = jasmine.createSpyObj('bitstreamService', {
findByHref: createSuccessfulRemoteDataObject$(bitstream),
});
httpClient = jasmine.createSpyObj('httpClient', {
get: observableOf('Script text'),
});
requestService = jasmine.createSpyObj('requestService', ['removeByHrefSubstring', 'setStaleByHrefSubstring']);
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), RouterTestingModule],
declarations: [CollectionSourceControlsComponent, VarDirective],
providers: [
{provide: ScriptDataService, useValue: scriptDataService},
{provide: ProcessDataService, useValue: processDataService},
{provide: RequestService, useValue: requestService},
{provide: NotificationsService, useValue: notificationsService},
{provide: CollectionDataService, useValue: collectionService},
{provide: HttpClient, useValue: httpClient},
{provide: BitstreamDataService, useValue: bitstreamService}
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(CollectionSourceControlsComponent);
comp = fixture.componentInstance;
comp.isEnabled = true;
comp.collection = collection;
comp.shouldShow = true;
fixture.detectChanges();
});
describe('init', () => {
it('should', () => {
expect(comp).toBeTruthy();
});
});
describe('testConfiguration', () => {
it('should invoke a script and ping the resulting process until completed and show the resulting info', () => {
comp.testConfiguration(contentSource);
scheduler.flush();
expect(scriptDataService.invoke).toHaveBeenCalledWith('harvest', [
{name: '-g', value: null},
{name: '-a', value: contentSource.oaiSource},
{name: '-i', value: new ContentSourceSetSerializer().Serialize(contentSource.oaiSetId)},
], []);
expect(processDataService.findById).toHaveBeenCalledWith(process.processId, false);
expect(bitstreamService.findByHref).toHaveBeenCalledWith(process._links.output.href);
expect(notificationsService.info).toHaveBeenCalledWith(jasmine.anything() as any, 'Script text');
});
});
describe('importNow', () => {
it('should invoke a script that will start the harvest', () => {
comp.importNow();
scheduler.flush();
expect(scriptDataService.invoke).toHaveBeenCalledWith('harvest', [
{name: '-r', value: null},
{name: '-c', value: collection.uuid},
], []);
expect(processDataService.findById).toHaveBeenCalledWith(process.processId, false);
expect(notificationsService.success).toHaveBeenCalled();
});
});
describe('resetAndReimport', () => {
it('should invoke a script that will start the harvest', () => {
comp.resetAndReimport();
scheduler.flush();
expect(scriptDataService.invoke).toHaveBeenCalledWith('harvest', [
{name: '-o', value: null},
{name: '-c', value: collection.uuid},
], []);
expect(processDataService.findById).toHaveBeenCalledWith(process.processId, false);
expect(notificationsService.success).toHaveBeenCalled();
});
});
describe('the controls', () => {
it('should be shown when shouldShow is true', () => {
comp.shouldShow = true;
fixture.detectChanges();
const buttons = fixture.debugElement.queryAll(By.css('button'));
expect(buttons.length).toEqual(3);
});
it('should be shown when shouldShow is false', () => {
comp.shouldShow = false;
fixture.detectChanges();
const buttons = fixture.debugElement.queryAll(By.css('button'));
expect(buttons.length).toEqual(0);
});
it('should be disabled when isEnabled is false', () => {
comp.shouldShow = true;
comp.isEnabled = false;
fixture.detectChanges();
const buttons = fixture.debugElement.queryAll(By.css('button'));
expect(buttons[0].nativeElement.disabled).toBeTrue();
expect(buttons[1].nativeElement.disabled).toBeTrue();
expect(buttons[2].nativeElement.disabled).toBeTrue();
});
it('should be enabled when isEnabled is true', () => {
comp.shouldShow = true;
comp.isEnabled = true;
fixture.detectChanges();
const buttons = fixture.debugElement.queryAll(By.css('button'));
expect(buttons[0].nativeElement.disabled).toBeFalse();
expect(buttons[1].nativeElement.disabled).toBeFalse();
expect(buttons[2].nativeElement.disabled).toBeFalse();
});
it('should call the corresponding button when clicked', () => {
spyOn(comp, 'testConfiguration');
spyOn(comp, 'importNow');
spyOn(comp, 'resetAndReimport');
comp.shouldShow = true;
comp.isEnabled = true;
fixture.detectChanges();
const buttons = fixture.debugElement.queryAll(By.css('button'));
buttons[0].triggerEventHandler('click', null);
expect(comp.testConfiguration).toHaveBeenCalled();
buttons[1].triggerEventHandler('click', null);
expect(comp.importNow).toHaveBeenCalled();
buttons[2].triggerEventHandler('click', null);
expect(comp.resetAndReimport).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,231 @@
import { Component, Input, OnDestroy } from '@angular/core';
import { ScriptDataService } from '../../../../core/data/processes/script-data.service';
import { ContentSource } from '../../../../core/shared/content-source.model';
import { ProcessDataService } from '../../../../core/data/processes/process-data.service';
import {
getAllCompletedRemoteData,
getAllSucceededRemoteDataPayload,
getFirstCompletedRemoteData,
getFirstSucceededRemoteDataPayload
} from '../../../../core/shared/operators';
import { filter, map, switchMap, tap } from 'rxjs/operators';
import { hasValue, hasValueOperator } from '../../../../shared/empty.util';
import { ProcessStatus } from '../../../../process-page/processes/process-status.model';
import { BehaviorSubject, Observable, Subscription } from 'rxjs';
import { RequestService } from '../../../../core/data/request.service';
import { NotificationsService } from '../../../../shared/notifications/notifications.service';
import { Collection } from '../../../../core/shared/collection.model';
import { CollectionDataService } from '../../../../core/data/collection-data.service';
import { Process } from '../../../../process-page/processes/process.model';
import { TranslateService } from '@ngx-translate/core';
import { HttpClient } from '@angular/common/http';
import { BitstreamDataService } from '../../../../core/data/bitstream-data.service';
import { ContentSourceSetSerializer } from '../../../../core/shared/content-source-set-serializer';
/**
* Component that contains the controls to run, reset and test the harvest
*/
@Component({
selector: 'ds-collection-source-controls',
styleUrls: ['./collection-source-controls.component.scss'],
templateUrl: './collection-source-controls.component.html',
})
export class CollectionSourceControlsComponent implements OnDestroy {
/**
* Should the controls be enabled.
*/
@Input() isEnabled: boolean;
/**
* The current collection
*/
@Input() collection: Collection;
/**
* Should the control section be shown
*/
@Input() shouldShow: boolean;
contentSource$: Observable<ContentSource>;
private subs: Subscription[] = [];
testConfigRunning$ = new BehaviorSubject(false);
importRunning$ = new BehaviorSubject(false);
reImportRunning$ = new BehaviorSubject(false);
constructor(private scriptDataService: ScriptDataService,
private processDataService: ProcessDataService,
private requestService: RequestService,
private notificationsService: NotificationsService,
private collectionService: CollectionDataService,
private translateService: TranslateService,
private httpClient: HttpClient,
private bitstreamService: BitstreamDataService
) {
}
ngOnInit() {
// ensure the contentSource gets updated after being set to stale
this.contentSource$ = this.collectionService.findByHref(this.collection._links.self.href, false).pipe(
getAllSucceededRemoteDataPayload(),
switchMap((collection) => this.collectionService.getContentSource(collection.uuid, false)),
getAllSucceededRemoteDataPayload()
);
}
/**
* Tests the provided content source's configuration.
* @param contentSource - The content source to be tested
*/
testConfiguration(contentSource) {
this.testConfigRunning$.next(true);
this.subs.push(this.scriptDataService.invoke('harvest', [
{name: '-g', value: null},
{name: '-a', value: contentSource.oaiSource},
{name: '-i', value: new ContentSourceSetSerializer().Serialize(contentSource.oaiSetId)},
], []).pipe(
getFirstCompletedRemoteData(),
tap((rd) => {
if (rd.hasFailed) {
// show a notification when the script invocation fails
this.notificationsService.error(this.translateService.get('collection.source.controls.test.submit.error'));
this.testConfigRunning$.next(false);
}
}),
// filter out responses that aren't successful since the pinging of the process only needs to happen when the invocation was successful.
filter((rd) => rd.hasSucceeded && hasValue(rd.payload)),
switchMap((rd) => this.processDataService.findById(rd.payload.processId, false)),
getAllCompletedRemoteData(),
filter((rd) => !rd.isStale && (rd.hasSucceeded || rd.hasFailed)),
map((rd) => rd.payload),
hasValueOperator(),
).subscribe((process: Process) => {
if (process.processStatus.toString() !== ProcessStatus[ProcessStatus.COMPLETED].toString() &&
process.processStatus.toString() !== ProcessStatus[ProcessStatus.FAILED].toString()) {
// Ping the current process state every 5s
setTimeout(() => {
this.requestService.setStaleByHrefSubstring(process._links.self.href);
}, 5000);
}
if (process.processStatus.toString() === ProcessStatus[ProcessStatus.FAILED].toString()) {
this.notificationsService.error(this.translateService.get('collection.source.controls.test.failed'));
this.testConfigRunning$.next(false);
}
if (process.processStatus.toString() === ProcessStatus[ProcessStatus.COMPLETED].toString()) {
this.bitstreamService.findByHref(process._links.output.href).pipe(getFirstSucceededRemoteDataPayload()).subscribe((bitstream) => {
this.httpClient.get(bitstream._links.content.href, {responseType: 'text'}).subscribe((data: any) => {
const output = data.replaceAll(new RegExp('.*\\@(.*)', 'g'), '$1')
.replaceAll('The script has started', '')
.replaceAll('The script has completed', '');
this.notificationsService.info(this.translateService.get('collection.source.controls.test.completed'), output);
});
});
this.testConfigRunning$.next(false);
}
}
));
}
/**
* Start the harvest for the current collection
*/
importNow() {
this.importRunning$.next(true);
this.subs.push(this.scriptDataService.invoke('harvest', [
{name: '-r', value: null},
{name: '-c', value: this.collection.uuid},
], [])
.pipe(
getFirstCompletedRemoteData(),
tap((rd) => {
if (rd.hasFailed) {
this.notificationsService.error(this.translateService.get('collection.source.controls.import.submit.error'));
this.importRunning$.next(false);
} else {
this.notificationsService.success(this.translateService.get('collection.source.controls.import.submit.success'));
}
}),
filter((rd) => rd.hasSucceeded && hasValue(rd.payload)),
switchMap((rd) => this.processDataService.findById(rd.payload.processId, false)),
getAllCompletedRemoteData(),
filter((rd) => !rd.isStale && (rd.hasSucceeded || rd.hasFailed)),
map((rd) => rd.payload),
hasValueOperator(),
).subscribe((process) => {
if (process.processStatus.toString() !== ProcessStatus[ProcessStatus.COMPLETED].toString() &&
process.processStatus.toString() !== ProcessStatus[ProcessStatus.FAILED].toString()) {
// Ping the current process state every 5s
setTimeout(() => {
this.requestService.setStaleByHrefSubstring(process._links.self.href);
this.requestService.setStaleByHrefSubstring(this.collection._links.self.href);
}, 5000);
}
if (process.processStatus.toString() === ProcessStatus[ProcessStatus.FAILED].toString()) {
this.notificationsService.error(this.translateService.get('collection.source.controls.import.failed'));
this.importRunning$.next(false);
}
if (process.processStatus.toString() === ProcessStatus[ProcessStatus.COMPLETED].toString()) {
this.notificationsService.success(this.translateService.get('collection.source.controls.import.completed'));
this.requestService.setStaleByHrefSubstring(this.collection._links.self.href);
this.importRunning$.next(false);
}
}
));
}
/**
* Reset and reimport the current collection
*/
resetAndReimport() {
this.reImportRunning$.next(true);
this.subs.push(this.scriptDataService.invoke('harvest', [
{name: '-o', value: null},
{name: '-c', value: this.collection.uuid},
], [])
.pipe(
getFirstCompletedRemoteData(),
tap((rd) => {
if (rd.hasFailed) {
this.notificationsService.error(this.translateService.get('collection.source.controls.reset.submit.error'));
this.reImportRunning$.next(false);
} else {
this.notificationsService.success(this.translateService.get('collection.source.controls.reset.submit.success'));
}
}),
filter((rd) => rd.hasSucceeded && hasValue(rd.payload)),
switchMap((rd) => this.processDataService.findById(rd.payload.processId, false)),
getAllCompletedRemoteData(),
filter((rd) => !rd.isStale && (rd.hasSucceeded || rd.hasFailed)),
map((rd) => rd.payload),
hasValueOperator(),
).subscribe((process) => {
if (process.processStatus.toString() !== ProcessStatus[ProcessStatus.COMPLETED].toString() &&
process.processStatus.toString() !== ProcessStatus[ProcessStatus.FAILED].toString()) {
// Ping the current process state every 5s
setTimeout(() => {
this.requestService.setStaleByHrefSubstring(process._links.self.href);
this.requestService.setStaleByHrefSubstring(this.collection._links.self.href);
}, 5000);
}
if (process.processStatus.toString() === ProcessStatus[ProcessStatus.FAILED].toString()) {
this.notificationsService.error(this.translateService.get('collection.source.controls.reset.failed'));
this.reImportRunning$.next(false);
}
if (process.processStatus.toString() === ProcessStatus[ProcessStatus.COMPLETED].toString()) {
this.notificationsService.success(this.translateService.get('collection.source.controls.reset.completed'));
this.requestService.setStaleByHrefSubstring(this.collection._links.self.href);
this.reImportRunning$.next(false);
}
}
));
}
ngOnDestroy(): void {
this.subs.forEach((sub) => {
if (hasValue(sub)) {
sub.unsubscribe();
}
});
}
}

View File

@@ -1,57 +1,74 @@
<div class="container-fluid">
<div class="d-inline-block float-right">
<button class=" btn btn-danger" *ngIf="!(isReinstatable() | async)"
[disabled]="!(hasChanges() | async)"
(click)="discard()"><i
class="fas fa-times"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.discard-button" | translate}}</span>
</button>
<button class="btn btn-warning" *ngIf="isReinstatable() | async"
(click)="reinstate()"><i
class="fas fa-undo-alt"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.reinstate-button" | translate}}</span>
</button>
<button class="btn btn-primary" [disabled]="!(hasChanges() | async) || !isValid() || (initialHarvestType === harvestTypeNone && contentSource.harvestType === initialHarvestType)"
(click)="onSubmit()"><i
class="fas fa-save"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.save-button" | translate}}</span>
</button>
</div>
<h4>{{ 'collection.edit.tabs.source.head' | translate }}</h4>
<div *ngIf="contentSource" class="form-check mb-4">
<input type="checkbox" class="form-check-input" id="externalSourceCheck" [checked]="(contentSource?.harvestType !== harvestTypeNone)" (change)="changeExternalSource()">
<label class="form-check-label" for="externalSourceCheck">{{ 'collection.edit.tabs.source.external' | translate }}</label>
</div>
<ds-loading *ngIf="!contentSource" [message]="'loading.content-source' | translate"></ds-loading>
<h4 *ngIf="contentSource && (contentSource?.harvestType !== harvestTypeNone)">{{ 'collection.edit.tabs.source.form.head' | translate }}</h4>
<div class="d-inline-block float-right">
<button class=" btn btn-danger" *ngIf="!(isReinstatable() | async)"
[disabled]="!(hasChanges() | async)"
(click)="discard()"><i
class="fas fa-times"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.discard-button" | translate}}</span>
</button>
<button class="btn btn-warning" *ngIf="isReinstatable() | async"
(click)="reinstate()"><i
class="fas fa-undo-alt"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.reinstate-button" | translate}}</span>
</button>
<button class="btn btn-primary"
[disabled]="!(hasChanges() | async) || !isValid() || (initialHarvestType === harvestTypeNone && contentSource.harvestType === initialHarvestType)"
(click)="onSubmit()"><i
class="fas fa-save"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.save-button" | translate}}</span>
</button>
</div>
<h4>{{ 'collection.edit.tabs.source.head' | translate }}</h4>
<div *ngIf="contentSource" class="form-check mb-4">
<input type="checkbox" class="form-check-input" id="externalSourceCheck"
[checked]="(contentSource?.harvestType !== harvestTypeNone)" (change)="changeExternalSource()">
<label class="form-check-label"
for="externalSourceCheck">{{ 'collection.edit.tabs.source.external' | translate }}</label>
</div>
<ds-loading *ngIf="!contentSource" [message]="'loading.content-source' | translate"></ds-loading>
<h4 *ngIf="contentSource && (contentSource?.harvestType !== harvestTypeNone)">{{ 'collection.edit.tabs.source.form.head' | translate }}</h4>
</div>
<ds-form *ngIf="formGroup && contentSource && (contentSource?.harvestType !== harvestTypeNone)"
[formId]="'collection-source-form-id'"
[formGroup]="formGroup"
[formModel]="formModel"
[formLayout]="formLayout"
[displaySubmit]="false"
[displayCancel]="false"
(dfChange)="onChange($event)"
(submitForm)="onSubmit()"
(cancel)="onCancel()"></ds-form>
<div class="container-fluid" *ngIf="(contentSource?.harvestType !== harvestTypeNone)">
<div class="d-inline-block float-right">
<button class=" btn btn-danger" *ngIf="!(isReinstatable() | async)"
[disabled]="!(hasChanges() | async)"
(click)="discard()"><i
class="fas fa-times"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.discard-button" | translate}}</span>
</button>
<button class="btn btn-warning" *ngIf="isReinstatable() | async"
(click)="reinstate()"><i
class="fas fa-undo-alt"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.reinstate-button" | translate}}</span>
</button>
<button class="btn btn-primary" [disabled]="!(hasChanges() | async) || !isValid() || (initialHarvestType === harvestTypeNone && contentSource.harvestType === initialHarvestType)"
(click)="onSubmit()"><i
class="fas fa-save"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.save-button" | translate}}</span>
</button>
</div>
<div class="row">
<ds-form *ngIf="formGroup && contentSource && (contentSource?.harvestType !== harvestTypeNone)"
[formId]="'collection-source-form-id'"
[formGroup]="formGroup"
[formModel]="formModel"
[formLayout]="formLayout"
[displaySubmit]="false"
[displayCancel]="false"
(dfChange)="onChange($event)"
(submitForm)="onSubmit()"
(cancel)="onCancel()"></ds-form>
</div>
<div class="container mt-2" *ngIf="(contentSource?.harvestType !== harvestTypeNone)">
<div class="row">
<div class="col-12">
<div class="d-inline-block float-right ml-1">
<button class=" btn btn-danger" *ngIf="!(isReinstatable() | async)"
[disabled]="!(hasChanges() | async)"
(click)="discard()"><i
class="fas fa-times"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.discard-button" | translate}}</span>
</button>
<button class="btn btn-warning" *ngIf="isReinstatable() | async"
(click)="reinstate()"><i
class="fas fa-undo-alt"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.reinstate-button" | translate}}</span>
</button>
<button class="btn btn-primary"
[disabled]="!(hasChanges() | async) || !isValid() || (initialHarvestType === harvestTypeNone && contentSource.harvestType === initialHarvestType)"
(click)="onSubmit()"><i
class="fas fa-save"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.save-button" | translate}}</span>
</button>
</div>
</div>
</div>
</div>
<ds-collection-source-controls
[isEnabled]="!(hasChanges()|async)"
[shouldShow]="contentSource?.harvestType !== harvestTypeNone"
[collection]="(collectionRD$ |async)?.payload"
>
</ds-collection-source-controls>

View File

@@ -62,7 +62,8 @@ describe('CollectionSourceComponent', () => {
label: 'DSpace Intermediate Metadata',
nameSpace: 'http://www.dspace.org/xmlns/dspace/dim'
}
]
],
_links: { self: { href: 'contentsource-selflink' } }
});
fieldUpdate = {
field: contentSource,
@@ -115,7 +116,7 @@ describe('CollectionSourceComponent', () => {
updateContentSource: observableOf(contentSource),
getHarvesterEndpoint: observableOf('harvester-endpoint')
});
requestService = jasmine.createSpyObj('requestService', ['removeByHrefSubstring']);
requestService = jasmine.createSpyObj('requestService', ['removeByHrefSubstring', 'setStaleByHrefSubstring']);
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), RouterTestingModule],

View File

@@ -380,7 +380,7 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem
switchMap((uuid) => this.collectionService.getHarvesterEndpoint(uuid)),
take(1)
).subscribe((endpoint) => this.requestService.removeByHrefSubstring(endpoint));
this.requestService.setStaleByHrefSubstring(this.contentSource._links.self.href);
// Update harvester
this.collectionRD$.pipe(
getFirstSucceededRemoteData(),

View File

@@ -9,6 +9,7 @@ import { CollectionCurateComponent } from './collection-curate/collection-curate
import { CollectionSourceComponent } from './collection-source/collection-source.component';
import { CollectionAuthorizationsComponent } from './collection-authorizations/collection-authorizations.component';
import { CollectionFormModule } from '../collection-form/collection-form.module';
import { CollectionSourceControlsComponent } from './collection-source/collection-source-controls/collection-source-controls.component';
/**
* Module that contains all components related to the Edit Collection page administrator functionality
@@ -26,6 +27,8 @@ import { CollectionFormModule } from '../collection-form/collection-form.module'
CollectionRolesComponent,
CollectionCurateComponent,
CollectionSourceComponent,
CollectionSourceControlsComponent,
CollectionAuthorizationsComponent
]
})

View File

@@ -1,9 +1,8 @@
import { Subscription } from 'rxjs/internal/Subscription';
import { FindListOptions } from '../core/data/request.models';
import { hasValue } from '../shared/empty.util';
import { CommunityListService, FlatNode } from './community-list-service';
import { CollectionViewer, DataSource } from '@angular/cdk/collections';
import { BehaviorSubject, Observable, } from 'rxjs';
import { BehaviorSubject, Observable, Subscription } from 'rxjs';
import { finalize } from 'rxjs/operators';
/**

View File

@@ -42,7 +42,6 @@ import {
UnsetUserAsIdleAction
} from './auth.actions';
import { NativeWindowRef, NativeWindowService } from '../services/window.service';
import { Base64EncodeUrl } from '../../shared/utils/encode-decode.util';
import { RouteService } from '../services/route.service';
import { EPersonDataService } from '../eperson/eperson-data.service';
import { getAllSucceededRemoteDataPayload } from '../shared/operators';
@@ -103,7 +102,7 @@ export class AuthService {
*/
public authenticate(user: string, password: string): Observable<AuthStatus> {
// Attempt authenticating the user using the supplied credentials.
const body = (`password=${Base64EncodeUrl(password)}&user=${Base64EncodeUrl(user)}`);
const body = (`password=${encodeURIComponent(password)}&user=${encodeURIComponent(user)}`);
const options: HttpOptions = Object.create({});
let headers = new HttpHeaders();
headers = headers.append('Content-Type', 'application/x-www-form-urlencoded');

View File

@@ -10,8 +10,7 @@ import {
LinkDefinition
} from './build-decorators';
import { RemoteData } from '../../data/remote-data';
import { Observable } from 'rxjs/internal/Observable';
import { EMPTY } from 'rxjs';
import { EMPTY, Observable } from 'rxjs';
import { ResourceType } from '../../shared/resource-type';
/**

View File

@@ -17,7 +17,7 @@ import {
createFailedRemoteDataObject$,
createSuccessfulRemoteDataObject,
createSuccessfulRemoteDataObject$
} from 'src/app/shared/remote-data.utils';
} from '../../shared/remote-data.utils';
import { cold, getTestScheduler, hot } from 'jasmine-marbles';
import { TestScheduler } from 'rxjs/testing';
import { Observable } from 'rxjs';

View File

@@ -27,12 +27,7 @@ import { CommunityDataService } from './community-data.service';
import { DSOChangeAnalyzer } from './dso-change-analyzer.service';
import { PaginatedList } from './paginated-list.model';
import { RemoteData } from './remote-data';
import {
ContentSourceRequest,
FindListOptions,
UpdateContentSourceRequest,
RestRequest
} from './request.models';
import { ContentSourceRequest, FindListOptions, RestRequest, UpdateContentSourceRequest } from './request.models';
import { RequestService } from './request.service';
import { BitstreamDataService } from './bitstream-data.service';
@@ -84,16 +79,48 @@ export class CollectionDataService extends ComColDataService<Collection> {
filter((collections: RemoteData<PaginatedList<Collection>>) => !collections.isResponsePending));
}
/**
* Get all collections the user is authorized to submit to
*
* @param query limit the returned collection to those with metadata values matching the query terms.
* @param entityType The entity type used to limit the returned collection
* @param options The [[FindListOptions]] object
* @param reRequestOnStale Whether or not the request should automatically be re-requested after
* the response becomes stale
* @param linksToFollow The array of [[FollowLinkConfig]]
* @return Observable<RemoteData<PaginatedList<Collection>>>
* collection list
*/
getAuthorizedCollectionByEntityType(
query: string,
entityType: string,
options: FindListOptions = {},
reRequestOnStale = true,
...linksToFollow: FollowLinkConfig<Collection>[]): Observable<RemoteData<PaginatedList<Collection>>> {
const searchHref = 'findSubmitAuthorizedByEntityType';
options = Object.assign({}, options, {
searchParams: [
new RequestParam('query', query),
new RequestParam('entityType', entityType)
]
});
return this.searchBy(searchHref, options, true, reRequestOnStale, ...linksToFollow).pipe(
filter((collections: RemoteData<PaginatedList<Collection>>) => !collections.isResponsePending));
}
/**
* Get all collections the user is authorized to submit to, by community
*
* @param communityId The community id
* @param query limit the returned collection to those with metadata values matching the query terms.
* @param options The [[FindListOptions]] object
* @param reRequestOnStale Whether or not the request should automatically be re-
* requested after the response becomes stale
* @return Observable<RemoteData<PaginatedList<Collection>>>
* collection list
*/
getAuthorizedCollectionByCommunity(communityId: string, query: string, options: FindListOptions = {}): Observable<RemoteData<PaginatedList<Collection>>> {
getAuthorizedCollectionByCommunity(communityId: string, query: string, options: FindListOptions = {}, reRequestOnStale = true,): Observable<RemoteData<PaginatedList<Collection>>> {
const searchHref = 'findSubmitAuthorizedByCommunity';
options = Object.assign({}, options, {
searchParams: [
@@ -102,7 +129,38 @@ export class CollectionDataService extends ComColDataService<Collection> {
]
});
return this.searchBy(searchHref, options).pipe(
return this.searchBy(searchHref, options, reRequestOnStale).pipe(
filter((collections: RemoteData<PaginatedList<Collection>>) => !collections.isResponsePending));
}
/**
* Get all collections the user is authorized to submit to, by community and has the metadata
*
* @param communityId The community id
* @param entityType The entity type used to limit the returned collection
* @param options The [[FindListOptions]] object
* @param reRequestOnStale Whether or not the request should automatically be re-requested after
* the response becomes stale
* @param linksToFollow The array of [[FollowLinkConfig]]
* @return Observable<RemoteData<PaginatedList<Collection>>>
* collection list
*/
getAuthorizedCollectionByCommunityAndEntityType(
communityId: string,
entityType: string,
options: FindListOptions = {},
reRequestOnStale = true,
...linksToFollow: FollowLinkConfig<Collection>[]): Observable<RemoteData<PaginatedList<Collection>>> {
const searchHref = 'findSubmitAuthorizedByCommunityAndEntityType';
const searchParams = [
new RequestParam('uuid', communityId),
new RequestParam('entityType', entityType)
];
options = Object.assign({}, options, {
searchParams: searchParams
});
return this.searchBy(searchHref, options, true, reRequestOnStale, ...linksToFollow).pipe(
filter((collections: RemoteData<PaginatedList<Collection>>) => !collections.isResponsePending));
}
@@ -138,7 +196,7 @@ export class CollectionDataService extends ComColDataService<Collection> {
* Get the collection's content harvester
* @param collectionId
*/
getContentSource(collectionId: string): Observable<RemoteData<ContentSource>> {
getContentSource(collectionId: string, useCachedVersionIfAvailable = true): Observable<RemoteData<ContentSource>> {
const href$ = this.getHarvesterEndpoint(collectionId).pipe(
isNotEmptyOperator(),
take(1)
@@ -146,7 +204,7 @@ export class CollectionDataService extends ComColDataService<Collection> {
href$.subscribe((href: string) => {
const request = new ContentSourceRequest(this.requestService.generateRequestId(), href);
this.requestService.send(request, true);
this.requestService.send(request, useCachedVersionIfAvailable);
});
return this.rdbService.buildSingle<ContentSource>(href$);
@@ -208,10 +266,20 @@ export class CollectionDataService extends ComColDataService<Collection> {
}
/**
* Returns {@link RemoteData} of {@link Collection} that is the owing collection of the given item
* Returns {@link RemoteData} of {@link Collection} that is the owning collection of the given item
* @param item Item we want the owning collection of
*/
findOwningCollectionFor(item: Item): Observable<RemoteData<Collection>> {
return this.findByHref(item._links.owningCollection.href);
}
/**
* Get a list of mapped collections for the given item.
* @param item Item for which the mapped collections should be retrieved.
* @param findListOptions Pagination and search options.
*/
findMappedCollectionsFor(item: Item, findListOptions?: FindListOptions): Observable<RemoteData<PaginatedList<Collection>>> {
return this.findAllByHref(item._links.mappedCollections.href, findListOptions);
}
}

View File

@@ -10,13 +10,14 @@ import { NotificationsService } from '../../shared/notifications/notifications.s
import { HttpClient } from '@angular/common/http';
import { DefaultChangeAnalyzer } from './default-change-analyzer.service';
import { Injectable } from '@angular/core';
import { FindListOptions } from './request.models';
import { Observable } from 'rxjs';
import { switchMap, take, map } from 'rxjs/operators';
import { filter, map, switchMap, take } from 'rxjs/operators';
import { RemoteData } from './remote-data';
import { RelationshipType } from '../shared/item-relationships/relationship-type.model';
import { PaginatedList } from './paginated-list.model';
import { ItemType } from '../shared/item-relationships/item-type.model';
import { getRemoteDataPayload, getFirstSucceededRemoteData } from '../shared/operators';
import { getFirstSucceededRemoteData, getRemoteDataPayload } from '../shared/operators';
import { RelationshipTypeService } from './relationship-type.service';
/**
@@ -56,7 +57,7 @@ export class EntityTypeService extends DataService<ItemType> {
/**
* Check whether a given entity type is the left type of a given relationship type, as an observable boolean
* @param relationshipType the relationship type for which to check whether the given entity type is the left type
* @param entityType the entity type for which to check whether it is the left type of the given relationship type
* @param itemType the entity type for which to check whether it is the left type of the given relationship type
*/
isLeftType(relationshipType: RelationshipType, itemType: ItemType): Observable<boolean> {
@@ -67,6 +68,73 @@ export class EntityTypeService extends DataService<ItemType> {
);
}
/**
* Returns a list of entity types for which there is at least one collection in which the user is authorized to submit
*
* @param {FindListOptions} options
*/
getAllAuthorizedRelationshipType(options: FindListOptions = {}): Observable<RemoteData<PaginatedList<ItemType>>> {
const searchHref = 'findAllByAuthorizedCollection';
return this.searchBy(searchHref, options).pipe(
filter((type: RemoteData<PaginatedList<ItemType>>) => !type.isResponsePending));
}
/**
* Used to verify if there are one or more entities available
*/
hasMoreThanOneAuthorized(): Observable<boolean> {
const findListOptions: FindListOptions = {
elementsPerPage: 2,
currentPage: 1
};
return this.getAllAuthorizedRelationshipType(findListOptions).pipe(
map((result: RemoteData<PaginatedList<ItemType>>) => {
let output: boolean;
if (result.payload) {
output = ( result.payload.page.length > 1 );
} else {
output = false;
}
return output;
})
);
}
/**
* It returns a list of entity types for which there is at least one collection
* in which the user is authorized to submit supported by at least one external data source provider
*
* @param {FindListOptions} options
*/
getAllAuthorizedRelationshipTypeImport(options: FindListOptions = {}): Observable<RemoteData<PaginatedList<ItemType>>> {
const searchHref = 'findAllByAuthorizedExternalSource';
return this.searchBy(searchHref, options).pipe(
filter((type: RemoteData<PaginatedList<ItemType>>) => !type.isResponsePending));
}
/**
* Used to verify if there are one or more entities available. To use with external source import.
*/
hasMoreThanOneAuthorizedImport(): Observable<boolean> {
const findListOptions: FindListOptions = {
elementsPerPage: 2,
currentPage: 1
};
return this.getAllAuthorizedRelationshipTypeImport(findListOptions).pipe(
map((result: RemoteData<PaginatedList<ItemType>>) => {
let output: boolean;
if (result.payload) {
output = ( result.payload.page.length > 1 );
} else {
output = false;
}
return output;
})
);
}
/**
* Get the allowed relationship types for an entity type
* @param entityTypeId

View File

@@ -7,7 +7,7 @@ import { PostRequest } from './request.models';
import { Registration } from '../shared/registration.model';
import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub';
import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils';
import { of as observableOf } from 'rxjs/internal/observable/of';
import { of as observableOf } from 'rxjs';
import { TestScheduler } from 'rxjs/testing';
describe('EpersonRegistrationService', () => {

View File

@@ -14,6 +14,7 @@ export enum FeatureID {
IsCollectionAdmin = 'isCollectionAdmin',
IsCommunityAdmin = 'isCommunityAdmin',
CanDownload = 'canDownload',
CanRequestACopy = 'canRequestACopy',
CanManageVersions = 'canManageVersions',
CanManageBitstreamBundles = 'canManageBitstreamBundles',
CanManageRelationships = 'canManageRelationships',
@@ -21,4 +22,7 @@ export enum FeatureID {
CanManagePolicies = 'canManagePolicies',
CanMakePrivate = 'canMakePrivate',
CanMove = 'canMove',
CanEditVersion = 'canEditVersion',
CanDeleteVersion = 'canDeleteVersion',
CanCreateVersion = 'canCreateVersion',
}

View File

@@ -14,7 +14,7 @@ import { FindListOptions } from './request.models';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { dataService } from '../cache/builders/build-decorators';
import { RemoteData } from './remote-data';
import { Observable } from 'rxjs/internal/Observable';
import { Observable } from 'rxjs';
import { PaginatedList } from './paginated-list.model';
import { ITEM_TYPE } from '../shared/item-relationships/item-type.resource-type';
import { LICENSE } from '../shared/license.resource-type';

View File

@@ -31,7 +31,7 @@ describe('ItemDataService', () => {
},
removeByHrefSubstring(href: string) {
// Do nothing
}
},
}) as RequestService;
const rdbService = getMockRemoteDataBuildService();
@@ -184,4 +184,14 @@ describe('ItemDataService', () => {
});
});
describe('when cache is invalidated', () => {
beforeEach(() => {
service = initTestService();
});
it('should call setStaleByHrefSubstring', () => {
service.invalidateItemCache('uuid');
expect(requestService.setStaleByHrefSubstring).toHaveBeenCalledWith('item/uuid');
});
});
});

View File

@@ -59,6 +59,7 @@ export class ItemDataService extends DataService<Item> {
* Get the endpoint for browsing items
* (When options.sort.field is empty, the default field to browse by will be 'dc.date.issued')
* @param {FindListOptions} options
* @param linkPath
* @returns {Observable<string>}
*/
public getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable<string> {
@@ -287,4 +288,13 @@ export class ItemDataService extends DataService<Item> {
switchMap((url: string) => this.halService.getEndpoint('bitstreams', `${url}/${itemId}`))
);
}
/**
* Invalidate the cache of the item
* @param itemUUID
*/
invalidateItemCache(itemUUID: string) {
this.requestService.setStaleByHrefSubstring('item/' + itemUUID);
}
}

View File

@@ -0,0 +1,95 @@
import { ItemRequestDataService } from './item-request-data.service';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { RequestService } from './request.service';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { of as observableOf } from 'rxjs';
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { ItemRequest } from '../shared/item-request.model';
import { PostRequest } from './request.models';
import { RequestCopyEmail } from '../../request-copy/email-request-copy/request-copy-email.model';
import { RestRequestMethod } from './rest-request-method';
describe('ItemRequestDataService', () => {
let service: ItemRequestDataService;
let requestService: RequestService;
let rdbService: RemoteDataBuildService;
let halService: HALEndpointService;
const restApiEndpoint = 'rest/api/endpoint/';
const requestId = 'request-id';
let itemRequest: ItemRequest;
beforeEach(() => {
itemRequest = Object.assign(new ItemRequest(), {
token: 'item-request-token',
});
requestService = jasmine.createSpyObj('requestService', {
generateRequestId: requestId,
send: '',
});
rdbService = jasmine.createSpyObj('rdbService', {
buildFromRequestUUID: createSuccessfulRemoteDataObject$(itemRequest),
});
halService = jasmine.createSpyObj('halService', {
getEndpoint: observableOf(restApiEndpoint),
});
service = new ItemRequestDataService(requestService, rdbService, null, null, halService, null, null, null);
});
describe('requestACopy', () => {
it('should send a POST request containing the provided item request', (done) => {
service.requestACopy(itemRequest).subscribe(() => {
expect(requestService.send).toHaveBeenCalledWith(new PostRequest(requestId, restApiEndpoint, itemRequest));
done();
});
});
});
describe('grant', () => {
let email: RequestCopyEmail;
beforeEach(() => {
email = new RequestCopyEmail('subject', 'message');
});
it('should send a PUT request containing the correct properties', (done) => {
service.grant(itemRequest.token, email, true).subscribe(() => {
expect(requestService.send).toHaveBeenCalledWith(jasmine.objectContaining({
method: RestRequestMethod.PUT,
body: JSON.stringify({
acceptRequest: true,
responseMessage: email.message,
subject: email.subject,
suggestOpenAccess: true,
}),
}));
done();
});
});
});
describe('deny', () => {
let email: RequestCopyEmail;
beforeEach(() => {
email = new RequestCopyEmail('subject', 'message');
});
it('should send a PUT request containing the correct properties', (done) => {
service.deny(itemRequest.token, email).subscribe(() => {
expect(requestService.send).toHaveBeenCalledWith(jasmine.objectContaining({
method: RestRequestMethod.PUT,
body: JSON.stringify({
acceptRequest: false,
responseMessage: email.message,
subject: email.subject,
suggestOpenAccess: false,
}),
}));
done();
});
});
});
});

View File

@@ -0,0 +1,131 @@
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { distinctUntilChanged, filter, find, map } from 'rxjs/operators';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { getFirstCompletedRemoteData, sendRequest } from '../shared/operators';
import { RemoteData } from './remote-data';
import { PostRequest, PutRequest } from './request.models';
import { RequestService } from './request.service';
import { ItemRequest } from '../shared/item-request.model';
import { hasValue, isNotEmpty } from '../../shared/empty.util';
import { DataService } from './data.service';
import { Store } from '@ngrx/store';
import { CoreState } from '../core.reducers';
import { ObjectCacheService } from '../cache/object-cache.service';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { DefaultChangeAnalyzer } from './default-change-analyzer.service';
import { RequestCopyEmail } from '../../request-copy/email-request-copy/request-copy-email.model';
import { HttpOptions } from '../dspace-rest/dspace-rest.service';
/**
* A service responsible for fetching/sending data from/to the REST API on the itemrequests endpoint
*/
@Injectable(
{
providedIn: 'root',
}
)
export class ItemRequestDataService extends DataService<ItemRequest> {
protected linkPath = 'itemrequests';
constructor(
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected store: Store<CoreState>,
protected objectCache: ObjectCacheService,
protected halService: HALEndpointService,
protected notificationsService: NotificationsService,
protected http: HttpClient,
protected comparator: DefaultChangeAnalyzer<ItemRequest>,
) {
super();
}
getItemRequestEndpoint(): Observable<string> {
return this.halService.getEndpoint(this.linkPath);
}
/**
* Get the endpoint for an {@link ItemRequest} by their token
* @param token
*/
getItemRequestEndpointByToken(token: string): Observable<string> {
return this.halService.getEndpoint(this.linkPath).pipe(
filter((href: string) => isNotEmpty(href)),
map((href: string) => `${href}/${token}`));
}
/**
* Request a copy of an item
* @param itemRequest
*/
requestACopy(itemRequest: ItemRequest): Observable<RemoteData<ItemRequest>> {
const requestId = this.requestService.generateRequestId();
const href$ = this.getItemRequestEndpoint();
href$.pipe(
find((href: string) => hasValue(href)),
map((href: string) => {
const request = new PostRequest(requestId, href, itemRequest);
this.requestService.send(request);
})
).subscribe();
return this.rdbService.buildFromRequestUUID<ItemRequest>(requestId).pipe(
getFirstCompletedRemoteData()
);
}
/**
* Deny the request of an item
* @param token Token of the {@link ItemRequest}
* @param email Email to send back to the user requesting the item
*/
deny(token: string, email: RequestCopyEmail): Observable<RemoteData<ItemRequest>> {
return this.process(token, email, false);
}
/**
* Grant the request of an item
* @param token Token of the {@link ItemRequest}
* @param email Email to send back to the user requesting the item
* @param suggestOpenAccess Whether or not to suggest the item to become open access
*/
grant(token: string, email: RequestCopyEmail, suggestOpenAccess = false): Observable<RemoteData<ItemRequest>> {
return this.process(token, email, true, suggestOpenAccess);
}
/**
* Process the request of an item
* @param token Token of the {@link ItemRequest}
* @param email Email to send back to the user requesting the item
* @param grant Grant or deny the request (true = grant, false = deny)
* @param suggestOpenAccess Whether or not to suggest the item to become open access
*/
process(token: string, email: RequestCopyEmail, grant: boolean, suggestOpenAccess = false): Observable<RemoteData<ItemRequest>> {
const requestId = this.requestService.generateRequestId();
this.getItemRequestEndpointByToken(token).pipe(
distinctUntilChanged(),
map((endpointURL: string) => {
const options: HttpOptions = Object.create({});
let headers = new HttpHeaders();
headers = headers.append('Content-Type', 'application/json');
options.headers = headers;
return new PutRequest(requestId, endpointURL, JSON.stringify({
acceptRequest: grant,
responseMessage: email.message,
subject: email.subject,
suggestOpenAccess,
}), options);
}),
sendRequest(this.requestService)).subscribe();
return this.rdbService.buildFromRequestUUID(requestId);
}
}

View File

@@ -15,13 +15,14 @@ import { HALEndpointService } from '../shared/hal-endpoint.service';
import { ItemType } from '../shared/item-relationships/item-type.model';
import { RelationshipType } from '../shared/item-relationships/relationship-type.model';
import { RELATIONSHIP_TYPE } from '../shared/item-relationships/relationship-type.resource-type';
import { getFirstSucceededRemoteData, getFirstCompletedRemoteData } from '../shared/operators';
import { getFirstSucceededRemoteData, getFirstCompletedRemoteData, getRemoteDataPayload } from '../shared/operators';
import { DataService } from './data.service';
import { DefaultChangeAnalyzer } from './default-change-analyzer.service';
import { ItemDataService } from './item-data.service';
import { PaginatedList } from './paginated-list.model';
import { RemoteData } from './remote-data';
import { RequestService } from './request.service';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
/**
* Check if one side of a RelationshipType is the ItemType with the given label
@@ -120,4 +121,33 @@ export class RelationshipTypeService extends DataService<RelationshipType> {
})
);
}
/**
* Search of the given RelationshipType if has the given itemTypes on its left and right sides.
* Returns an observable of the given RelationshipType if it matches, null if it doesn't
*
* @param type The RelationshipType to check
*/
searchByEntityType(type: string,useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<RelationshipType>[]): Observable<PaginatedList<RelationshipType>> {
return this.searchBy(
'byEntityType',
{
searchParams: [
{
fieldName: 'type',
fieldValue: type
},
{
fieldName: 'size',
fieldValue: 100
},
]
}, useCachedVersionIfAvailable,reRequestOnStale,...linksToFollow).pipe(
getFirstSucceededRemoteData(),
getRemoteDataPayload(),
) as Observable<PaginatedList<RelationshipType>>;
}
}

View File

@@ -469,4 +469,58 @@ export class RelationshipService extends DataService<Relationship> {
update(object: Relationship): Observable<RemoteData<Relationship>> {
return this.put(object);
}
/**
* Patch isn't supported on the relationship endpoint, so use put instead.
*
* @param typeId the relationship type id to apply as a filter to the returned relationships
* @param itemUuid The uuid of the item to be checked on the side defined by relationshipLabel
* @param relationshipLabel the name of the relation as defined from the side of the itemUuid
* @param arrayOfItemIds The uuid of the items to be found on the other side of returned relationships
*/
searchByItemsAndType(typeId: string,itemUuid: string,relationshipLabel: string, arrayOfItemIds: string[] ): Observable<RemoteData<PaginatedList<Relationship>>> {
const searchParams = [
{
fieldName: 'typeId',
fieldValue: typeId
},
{
fieldName: 'focusItem',
fieldValue: itemUuid
},
{
fieldName: 'relationshipLabel',
fieldValue: relationshipLabel
},
{
fieldName: 'size',
fieldValue: arrayOfItemIds.length
},
{
fieldName: 'embed',
fieldValue: 'leftItem'
},
{
fieldName: 'embed',
fieldValue: 'rightItem'
},
];
arrayOfItemIds.forEach( (itemId) => {
searchParams.push(
{
fieldName: 'relatedItem',
fieldValue: itemId
},
);
});
return this.searchBy(
'byItemsAndType',
{
searchParams: searchParams
}) as Observable<RemoteData<PaginatedList<Relationship>>>;
}
}

View File

@@ -0,0 +1,181 @@
import { HttpClient } from '@angular/common/http';
import { of as observableOf } from 'rxjs';
import { TestScheduler } from 'rxjs/testing';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { ObjectCacheService } from '../cache/object-cache.service';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { RequestService } from './request.service';
import { PageInfo } from '../shared/page-info.model';
import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { RequestEntry } from './request.reducer';
import { HrefOnlyDataService } from './href-only-data.service';
import { getMockHrefOnlyDataService } from '../../shared/mocks/href-only-data.service.mock';
import { Store } from '@ngrx/store';
import { CoreState } from '../core.reducers';
import { RestResponse } from '../cache/response.models';
import { cold, getTestScheduler, hot } from 'jasmine-marbles';
import { Item } from '../shared/item.model';
import { VersionDataService } from './version-data.service';
import { Version } from '../shared/version.model';
import { VersionHistory } from '../shared/version-history.model';
import { followLink } from '../../shared/utils/follow-link-config.model';
describe('VersionDataService test', () => {
let scheduler: TestScheduler;
let service: VersionDataService;
let requestService: RequestService;
let rdbService: RemoteDataBuildService;
let objectCache: ObjectCacheService;
let halService: HALEndpointService;
let hrefOnlyDataService: HrefOnlyDataService;
let responseCacheEntry: RequestEntry;
const item = Object.assign(new Item(), {
id: '1234-1234',
uuid: '1234-1234',
bundles: observableOf({}),
metadata: {
'dc.title': [
{
language: 'en_US',
value: 'This is just another title'
}
],
'dc.type': [
{
language: null,
value: 'Article'
}
],
'dc.contributor.author': [
{
language: 'en_US',
value: 'Smith, Donald'
}
],
'dc.date.issued': [
{
language: null,
value: '2015-06-26'
}
]
}
});
const itemRD = createSuccessfulRemoteDataObject(item);
const versionHistory = Object.assign(new VersionHistory(), {
id: '1',
draftVersion: true,
});
const mockVersion: Version = Object.assign(new Version(), {
item: createSuccessfulRemoteDataObject$(item),
versionhistory: createSuccessfulRemoteDataObject$(versionHistory),
version: 1,
});
const mockVersionRD = createSuccessfulRemoteDataObject(mockVersion);
const endpointURL = `https://rest.api/rest/api/versioning/versions`;
const findByIdRequestURL = `https://rest.api/rest/api/versioning/versions/${mockVersion.id}`;
const findByIdRequestURL$ = observableOf(findByIdRequestURL);
const requestUUID = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a';
objectCache = {} as ObjectCacheService;
const notificationsService = {} as NotificationsService;
const http = {} as HttpClient;
const comparator = {} as any;
const comparatorEntry = {} as any;
const store = {} as Store<CoreState>;
const pageInfo = new PageInfo();
function initTestService() {
hrefOnlyDataService = getMockHrefOnlyDataService();
return new VersionDataService(
requestService,
rdbService,
store,
objectCache,
halService,
notificationsService,
http,
comparatorEntry
);
}
describe('', () => {
beforeEach(() => {
scheduler = getTestScheduler();
halService = jasmine.createSpyObj('halService', {
getEndpoint: cold('a', { a: endpointURL })
});
responseCacheEntry = new RequestEntry();
responseCacheEntry.request = { href: 'https://rest.api/' } as any;
responseCacheEntry.response = new RestResponse(true, 200, 'Success');
requestService = jasmine.createSpyObj('requestService', {
generateRequestId: requestUUID,
send: true,
removeByHrefSubstring: {},
getByHref: observableOf(responseCacheEntry),
getByUUID: observableOf(responseCacheEntry),
});
rdbService = jasmine.createSpyObj('rdbService', {
buildSingle: hot('(a|)', {
a: mockVersionRD
})
});
service = initTestService();
spyOn((service as any), 'findByHref').and.callThrough();
spyOn((service as any), 'getIDHrefObs').and.returnValue(findByIdRequestURL$);
});
afterEach(() => {
service = null;
});
describe('getHistoryFromVersion', () => {
it('should proxy the call to DataService.findByHref', () => {
scheduler.schedule(() => service.getHistoryFromVersion(mockVersion, true, true));
scheduler.flush();
expect((service as any).findByHref).toHaveBeenCalledWith(findByIdRequestURL$, true, true, followLink('versionhistory'));
});
it('should return a VersionHistory', () => {
const result = service.getHistoryFromVersion(mockVersion, true, true);
const expected = cold('(a|)', {
a: versionHistory
});
expect(result).toBeObservable(expected);
});
it('should return an EMPTY observable when version is not given', () => {
const result = service.getHistoryFromVersion(null);
const expected = cold('|');
expect(result).toBeObservable(expected);
});
});
describe('getHistoryIdFromVersion', () => {
it('should return the version history id', () => {
spyOn((service as any), 'getHistoryFromVersion').and.returnValue(observableOf(versionHistory));
const result = service.getHistoryIdFromVersion(mockVersion);
const expected = cold('(a|)', {
a: versionHistory.id
});
expect(result).toBeObservable(expected);
});
});
});
});

View File

@@ -10,10 +10,14 @@ import { HALEndpointService } from '../shared/hal-endpoint.service';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { HttpClient } from '@angular/common/http';
import { DefaultChangeAnalyzer } from './default-change-analyzer.service';
import { FindListOptions } from './request.models';
import { Observable } from 'rxjs';
import { EMPTY, Observable } from 'rxjs';
import { dataService } from '../cache/builders/build-decorators';
import { VERSION } from '../shared/version.resource-type';
import { VersionHistory } from '../shared/version-history.model';
import { followLink } from '../../shared/utils/follow-link-config.model';
import { getFirstSucceededRemoteDataPayload } from '../shared/operators';
import { map, switchMap } from 'rxjs/operators';
import { isNotEmpty } from '../../shared/empty.util';
/**
* Service responsible for handling requests related to the Version object
@@ -36,9 +40,29 @@ export class VersionDataService extends DataService<Version> {
}
/**
* Get the endpoint for browsing versions
* Get the version history for the given version
* @param version
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
* no valid cached version. Defaults to true
* @param reRequestOnStale Whether or not the request should automatically be re-
* requested after the response becomes stale
*/
getBrowseEndpoint(options: FindListOptions = {}, linkPath?: string): Observable<string> {
return this.halService.getEndpoint(this.linkPath);
getHistoryFromVersion(version: Version, useCachedVersionIfAvailable = false, reRequestOnStale = true): Observable<VersionHistory> {
return isNotEmpty(version) ? this.findById(version.id, useCachedVersionIfAvailable, reRequestOnStale, followLink('versionhistory')).pipe(
getFirstSucceededRemoteDataPayload(),
switchMap((res: Version) => res.versionhistory),
getFirstSucceededRemoteDataPayload(),
) : EMPTY;
}
/**
* Get the ID of the version history for the given version
* @param version
*/
getHistoryIdFromVersion(version: Version): Observable<string> {
return this.getHistoryFromVersion(version).pipe(
map((versionHistory: VersionHistory) => versionHistory.id),
);
}
}

View File

@@ -6,6 +6,14 @@ import { NotificationsServiceStub } from '../../shared/testing/notifications-ser
import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub';
import { getMockRequestService } from '../../shared/mocks/request.service.mock';
import { VersionDataService } from './version-data.service';
import { fakeAsync, waitForAsync } from '@angular/core/testing';
import { VersionHistory } from '../shared/version-history.model';
import { Version } from '../shared/version.model';
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { createPaginatedList } from '../../shared/testing/utils.test';
import { Item } from '../shared/item.model';
import { of } from 'rxjs';
import SpyObj = jasmine.SpyObj;
const url = 'fake-url';
@@ -16,9 +24,97 @@ describe('VersionHistoryDataService', () => {
let notificationsService: any;
let rdbService: RemoteDataBuildService;
let objectCache: ObjectCacheService;
let versionService: VersionDataService;
let versionService: SpyObj<VersionDataService>;
let halService: any;
const versionHistoryId = 'version-history-id';
const versionHistoryDraftId = 'version-history-draft-id';
const version1Id = 'version-1-id';
const version2Id = 'version-1-id';
const item1Uuid = 'item-1-uuid';
const item2Uuid = 'item-2-uuid';
const versionHistory = Object.assign(new VersionHistory(), {
id: versionHistoryId,
draftVersion: false,
});
const versionHistoryDraft = Object.assign(new VersionHistory(), {
id: versionHistoryDraftId,
draftVersion: true,
});
const version1 = Object.assign(new Version(), {
id: version1Id,
version: 1,
created: new Date(2020, 1, 1),
summary: 'first version',
versionhistory: createSuccessfulRemoteDataObject$(versionHistory),
_links: {
self: {
href: 'version1-url',
},
},
});
const version2 = Object.assign(new Version(), {
id: version2Id,
version: 2,
summary: 'second version',
created: new Date(2020, 1, 2),
versionhistory: createSuccessfulRemoteDataObject$(versionHistory),
_links: {
self: {
href: 'version2-url',
},
},
});
const versions = [version1, version2];
versionHistory.versions = createSuccessfulRemoteDataObject$(createPaginatedList(versions));
const item1 = Object.assign(new Item(), {
uuid: item1Uuid,
handle: '123456789/1',
version: createSuccessfulRemoteDataObject$(version1),
_links: {
self: {
href: '/items/' + item2Uuid,
}
}
});
const item2 = Object.assign(new Item(), {
uuid: item2Uuid,
handle: '123456789/2',
version: createSuccessfulRemoteDataObject$(version2),
_links: {
self: {
href: '/items/' + item2Uuid,
}
}
});
const items = [item1, item2];
version1.item = createSuccessfulRemoteDataObject$(item1);
version2.item = createSuccessfulRemoteDataObject$(item2);
/**
* Create a VersionHistoryDataService used for testing
* @param requestEntry$ Supply a requestEntry to be returned by the REST API (optional)
*/
function createService(requestEntry$?) {
requestService = getMockRequestService(requestEntry$);
rdbService = jasmine.createSpyObj('rdbService', {
buildList: jasmine.createSpy('buildList'),
buildFromRequestUUID: jasmine.createSpy('buildFromRequestUUID'),
});
objectCache = jasmine.createSpyObj('objectCache', {
remove: jasmine.createSpy('remove')
});
versionService = jasmine.createSpyObj('objectCache', {
findByHref: jasmine.createSpy('findByHref'),
findAllByHref: jasmine.createSpy('findAllByHref'),
getHistoryFromVersion: jasmine.createSpy('getHistoryFromVersion'),
});
halService = new HALEndpointServiceStub(url);
notificationsService = new NotificationsServiceStub();
service = new VersionHistoryDataService(requestService, rdbService, null, objectCache, halService, notificationsService, versionService, null, null);
}
beforeEach(() => {
createService();
});
@@ -35,24 +131,70 @@ describe('VersionHistoryDataService', () => {
});
});
/**
* Create a VersionHistoryDataService used for testing
* @param requestEntry$ Supply a requestEntry to be returned by the REST API (optional)
*/
function createService(requestEntry$?) {
requestService = getMockRequestService(requestEntry$);
rdbService = jasmine.createSpyObj('rdbService', {
buildList: jasmine.createSpy('buildList')
describe('when getVersions is called', () => {
beforeEach(waitForAsync(() => {
service.getVersions(versionHistoryId);
}));
it('findAllByHref should have been called', () => {
expect(versionService.findAllByHref).toHaveBeenCalled();
});
objectCache = jasmine.createSpyObj('objectCache', {
remove: jasmine.createSpy('remove')
});
versionService = jasmine.createSpyObj('objectCache', {
findAllByHref: jasmine.createSpy('findAllByHref')
});
halService = new HALEndpointServiceStub(url);
notificationsService = new NotificationsServiceStub();
});
describe('when getBrowseEndpoint is called', () => {
it('should return the correct value', () => {
service.getBrowseEndpoint().subscribe((res) => {
expect(res).toBe(url + '/versionhistories');
});
});
});
describe('when getVersionsEndpoint is called', () => {
it('should return the correct value', () => {
service.getVersionsEndpoint(versionHistoryId).subscribe((res) => {
expect(res).toBe(url + '/versions');
});
});
});
describe('when cache is invalidated', () => {
it('should call setStaleByHrefSubstring', () => {
service.invalidateVersionHistoryCache(versionHistoryId);
expect(requestService.setStaleByHrefSubstring).toHaveBeenCalledWith('versioning/versionhistories/' + versionHistoryId);
});
});
describe('isLatest$', () => {
beforeEach(waitForAsync(() => {
spyOn(service, 'getLatestVersion$').and.returnValue(of(version2));
}));
it('should return false for version1', () => {
service.isLatest$(version1).subscribe((res) => {
expect(res).toBe(false);
});
});
it('should return true for version2', () => {
service.isLatest$(version2).subscribe((res) => {
expect(res).toBe(true);
});
});
});
describe('hasDraftVersion$', () => {
beforeEach(waitForAsync(() => {
versionService.findByHref.and.returnValue(createSuccessfulRemoteDataObject$<Version>(version1));
}));
it('should return false if draftVersion is false', fakeAsync(() => {
versionService.getHistoryFromVersion.and.returnValue(of(versionHistory));
service.hasDraftVersion$('href').subscribe((res) => {
expect(res).toBeFalse();
});
}));
it('should return true if draftVersion is true', fakeAsync(() => {
versionService.getHistoryFromVersion.and.returnValue(of(versionHistoryDraft));
service.hasDraftVersion$('href').subscribe((res) => {
expect(res).toBeTrue();
});
}));
});
service = new VersionHistoryDataService(requestService, rdbService, null, objectCache, halService, notificationsService, versionService, null, null);
}
});

View File

@@ -8,19 +8,30 @@ import { CoreState } from '../core.reducers';
import { ObjectCacheService } from '../cache/object-cache.service';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { HttpClient } from '@angular/common/http';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { DefaultChangeAnalyzer } from './default-change-analyzer.service';
import { FindListOptions } from './request.models';
import { Observable } from 'rxjs';
import { FindListOptions, PostRequest, RestRequest } from './request.models';
import { Observable, of } from 'rxjs';
import { PaginatedSearchOptions } from '../../shared/search/paginated-search-options.model';
import { RemoteData } from './remote-data';
import { PaginatedList } from './paginated-list.model';
import { Version } from '../shared/version.model';
import { map, switchMap } from 'rxjs/operators';
import { filter, map, switchMap, take } from 'rxjs/operators';
import { dataService } from '../cache/builders/build-decorators';
import { VERSION_HISTORY } from '../shared/version-history.resource-type';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { VersionDataService } from './version-data.service';
import { HttpOptions } from '../dspace-rest/dspace-rest.service';
import {
getAllSucceededRemoteData,
getFirstCompletedRemoteData,
getFirstSucceededRemoteDataPayload,
getRemoteDataPayload,
sendRequest
} from '../shared/operators';
import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
import { hasValueOperator } from '../../shared/empty.util';
import { Item } from '../shared/item.model';
/**
* Service responsible for handling requests related to the VersionHistory object
@@ -79,4 +90,129 @@ export class VersionHistoryDataService extends DataService<VersionHistory> {
return this.versionDataService.findAllByHref(hrefObs, undefined, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
}
/**
* Create a new version for an item
* @param itemHref the item for which create a new version
* @param summary the summary of the new version
*/
createVersion(itemHref: string, summary: string): Observable<RemoteData<Version>> {
const requestOptions: HttpOptions = Object.create({});
let requestHeaders = new HttpHeaders();
requestHeaders = requestHeaders.append('Content-Type', 'text/uri-list');
requestOptions.headers = requestHeaders;
return this.halService.getEndpoint(this.versionsEndpoint).pipe(
take(1),
map((endpointUrl: string) => (summary?.length > 0) ? `${endpointUrl}?summary=${summary}` : `${endpointUrl}`),
map((endpointURL: string) => new PostRequest(this.requestService.generateRequestId(), endpointURL, itemHref, requestOptions)),
sendRequest(this.requestService),
switchMap((restRequest: RestRequest) => this.rdbService.buildFromRequestUUID(restRequest.uuid)),
getFirstCompletedRemoteData()
) as Observable<RemoteData<Version>>;
}
/**
* Get the latest version in a version history
* @param versionHistory
*/
getLatestVersionFromHistory$(versionHistory: VersionHistory): Observable<Version> {
// Pagination options to fetch a single version on the first page (this is the latest version in the history)
const latestVersionOptions = Object.assign(new PaginationComponentOptions(), {
id: 'item-newest-version-options',
currentPage: 1,
pageSize: 1
});
const latestVersionSearch = new PaginatedSearchOptions({pagination: latestVersionOptions});
return this.getVersions(versionHistory.id, latestVersionSearch, false, true, followLink('item')).pipe(
getAllSucceededRemoteData(),
getRemoteDataPayload(),
hasValueOperator(),
filter((versions) => versions.page.length > 0),
map((versions) => versions.page[0])
);
}
/**
* Get the latest version (return null if the specified version is null)
* @param version
*/
getLatestVersion$(version: Version): Observable<Version> {
// retrieve again version, including with versionHistory
return version.id ? this.versionDataService.findById(version.id, false, true, followLink('versionhistory')).pipe(
getFirstSucceededRemoteDataPayload(),
switchMap((res) => res.versionhistory),
getFirstSucceededRemoteDataPayload(),
switchMap((versionHistoryRD) => this.getLatestVersionFromHistory$(versionHistoryRD)),
) : of(null);
}
/**
* Check if the given version is the latest (return null if `version` is null)
* @param version
* @returns `true` if the specified version is the latest one, `false` otherwise, or `null` if the specified version is null
*/
isLatest$(version: Version): Observable<boolean> {
return version ? this.getLatestVersion$(version).pipe(
take(1),
switchMap((latestVersion) => of(version.version === latestVersion.version))
) : of(null);
}
/**
* Check if a worskpace item exists in the version history (return null if there is no version history)
* @param versionHref the href of the version
* @returns `true` if a workspace item exists, `false` otherwise, or `null` if a version history does not exist
*/
hasDraftVersion$(versionHref: string): Observable<boolean> {
return this.versionDataService.findByHref(versionHref, true, true, followLink('versionhistory')).pipe(
getFirstCompletedRemoteData(),
switchMap((res) => {
if (res.hasSucceeded && !res.hasNoContent) {
return of(res).pipe(
getFirstSucceededRemoteDataPayload(),
switchMap((version) => this.versionDataService.getHistoryFromVersion(version)),
map((versionHistory) => versionHistory ? versionHistory.draftVersion : false),
);
} else {
return of(false);
}
}),
);
}
/**
* Get the item of the latest version in a version history
* @param versionHistory
*/
getLatestVersionItemFromHistory$(versionHistory: VersionHistory): Observable<Item> {
return this.getLatestVersionFromHistory$(versionHistory).pipe(
switchMap((newLatestVersion) => newLatestVersion.item),
getFirstSucceededRemoteDataPayload(),
);
}
/**
* Get the item of the latest version from any version in the version history
* @param version
*/
getVersionHistoryFromVersion$(version: Version): Observable<VersionHistory> {
return this.versionDataService.getHistoryIdFromVersion(version).pipe(
take(1),
switchMap((res) => this.findById(res)),
getFirstSucceededRemoteDataPayload(),
);
}
/**
* Invalidate the cache of the version history
* @param versionHistoryID
*/
invalidateVersionHistoryCache(versionHistoryID: string) {
this.requestService.setStaleByHrefSubstring('versioning/versionhistories/' + versionHistoryID);
}
}

View File

@@ -56,11 +56,11 @@ describe('EPersonDataService', () => {
}
function init() {
restEndpointURL = 'https://dspace.4science.it/dspace-spring-rest/api/eperson';
restEndpointURL = 'https://rest.api/dspace-spring-rest/api/eperson';
epersonsEndpoint = `${restEndpointURL}/epersons`;
epeople = [EPersonMock, EPersonMock2];
epeople$ = createSuccessfulRemoteDataObject$(createPaginatedList([epeople]));
rdbService = getMockRemoteDataBuildServiceHrefMap(undefined, { 'https://dspace.4science.it/dspace-spring-rest/api/eperson/epersons': epeople$ });
rdbService = getMockRemoteDataBuildServiceHrefMap(undefined, { 'https://rest.api/dspace-spring-rest/api/eperson/epersons': epeople$ });
halService = new HALEndpointServiceStub(restEndpointURL);
TestBed.configureTestingModule({

View File

@@ -21,6 +21,7 @@ describe('LocaleService test suite', () => {
let spyOnSet;
let authService;
let routeService;
let document;
authService = jasmine.createSpyObj('AuthService', {
isAuthenticated: jasmine.createSpy('isAuthenticated'),
@@ -43,6 +44,7 @@ describe('LocaleService test suite', () => {
{ provide: CookieService, useValue: new CookieServiceMock() },
{ provide: AuthService, userValue: authService },
{ provide: RouteService, useValue: routeServiceStub },
{ provide: Document, useValue: document },
]
});
}));
@@ -52,7 +54,8 @@ describe('LocaleService test suite', () => {
translateService = TestBed.inject(TranslateService);
routeService = TestBed.inject(RouteService);
window = new NativeWindowRef();
service = new LocaleService(window, cookieService, translateService, authService, routeService);
document = { documentElement: { lang: 'en' } };
service = new LocaleService(window, cookieService, translateService, authService, routeService, document);
serviceAsAny = service;
spyOnGet = spyOn(cookieService, 'get');
spyOnSet = spyOn(cookieService, 'set');
@@ -114,6 +117,12 @@ describe('LocaleService test suite', () => {
expect(translateService.use).toHaveBeenCalledWith('es');
expect(service.saveLanguageCodeToCookie).toHaveBeenCalledWith('es');
});
it('should set the current language on the html tag', () => {
spyOn(service, 'getCurrentLanguageCode').and.returnValue('es');
service.setCurrentLanguageCode();
expect((service as any).document.documentElement.lang).toEqual('es');
});
});
describe('', () => {

View File

@@ -10,6 +10,7 @@ import { combineLatest, Observable, of as observableOf } from 'rxjs';
import { map, mergeMap, take } from 'rxjs/operators';
import { NativeWindowRef, NativeWindowService } from '../services/window.service';
import { RouteService } from '../services/route.service';
import { DOCUMENT } from '@angular/common';
export const LANG_COOKIE = 'dsLanguage';
@@ -38,7 +39,9 @@ export class LocaleService {
protected cookie: CookieService,
protected translate: TranslateService,
protected authService: AuthService,
protected routeService: RouteService) {
protected routeService: RouteService,
@Inject(DOCUMENT) private document: any
) {
}
/**
@@ -148,6 +151,7 @@ export class LocaleService {
}
this.translate.use(lang);
this.saveLanguageCodeToCookie(lang);
this.document.documentElement.lang = lang;
}
/**

View File

@@ -10,7 +10,6 @@ import { hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util';
import { difference } from '../../shared/object.util';
import { isNumeric } from 'rxjs/internal-compatibility';
@Injectable({
providedIn: 'root',
})

View File

@@ -0,0 +1,26 @@
import { ContentSourceSetSerializer } from './content-source-set-serializer';
describe('ContentSourceSetSerializer', () => {
let serializer: ContentSourceSetSerializer;
beforeEach(() => {
serializer = new ContentSourceSetSerializer();
});
describe('Serialize', () => {
it('should return all when the value is empty', () => {
expect(serializer.Serialize('')).toEqual('all');
});
it('should return the value when it is not empty', () => {
expect(serializer.Serialize('test-value')).toEqual('test-value');
});
});
describe('Deserialize', () => {
it('should return an empty value when the value is \'all\'', () => {
expect(serializer.Deserialize('all')).toEqual('');
});
it('should return the value when it is not \'all\'', () => {
expect(serializer.Deserialize('test-value')).toEqual('test-value');
});
});
});

View File

@@ -0,0 +1,31 @@
import { isEmpty } from '../../shared/empty.util';
/**
* Serializer to create convert the 'all' value supported by the server to an empty string and vice versa.
*/
export class ContentSourceSetSerializer {
/**
* Method to serialize a setId
* @param {string} setId
* @returns {string} the provided set ID, unless when an empty set ID is provided. In that case, 'all' will be returned.
*/
Serialize(setId: string): any {
if (isEmpty(setId)) {
return 'all';
}
return setId;
}
/**
* Method to deserialize a setId
* @param {string} setId
* @returns {string} the provided set ID. When 'all' is provided, an empty set ID will be returned.
*/
Deserialize(setId: string): string {
if (setId === 'all') {
return '';
}
return setId;
}
}

View File

@@ -1,4 +1,4 @@
import { autoserializeAs, deserializeAs, deserialize } from 'cerialize';
import { autoserializeAs, deserialize, deserializeAs, serializeAs } from 'cerialize';
import { HALLink } from './hal-link.model';
import { MetadataConfig } from './metadata-config.model';
import { CacheableObject } from '../cache/object-cache.reducer';
@@ -6,6 +6,7 @@ import { typedObject } from '../cache/builders/build-decorators';
import { CONTENT_SOURCE } from './content-source.resource-type';
import { excludeFromEquals } from '../utilities/equals.decorators';
import { ResourceType } from './resource-type';
import { ContentSourceSetSerializer } from './content-source-set-serializer';
/**
* The type of content harvesting used
@@ -49,7 +50,8 @@ export class ContentSource extends CacheableObject {
/**
* OAI Specific set ID
*/
@autoserializeAs('oai_set_id')
@deserializeAs(new ContentSourceSetSerializer(), 'oai_set_id')
@serializeAs(new ContentSourceSetSerializer(), 'oai_set_id')
oaiSetId: string;
/**
@@ -70,6 +72,30 @@ export class ContentSource extends CacheableObject {
*/
metadataConfigs: MetadataConfig[];
/**
* The current harvest status
*/
@autoserializeAs('harvest_status')
harvestStatus: string;
/**
* The last's harvest start time
*/
@autoserializeAs('harvest_start_time')
harvestStartTime: string;
/**
* When the collection was last harvested
*/
@autoserializeAs('last_harvested')
lastHarvested: string;
/**
* The current harvest message
*/
@autoserializeAs('harvest_message')
message: string;
/**
* The {@link HALLink}s for this ContentSource
*/

View File

@@ -1,10 +1,15 @@
import { autoserialize, deserialize } from 'cerialize';
import { typedObject } from '../cache/builders/build-decorators';
import { link, typedObject } from '../cache/builders/build-decorators';
import { CacheableObject } from '../cache/object-cache.reducer';
import { excludeFromEquals } from '../utilities/equals.decorators';
import { EXTERNAL_SOURCE } from './external-source.resource-type';
import { HALLink } from './hal-link.model';
import { ResourceType } from './resource-type';
import { RemoteData } from '../data/remote-data';
import { PaginatedList } from '../data/paginated-list.model';
import { Observable } from 'rxjs';
import { ITEM_TYPE } from './item-relationships/item-type.resource-type';
import { ItemType } from './item-relationships/item-type.model';
/**
* Model class for an external source
@@ -38,6 +43,13 @@ export class ExternalSource extends CacheableObject {
@autoserialize
hierarchical: boolean;
/**
* The list of entity types that are compatible with this external source
* Will be undefined unless the entityTypes {@link HALLink} has been resolved.
*/
@link(ITEM_TYPE, true)
entityTypes?: Observable<RemoteData<PaginatedList<ItemType>>>;
/**
* The {@link HALLink}s for this ExternalSource
*/
@@ -45,5 +57,6 @@ export class ExternalSource extends CacheableObject {
_links: {
self: HALLink;
entries: HALLink;
entityTypes: HALLink;
};
}

View File

@@ -5,7 +5,7 @@ import { map, take } from 'rxjs/operators';
import { NativeWindowRef, NativeWindowService } from '../services/window.service';
import { URLCombiner } from '../url-combiner/url-combiner';
import { hasValue } from '../../shared/empty.util';
import { Observable } from 'rxjs/internal/Observable';
import { Observable } from 'rxjs';
/**
* Provides utility methods to save files on the client-side.

View File

@@ -6,5 +6,9 @@ import { ResourceType } from '../resource-type';
* Needs to be in a separate file to prevent circular
* dependencies in webpack.
*/
export const ITEM_TYPE = new ResourceType('entitytype');
/**
* The unset entity type
*/
export const NONE_ENTITY_TYPE = 'none';

View File

@@ -0,0 +1,90 @@
import { autoserialize, deserialize } from 'cerialize';
import { typedObject } from '../cache/builders/build-decorators';
import { excludeFromEquals } from '../utilities/equals.decorators';
import { ResourceType } from './resource-type';
import { ITEM_REQUEST } from './item-request.resource-type';
import { CacheableObject } from '../cache/object-cache.reducer';
import { HALLink } from './hal-link.model';
/**
* Model class for an ItemRequest
*/
@typedObject
export class ItemRequest implements CacheableObject {
static type = ITEM_REQUEST;
/**
* The object type
*/
@excludeFromEquals
@autoserialize
type: ResourceType;
/**
* opaque string which uniquely identifies this request
*/
@autoserialize
token: string;
/**
* true if the request is for all bitstreams of the item.
*/
@autoserialize
allfiles: boolean;
/**
* email address of the person requesting the files.
*/
@autoserialize
requestEmail: string;
/**
* Human-readable name of the person requesting the files.
*/
@autoserialize
requestName: string;
/**
* arbitrary message provided by the person requesting the files.
*/
@autoserialize
requestMessage: string;
/**
* date that the request was recorded.
*/
@autoserialize
requestDate: string;
/**
* true if the request has been granted.
*/
@autoserialize
acceptRequest: boolean;
/**
* date that the request was granted or denied.
*/
@autoserialize
decisionDate: string;
/**
* date on which the request is considered expired.
*/
@autoserialize
expires: string;
/**
* UUID of the requested Item.
*/
@autoserialize
itemId: string;
/**
* UUID of the requested bitstream.
*/
@autoserialize
bitstreamId: string;
/**
* The {@link HALLink}s for this ItemRequest
*/
@deserialize
_links: {
self: HALLink;
item: HALLink;
bitstream: HALLink;
};
}

View File

@@ -0,0 +1,9 @@
import { ResourceType } from './resource-type';
/**
* The resource type for ItemRequest.
*
* Needs to be in a separate file to prevent circular
* dependencies in webpack.
*/
export const ITEM_REQUEST = new ResourceType('itemrequest');

View File

@@ -1,4 +1,4 @@
import * as uuidv4 from 'uuid/v4';
import { v4 as uuidv4 } from 'uuid';
import { autoserialize, Serialize, Deserialize } from 'cerialize';
import { hasValue } from '../../shared/empty.util';
/* tslint:disable:max-classes-per-file */

View File

@@ -1,5 +1,5 @@
import { isUndefined } from '../../shared/empty.util';
import * as uuidv4 from 'uuid/v4';
import { v4 as uuidv4 } from 'uuid';
import { MetadataMap, MetadataValue, MetadataValueFilter, MetadatumViewModel } from './metadata.models';
import { Metadata } from './metadata.utils';

View File

@@ -26,7 +26,7 @@ import { hasNoValue, hasValue, isNotEmpty, isNotEmptyOperator } from '../../../s
import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
import { SearchConfig } from './search-filters/search-config.model';
import { SearchService } from './search.service';
import { of } from 'rxjs/internal/observable/of';
import { of } from 'rxjs';
import { PaginationService } from '../../pagination/pagination.service';
/**

View File

@@ -1,8 +1,8 @@
import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs';
import { combineLatest as observableCombineLatest, Observable } from 'rxjs';
import { Injectable, OnDestroy } from '@angular/core';
import { Router } from '@angular/router';
import { map, switchMap, take } from 'rxjs/operators';
import { followLink, FollowLinkConfig } from '../../../shared/utils/follow-link-config.model';
import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model';
import { LinkService } from '../../cache/builders/link.service';
import { PaginatedList } from '../../data/paginated-list.model';
import { ResponseParsingService } from '../../data/parsing.service';
@@ -13,7 +13,7 @@ import { DSpaceObject } from '../dspace-object.model';
import { GenericConstructor } from '../generic-constructor';
import { HALEndpointService } from '../hal-endpoint.service';
import { URLCombiner } from '../../url-combiner/url-combiner';
import { hasValue, isEmpty, isNotEmpty, hasValueOperator } from '../../../shared/empty.util';
import { hasValue, hasValueOperator, isNotEmpty } from '../../../shared/empty.util';
import { SearchOptions } from '../../../shared/search/search-options.model';
import { SearchFilterConfig } from '../../../shared/search/search-filter-config.model';
import { SearchResponseParsingService } from '../../data/search-response-parsing.service';
@@ -21,16 +21,11 @@ import { SearchObjects } from '../../../shared/search/search-objects.model';
import { FacetValueResponseParsingService } from '../../data/facet-value-response-parsing.service';
import { FacetConfigResponseParsingService } from '../../data/facet-config-response-parsing.service';
import { PaginatedSearchOptions } from '../../../shared/search/paginated-search-options.model';
import { Community } from '../community.model';
import { CommunityDataService } from '../../data/community-data.service';
import { ViewMode } from '../view-mode.model';
import { DSpaceObjectDataService } from '../../data/dspace-object-data.service';
import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service';
import {
getFirstSucceededRemoteData,
getFirstCompletedRemoteData,
getRemoteDataPayload
} from '../operators';
import { getFirstCompletedRemoteData, getRemoteDataPayload } from '../operators';
import { RouteService } from '../../services/route.service';
import { SearchResult } from '../../../shared/search/search-result.model';
import { ListableObject } from '../../../shared/object-collection/shared/listable-object.model';
@@ -395,48 +390,6 @@ export class SearchService implements OnDestroy {
return this.rdb.buildFromHref(href);
}
/**
* Request a list of DSpaceObjects that can be used as a scope, based on the current scope
* @param {string} scopeId UUID of the current scope, if the scope is empty, the repository wide scopes will be returned
* @returns {Observable<DSpaceObject[]>} Emits a list of DSpaceObjects which represent possible scopes
*/
getScopes(scopeId?: string): Observable<DSpaceObject[]> {
if (isEmpty(scopeId)) {
const top: Observable<Community[]> = this.communityService.findTop({ elementsPerPage: 9999 }).pipe(
getFirstSucceededRemoteData(),
map(
(communities: RemoteData<PaginatedList<Community>>) => communities.payload.page
)
);
return top;
}
const scopeObject: Observable<RemoteData<DSpaceObject>> = this.dspaceObjectService.findById(scopeId).pipe(getFirstSucceededRemoteData());
const scopeList: Observable<DSpaceObject[]> = scopeObject.pipe(
switchMap((dsoRD: RemoteData<DSpaceObject>) => {
if ((dsoRD.payload as any).type === Community.type.value) {
const community: Community = dsoRD.payload as Community;
this.linkService.resolveLinks(community, followLink('subcommunities'), followLink('collections'));
return observableCombineLatest([
community.subcommunities.pipe(getFirstCompletedRemoteData()),
community.collections.pipe(getFirstCompletedRemoteData())
]).pipe(
map(([subCommunities, collections]) => {
/*if this is a community, we also need to show the direct children*/
return [community, ...subCommunities.payload.page, ...collections.payload.page];
})
);
} else {
return observableOf([dsoRD.payload]);
}
}
));
return scopeList;
}
/**
* Requests the current view mode based on the current URL
* @returns {Observable<ViewMode>} The current view mode

View File

@@ -1,5 +1,5 @@
import { Injectable } from '@angular/core';
import * as uuidv4 from 'uuid/v4';
import { v4 as uuidv4 } from 'uuid';
@Injectable()
export class UUIDService {

View File

@@ -22,6 +22,7 @@ export class VersionHistory extends DSpaceObject {
_links: {
self: HALLink;
versions: HALLink;
draftVersion: HALLink;
};
/**
@@ -30,6 +31,24 @@ export class VersionHistory extends DSpaceObject {
@autoserialize
id: string;
/**
* The summary of this Version History
*/
@autoserialize
summary: string;
/**
* The name of the submitter of this Version History
*/
@autoserialize
submitterName: string;
/**
* Whether exist a workspace item
*/
@autoserialize
draftVersion: boolean;
/**
* The list of versions within this history
*/

View File

@@ -0,0 +1,150 @@
import { HttpClient } from '@angular/common/http';
import { of as observableOf } from 'rxjs';
import { TestScheduler } from 'rxjs/testing';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { ObjectCacheService } from '../cache/object-cache.service';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { RequestService } from '../data/request.service';
import { PageInfo } from '../shared/page-info.model';
import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils';
import { RequestEntry } from '../data/request.reducer';
import { HrefOnlyDataService } from '../data/href-only-data.service';
import { getMockHrefOnlyDataService } from '../../shared/mocks/href-only-data.service.mock';
import { Store } from '@ngrx/store';
import { CoreState } from '../core.reducers';
import { RestResponse } from '../cache/response.models';
import { cold, getTestScheduler, hot } from 'jasmine-marbles';
import { Item } from '../shared/item.model';
import { WorkflowItemDataService } from './workflowitem-data.service';
import { WorkflowItem } from './models/workflowitem.model';
describe('WorkflowItemDataService test', () => {
let scheduler: TestScheduler;
let service: WorkflowItemDataService;
let requestService: RequestService;
let rdbService: RemoteDataBuildService;
let objectCache: ObjectCacheService;
let halService: HALEndpointService;
let hrefOnlyDataService: HrefOnlyDataService;
let responseCacheEntry: RequestEntry;
const item = Object.assign(new Item(), {
id: '1234-1234',
uuid: '1234-1234',
bundles: observableOf({}),
metadata: {
'dc.title': [
{
language: 'en_US',
value: 'This is just another title'
}
],
'dc.type': [
{
language: null,
value: 'Article'
}
],
'dc.contributor.author': [
{
language: 'en_US',
value: 'Smith, Donald'
}
],
'dc.date.issued': [
{
language: null,
value: '2015-06-26'
}
]
}
});
const itemRD = createSuccessfulRemoteDataObject(item);
const wsi = Object.assign(new WorkflowItem(), { item: observableOf(itemRD), id: '1234', uuid: '1234' });
const wsiRD = createSuccessfulRemoteDataObject(wsi);
const endpointURL = `https://rest.api/rest/api/submission/workspaceitems`;
const searchRequestURL = `https://rest.api/rest/api/submission/workspaceitems/search/item?uuid=1234-1234`;
const searchRequestURL$ = observableOf(searchRequestURL);
const requestUUID = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a';
objectCache = {} as ObjectCacheService;
const notificationsService = {} as NotificationsService;
const http = {} as HttpClient;
const comparator = {} as any;
const comparatorEntry = {} as any;
const store = {} as Store<CoreState>;
const pageInfo = new PageInfo();
function initTestService() {
hrefOnlyDataService = getMockHrefOnlyDataService();
return new WorkflowItemDataService(
comparatorEntry,
halService,
http,
notificationsService,
requestService,
rdbService,
objectCache,
store
);
}
describe('', () => {
beforeEach(() => {
scheduler = getTestScheduler();
halService = jasmine.createSpyObj('halService', {
getEndpoint: cold('a', { a: endpointURL })
});
responseCacheEntry = new RequestEntry();
responseCacheEntry.request = { href: 'https://rest.api/' } as any;
responseCacheEntry.response = new RestResponse(true, 200, 'Success');
requestService = jasmine.createSpyObj('requestService', {
generateRequestId: requestUUID,
send: true,
removeByHrefSubstring: {},
getByHref: observableOf(responseCacheEntry),
getByUUID: observableOf(responseCacheEntry),
});
rdbService = jasmine.createSpyObj('rdbService', {
buildSingle: hot('a|', {
a: wsiRD
})
});
service = initTestService();
spyOn((service as any), 'findByHref').and.callThrough();
spyOn((service as any), 'getSearchByHref').and.returnValue(searchRequestURL$);
});
afterEach(() => {
service = null;
});
describe('findByItem', () => {
it('should proxy the call to DataService.findByHref', () => {
scheduler.schedule(() => service.findByItem('1234-1234', true, true, pageInfo));
scheduler.flush();
expect((service as any).findByHref).toHaveBeenCalledWith(searchRequestURL$, true, true);
});
it('should return a RemoteData<WorkspaceItem> for the search', () => {
const result = service.findByItem('1234-1234', true, true, pageInfo);
const expected = cold('a|', {
a: wsiRD
});
expect(result).toBeObservable(expected);
});
});
});
});

View File

@@ -9,7 +9,7 @@ import { DataService } from '../data/data.service';
import { RequestService } from '../data/request.service';
import { WorkflowItem } from './models/workflowitem.model';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { DeleteByIDRequest } from '../data/request.models';
import { DeleteByIDRequest, FindListOptions } from '../data/request.models';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { ObjectCacheService } from '../cache/object-cache.service';
import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service';
@@ -19,6 +19,9 @@ import { hasValue } from '../../shared/empty.util';
import { RemoteData } from '../data/remote-data';
import { NoContent } from '../shared/NoContent.model';
import { getFirstCompletedRemoteData } from '../shared/operators';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { WorkspaceItem } from './models/workspaceitem.model';
import { RequestParam } from '../cache/models/request-param.model';
/**
* A service that provides methods to make REST requests with workflow items endpoint.
@@ -27,6 +30,7 @@ import { getFirstCompletedRemoteData } from '../shared/operators';
@dataService(WorkflowItem.type)
export class WorkflowItemDataService extends DataService<WorkflowItem> {
protected linkPath = 'workflowitems';
protected searchByItemLinkPath = 'item';
protected responseMsToLive = 10 * 1000;
constructor(
@@ -86,4 +90,23 @@ export class WorkflowItemDataService extends DataService<WorkflowItem> {
return this.rdbService.buildFromRequestUUID(requestId);
}
/**
* Return the WorkflowItem object found through the UUID of an item
*
* @param uuid The uuid of the item
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
* no valid cached version. Defaults to true
* @param reRequestOnStale Whether or not the request should automatically be re-
* requested after the response becomes stale
* @param options The {@link FindListOptions} object
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
*/
public findByItem(uuid: string, useCachedVersionIfAvailable = false, reRequestOnStale = true, options: FindListOptions = {}, ...linksToFollow: FollowLinkConfig<WorkspaceItem>[]): Observable<RemoteData<WorkspaceItem>> {
const findListOptions = new FindListOptions();
findListOptions.searchParams = [new RequestParam('uuid', encodeURIComponent(uuid))];
const href$ = this.getSearchByHref(this.searchByItemLinkPath, findListOptions, ...linksToFollow);
return this.findByHref(href$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
}
}

View File

@@ -0,0 +1,150 @@
import { HttpClient } from '@angular/common/http';
import { of as observableOf } from 'rxjs';
import { TestScheduler } from 'rxjs/testing';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { ObjectCacheService } from '../cache/object-cache.service';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { RequestService } from '../data/request.service';
import { PageInfo } from '../shared/page-info.model';
import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils';
import { RequestEntry } from '../data/request.reducer';
import { HrefOnlyDataService } from '../data/href-only-data.service';
import { getMockHrefOnlyDataService } from '../../shared/mocks/href-only-data.service.mock';
import { WorkspaceitemDataService } from './workspaceitem-data.service';
import { Store } from '@ngrx/store';
import { CoreState } from '../core.reducers';
import { RestResponse } from '../cache/response.models';
import { cold, getTestScheduler, hot } from 'jasmine-marbles';
import { Item } from '../shared/item.model';
import { WorkspaceItem } from './models/workspaceitem.model';
describe('WorkspaceitemDataService test', () => {
let scheduler: TestScheduler;
let service: WorkspaceitemDataService;
let requestService: RequestService;
let rdbService: RemoteDataBuildService;
let objectCache: ObjectCacheService;
let halService: HALEndpointService;
let hrefOnlyDataService: HrefOnlyDataService;
let responseCacheEntry: RequestEntry;
const item = Object.assign(new Item(), {
id: '1234-1234',
uuid: '1234-1234',
bundles: observableOf({}),
metadata: {
'dc.title': [
{
language: 'en_US',
value: 'This is just another title'
}
],
'dc.type': [
{
language: null,
value: 'Article'
}
],
'dc.contributor.author': [
{
language: 'en_US',
value: 'Smith, Donald'
}
],
'dc.date.issued': [
{
language: null,
value: '2015-06-26'
}
]
}
});
const itemRD = createSuccessfulRemoteDataObject(item);
const wsi = Object.assign(new WorkspaceItem(), { item: observableOf(itemRD), id: '1234', uuid: '1234' });
const wsiRD = createSuccessfulRemoteDataObject(wsi);
const endpointURL = `https://rest.api/rest/api/submission/workspaceitems`;
const searchRequestURL = `https://rest.api/rest/api/submission/workspaceitems/search/item?uuid=1234-1234`;
const searchRequestURL$ = observableOf(searchRequestURL);
const requestUUID = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a';
objectCache = {} as ObjectCacheService;
const notificationsService = {} as NotificationsService;
const http = {} as HttpClient;
const comparator = {} as any;
const comparatorEntry = {} as any;
const store = {} as Store<CoreState>;
const pageInfo = new PageInfo();
function initTestService() {
hrefOnlyDataService = getMockHrefOnlyDataService();
return new WorkspaceitemDataService(
comparatorEntry,
halService,
http,
notificationsService,
requestService,
rdbService,
objectCache,
store
);
}
describe('', () => {
beforeEach(() => {
scheduler = getTestScheduler();
halService = jasmine.createSpyObj('halService', {
getEndpoint: cold('a', { a: endpointURL })
});
responseCacheEntry = new RequestEntry();
responseCacheEntry.request = { href: 'https://rest.api/' } as any;
responseCacheEntry.response = new RestResponse(true, 200, 'Success');
requestService = jasmine.createSpyObj('requestService', {
generateRequestId: requestUUID,
send: true,
removeByHrefSubstring: {},
getByHref: observableOf(responseCacheEntry),
getByUUID: observableOf(responseCacheEntry),
});
rdbService = jasmine.createSpyObj('rdbService', {
buildSingle: hot('a|', {
a: wsiRD
})
});
service = initTestService();
spyOn((service as any), 'findByHref').and.callThrough();
spyOn((service as any), 'getSearchByHref').and.returnValue(searchRequestURL$);
});
afterEach(() => {
service = null;
});
describe('findByItem', () => {
it('should proxy the call to DataService.findByHref', () => {
scheduler.schedule(() => service.findByItem('1234-1234', true, true, pageInfo));
scheduler.flush();
expect((service as any).findByHref).toHaveBeenCalledWith(searchRequestURL$, true, true);
});
it('should return a RemoteData<WorkspaceItem> for the search', () => {
const result = service.findByItem('1234-1234', true, true, pageInfo);
const expected = cold('a|', {
a: wsiRD
});
expect(result).toBeObservable(expected);
});
});
});
});

View File

@@ -12,6 +12,11 @@ import { NotificationsService } from '../../shared/notifications/notifications.s
import { ObjectCacheService } from '../cache/object-cache.service';
import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service';
import { WorkspaceItem } from './models/workspaceitem.model';
import { Observable } from 'rxjs';
import { RemoteData } from '../data/remote-data';
import { FindListOptions } from '../data/request.models';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { RequestParam } from '../cache/models/request-param.model';
/**
* A service that provides methods to make REST requests with workspaceitems endpoint.
@@ -20,6 +25,7 @@ import { WorkspaceItem } from './models/workspaceitem.model';
@dataService(WorkspaceItem.type)
export class WorkspaceitemDataService extends DataService<WorkspaceItem> {
protected linkPath = 'workspaceitems';
protected searchByItemLinkPath = 'item';
constructor(
protected comparator: DSOChangeAnalyzer<WorkspaceItem>,
@@ -33,4 +39,22 @@ export class WorkspaceitemDataService extends DataService<WorkspaceItem> {
super();
}
/**
* Return the WorkspaceItem object found through the UUID of an item
*
* @param uuid The uuid of the item
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
* no valid cached version. Defaults to true
* @param reRequestOnStale Whether or not the request should automatically be re-
* requested after the response becomes stale
* @param options The {@link FindListOptions} object
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
*/
public findByItem(uuid: string, useCachedVersionIfAvailable = false, reRequestOnStale = true, options: FindListOptions = {}, ...linksToFollow: FollowLinkConfig<WorkspaceItem>[]): Observable<RemoteData<WorkspaceItem>> {
const findListOptions = new FindListOptions();
findListOptions.searchParams = [new RequestParam('uuid', encodeURIComponent(uuid))];
const href$ = this.getSearchByHref(this.searchByItemLinkPath, findListOptions, ...linksToFollow);
return this.findByHref(href$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
}
}

View File

@@ -8,7 +8,7 @@ import { RemoteDataBuildService } from '../cache/builders/remote-data-build.serv
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { CoreState } from '../core.reducers';
import { ClaimedTaskDataService } from './claimed-task-data.service';
import { of as observableOf } from 'rxjs/internal/observable/of';
import { of as observableOf } from 'rxjs';
import { FindListOptions } from '../data/request.models';
import { RequestParam } from '../cache/models/request-param.model';
import { getTestScheduler } from 'jasmine-marbles';

View File

@@ -10,7 +10,7 @@ import { CoreState } from '../core.reducers';
import { PoolTaskDataService } from './pool-task-data.service';
import { getTestScheduler } from 'jasmine-marbles';
import { TestScheduler } from 'rxjs/testing';
import { of as observableOf } from 'rxjs/internal/observable/of';
import { of as observableOf } from 'rxjs';
import { FindListOptions } from '../data/request.models';
import { RequestParam } from '../cache/models/request-param.model';
import { HttpOptions } from '../dspace-rest/dspace-rest.service';

View File

@@ -17,7 +17,7 @@ import { HttpClient, HttpHeaders } from '@angular/common/http';
import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service';
import { ChangeAnalyzer } from '../data/change-analyzer';
import { compare, Operation } from 'fast-json-patch';
import { of as observableOf } from 'rxjs/internal/observable/of';
import { of as observableOf } from 'rxjs';
import { HttpOptions } from '../dspace-rest/dspace-rest.service';
import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock';
import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub';

View File

@@ -8,11 +8,10 @@ import {
HttpResponse,
HttpXsrfTokenExtractor
} from '@angular/common/http';
import { Observable } from 'rxjs/internal/Observable';
import { Observable, throwError } from 'rxjs';
import { tap, catchError } from 'rxjs/operators';
import { RESTURLCombiner } from '../url-combiner/rest-url-combiner';
import { CookieService } from '../services/cookie.service';
import { throwError } from 'rxjs';
// Name of XSRF header we may send in requests to backend (this is a standard name defined by Angular)
export const XSRF_REQUEST_HEADER = 'X-XSRF-TOKEN';

View File

@@ -28,6 +28,7 @@ import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-dat
import { TruncatableService } from '../../../../shared/truncatable/truncatable.service';
import { TruncatePipe } from '../../../../shared/utils/truncate.pipe';
import { JournalComponent } from './journal.component';
import { RouteService } from '../../../../core/services/route.service';
let comp: JournalComponent;
let fixture: ComponentFixture<JournalComponent>;
@@ -86,6 +87,7 @@ describe('JournalComponent', () => {
{ provide: NotificationsService, useValue: {} },
{ provide: DefaultChangeAnalyzer, useValue: {} },
{ provide: BitstreamDataService, useValue: mockBitstreamDataService },
{ provide: RouteService, useValue: {} }
],
schemas: [NO_ERRORS_SCHEMA]

View File

@@ -21,6 +21,7 @@ import { createPaginatedList } from '../../../shared/testing/utils.test';
import { RouterStub } from '../../../shared/testing/router.stub';
import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub';
import { AuthServiceStub } from '../../../shared/testing/auth-service.stub';
import { getTestScheduler } from 'jasmine-marbles';
describe('UploadBistreamComponent', () => {
let comp: UploadBitstreamComponent;
@@ -76,7 +77,8 @@ describe('UploadBistreamComponent', () => {
const restEndpoint = 'fake-rest-endpoint';
const mockItemDataService = jasmine.createSpyObj('mockItemDataService', {
getBitstreamsEndpoint: observableOf(restEndpoint),
createBundle: createSuccessfulRemoteDataObject$(createdBundle)
createBundle: createSuccessfulRemoteDataObject$(createdBundle),
getBundles: createSuccessfulRemoteDataObject$([bundle])
});
const bundleService = jasmine.createSpyObj('bundleService', {
getBitstreamsEndpoint: observableOf(restEndpoint),
@@ -92,6 +94,22 @@ describe('UploadBistreamComponent', () => {
removeByHrefSubstring: {}
});
describe('on init', () => {
beforeEach(waitForAsync(() => {
createUploadBitstreamTestingModule({
bundle: bundle.id
});
}));
beforeEach(() => {
loadFixtureAndComp();
});
it('should initialize the bundles', () => {
expect(comp.bundlesRD$).toBeDefined();
getTestScheduler().expectObservable(comp.bundlesRD$).toBe('(a|)', {a: createSuccessfulRemoteDataObject([bundle])});
});
});
describe('when a file is uploaded', () => {
beforeEach(waitForAsync(() => {
createUploadBitstreamTestingModule({});

View File

@@ -2,7 +2,7 @@ import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { Observable, Subscription } from 'rxjs';
import { RemoteData } from '../../../core/data/remote-data';
import { Item } from '../../../core/shared/item.model';
import { map, switchMap, take } from 'rxjs/operators';
import { map, take } from 'rxjs/operators';
import { ActivatedRoute, Router } from '@angular/router';
import { UploaderOptions } from '../../../shared/uploader/uploader-options.model';
import { hasValue, isEmpty, isNotEmpty } from '../../../shared/empty.util';
@@ -108,9 +108,7 @@ export class UploadBitstreamComponent implements OnInit, OnDestroy {
this.itemId = this.route.snapshot.params.id;
this.entityType = this.route.snapshot.params['entity-type'];
this.itemRD$ = this.route.data.pipe(map((data) => data.dso));
this.bundlesRD$ = this.itemRD$.pipe(
switchMap((itemRD: RemoteData<Item>) => itemRD.payload.bundles)
);
this.bundlesRD$ = this.itemService.getBundles(this.itemId);
this.selectedBundleId = this.route.snapshot.queryParams.bundle;
if (isNotEmpty(this.selectedBundleId)) {
this.bundleService.findById(this.selectedBundleId).pipe(

View File

@@ -7,7 +7,7 @@ import { ActivatedRoute, Router } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { of as observableOf } from 'rxjs/internal/observable/of';
import { of as observableOf } from 'rxjs';
import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model';
import { CollectionDataService } from '../../../core/data/collection-data.service';
import { ItemDataService } from '../../../core/data/item-data.service';

View File

@@ -1,10 +1,10 @@
<td>
<div class="metadata-field">
<div *ngIf="!(editable | async)">
<span>{{metadata?.key?.split('.').join('.&#8203;')}}</span>
<span >{{metadata?.key?.split('.').join('.&#8203;')}}</span>
</div>
<div *ngIf="(editable | async)" class="field-container">
<ds-validation-suggestions [suggestions]="(metadataFieldSuggestions | async)"
<ds-validation-suggestions [disable]="fieldUpdate.changeType != 1" [suggestions]="(metadataFieldSuggestions | async)"
[(ngModel)]="metadata.key"
[url]="this.url"
[metadata]="this.metadata"

View File

@@ -463,4 +463,43 @@ describe('EditInPlaceFieldComponent', () => {
});
});
describe('canEditMetadataField', () => {
describe('when the fieldUpdate\'s changeType is currently ADD', () => {
beforeEach(() => {
objectUpdatesService.isEditable.and.returnValue(observableOf(true));
comp.fieldUpdate.changeType = FieldChangeType.ADD;
fixture.detectChanges();
});
it('can edit metadata field', () => {
const disabledMetadataField = fixture.debugElement.query(By.css('ds-validation-suggestions'))
.componentInstance.disable;
expect(disabledMetadataField).toBe(false);
});
});
describe('when the fieldUpdate\'s changeType is currently REMOVE', () => {
beforeEach(() => {
objectUpdatesService.isEditable.and.returnValue(observableOf(true));
comp.fieldUpdate.changeType = FieldChangeType.REMOVE;
fixture.detectChanges();
});
it('can edit metadata field', () => {
const disabledMetadataField = fixture.debugElement.query(By.css('ds-validation-suggestions'))
.componentInstance.disable;
expect(disabledMetadataField).toBe(true);
});
});
describe('when the fieldUpdate\'s changeType is currently UPDATE', () => {
beforeEach(() => {
objectUpdatesService.isEditable.and.returnValue(observableOf(true));
comp.fieldUpdate.changeType = FieldChangeType.UPDATE;
fixture.detectChanges();
});
it('can edit metadata field', () => {
const disabledMetadataField = fixture.debugElement.query(By.css('ds-validation-suggestions'))
.componentInstance.disable;
expect(disabledMetadataField).toBe(true);
});
});
});
});

View File

@@ -1,66 +1,69 @@
<div class="item-metadata">
<div class="button-row top d-flex mb-2">
<button class="mr-auto btn btn-success"
(click)="add()"><i
class="fas fa-plus"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.add-button" | translate}}</span>
</button>
<button class="btn btn-warning" *ngIf="isReinstatable() | async"
(click)="reinstate()"><i
class="fas fa-undo-alt"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.reinstate-button" | translate}}</span>
</button>
<button class="btn btn-primary" [disabled]="!(hasChanges() | async) || !(isValid() | async)"
(click)="submit()"><i
class="fas fa-save"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.save-button" | translate}}</span>
</button>
<button class="btn btn-danger" *ngIf="!(isReinstatable() | async)"
[disabled]="!(hasChanges() | async)"
(click)="discard()"><i
class="fas fa-times"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.discard-button" | translate}}</span>
</button>
</div>
<table class="table table-responsive table-striped table-bordered" *ngIf="((updates$ | async)| dsObjectValues).length > 0">
<tbody>
<tr>
<th><span id="fieldName">{{'item.edit.metadata.headers.field' | translate}}</span></th>
<th><span id="fieldValue">{{'item.edit.metadata.headers.value' | translate}}</span></th>
<th class="text-center"><span id="fieldLang">{{'item.edit.metadata.headers.language' | translate}}</span></th>
<th class="text-center">{{'item.edit.metadata.headers.edit' | translate}}</th>
</tr>
<tr *ngFor="let updateValue of ((updates$ | async)| dsObjectValues); trackBy: trackUpdate"
ds-edit-in-place-field
[fieldUpdate]="updateValue || {}"
[url]="url"
[ngClass]="{
<div class="button-row top d-flex mb-2">
<button class="mr-auto btn btn-success"
(click)="add()"><i
class="fas fa-plus"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.add-button" | translate}}</span>
</button>
<button class="btn btn-warning" *ngIf="isReinstatable() | async"
(click)="reinstate()"><i
class="fas fa-undo-alt"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.reinstate-button" | translate}}</span>
</button>
<button class="btn btn-primary" [disabled]="!(hasChanges() | async) || !(isValid() | async)"
(click)="submit()"><i
class="fas fa-save"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.save-button" | translate}}</span>
</button>
<button class="btn btn-danger" *ngIf="!(isReinstatable() | async)"
[disabled]="!(hasChanges() | async)"
(click)="discard()"><i
class="fas fa-times"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.discard-button" | translate}}</span>
</button>
</div>
<table class="table table-responsive table-striped table-bordered"
*ngIf="((updates$ | async)| dsObjectValues).length > 0">
<thead>
<tr>
<th><span id="fieldName">{{'item.edit.metadata.headers.field' | translate}}</span></th>
<th><span id="fieldValue">{{'item.edit.metadata.headers.value' | translate}}</span></th>
<th class="text-center"><span id="fieldLang">{{'item.edit.metadata.headers.language' | translate}}</span></th>
<th class="text-center">{{'item.edit.metadata.headers.edit' | translate}}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let updateValue of ((updates$ | async)| dsObjectValues); trackBy: trackUpdate"
ds-edit-in-place-field
[fieldUpdate]="updateValue || {}"
[url]="url"
[ngClass]="{
'table-warning': updateValue.changeType === 0,
'table-danger': updateValue.changeType === 2,
'table-success': updateValue.changeType === 1
}">
</tr>
</tbody>
</table>
<div *ngIf="((updates$ | async)| dsObjectValues).length == 0">
<ds-alert [content]="'item.edit.metadata.empty'" [type]="AlertTypeEnum.Info"></ds-alert>
</tr>
</tbody>
</table>
<div *ngIf="((updates$ | async)| dsObjectValues).length == 0">
<ds-alert [content]="'item.edit.metadata.empty'" [type]="AlertTypeEnum.Info"></ds-alert>
</div>
<div class="button-row bottom">
<div class="mt-2 float-right">
<button class="btn btn-warning" *ngIf="isReinstatable() | async"
(click)="reinstate()"><i
class="fas fa-undo-alt"></i> {{"item.edit.metadata.reinstate-button" | translate}}
</button>
<button class="btn btn-primary mr-0" [disabled]="!(hasChanges() | async)"
(click)="submit()"><i
class="fas fa-save"></i> {{"item.edit.metadata.save-button" | translate}}
</button>
<button class="btn btn-danger" *ngIf="!(isReinstatable() | async)"
[disabled]="!(hasChanges() | async)"
(click)="discard()"><i
class="fas fa-times"></i> {{"item.edit.metadata.discard-button" | translate}}
</button>
</div>
<div class="button-row bottom">
<div class="mt-2 float-right">
<button class="btn btn-warning" *ngIf="isReinstatable() | async"
(click)="reinstate()"><i
class="fas fa-undo-alt"></i> {{"item.edit.metadata.reinstate-button" | translate}}
</button>
<button class="btn btn-primary mr-0" [disabled]="!(hasChanges() | async)"
(click)="submit()"><i
class="fas fa-save"></i> {{"item.edit.metadata.save-button" | translate}}
</button>
<button class="btn btn-danger" *ngIf="!(isReinstatable() | async)"
[disabled]="!(hasChanges() | async)"
(click)="discard()"><i
class="fas fa-times"></i> {{"item.edit.metadata.discard-button" | translate}}
</button>
</div>
</div>
</div>
</div>

View File

@@ -1,6 +1,6 @@
<h5>
{{getRelationshipMessageKey() | async | translate}}
<button class="ml-2 btn btn-success" (click)="openLookup()">
<button class="ml-2 btn btn-success" [disabled]="(hasChanges | async)" (click)="openLookup()">
<i class="fas fa-plus"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.relationships.edit.buttons.add" | translate}}</span>
</button>

View File

@@ -22,6 +22,7 @@ import { HostWindowService } from '../../../../shared/host-window.service';
import { HostWindowServiceStub } from '../../../../shared/testing/host-window-service.stub';
import { PaginationComponent } from '../../../../shared/pagination/pagination.component';
import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model';
import { RelationshipTypeService } from '../../../../core/data/relationship-type.service';
let comp: EditRelationshipListComponent;
let fixture: ComponentFixture<EditRelationshipListComponent>;
@@ -33,6 +34,7 @@ let relationshipService;
let selectableListService;
let paginationService;
let hostWindowService;
const relationshipTypeService = {};
const url = 'http://test-url.com/test-url';
@@ -57,6 +59,7 @@ describe('EditRelationshipListComponent', () => {
comp.itemType = entityType;
comp.url = url;
comp.relationshipType = relationshipType;
comp.hasChanges = observableOf(false);
fixture.detectChanges();
};
@@ -181,6 +184,7 @@ describe('EditRelationshipListComponent', () => {
{ provide: LinkService, useValue: linkService },
{ provide: PaginationService, useValue: paginationService },
{ provide: HostWindowService, useValue: hostWindowService },
{ provide: RelationshipTypeService, useValue: relationshipTypeService },
], schemas: [
NO_ERRORS_SCHEMA
]
@@ -275,6 +279,24 @@ describe('EditRelationshipListComponent', () => {
expect(label).toEqual('isAuthorOfPublication');
});
});
describe('changes managment for add buttons', () => {
it('should show enabled add buttons', () => {
const element = de.query(By.css('.btn-success'));
expect(element.nativeElement?.disabled).toBeFalse();
});
it('after hash changes changed', () => {
comp.hasChanges = observableOf(true);
fixture.detectChanges();
const element = de.query(By.css('.btn-success'));
expect(element.nativeElement?.disabled).toBeTrue();
});
});
});
});

View File

@@ -1,14 +1,9 @@
import { Component, Input, OnInit, OnDestroy } from '@angular/core';
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core';
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
import { LinkService } from '../../../../core/cache/builders/link.service';
import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions';
import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service';
import {
combineLatest as observableCombineLatest,
Observable,
of as observableOf,
from as observableFrom
} from 'rxjs';
import { combineLatest as observableCombineLatest, from as observableFrom, BehaviorSubject, Observable, Subscription } from 'rxjs';
import {
FieldUpdate,
FieldUpdates,
@@ -16,39 +11,28 @@ import {
} from '../../../../core/data/object-updates/object-updates.reducer';
import { RelationshipService } from '../../../../core/data/relationship.service';
import { Item } from '../../../../core/shared/item.model';
import {
defaultIfEmpty,
map,
mergeMap,
switchMap,
take,
startWith,
toArray,
tap
} from 'rxjs/operators';
import { hasValue, hasValueOperator, hasNoValue } from '../../../../shared/empty.util';
import { defaultIfEmpty, map, mergeMap, startWith, switchMap, take, tap, toArray } from 'rxjs/operators';
import { hasNoValue, hasValue, hasValueOperator } from '../../../../shared/empty.util';
import { Relationship } from '../../../../core/shared/item-relationships/relationship.model';
import { RelationshipType } from '../../../../core/shared/item-relationships/relationship-type.model';
import {
getRemoteDataPayload,
getAllSucceededRemoteData,
getFirstSucceededRemoteData,
getFirstSucceededRemoteDataPayload,
getAllSucceededRemoteData,
getRemoteDataPayload,
} from '../../../../core/shared/operators';
import { ItemType } from '../../../../core/shared/item-relationships/item-type.model';
import { DsDynamicLookupRelationModalComponent } from '../../../../shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component';
import { RelationshipOptions } from '../../../../shared/form/builder/models/relationship-options.model';
import { ItemSearchResult } from '../../../../shared/object-collection/shared/item-search-result.model';
import { SelectableListService } from '../../../../shared/object-list/selectable-list/selectable-list.service';
import { SearchResult } from '../../../../shared/search/search-result.model';
import { followLink } from '../../../../shared/utils/follow-link-config.model';
import { PaginatedList } from '../../../../core/data/paginated-list.model';
import { RemoteData } from '../../../../core/data/remote-data';
import { Collection } from '../../../../core/shared/collection.model';
import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject';
import { Subscription } from 'rxjs/internal/Subscription';
import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model';
import { PaginationService } from '../../../../core/pagination/pagination.service';
import { RelationshipTypeService } from '../../../../core/data/relationship-type.service';
@Component({
selector: 'ds-edit-relationship-list',
@@ -79,6 +63,16 @@ export class EditRelationshipListComponent implements OnInit, OnDestroy {
*/
@Input() relationshipType: RelationshipType;
/**
* If updated information has changed
*/
@Input() hasChanges!: Observable<boolean>;
/**
* The event emmiter to submit the new information
*/
@Output() submit: EventEmitter<any> = new EventEmitter();
/**
* Observable that emits the left and right item type of {@link relationshipType} simultaneously.
*/
@@ -138,10 +132,12 @@ export class EditRelationshipListComponent implements OnInit, OnDestroy {
*/
modalRef: NgbModalRef;
constructor(
protected objectUpdatesService: ObjectUpdatesService,
protected linkService: LinkService,
protected relationshipService: RelationshipService,
protected relationshipTypeService: RelationshipTypeService,
protected modalService: NgbModal,
protected paginationService: PaginationService,
protected selectableListService: SelectableListService,
@@ -207,104 +203,174 @@ export class EditRelationshipListComponent implements OnInit, OnDestroy {
});
const modalComp: DsDynamicLookupRelationModalComponent = this.modalRef.componentInstance;
modalComp.repeatable = true;
modalComp.isEditRelationship = true;
modalComp.listId = this.listId;
modalComp.item = this.item;
modalComp.relationshipType = this.relationshipType;
modalComp.currentItemIsLeftItem$ = this.currentItemIsLeftItem$;
modalComp.toAdd = [];
modalComp.toRemove = [];
modalComp.isPending = false;
this.item.owningCollection.pipe(
getFirstSucceededRemoteDataPayload()
).subscribe((collection: Collection) => {
modalComp.collection = collection;
});
modalComp.select = (...selectableObjects: SearchResult<Item>[]) => {
selectableObjects.forEach((searchResult) => {
const relatedItem: Item = searchResult.indexableObject;
this.getFieldUpdatesForRelatedItem(relatedItem)
.subscribe((identifiables) => {
identifiables.forEach((identifiable) =>
this.objectUpdatesService.removeSingleFieldUpdate(this.url, identifiable.uuid)
);
if (identifiables.length === 0) {
this.relationshipService.getNameVariant(this.listId, relatedItem.uuid)
.subscribe((nameVariant) => {
const update = {
uuid: this.relationshipType.id + '-' + relatedItem.uuid,
nameVariant,
type: this.relationshipType,
relatedItem,
} as RelationshipIdentifiable;
this.objectUpdatesService.saveAddFieldUpdate(this.url, update);
});
}
this.loading$.next(true);
// emit the last page again to trigger a fieldupdates refresh
this.relationshipsRd$.next(this.relationshipsRd$.getValue());
});
const foundIndex = modalComp.toRemove.findIndex( el => el.uuid === relatedItem.uuid);
if (foundIndex !== -1) {
modalComp.toRemove.splice(foundIndex,1);
} else {
this.getRelationFromId(relatedItem)
.subscribe((relationship: Relationship) => {
if (!relationship ) {
modalComp.toAdd.push(searchResult);
} else {
const foundIndexRemove = modalComp.toRemove.findIndex( el => el.indexableObject.uuid === relatedItem.uuid);
if (foundIndexRemove !== -1) {
modalComp.toRemove.splice(foundIndexRemove,1);
}
}
this.loading$.next(true);
// emit the last page again to trigger a fieldupdates refresh
this.relationshipsRd$.next(this.relationshipsRd$.getValue());
});
}
});
};
modalComp.deselect = (...selectableObjects: SearchResult<Item>[]) => {
selectableObjects.forEach((searchResult) => {
const relatedItem: Item = searchResult.indexableObject;
this.objectUpdatesService.removeSingleFieldUpdate(this.url, this.relationshipType.id + '-' + relatedItem.uuid);
this.getFieldUpdatesForRelatedItem(relatedItem)
.subscribe((identifiables) =>
identifiables.forEach((identifiable) =>
this.objectUpdatesService.saveRemoveFieldUpdate(this.url, identifiable)
)
);
const foundIndex = modalComp.toAdd.findIndex( el => el.indexableObject.uuid === relatedItem.uuid);
if (foundIndex !== -1) {
modalComp.toAdd.splice(foundIndex,1);
} else {
modalComp.toRemove.push(searchResult);
}
});
};
modalComp.submitEv = () => {
const subscriptions = [];
modalComp.toAdd.forEach((searchResult: SearchResult<Item>) => {
const relatedItem = searchResult.indexableObject;
subscriptions.push(this.relationshipService.getNameVariant(this.listId, relatedItem.uuid).pipe(
map((nameVariant) => {
const update = {
uuid: this.relationshipType.id + '-' + searchResult.indexableObject.uuid,
nameVariant,
type: this.relationshipType,
relatedItem,
} as RelationshipIdentifiable;
this.objectUpdatesService.saveAddFieldUpdate(this.url, update);
return update;
})
));
});
this.loading$.next(true);
// emit the last page again to trigger a fieldupdates refresh
this.relationshipsRd$.next(this.relationshipsRd$.getValue());
modalComp.toRemove.forEach( (searchResult) => {
subscriptions.push(this.relationshipService.getNameVariant(this.listId, searchResult.indexableObjectuuid).pipe(
switchMap((nameVariant) => {
return this.getRelationFromId(searchResult.indexableObject).pipe(
map( (relationship: Relationship) => {
const update = {
uuid: relationship.id,
nameVariant,
type: this.relationshipType,
relationship,
} as RelationshipIdentifiable;
this.objectUpdatesService.saveRemoveFieldUpdate(this.url,update);
return update;
})
);
})
));
});
observableCombineLatest(subscriptions).subscribe( (res) => {
// Wait until the states changes since there are multiple items
setTimeout( () => {
this.submit.emit();
},1000);
modalComp.isPending = true;
});
};
modalComp.discardEv = () => {
modalComp.toAdd.forEach( (searchResult) => {
this.selectableListService.deselectSingle(this.listId,searchResult);
});
modalComp.toRemove.forEach( (searchResult) => {
this.selectableListService.selectSingle(this.listId,searchResult);
});
modalComp.toAdd = [];
modalComp.toRemove = [];
};
this.relatedEntityType$
.pipe(take(1))
.subscribe((relatedEntityType) => {
modalComp.relationshipOptions = Object.assign(
new RelationshipOptions(), {
relationshipType: relatedEntityType.label,
// filter: this.getRelationshipMessageKey(),
searchConfiguration: relatedEntityType.label.toLowerCase(),
nameVariants: true,
nameVariants: 'true',
}
);
});
this.selectableListService.deselectAll(this.listId);
this.updates$.pipe(
switchMap((updates) =>
Object.values(updates).length > 0 ?
observableCombineLatest(
Object.values(updates)
.filter((update) => update.changeType !== FieldChangeType.REMOVE)
.map((update) => {
const field = update.field as RelationshipIdentifiable;
if (field.relationship) {
return this.getRelatedItem(field.relationship);
} else {
return observableOf(field.relatedItem);
}
})
) : observableOf([])
),
take(1),
map((items) => items.map((item) => {
const searchResult = new ItemSearchResult();
searchResult.indexableObject = item;
searchResult.hitHighlights = {};
return searchResult;
})),
).subscribe((items) => {
this.selectableListService.select(this.listId, items);
});
}
getRelationFromId(relatedItem) {
return this.currentItemIsLeftItem$.pipe(
take(1),
switchMap( isLeft => {
let apiCall;
if (isLeft) {
apiCall = this.relationshipService.searchByItemsAndType( this.relationshipType.id, this.item.uuid, this.relationshipType.leftwardType ,[relatedItem.id] ).pipe(
getFirstSucceededRemoteData(),
getRemoteDataPayload(),
);
} else {
apiCall = this.relationshipService.searchByItemsAndType( this.relationshipType.id, this.item.uuid, this.relationshipType.rightwardType ,[relatedItem.id] ).pipe(
getFirstSucceededRemoteData(),
getRemoteDataPayload(),
);
}
return apiCall.pipe(
map( (res: PaginatedList<Relationship>) => res.page[0])
);
}
));
}
/**
* Get the existing field updates regarding a relationship with a given item
* @param relatedItem The item for which to get the existing field updates
*/
private getFieldUpdatesForRelatedItem(relatedItem: Item): Observable<RelationshipIdentifiable[]> {
return this.updates$.pipe(
take(1),
map((updates) => Object.values(updates)
@@ -316,11 +382,34 @@ export class EditRelationshipListComponent implements OnInit, OnDestroy {
identifiables.map((identifiable) => this.getRelatedItem(identifiable.relationship))
).pipe(
defaultIfEmpty([]),
map((relatedItems) =>
identifiables.filter((identifiable, index) => relatedItems[index].uuid === relatedItem.uuid)
map((relatedItems) => {
return identifiables.filter( (identifiable, index) => {
return relatedItems[index].uuid === relatedItem.uuid;
});
}
),
)
),
)
);
}
/**
* Check if the given item is related with the item we are editing relationships
* @param relatedItem The item for which to get the existing field updates
*/
private getIsRelatedItem(relatedItem: Item): Observable<boolean> {
return this.currentItemIsLeftItem$.pipe(
take(1),
map( isLeft => {
if (isLeft) {
const listOfRelatedItems = this.item.allMetadataValues( 'relation.' + this.relationshipType.leftwardType );
return !!listOfRelatedItems.find( (uuid) => uuid === relatedItem.uuid );
} else {
const listOfRelatedItems = this.item.allMetadataValues( 'relation.' + this.relationshipType.rightwardType );
return !!listOfRelatedItems.find( (uuid) => uuid === relatedItem.uuid );
}
})
);
}
@@ -337,6 +426,7 @@ export class EditRelationshipListComponent implements OnInit, OnDestroy {
}
ngOnInit(): void {
// store the left and right type of the relationship in a single observable
this.relationshipLeftAndRightType$ = observableCombineLatest([
this.relationshipType.leftType,
@@ -373,6 +463,7 @@ export class EditRelationshipListComponent implements OnInit, OnDestroy {
})
);
// initialize the pagination options
this.paginationConfig = new PaginationComponentOptions();
this.paginationConfig.id = `er${this.relationshipType.id}`;

View File

@@ -79,7 +79,7 @@ export class EditRelationshipComponent implements OnChanges {
* Sets the current relationship based on the fieldUpdate input field
*/
ngOnChanges(): void {
if (this.relationship) {
if (this.relationship && (!!this.relationship.leftItem || !!this.relationship.rightItem)) {
this.leftItem$ = this.relationship.leftItem.pipe(
getFirstSucceededRemoteData(),
getRemoteDataPayload(),

View File

@@ -27,6 +27,8 @@
[item]="item"
[itemType]="entityType$ | async"
[relationshipType]="relationshipType"
[hasChanges] = hasChanges()
(submit) = submit()
></ds-edit-relationship-list>
</div>
</ng-container>

View File

@@ -25,6 +25,8 @@ import { RouterStub } from '../../../shared/testing/router.stub';
import { ItemRelationshipsComponent } from './item-relationships.component';
import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
import { createPaginatedList } from '../../../shared/testing/utils.test';
import { RelationshipTypeService } from '../../../core/data/relationship-type.service';
import { relationshipTypes } from '../../../shared/testing/relationship-types.mock';
let comp: any;
let fixture: ComponentFixture<ItemRelationshipsComponent>;
@@ -46,6 +48,7 @@ const notificationsService = jasmine.createSpyObj('notificationsService',
}
);
const router = new RouterStub();
let relationshipTypeService;
let routeStub;
let itemService;
@@ -178,6 +181,13 @@ describe('ItemRelationshipsComponent', () => {
}
);
relationshipTypeService = jasmine.createSpyObj('searchByEntityType',
{
searchByEntityType: observableOf(relationshipTypes)
}
);
requestService = jasmine.createSpyObj('requestService',
{
removeByHrefSubstring: {},
@@ -210,6 +220,7 @@ describe('ItemRelationshipsComponent', () => {
{ provide: EntityTypeService, useValue: entityTypeService },
{ provide: ObjectCacheService, useValue: objectCache },
{ provide: RequestService, useValue: requestService },
{ provide: RelationshipTypeService, useValue: relationshipTypeService },
ChangeDetectorRef
], schemas: [
NO_ERRORS_SCHEMA
@@ -255,4 +266,22 @@ describe('ItemRelationshipsComponent', () => {
expect(relationshipService.deleteRelationship).toHaveBeenCalledWith(relationships[1].uuid, 'left');
});
});
describe('discard', () => {
beforeEach(() => {
comp.item.firstMetadataValue = (info) => {
return 'Publication';
};
fixture.detectChanges();
comp.initializeUpdates();
fixture.detectChanges();
});
it('it should call relationshipTypeService.searchByEntityType', () => {
expect(relationshipTypeService.searchByEntityType).toHaveBeenCalled();
});
});
});

View File

@@ -6,13 +6,8 @@ import {
FieldUpdates,
RelationshipIdentifiable,
} from '../../../core/data/object-updates/object-updates.reducer';
import { Observable } from 'rxjs/internal/Observable';
import { map, startWith, switchMap, take } from 'rxjs/operators';
import {
combineLatest as observableCombineLatest,
of as observableOf,
zip as observableZip
} from 'rxjs';
import { combineLatest as observableCombineLatest, of as observableOf, zip as observableZip, Observable } from 'rxjs';
import { followLink } from '../../../shared/utils/follow-link-config.model';
import { AbstractItemUpdateComponent } from '../abstract-item-update/abstract-item-update.component';
import { ItemDataService } from '../../../core/data/item-data.service';
@@ -32,6 +27,9 @@ import { FieldChangeType } from '../../../core/data/object-updates/object-update
import { Relationship } from '../../../core/shared/item-relationships/relationship.model';
import { NoContent } from '../../../core/shared/NoContent.model';
import { hasValue } from '../../../shared/empty.util';
import { RelationshipTypeService } from '../../../core/data/relationship-type.service';
import { PaginatedList } from '../../../core/data/paginated-list.model';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
@Component({
selector: 'ds-item-relationships',
@@ -65,7 +63,9 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent {
public objectCache: ObjectCacheService,
public requestService: RequestService,
public entityTypeService: EntityTypeService,
protected relationshipTypeService: RelationshipTypeService,
public cdr: ChangeDetectorRef,
protected modalService: NgbModal,
) {
super(itemService, objectUpdatesService, router, notificationsService, translateService, route);
}
@@ -77,27 +77,16 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent {
const label = this.item.firstMetadataValue('dspace.entity.type');
if (label !== undefined) {
this.relationshipTypes$ = this.relationshipTypeService.searchByEntityType(label, true, true, ...this.getRelationshipTypeFollowLinks())
.pipe(
map((relationshipTypes: PaginatedList<RelationshipType>) => relationshipTypes.page)
);
this.entityType$ = this.entityTypeService.getEntityTypeByLabel(label).pipe(
getFirstSucceededRemoteData(),
getRemoteDataPayload(),
);
this.relationshipTypes$ = this.entityType$.pipe(
switchMap((entityType) =>
this.entityTypeService.getEntityTypeRelationships(
entityType.id,
true,
true,
followLink('leftType'),
followLink('rightType'))
.pipe(
getFirstSucceededRemoteData(),
getRemoteDataPayload(),
map((relationshipTypes) => relationshipTypes.page),
)
),
);
} else {
this.entityType$ = observableOf(undefined);
}
@@ -157,6 +146,7 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent {
this.initializeOriginalFields();
this.cdr.detectChanges();
this.displayNotifications(response);
this.modalService.dismissAll();
}
})
);
@@ -233,4 +223,13 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent {
this.objectUpdatesService.initialize(this.url, items, this.item.lastModified);
});
}
getRelationshipTypeFollowLinks() {
return [
followLink('leftType'),
followLink('rightType')
];
}
}

View File

@@ -1,6 +1,4 @@
<div class="mt-4">
<ds-alert [content]="'item.edit.tabs.versionhistory.under-construction'" [type]="AlertTypeEnum.Warning"></ds-alert>
</div>
<div class="mt-2" *ngVar="(itemRD$ | async)?.payload as item">
<ds-item-versions *ngIf="item" [item]="item" [displayWhenEmpty]="true" [displayTitle]="false"></ds-item-versions>
<ds-item-versions *ngIf="item" [item]="item" [displayWhenEmpty]="true" [displayTitle]="false"
[displayActions]="true"></ds-item-versions>
</div>

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