Merge remote-tracking branch 'origin/main' into CST-4510-entity-selection-porting

# Conflicts:
#	src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.ts
This commit is contained in:
Giuseppe Digilio
2021-10-21 15:23:02 +02:00
143 changed files with 11593 additions and 6582 deletions

View File

@@ -0,0 +1,15 @@
import { TEST_ENTITY_PUBLICATION } from 'cypress/support';
import { testA11y } from 'cypress/support/utils';
describe('Breadcrumbs', () => {
it('should pass accessibility tests', () => {
// Visit an Item, as those have more breadcrumbs
cy.visit('/entities/publication/' + TEST_ENTITY_PUBLICATION);
// Wait for breadcrumbs to be visible
cy.get('ds-breadcrumbs').should('be.visible');
// Analyze <ds-breadcrumbs> for accessibility
testA11y('ds-breadcrumbs');
});
});

View File

@@ -0,0 +1,13 @@
import { testA11y } from 'cypress/support/utils';
describe('Browse By Author', () => {
it('should pass accessibility tests', () => {
cy.visit('/browse/author');
// Wait for <ds-browse-by-metadata-page> to be visible
cy.get('ds-browse-by-metadata-page').should('be.visible');
// Analyze <ds-browse-by-metadata-page> for accessibility
testA11y('ds-browse-by-metadata-page');
});
});

View File

@@ -0,0 +1,13 @@
import { testA11y } from 'cypress/support/utils';
describe('Browse By Date Issued', () => {
it('should pass accessibility tests', () => {
cy.visit('/browse/dateissued');
// Wait for <ds-browse-by-date-page> to be visible
cy.get('ds-browse-by-date-page').should('be.visible');
// Analyze <ds-browse-by-date-page> for accessibility
testA11y('ds-browse-by-date-page');
});
});

View File

@@ -0,0 +1,13 @@
import { testA11y } from 'cypress/support/utils';
describe('Browse By Subject', () => {
it('should pass accessibility tests', () => {
cy.visit('/browse/subject');
// Wait for <ds-browse-by-metadata-page> to be visible
cy.get('ds-browse-by-metadata-page').should('be.visible');
// Analyze <ds-browse-by-metadata-page> for accessibility
testA11y('ds-browse-by-metadata-page');
});
});

View File

@@ -0,0 +1,13 @@
import { testA11y } from 'cypress/support/utils';
describe('Browse By Title', () => {
it('should pass accessibility tests', () => {
cy.visit('/browse/title');
// Wait for <ds-browse-by-title-page> to be visible
cy.get('ds-browse-by-title-page').should('be.visible');
// Analyze <ds-browse-by-title-page> for accessibility
testA11y('ds-browse-by-title-page');
});
});

View File

@@ -0,0 +1,15 @@
import { TEST_COLLECTION } from 'cypress/support';
import { testA11y } from 'cypress/support/utils';
describe('Collection Page', () => {
it('should pass accessibility tests', () => {
cy.visit('/collections/' + TEST_COLLECTION);
// <ds-collection-page> tag must be loaded
cy.get('ds-collection-page').should('exist');
// Analyze <ds-collection-page> for accessibility issues
testA11y('ds-collection-page');
});
});

View File

@@ -0,0 +1,32 @@
import { TEST_COLLECTION } from 'cypress/support';
import { testA11y } from 'cypress/support/utils';
describe('Collection Statistics Page', () => {
const COLLECTIONSTATISTICSPAGE = '/statistics/collections/' + TEST_COLLECTION;
it('should load if you click on "Statistics" from a Collection page', () => {
cy.visit('/collections/' + TEST_COLLECTION);
cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click();
cy.location('pathname').should('eq', COLLECTIONSTATISTICSPAGE);
});
it('should contain a "Total visits" section', () => {
cy.visit(COLLECTIONSTATISTICSPAGE);
cy.get('.' + TEST_COLLECTION + '_TotalVisits').should('exist');
});
it('should contain a "Total visits per month" section', () => {
cy.visit(COLLECTIONSTATISTICSPAGE);
cy.get('.' + TEST_COLLECTION + '_TotalVisitsPerMonth').should('exist');
});
it('should pass accessibility tests', () => {
cy.visit(COLLECTIONSTATISTICSPAGE);
// <ds-collection-statistics-page> tag must be loaded
cy.get('ds-collection-statistics-page').should('exist');
// Analyze <ds-collection-statistics-page> for accessibility issues
testA11y('ds-collection-statistics-page');
});
});

View File

@@ -0,0 +1,25 @@
import { Options } from 'cypress-axe';
import { testA11y } from 'cypress/support/utils';
describe('Community List Page', () => {
it('should pass accessibility tests', () => {
cy.visit('/community-list');
// <ds-community-list-page> tag must be loaded
cy.get('ds-community-list-page').should('exist');
// Open first Community (to show Collections)...that way we scan sub-elements as well
cy.get('ds-community-list :nth-child(1) > .btn-group > .btn').click();
// Analyze <ds-community-list-page> for accessibility issues
// Disable heading-order checks until it is fixed
testA11y('ds-community-list-page',
{
rules: {
'heading-order': { enabled: false }
}
} as Options
);
});
});

View File

@@ -0,0 +1,15 @@
import { TEST_COMMUNITY } from 'cypress/support';
import { testA11y } from 'cypress/support/utils';
describe('Community Page', () => {
it('should pass accessibility tests', () => {
cy.visit('/communities/' + TEST_COMMUNITY);
// <ds-community-page> tag must be loaded
cy.get('ds-community-page').should('exist');
// Analyze <ds-community-page> for accessibility issues
testA11y('ds-community-page',);
});
});

View File

@@ -0,0 +1,32 @@
import { TEST_COMMUNITY } from 'cypress/support';
import { testA11y } from 'cypress/support/utils';
describe('Community Statistics Page', () => {
const COMMUNITYSTATISTICSPAGE = '/statistics/communities/' + TEST_COMMUNITY;
it('should load if you click on "Statistics" from a Community page', () => {
cy.visit('/communities/' + TEST_COMMUNITY);
cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click();
cy.location('pathname').should('eq', COMMUNITYSTATISTICSPAGE);
});
it('should contain a "Total visits" section', () => {
cy.visit(COMMUNITYSTATISTICSPAGE);
cy.get('.' + TEST_COMMUNITY + '_TotalVisits').should('exist');
});
it('should contain a "Total visits per month" section', () => {
cy.visit(COMMUNITYSTATISTICSPAGE);
cy.get('.' + TEST_COMMUNITY + '_TotalVisitsPerMonth').should('exist');
});
it('should pass accessibility tests', () => {
cy.visit(COMMUNITYSTATISTICSPAGE);
// <ds-community-statistics-page> tag must be loaded
cy.get('ds-community-statistics-page').should('exist');
// Analyze <ds-community-statistics-page> for accessibility issues
testA11y('ds-community-statistics-page');
});
});

View File

@@ -0,0 +1,13 @@
import { testA11y } from 'cypress/support/utils';
describe('Footer', () => {
it('should pass accessibility tests', () => {
cy.visit('/');
// Footer must first be visible
cy.get('ds-footer').should('be.visible');
// Analyze <ds-footer> for accessibility
testA11y('ds-footer');
});
});

View File

@@ -0,0 +1,19 @@
import { testA11y } from 'cypress/support/utils';
describe('Header', () => {
it('should pass accessibility tests', () => {
cy.visit('/');
// Header must first be visible
cy.get('ds-header').should('be.visible');
// Analyze <ds-header> for accessibility
testA11y({
include: ['ds-header'],
exclude: [
['#search-navbar-container'], // search in navbar has duplicative ID. Will be fixed in #1174
['.dropdownLogin'] // "Log in" link has color contrast issues. Will be fixed in #1149
],
});
});
});

View File

@@ -0,0 +1,19 @@
import { testA11y } from 'cypress/support/utils';
describe('Site Statistics Page', () => {
it('should load if you click on "Statistics" from homepage', () => {
cy.visit('/');
cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click();
cy.location('pathname').should('eq', '/statistics');
});
it('should pass accessibility tests', () => {
cy.visit('/statistics');
// <ds-site-statistics-page> tag must be loaded
cy.get('ds-site-statistics-page').should('exist');
// Analyze <ds-site-statistics-page> for accessibility issues
testA11y('ds-site-statistics-page');
});
});

View File

@@ -1,3 +1,5 @@
import { testA11y } from 'cypress/support/utils';
describe('Homepage', () => {
beforeEach(() => {
// All tests start with visiting homepage
@@ -20,18 +22,11 @@ describe('Homepage', () => {
cy.url().should('include', 'query=' + encodeURI(queryString));
});
// it('should pass accessibility tests', () => {
// // first must inject Axe into current page
// cy.injectAxe();
it('should pass accessibility tests', () => {
// Wait for homepage tag to appear
cy.get('ds-home-page').should('be.visible');
// // Analyze entire page for accessibility issues
// // NOTE: this test checks accessibility of header/footer as well
// cy.checkA11y({
// exclude: [
// ['#klaro'], // Klaro plugin (privacy policy popup) has color contrast issues
// ['#search-navbar-container'], // search in navbar has duplicative ID. Will be fixed in #1174
// ['.dropdownLogin'] // "Log in" link in header has color contrast issues
// ],
// });
// });
// Analyze <ds-home-page> for accessibility issues
testA11y('ds-home-page');
});
});

View File

@@ -1,15 +1,31 @@
describe('Item Page', () => {
const ITEMPAGE = '/items/e98b0f27-5c19-49a0-960d-eb6ad5287067';
const ENTITYPAGE = '/entities/publication/e98b0f27-5c19-49a0-960d-eb6ad5287067';
import { Options } from 'cypress-axe';
import { TEST_ENTITY_PUBLICATION } from 'cypress/support';
import { testA11y } from 'cypress/support/utils';
it('should contain element ds-item-page when navigating to an item page', () => {
cy.visit(ENTITYPAGE);
cy.get('ds-item-page').should('exist');
});
describe('Item Page', () => {
const ITEMPAGE = '/items/' + TEST_ENTITY_PUBLICATION;
const ENTITYPAGE = '/entities/publication/' + TEST_ENTITY_PUBLICATION;
// Test that entities will redirect to /entities/[type]/[uuid] when accessed via /items/[uuid]
it('should redirect to the entity page when navigating to an item page', () => {
cy.visit(ITEMPAGE);
cy.location('pathname').should('eq', ENTITYPAGE);
});
it('should pass accessibility tests', () => {
cy.visit(ENTITYPAGE);
// <ds-item-page> tag must be loaded
cy.get('ds-item-page').should('exist');
// Analyze <ds-item-page> for accessibility issues
// Disable heading-order checks until it is fixed
testA11y('ds-item-page',
{
rules: {
'heading-order': { enabled: false }
}
} as Options
);
});
});

View File

@@ -1,6 +1,14 @@
import { TEST_ENTITY_PUBLICATION } from 'cypress/support';
import { testA11y } from 'cypress/support/utils';
describe('Item Statistics Page', () => {
const ITEMUUID = 'e98b0f27-5c19-49a0-960d-eb6ad5287067';
const ITEMSTATISTICSPAGE = '/statistics/items/' + ITEMUUID;
const ITEMSTATISTICSPAGE = '/statistics/items/' + TEST_ENTITY_PUBLICATION;
it('should load if you click on "Statistics" from an Item/Entity page', () => {
cy.visit('/entities/publication/' + TEST_ENTITY_PUBLICATION);
cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click();
cy.location('pathname').should('eq', ITEMSTATISTICSPAGE);
});
it('should contain element ds-item-statistics-page when navigating to an item statistics page', () => {
cy.visit(ITEMSTATISTICSPAGE);
@@ -8,18 +16,23 @@ describe('Item Statistics Page', () => {
cy.get('ds-item-page').should('not.exist');
});
it('should contain the item statistics page url when navigating to an item statistics page', () => {
cy.visit(ITEMSTATISTICSPAGE);
cy.location('pathname').should('eq', ITEMSTATISTICSPAGE);
});
it('should contain a "Total visits" section', () => {
cy.visit(ITEMSTATISTICSPAGE);
cy.get('.' + ITEMUUID + '_TotalVisits').should('exist');
cy.get('.' + TEST_ENTITY_PUBLICATION + '_TotalVisits').should('exist');
});
it('should contain a "Total visits per month" section', () => {
cy.visit(ITEMSTATISTICSPAGE);
cy.get('.' + ITEMUUID + '_TotalVisitsPerMonth').should('exist');
cy.get('.' + TEST_ENTITY_PUBLICATION + '_TotalVisitsPerMonth').should('exist');
});
it('should pass accessibility tests', () => {
cy.visit(ITEMSTATISTICSPAGE);
// <ds-item-statistics-page> tag must be loaded
cy.get('ds-item-statistics-page').should('exist');
// Analyze <ds-item-statistics-page> for accessibility issues
testA11y('ds-item-statistics-page');
});
});

View File

@@ -1,3 +1,6 @@
import { Options } from 'cypress-axe';
import { testA11y } from 'cypress/support/utils';
describe('Search Page', () => {
// unique ID of the search form (for selecting specific elements below)
const SEARCHFORM_ID = '#search-form';
@@ -16,4 +19,54 @@ describe('Search Page', () => {
cy.get(SEARCHFORM_ID + ' button.search-button').click();
cy.url().should('include', 'query=' + encodeURI(queryString));
});
it('should pass accessibility tests', () => {
cy.visit('/search');
// <ds-search-page> tag must be loaded
cy.get('ds-search-page').should('exist');
// Click each filter toggle to open *every* filter
// (As we want to scan filter section for accessibility issues as well)
cy.get('.filter-toggle').click({ multiple: true });
// Analyze <ds-search-page> for accessibility issues
testA11y(
{
include: ['ds-search-page'],
exclude: [
['nouislider'] // Date filter slider is missing ARIA labels. Will be fixed by #1175
],
},
{
rules: {
// Search filters fail these two "moderate" impact rules
'heading-order': { enabled: false },
'landmark-unique': { enabled: false }
}
} as Options
);
});
it('should pass accessibility tests in Grid view', () => {
cy.visit('/search');
// Click to display grid view
// TODO: These buttons should likely have an easier way to uniquely select
cy.get('#search-sidebar-content > ds-view-mode-switch > .btn-group > [href="/search?spc.sf=score&spc.sd=DESC&view=grid"] > .fas').click();
// <ds-search-page> tag must be loaded
cy.get('ds-search-page').should('exist');
// Analyze <ds-search-page> for accessibility issues
testA11y('ds-search-page',
{
rules: {
// Search filters fail these two "moderate" impact rules
'heading-order': { enabled: false },
'landmark-unique': { enabled: false }
}
} as Options
);
});
});

View File

@@ -1,5 +1,16 @@
// Plugins enable you to tap into, modify, or extend the internal behavior of Cypress
// For more info, visit https://on.cypress.io/plugins-api
/* tslint:disable:no-empty */
module.exports = (on, config) => { };
/* tslint:enable:no-empty */
module.exports = (on, config) => {
// Define "log" and "table" tasks, used for logging accessibility errors during CI
// Borrowed from https://github.com/component-driven/cypress-axe#in-cypress-plugins-file
on('task', {
log(message: string) {
console.log(message);
return null;
},
table(message: string) {
console.table(message);
return null;
}
});
};

View File

@@ -19,3 +19,8 @@
// Import Cypress Axe tools for all tests
// https://github.com/component-driven/cypress-axe
import 'cypress-axe';
// Global constants used in tests
export const TEST_COLLECTION = '282164f5-d325-4740-8dd1-fa4d6d3e7200';
export const TEST_COMMUNITY = '0958c910-2037-42a9-81c7-dca80e3892b4';
export const TEST_ENTITY_PUBLICATION = 'e98b0f27-5c19-49a0-960d-eb6ad5287067';

44
cypress/support/utils.ts Normal file
View File

@@ -0,0 +1,44 @@
import { Result } from 'axe-core';
import { Options } from 'cypress-axe';
// Log violations to terminal/commandline in a table format.
// Uses 'log' and 'table' tasks defined in ../plugins/index.ts
// Borrowed from https://github.com/component-driven/cypress-axe#in-your-spec-file
function terminalLog(violations: Result[]) {
cy.task(
'log',
`${violations.length} accessibility violation${violations.length === 1 ? '' : 's'} ${violations.length === 1 ? 'was' : 'were'} detected`
);
// pluck specific keys to keep the table readable
const violationData = violations.map(
({ id, impact, description, helpUrl, nodes }) => ({
id,
impact,
description,
helpUrl,
nodes: nodes.length,
html: nodes.map(node => node.html)
})
);
// Print violations as an array, since 'node.html' above often breaks table alignment
cy.task('log', violationData);
// Optionally, uncomment to print as a table
// cy.task('table', violationData);
}
// Custom "testA11y()" method which checks accessibility using cypress-axe
// while also ensuring any violations are logged to the terminal (see terminalLog above)
// This method MUST be called after cy.visit(), as cy.injectAxe() must be called after page load
export const testA11y = (context?: any, options?: Options) => {
cy.injectAxe();
cy.configureAxe({
rules: [
// Disable color contrast checks as they are inaccurate / result in a lot of false positives
// See also open issues in axe-core: https://github.com/dequelabs/axe-core/labels/color%20contrast
{ id: 'color-contrast', enabled: false },
]
});
cy.checkA11y(context, options, terminalLog);
};

View File

@@ -6,7 +6,8 @@
"compilerOptions": {
"types": [
"cypress",
"cypress-axe"
"cypress-axe",
"node"
]
}
}

View File

@@ -152,7 +152,7 @@
"copy-webpack-plugin": "^6.4.1",
"css-loader": "3.4.0",
"cssnano": "^4.1.10",
"cypress": "8.3.1",
"cypress": "8.6.0",
"cypress-axe": "^0.13.0",
"deep-freeze": "0.0.1",
"dotenv": "^8.2.0",

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';
@@ -33,10 +33,11 @@ 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
@@ -161,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,
@@ -187,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`),
@@ -219,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(
@@ -260,6 +272,13 @@ 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();
@@ -280,7 +299,13 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
);
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))
@@ -343,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();
}
});
@@ -382,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();
}
});
@@ -395,28 +420,6 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
}
}
/**
* 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
* @param ePerson ePerson values to check
* @param notificationSection whether in create or edit
*/
private showNotificationIfEmailInUse(ePerson: EPerson, notificationSection: string) {
// Relevant message for email in use
this.subs.push(this.epersonService.searchByScope('email', ePerson.email, {
currentPage: 1,
elementsPerPage: 0
}).pipe(getFirstSucceededRemoteData(), getRemoteDataPayload())
.subscribe((list: PaginatedList<EPerson>) => {
if (list.totalElements > 0) {
this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.' + notificationSection + '.failure.emailInUse', {
name: ePerson.name,
email: ePerson.email
}));
}
}));
}
/**
* Event triggered when the user changes page
* @param event
@@ -428,15 +431,6 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
});
}
/**
* Update the list of groups by fetching it from the rest api or cache
*/
private updateGroups(options) {
this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => {
this.groups = this.groupsDataService.findAllByHref(eperson._links.groups.href, options);
}));
}
/**
* Start impersonating the EPerson
*/
@@ -471,7 +465,8 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
}
this.cancelForm.emit();
});
}}
}
}
});
});
}
@@ -491,8 +486,10 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
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
@@ -503,4 +500,35 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
});
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
* @param ePerson ePerson values to check
* @param notificationSection whether in create or edit
*/
private showNotificationIfEmailInUse(ePerson: EPerson, notificationSection: string) {
// Relevant message for email in use
this.subs.push(this.epersonService.searchByScope('email', ePerson.email, {
currentPage: 1,
elementsPerPage: 0
}).pipe(getFirstSucceededRemoteData(), getRemoteDataPayload())
.subscribe((list: PaginatedList<EPerson>) => {
if (list.totalElements > 0) {
this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.' + notificationSection + '.failure.emailInUse', {
name: ePerson.name,
email: ePerson.email
}));
}
}));
}
/**
* Update the list of groups by fetching it from the rest api or cache
*/
private updateGroups(options) {
this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => {
this.groups = this.groupsDataService.findAllByHref(eperson._links.groups.href, options);
}));
}
}

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

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

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

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

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

@@ -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();
service = new VersionHistoryDataService(requestService, rdbService, null, objectCache, halService, notificationsService, versionService, null, null);
}
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();
});
}));
});
});

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

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

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

@@ -22,14 +22,17 @@
<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>
<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 || {}"

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>

View File

@@ -1,5 +1,5 @@
import { ItemVersionHistoryComponent } from './item-version-history.component';
import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { VarDirective } from '../../../shared/utils/var.directive';
import { RouterTestingModule } from '@angular/router/testing';
import { TranslateModule } from '@ngx-translate/core';
@@ -18,12 +18,20 @@ describe('ItemVersionHistoryComponent', () => {
handle: '123456789/1',
});
const activatedRoute = {
parent: {
parent: {
data: observableOf({dso: createSuccessfulRemoteDataObject(item)})
}
}
};
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ItemVersionHistoryComponent, VarDirective],
imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])],
providers: [
{ provide: ActivatedRoute, useValue: { parent: { data: observableOf({ dso: createSuccessfulRemoteDataObject(item) }) } } }
{ provide: ActivatedRoute, useValue: activatedRoute }
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();

View File

@@ -30,6 +30,6 @@ export class ItemVersionHistoryComponent {
}
ngOnInit(): void {
this.itemRD$ = this.route.parent.data.pipe(map((data) => data.dso)).pipe(getFirstSucceededRemoteData()) as Observable<RemoteData<Item>>;
this.itemRD$ = this.route.parent.parent.data.pipe(map((data) => data.dso)).pipe(getFirstSucceededRemoteData()) as Observable<RemoteData<Item>>;
}
}

View File

@@ -33,7 +33,7 @@
</dl>
</div>
<div class="col-2">
<ds-file-download-link [bitstream]="file">
<ds-file-download-link [bitstream]="file" [item]="item">
{{"item.page.filesection.download" | translate}}
</ds-file-download-link>
</div>
@@ -74,7 +74,7 @@
</dl>
</div>
<div class="col-2">
<ds-file-download-link [bitstream]="file">
<ds-file-download-link [bitstream]="file" [item]="item">
{{"item.page.filesection.download" | translate}}
</ds-file-download-link>
</div>

View File

@@ -22,6 +22,10 @@ export function getItemEditRoute(item: Item) {
return new URLCombiner(getItemPageRoute(item), ITEM_EDIT_PATH).toString();
}
export function getItemEditVersionhistoryRoute(item: Item) {
return new URLCombiner(getItemPageRoute(item), ITEM_EDIT_PATH, ITEM_EDIT_VERSIONHISTORY_PATH).toString();
}
export function getEntityPageRoute(entityType: string, itemId: string) {
if (isNotEmpty(entityType)) {
return new URLCombiner('/entities', encodeURIComponent(entityType.toLowerCase()), itemId).toString();
@@ -34,5 +38,15 @@ export function getEntityEditRoute(entityType: string, itemId: string) {
return new URLCombiner(getEntityPageRoute(entityType, itemId), ITEM_EDIT_PATH).toString();
}
/**
* Get the route to an item's version
* @param versionId the ID of the version for which the route will be retrieved
*/
export function getItemVersionRoute(versionId: string) {
return new URLCombiner(getItemModuleRoute(), ITEM_VERSION_PATH, versionId).toString();
}
export const ITEM_EDIT_PATH = 'edit';
export const ITEM_EDIT_VERSIONHISTORY_PATH = 'versionhistory';
export const ITEM_VERSION_PATH = 'version';
export const UPLOAD_BITSTREAM_PATH = 'bitstreams/new';

View File

@@ -3,6 +3,7 @@ import { RouterModule } from '@angular/router';
import { ItemPageResolver } from './item-page.resolver';
import { AuthenticatedGuard } from '../core/auth/authenticated.guard';
import { ItemBreadcrumbResolver } from '../core/breadcrumbs/item-breadcrumb.resolver';
import { VersionResolver } from './version-page/version.resolver';
import { DSOBreadcrumbsService } from '../core/breadcrumbs/dso-breadcrumbs.service';
import { LinkService } from '../core/cache/builders/link.service';
import { UploadBitstreamComponent } from './bitstreams/upload/upload-bitstream.component';
@@ -12,6 +13,8 @@ import { MenuItemType } from '../shared/menu/initial-menus-state';
import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model';
import { ThemedItemPageComponent } from './simple/themed-item-page.component';
import { ThemedFullItemPageComponent } from './full/themed-full-item-page.component';
import { VersionPageComponent } from './version-page/version-page/version-page.component';
import { BitstreamRequestACopyPageComponent } from '../shared/bitstream-request-a-copy-page/bitstream-request-a-copy-page.component';
@NgModule({
imports: [
@@ -42,6 +45,10 @@ import { ThemedFullItemPageComponent } from './full/themed-full-item-page.compon
path: UPLOAD_BITSTREAM_PATH,
component: UploadBitstreamComponent,
canActivate: [AuthenticatedGuard]
},
{
path: ':request-a-copy',
component: BitstreamRequestACopyPageComponent,
}
],
data: {
@@ -58,6 +65,18 @@ import { ThemedFullItemPageComponent } from './full/themed-full-item-page.compon
}],
},
},
},
{
path: 'version',
children: [
{
path: ':id',
component: VersionPageComponent,
resolve: {
dso: VersionResolver,
},
}
],
}
])
],
@@ -67,6 +86,7 @@ import { ThemedFullItemPageComponent } from './full/themed-full-item-page.compon
DSOBreadcrumbsService,
LinkService,
ItemPageAdministratorGuard,
VersionResolver,
]
})

View File

@@ -31,6 +31,8 @@ import { MediaViewerComponent } from './media-viewer/media-viewer.component';
import { MediaViewerVideoComponent } from './media-viewer/media-viewer-video/media-viewer-video.component';
import { MediaViewerImageComponent } from './media-viewer/media-viewer-image/media-viewer-image.component';
import { NgxGalleryModule } from '@kolkov/ngx-gallery';
import { VersionPageComponent } from './version-page/version-page/version-page.component';
import { VersionedItemComponent } from './simple/item-types/versioned-item/versioned-item.component';
import { ThemedFileSectionComponent} from './simple/field-components/file-section/themed-file-section.component';
const ENTRY_COMPONENTS = [
@@ -62,7 +64,8 @@ const DECLARATIONS = [
AbstractIncrementalListComponent,
MediaViewerComponent,
MediaViewerVideoComponent,
MediaViewerImageComponent
MediaViewerImageComponent,
VersionPageComponent,
];
@NgModule({
@@ -77,7 +80,8 @@ const DECLARATIONS = [
NgxGalleryModule,
],
declarations: [
...DECLARATIONS
...DECLARATIONS,
VersionedItemComponent
],
exports: [
...DECLARATIONS

View File

@@ -1,7 +1,7 @@
<ng-container *ngVar="(bitstreams$ | async) as bitstreams">
<ds-metadata-field-wrapper *ngIf="bitstreams?.length > 0" [label]="label | translate">
<div class="file-section">
<ds-file-download-link *ngFor="let file of bitstreams; let last=last;" [bitstream]="file">
<ds-file-download-link *ngFor="let file of bitstreams; let last=last;" [bitstream]="file" [item]="item">
<span>{{file?.name}}</span>
<span>({{(file?.sizeBytes) | dsFileSize }})</span>
<span *ngIf="!last" innerHTML="{{separator}}"></span>

View File

@@ -5,7 +5,7 @@
<ds-item-versions-notice [item]="item"></ds-item-versions-notice>
<ds-view-tracker [object]="item"></ds-view-tracker>
<ds-listable-object-component-loader [object]="item" [viewMode]="viewMode"></ds-listable-object-component-loader>
<ds-item-versions class="mt-2" [item]="item"></ds-item-versions>
<ds-item-versions class="mt-2" [item]="item" [displayActions]="false"></ds-item-versions>
</div>
</div>
<ds-error *ngIf="itemRD?.hasFailed" message="{{'error.item' | translate}}"></ds-error>

View File

@@ -3,6 +3,9 @@
<ds-metadata-values [mdValues]="object?.allMetadata(['dc.title'])"></ds-metadata-values>
</h2>
<div class="pl-2">
<ds-dso-page-version-button (newVersionEvent)="onCreateNewVersion()" [dso]="object"
[tooltipMsgCreate]="'item.page.version.create'"
[tooltipMsgHasDraft]="'item.page.version.hasDraft'"></ds-dso-page-version-button>
<ds-dso-page-edit-button [pageRoute]="itemPageRoute" [dso]="object" [tooltipMsg]="'item.page.edit'"></ds-dso-page-edit-button>
</div>
</div>

View File

@@ -29,6 +29,12 @@ import { TruncatePipe } from '../../../../shared/utils/truncate.pipe';
import { GenericItemPageFieldComponent } from '../../field-components/specific-field/generic/generic-item-page-field.component';
import { createRelationshipsObservable } from '../shared/item.component.spec';
import { UntypedItemComponent } from './untyped-item.component';
import { VersionHistoryDataService } from '../../../../core/data/version-history-data.service';
import { VersionDataService } from '../../../../core/data/version-data.service';
import { RouterTestingModule } from '@angular/router/testing';
import { WorkspaceitemDataService } from '../../../../core/submission/workspaceitem-data.service';
import { SearchService } from '../../../../core/shared/search/search.service';
import { ItemVersionsSharedService } from '../../../../shared/item/item-versions/item-versions-shared.service';
const mockItem: Item = Object.assign(new Item(), {
bundles: createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])),
@@ -47,13 +53,16 @@ describe('UntypedItemComponent', () => {
}
};
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot({
imports: [
TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useClass: TranslateLoaderMock
}
})],
declarations: [UntypedItemComponent, GenericItemPageFieldComponent, TruncatePipe],
}),
RouterTestingModule,
],
declarations: [UntypedItemComponent, GenericItemPageFieldComponent, TruncatePipe ],
providers: [
{ provide: ItemDataService, useValue: {} },
{ provide: TruncatableService, useValue: {} },
@@ -68,9 +77,14 @@ describe('UntypedItemComponent', () => {
{ provide: HttpClient, useValue: {} },
{ provide: DSOChangeAnalyzer, useValue: {} },
{ provide: DefaultChangeAnalyzer, useValue: {} },
{ provide: VersionHistoryDataService, useValue: {} },
{ provide: VersionDataService, useValue: {} },
{ provide: BitstreamDataService, useValue: mockBitstreamDataService },
{ provide: WorkspaceitemDataService, useValue: {} },
{ provide: SearchService, useValue: {} },
{ provide: ItemDataService, useValue: {} },
{ provide: ItemVersionsSharedService, useValue: {} },
],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(UntypedItemComponent, {
set: { changeDetection: ChangeDetectionStrategy.Default }

View File

@@ -1,8 +1,8 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { Item } from '../../../../core/shared/item.model';
import { ItemComponent } from '../shared/item.component';
import { ViewMode } from '../../../../core/shared/view-mode.model';
import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator';
import { VersionedItemComponent } from '../versioned-item/versioned-item.component';
/**
* Component that represents a publication Item page
@@ -15,6 +15,6 @@ import { listableObjectComponent } from '../../../../shared/object-collection/sh
templateUrl: './untyped-item.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UntypedItemComponent extends ItemComponent {
export class UntypedItemComponent extends VersionedItemComponent {
}

View File

@@ -0,0 +1,93 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { VersionedItemComponent } from './versioned-item.component';
import { VersionHistoryDataService } from '../../../../core/data/version-history-data.service';
import { TranslateService } from '@ngx-translate/core';
import { VersionDataService } from '../../../../core/data/version-data.service';
import { NotificationsService } from '../../../../shared/notifications/notifications.service';
import { ItemVersionsSharedService } from '../../../../shared/item/item-versions/item-versions-shared.service';
import { Item } from '../../../../core/shared/item.model';
import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils';
import { buildPaginatedList } from '../../../../core/data/paginated-list.model';
import { PageInfo } from '../../../../core/shared/page-info.model';
import { MetadataMap } from '../../../../core/shared/metadata.models';
import { createRelationshipsObservable } from '../shared/item.component.spec';
import { RouterTestingModule } from '@angular/router/testing';
import { Component } from '@angular/core';
import { WorkspaceitemDataService } from '../../../../core/submission/workspaceitem-data.service';
import { SearchService } from '../../../../core/shared/search/search.service';
import { ItemDataService } from '../../../../core/data/item-data.service';
import { Version } from '../../../../core/shared/version.model';
const mockItem: Item = Object.assign(new Item(), {
bundles: createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])),
metadata: new MetadataMap(),
relationships: createRelationshipsObservable(),
_links: {
self: {
href: 'item-href'
},
version: {
href: 'version-href'
}
}
});
@Component({template: ''})
class DummyComponent {
}
describe('VersionedItemComponent', () => {
let component: VersionedItemComponent;
let fixture: ComponentFixture<VersionedItemComponent>;
let versionService: VersionDataService;
let versionHistoryService: VersionHistoryDataService;
const versionServiceSpy = jasmine.createSpyObj('versionService', {
findByHref: createSuccessfulRemoteDataObject$<Version>(new Version()),
});
const versionHistoryServiceSpy = jasmine.createSpyObj('versionHistoryService', {
createVersion: createSuccessfulRemoteDataObject$<Version>(new Version()),
});
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [VersionedItemComponent, DummyComponent],
imports: [RouterTestingModule],
providers: [
{ provide: VersionHistoryDataService, useValue: versionHistoryServiceSpy },
{ provide: TranslateService, useValue: {} },
{ provide: VersionDataService, useValue: versionServiceSpy },
{ provide: NotificationsService, useValue: {} },
{ provide: ItemVersionsSharedService, useValue: {} },
{ provide: WorkspaceitemDataService, useValue: {} },
{ provide: SearchService, useValue: {} },
{ provide: ItemDataService, useValue: {} },
]
}).compileComponents();
versionService = TestBed.inject(VersionDataService);
versionHistoryService = TestBed.inject(VersionHistoryDataService);
});
beforeEach(() => {
fixture = TestBed.createComponent(VersionedItemComponent);
component = fixture.componentInstance;
component.object = mockItem;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('when onCreateNewVersion() is called', () => {
it('should call versionService.findByHref', () => {
component.onCreateNewVersion();
expect(versionService.findByHref).toHaveBeenCalledWith('version-href');
});
});
});

View File

@@ -0,0 +1,78 @@
import { Component } from '@angular/core';
import { ItemComponent } from '../shared/item.component';
import { ItemVersionsSummaryModalComponent } from '../../../../shared/item/item-versions/item-versions-summary-modal/item-versions-summary-modal.component';
import { getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload } from '../../../../core/shared/operators';
import { RemoteData } from '../../../../core/data/remote-data';
import { Version } from '../../../../core/shared/version.model';
import { switchMap, tap } from 'rxjs/operators';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { VersionHistoryDataService } from '../../../../core/data/version-history-data.service';
import { TranslateService } from '@ngx-translate/core';
import { VersionDataService } from '../../../../core/data/version-data.service';
import { ItemVersionsSharedService } from '../../../../shared/item/item-versions/item-versions-shared.service';
import { Router } from '@angular/router';
import { WorkspaceitemDataService } from '../../../../core/submission/workspaceitem-data.service';
import { SearchService } from '../../../../core/shared/search/search.service';
import { Item } from '../../../../core/shared/item.model';
import { ItemDataService } from '../../../../core/data/item-data.service';
import { WorkspaceItem } from '../../../../core/submission/models/workspaceitem.model';
@Component({
selector: 'ds-versioned-item',
templateUrl: './versioned-item.component.html',
styleUrls: ['./versioned-item.component.scss']
})
export class VersionedItemComponent extends ItemComponent {
constructor(
private modalService: NgbModal,
private versionHistoryService: VersionHistoryDataService,
private translateService: TranslateService,
private versionService: VersionDataService,
private itemVersionShared: ItemVersionsSharedService,
private router: Router,
private workspaceItemDataService: WorkspaceitemDataService,
private searchService: SearchService,
private itemService: ItemDataService,
) {
super();
}
/**
* Open a modal that allows to create a new version starting from the specified item, with optional summary
*/
onCreateNewVersion(): void {
const item = this.object;
const versionHref = item._links.version.href;
// Open modal
const activeModal = this.modalService.open(ItemVersionsSummaryModalComponent);
// Show current version in modal
this.versionService.findByHref(versionHref).pipe(getFirstCompletedRemoteData()).subscribe((res: RemoteData<Version>) => {
// if res.hasNoContent then the item is unversioned
activeModal.componentInstance.firstVersion = res.hasNoContent;
activeModal.componentInstance.versionNumber = (res.hasNoContent ? undefined : res.payload.version);
});
// On createVersionEvent emitted create new version and notify
activeModal.componentInstance.createVersionEvent.pipe(
switchMap((summary: string) => this.versionHistoryService.createVersion(item._links.self.href, summary)),
getFirstCompletedRemoteData(),
// show success/failure notification
tap((res: RemoteData<Version>) => { this.itemVersionShared.notifyCreateNewVersion(res); }),
// get workspace item
getFirstSucceededRemoteDataPayload<Version>(),
switchMap((newVersion: Version) => this.itemService.findByHref(newVersion._links.item.href)),
getFirstSucceededRemoteDataPayload<Item>(),
switchMap((newVersionItem: Item) => this.workspaceItemDataService.findByItem(newVersionItem.uuid, true, false)),
getFirstSucceededRemoteDataPayload<WorkspaceItem>(),
).subscribe((wsItem) => {
const wsiId = wsItem.id;
const route = 'workspaceitems/' + wsiId + '/edit';
this.router.navigateByUrl(route);
});
}
}

View File

@@ -0,0 +1,68 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { VersionPageComponent } from './version-page.component';
import { ActivatedRoute } from '@angular/router';
import { ActivatedRouteStub } from '../../../shared/testing/active-router.stub';
import { of as observableOf } from 'rxjs';
import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
import { Item } from '../../../core/shared/item.model';
import { createPaginatedList } from '../../../shared/testing/utils.test';
import { createRelationshipsObservable } from '../../simple/item-types/shared/item.component.spec';
import { VersionDataService } from '../../../core/data/version-data.service';
import { AuthService } from '../../../core/auth/auth.service';
import { Version } from '../../../core/shared/version.model';
import { RouterTestingModule } from '@angular/router/testing';
import { Component } from '@angular/core';
const mockItem: Item = Object.assign(new Item(), {
bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])),
metadata: [],
relationships: createRelationshipsObservable(),
uuid: 'item-uuid',
});
const mockVersion: Version = Object.assign(new Version(), {
item: createSuccessfulRemoteDataObject$(mockItem),
version: 1,
});
@Component({ template: '' })
class DummyComponent {
}
describe('VersionPageComponent', () => {
let component: VersionPageComponent;
let fixture: ComponentFixture<VersionPageComponent>;
let authService: AuthService;
const mockRoute = Object.assign(new ActivatedRouteStub(), {
data: observableOf({dso: createSuccessfulRemoteDataObject(mockVersion)})
});
beforeEach(waitForAsync(() => {
authService = jasmine.createSpyObj('authService', {
isAuthenticated: observableOf(true),
setRedirectUrl: {}
});
TestBed.configureTestingModule({
declarations: [VersionPageComponent, DummyComponent],
imports: [RouterTestingModule.withRoutes([{ path: 'items/item-uuid', component: DummyComponent, pathMatch: 'full' }])],
providers: [
{ provide: ActivatedRoute, useValue: mockRoute },
{ provide: VersionDataService, useValue: {} },
{ provide: AuthService, useValue: authService },
],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(VersionPageComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,56 @@
import { Component, OnInit } from '@angular/core';
import { Observable } from 'rxjs';
import { RemoteData } from '../../../core/data/remote-data';
import { ActivatedRoute, Router } from '@angular/router';
import { AuthService } from '../../../core/auth/auth.service';
import { map, switchMap } from 'rxjs/operators';
import { getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload, redirectOn4xx } from '../../../core/shared/operators';
import { VersionDataService } from '../../../core/data/version-data.service';
import { Version } from '../../../core/shared/version.model';
import { Item } from '../../../core/shared/item.model';
import { getItemPageRoute } from '../../item-page-routing-paths';
import { getPageNotFoundRoute } from '../../../app-routing-paths';
@Component({
selector: 'ds-version-page',
templateUrl: './version-page.component.html',
styleUrls: ['./version-page.component.scss']
})
export class VersionPageComponent implements OnInit {
versionRD$: Observable<RemoteData<Version>>;
itemRD$: Observable<RemoteData<Item>>;
constructor(
protected route: ActivatedRoute,
private router: Router,
private versionService: VersionDataService,
private authService: AuthService,
) {
}
ngOnInit(): void {
/* Retrieve version from resolver or redirect on 4xx */
this.versionRD$ = this.route.data.pipe(
map((data) => data.dso as RemoteData<Version>),
redirectOn4xx(this.router, this.authService),
);
/* Retrieve item from version and reroute to item's page or handle missing item */
this.versionRD$.pipe(
getFirstSucceededRemoteDataPayload(),
switchMap((version) => version.item),
redirectOn4xx(this.router, this.authService),
getFirstCompletedRemoteData(),
).subscribe((itemRD) => {
if (itemRD.hasNoContent) {
this.router.navigateByUrl(getPageNotFoundRoute(), { skipLocationChange: true });
} else {
const itemUrl = getItemPageRoute(itemRD.payload);
this.router.navigateByUrl(itemUrl);
}
});
}
}

View File

@@ -0,0 +1,54 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Resolve, Router, RouterStateSnapshot } from '@angular/router';
import { Observable } from 'rxjs';
import { RemoteData } from '../../core/data/remote-data';
import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { getFirstCompletedRemoteData } from '../../core/shared/operators';
import { Store } from '@ngrx/store';
import { ResolvedAction } from '../../core/resolving/resolver.actions';
import { Version } from '../../core/shared/version.model';
import { VersionDataService } from '../../core/data/version-data.service';
/**
* The self links defined in this list are expected to be requested somewhere in the near future
* Requesting them as embeds will limit the number of requests
*/
export const VERSION_PAGE_LINKS_TO_FOLLOW: FollowLinkConfig<Version>[] = [
followLink('item'),
];
/**
* This class represents a resolver that requests a specific version before the route is activated
*/
@Injectable()
export class VersionResolver implements Resolve<RemoteData<Version>> {
constructor(
protected versionService: VersionDataService,
protected store: Store<any>,
protected router: Router
) {
}
/**
* Method for resolving a version based on the parameters in the current route
* @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot
* @param {RouterStateSnapshot} state The current RouterStateSnapshot
* @returns Observable<<RemoteData<Item>> Emits the found item based on the parameters in the current route,
* or an error if something went wrong
*/
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<RemoteData<Version>> {
const versionRD$ = this.versionService.findById(route.params.id,
true,
false,
...VERSION_PAGE_LINKS_TO_FOLLOW
).pipe(
getFirstCompletedRemoteData(),
);
versionRD$.subscribe((versionRD: RemoteData<Version>) => {
this.store.dispatch(new ResolvedAction(state.url, versionRD.payload));
});
return versionRD$;
}
}

View File

@@ -0,0 +1,9 @@
<div class="container" *ngVar="(itemRequestRD$ | async) as itemRequestRD">
<h3 class="mb-4">{{'deny-request-copy.header' | translate}}</h3>
<div *ngIf="itemRequestRD && itemRequestRD.hasSucceeded">
<p>{{'deny-request-copy.intro' | translate}}</p>
<ds-email-request-copy [subject]="subject$ | async" [message]="message$ | async" (send)="deny($event)"></ds-email-request-copy>
</div>
<ds-loading *ngIf="!itemRequestRD || itemRequestRD?.isLoading"></ds-loading>
</div>

View File

@@ -0,0 +1,177 @@
import { DenyRequestCopyComponent } from './deny-request-copy.component';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { VarDirective } from '../../shared/utils/var.directive';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { RouterTestingModule } from '@angular/router/testing';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { AuthService } from '../../core/auth/auth.service';
import { ItemDataService } from '../../core/data/item-data.service';
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
import { ItemRequestDataService } from '../../core/data/item-request-data.service';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { of as observableOf } from 'rxjs';
import {
createFailedRemoteDataObject$,
createSuccessfulRemoteDataObject,
createSuccessfulRemoteDataObject$
} from '../../shared/remote-data.utils';
import { ItemRequest } from '../../core/shared/item-request.model';
import { EPerson } from '../../core/eperson/models/eperson.model';
import { Item } from '../../core/shared/item.model';
import { RequestCopyEmail } from '../email-request-copy/request-copy-email.model';
describe('DenyRequestCopyComponent', () => {
let component: DenyRequestCopyComponent;
let fixture: ComponentFixture<DenyRequestCopyComponent>;
let router: Router;
let route: ActivatedRoute;
let authService: AuthService;
let translateService: TranslateService;
let itemDataService: ItemDataService;
let nameService: DSONameService;
let itemRequestService: ItemRequestDataService;
let notificationsService: NotificationsService;
let itemRequest: ItemRequest;
let user: EPerson;
let item: Item;
let itemName: string;
let itemUrl: string;
beforeEach(waitForAsync(() => {
itemRequest = Object.assign(new ItemRequest(), {
token: 'item-request-token',
requestName: 'requester name'
});
user = Object.assign(new EPerson(), {
metadata: {
'eperson.firstname': [
{
value: 'first'
}
],
'eperson.lastname': [
{
value: 'last'
}
]
},
email: 'user-email',
});
itemName = 'item-name';
itemUrl = 'item-url';
item = Object.assign(new Item(), {
id: 'item-id',
metadata: {
'dc.identifier.uri': [
{
value: itemUrl
}
],
'dc.title': [
{
value: itemName
}
]
}
});
router = jasmine.createSpyObj('router', {
navigateByUrl: jasmine.createSpy('navigateByUrl'),
});
route = jasmine.createSpyObj('route', {}, {
data: observableOf({
request: createSuccessfulRemoteDataObject(itemRequest),
}),
});
authService = jasmine.createSpyObj('authService', {
isAuthenticated: observableOf(true),
getAuthenticatedUserFromStore: observableOf(user),
});
itemDataService = jasmine.createSpyObj('itemDataService', {
findById: createSuccessfulRemoteDataObject$(item),
});
nameService = jasmine.createSpyObj('nameService', {
getName: itemName,
});
itemRequestService = jasmine.createSpyObj('itemRequestService', {
deny: createSuccessfulRemoteDataObject$(itemRequest),
});
notificationsService = jasmine.createSpyObj('notificationsService', ['success', 'error']);
TestBed.configureTestingModule({
declarations: [DenyRequestCopyComponent, VarDirective],
imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])],
providers: [
{ provide: Router, useValue: router },
{ provide: ActivatedRoute, useValue: route },
{ provide: AuthService, useValue: authService },
{ provide: ItemDataService, useValue: itemDataService },
{ provide: DSONameService, useValue: nameService },
{ provide: ItemRequestDataService, useValue: itemRequestService },
{ provide: NotificationsService, useValue: notificationsService },
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(DenyRequestCopyComponent);
component = fixture.componentInstance;
fixture.detectChanges();
translateService = (component as any).translateService;
spyOn(translateService, 'get').and.returnValue(observableOf('translated-message'));
});
it('message$ should be parameterized correctly', (done) => {
component.message$.subscribe(() => {
expect(translateService.get).toHaveBeenCalledWith(jasmine.anything(), Object.assign({
recipientName: itemRequest.requestName,
itemUrl: itemUrl,
itemName: itemName,
authorName: user.name,
authorEmail: user.email,
}));
done();
});
});
describe('deny', () => {
let email: RequestCopyEmail;
describe('when the request is successful', () => {
beforeEach(() => {
email = new RequestCopyEmail('subject', 'message');
(itemRequestService.deny as jasmine.Spy).and.returnValue(createSuccessfulRemoteDataObject$(itemRequest));
component.deny(email);
});
it('should display a success notification', () => {
expect(notificationsService.success).toHaveBeenCalled();
});
it('should navigate to the homepage', () => {
expect(router.navigateByUrl).toHaveBeenCalledWith('/');
});
});
describe('when the request is unsuccessful', () => {
beforeEach(() => {
email = new RequestCopyEmail('subject', 'message');
(itemRequestService.deny as jasmine.Spy).and.returnValue(createFailedRemoteDataObject$());
component.deny(email);
});
it('should display a success notification', () => {
expect(notificationsService.error).toHaveBeenCalled();
});
it('should not navigate', () => {
expect(router.navigateByUrl).not.toHaveBeenCalled();
});
});
});
});

View File

@@ -0,0 +1,112 @@
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { map, switchMap } from 'rxjs/operators';
import { ItemRequest } from '../../core/shared/item-request.model';
import { Observable } from 'rxjs/internal/Observable';
import {
getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload,
redirectOn4xx
} from '../../core/shared/operators';
import { RemoteData } from '../../core/data/remote-data';
import { AuthService } from '../../core/auth/auth.service';
import { TranslateService } from '@ngx-translate/core';
import { combineLatest as observableCombineLatest } from 'rxjs';
import { ItemDataService } from '../../core/data/item-data.service';
import { EPerson } from '../../core/eperson/models/eperson.model';
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
import { Item } from '../../core/shared/item.model';
import { isNotEmpty } from '../../shared/empty.util';
import { ItemRequestDataService } from '../../core/data/item-request-data.service';
import { RequestCopyEmail } from '../email-request-copy/request-copy-email.model';
import { NotificationsService } from '../../shared/notifications/notifications.service';
@Component({
selector: 'ds-deny-request-copy',
styleUrls: ['./deny-request-copy.component.scss'],
templateUrl: './deny-request-copy.component.html'
})
/**
* Component for denying an item request
*/
export class DenyRequestCopyComponent implements OnInit {
/**
* The item request to deny
*/
itemRequestRD$: Observable<RemoteData<ItemRequest>>;
/**
* The default subject of the message to send to the user requesting the item
*/
subject$: Observable<string>;
/**
* The default contents of the message to send to the user requesting the item
*/
message$: Observable<string>;
constructor(
private router: Router,
private route: ActivatedRoute,
private authService: AuthService,
private translateService: TranslateService,
private itemDataService: ItemDataService,
private nameService: DSONameService,
private itemRequestService: ItemRequestDataService,
private notificationsService: NotificationsService,
) {
}
ngOnInit(): void {
this.itemRequestRD$ = this.route.data.pipe(
map((data) => data.request as RemoteData<ItemRequest>),
getFirstCompletedRemoteData(),
redirectOn4xx(this.router, this.authService),
);
const msgParams$ = observableCombineLatest(
this.itemRequestRD$.pipe(getFirstSucceededRemoteDataPayload()),
this.authService.getAuthenticatedUserFromStore(),
).pipe(
switchMap(([itemRequest, user]: [ItemRequest, EPerson]) => {
return this.itemDataService.findById(itemRequest.itemId).pipe(
getFirstSucceededRemoteDataPayload(),
map((item: Item) => {
const uri = item.firstMetadataValue('dc.identifier.uri');
return Object.assign({
recipientName: itemRequest.requestName,
itemUrl: isNotEmpty(uri) ? uri : item.handle,
itemName: this.nameService.getName(item),
authorName: user.name,
authorEmail: user.email,
});
}),
);
}),
);
this.subject$ = this.translateService.get('deny-request-copy.email.subject');
this.message$ = msgParams$.pipe(
switchMap((params) => this.translateService.get('deny-request-copy.email.message', params)),
);
}
/**
* Deny the item request
* @param email Subject and contents of the message to send back to the user requesting the item
*/
deny(email: RequestCopyEmail) {
this.itemRequestRD$.pipe(
getFirstSucceededRemoteDataPayload(),
switchMap((itemRequest: ItemRequest) => this.itemRequestService.deny(itemRequest.token, email)),
getFirstCompletedRemoteData()
).subscribe((rd) => {
if (rd.hasSucceeded) {
this.notificationsService.success(this.translateService.get('deny-request-copy.success'));
this.router.navigateByUrl('/');
} else {
this.notificationsService.error(this.translateService.get('deny-request-copy.error'), rd.errorMessage);
}
});
}
}

View File

@@ -0,0 +1,30 @@
<form>
<div class="form-group">
<label for="subject">{{ 'grant-deny-request-copy.email.subject' | translate }}</label>
<input type="text" class="form-control" id="subject" [ngClass]="{'is-invalid': !subject || subject.length === 0}" [(ngModel)]="subject" name="subject">
<div *ngIf="!subject || subject.length === 0" class="invalid-feedback">
{{ 'grant-deny-request-copy.email.subject.empty' | translate }}
</div>
</div>
<div class="form-group">
<label for="message">{{ 'grant-deny-request-copy.email.message' | translate }}</label>
<textarea class="form-control" id="message" rows="8" [ngClass]="{'is-invalid': !message || message.length === 0}" [(ngModel)]="message" name="message"></textarea>
<div *ngIf="!message || message.length === 0" class="invalid-feedback">
{{ 'grant-deny-request-copy.email.message.empty' | translate }}
</div>
</div>
<ng-content></ng-content>
<div class="d-flex flex-row-reverse">
<button (click)="submit()"
[disabled]="!message || message.length === 0 || !subject || subject.length === 0"
class="btn btn-primary"
title="{{'grant-deny-request-copy.email.send' | translate }}">
<i class="fas fa-envelope"></i> {{'grant-deny-request-copy.email.send' | translate }}
</button>
<button (click)="return()"
class="btn btn-outline-secondary mr-1"
title="{{'grant-deny-request-copy.email.back' | translate }}">
<i class="fas fa-arrow-left"></i> {{'grant-deny-request-copy.email.back' | translate }}
</button>
</div>
</form>

View File

@@ -0,0 +1,47 @@
import { EmailRequestCopyComponent } from './email-request-copy.component';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { VarDirective } from '../../shared/utils/var.directive';
import { TranslateModule } from '@ngx-translate/core';
import { RouterTestingModule } from '@angular/router/testing';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { Location } from '@angular/common';
import { RequestCopyEmail } from './request-copy-email.model';
describe('EmailRequestCopyComponent', () => {
let component: EmailRequestCopyComponent;
let fixture: ComponentFixture<EmailRequestCopyComponent>;
let location: Location;
beforeEach(waitForAsync(() => {
location = jasmine.createSpyObj('location', ['back']);
TestBed.configureTestingModule({
declarations: [EmailRequestCopyComponent, VarDirective],
imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])],
providers: [
{ provide: Location, useValue: location },
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(EmailRequestCopyComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('return should navigate to the previous page', () => {
component.return();
expect(location.back).toHaveBeenCalled();
});
it('submit should emit an email object', () => {
spyOn(component.send, 'emit').and.stub();
component.subject = 'test-subject';
component.message = 'test-message';
component.submit();
expect(component.send.emit).toHaveBeenCalledWith(new RequestCopyEmail('test-subject', 'test-message'));
});
});

View File

@@ -0,0 +1,45 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { RequestCopyEmail } from './request-copy-email.model';
import { Location } from '@angular/common';
@Component({
selector: 'ds-email-request-copy',
styleUrls: ['./email-request-copy.component.scss'],
templateUrl: './email-request-copy.component.html'
})
/**
* A form component for an email to send back to the user requesting an item
*/
export class EmailRequestCopyComponent {
/**
* Event emitter for sending the email
*/
@Output() send: EventEmitter<RequestCopyEmail> = new EventEmitter<RequestCopyEmail>();
/**
* The subject of the email
*/
@Input() subject: string;
/**
* The contents of the email
*/
@Input() message: string;
constructor(protected location: Location) {
}
/**
* Submit the email
*/
submit() {
this.send.emit(new RequestCopyEmail(this.subject, this.message));
}
/**
* Return to the previous page
*/
return() {
this.location.back();
}
}

View File

@@ -0,0 +1,8 @@
/**
* A class representing an email to send back to the user requesting an item
*/
export class RequestCopyEmail {
constructor(public subject: string,
public message: string) {
}
}

View File

@@ -0,0 +1,30 @@
<div class="container" *ngVar="(itemRequestRD$ | async) as itemRequestRD">
<h3 class="mb-4">{{'grant-deny-request-copy.header' | translate}}</h3>
<div *ngIf="itemRequestRD && itemRequestRD.hasSucceeded">
<div *ngIf="!itemRequestRD.payload.decisionDate">
<p [innerHTML]="'grant-deny-request-copy.intro1' | translate:{ url: (itemUrl$ | async), name: (itemName$ | async) }"></p>
<p>{{'grant-deny-request-copy.intro2' | translate}}</p>
<div class="btn-group ">
<a [routerLink]="grantRoute$ | async"
class="btn btn-outline-primary"
title="{{'grant-deny-request-copy.grant' | translate }}">
{{'grant-deny-request-copy.grant' | translate }}
</a>
<a [routerLink]="denyRoute$ | async"
class="btn btn-outline-danger"
title="{{'grant-deny-request-copy.deny' | translate }}">
{{'grant-deny-request-copy.deny' | translate }}
</a>
</div>
</div>
<div *ngIf="itemRequestRD.payload.decisionDate" class="processed-message">
<p>{{'grant-deny-request-copy.processed' | translate}}</p>
<p class="text-center">
<a routerLink="/home" class="btn btn-primary">{{'grant-deny-request-copy.home-page' | translate}}</a>
</p>
</div>
</div>
<ds-loading *ngIf="!itemRequestRD || itemRequestRD?.isLoading"></ds-loading>
</div>

View File

@@ -0,0 +1,141 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { VarDirective } from '../../shared/utils/var.directive';
import { TranslateModule } from '@ngx-translate/core';
import { RouterTestingModule } from '@angular/router/testing';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { AuthService } from '../../core/auth/auth.service';
import { ItemDataService } from '../../core/data/item-data.service';
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
import { of as observableOf } from 'rxjs';
import {
createSuccessfulRemoteDataObject,
createSuccessfulRemoteDataObject$
} from '../../shared/remote-data.utils';
import { ItemRequest } from '../../core/shared/item-request.model';
import { Item } from '../../core/shared/item.model';
import { GrantDenyRequestCopyComponent } from './grant-deny-request-copy.component';
import { getItemPageRoute } from '../../item-page/item-page-routing-paths';
import { getRequestCopyDenyRoute, getRequestCopyGrantRoute } from '../request-copy-routing-paths';
import { By } from '@angular/platform-browser';
describe('GrantDenyRequestCopyComponent', () => {
let component: GrantDenyRequestCopyComponent;
let fixture: ComponentFixture<GrantDenyRequestCopyComponent>;
let router: Router;
let route: ActivatedRoute;
let authService: AuthService;
let itemDataService: ItemDataService;
let nameService: DSONameService;
let itemRequest: ItemRequest;
let item: Item;
let itemName: string;
let itemUrl: string;
beforeEach(waitForAsync(() => {
itemRequest = Object.assign(new ItemRequest(), {
token: 'item-request-token',
requestName: 'requester name'
});
itemName = 'item-name';
item = Object.assign(new Item(), {
id: 'item-id',
metadata: {
'dc.identifier.uri': [
{
value: itemUrl
}
],
'dc.title': [
{
value: itemName
}
]
}
});
itemUrl = getItemPageRoute(item);
route = jasmine.createSpyObj('route', {}, {
data: observableOf({
request: createSuccessfulRemoteDataObject(itemRequest),
}),
});
authService = jasmine.createSpyObj('authService', {
isAuthenticated: observableOf(true),
});
itemDataService = jasmine.createSpyObj('itemDataService', {
findById: createSuccessfulRemoteDataObject$(item),
});
nameService = jasmine.createSpyObj('nameService', {
getName: itemName,
});
TestBed.configureTestingModule({
declarations: [GrantDenyRequestCopyComponent, VarDirective],
imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])],
providers: [
{ provide: ActivatedRoute, useValue: route },
{ provide: AuthService, useValue: authService },
{ provide: ItemDataService, useValue: itemDataService },
{ provide: DSONameService, useValue: nameService },
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(GrantDenyRequestCopyComponent);
component = fixture.componentInstance;
fixture.detectChanges();
router = (component as any).router;
spyOn(router, 'navigateByUrl').and.stub();
});
it('should initialise itemName$', (done) => {
component.itemName$.subscribe((result) => {
expect(result).toEqual(itemName);
done();
});
});
it('should initialise itemUrl$', (done) => {
component.itemUrl$.subscribe((result) => {
expect(result).toEqual(itemUrl);
done();
});
});
it('should initialise denyRoute$', (done) => {
component.denyRoute$.subscribe((result) => {
expect(result).toEqual(getRequestCopyDenyRoute(itemRequest.token));
done();
});
});
it('should initialise grantRoute$', (done) => {
component.grantRoute$.subscribe((result) => {
expect(result).toEqual(getRequestCopyGrantRoute(itemRequest.token));
done();
});
});
describe('processed message', () => {
it('should not be displayed when decisionDate is undefined', () => {
const message = fixture.debugElement.query(By.css('.processed-message'));
expect(message).toBeNull();
});
it('should be displayed when decisionDate is defined', () => {
component.itemRequestRD$ = createSuccessfulRemoteDataObject$(Object.assign(new ItemRequest(), itemRequest, {
decisionDate: 'defined-date'
}));
fixture.detectChanges();
const message = fixture.debugElement.query(By.css('.processed-message'));
expect(message).not.toBeNull();
});
});
});

View File

@@ -0,0 +1,97 @@
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { map, switchMap } from 'rxjs/operators';
import { ItemRequest } from '../../core/shared/item-request.model';
import { Observable } from 'rxjs/internal/Observable';
import {
getFirstCompletedRemoteData,
getFirstSucceededRemoteDataPayload,
redirectOn4xx
} from '../../core/shared/operators';
import { RemoteData } from '../../core/data/remote-data';
import { AuthService } from '../../core/auth/auth.service';
import { getRequestCopyDenyRoute, getRequestCopyGrantRoute } from '../request-copy-routing-paths';
import { Item } from '../../core/shared/item.model';
import { ItemDataService } from '../../core/data/item-data.service';
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
import { getItemPageRoute } from '../../item-page/item-page-routing-paths';
@Component({
selector: 'ds-grant-deny-request-copy',
styleUrls: ['./grant-deny-request-copy.component.scss'],
templateUrl: './grant-deny-request-copy.component.html'
})
/**
* Component for an author to decide to grant or deny an item request
*/
export class GrantDenyRequestCopyComponent implements OnInit {
/**
* The item request to grant or deny
*/
itemRequestRD$: Observable<RemoteData<ItemRequest>>;
/**
* The item the request is requesting access to
*/
itemRD$: Observable<RemoteData<Item>>;
/**
* The name of the item
*/
itemName$: Observable<string>;
/**
* The url of the item
*/
itemUrl$: Observable<string>;
/**
* The route to the page for denying access to the item
*/
denyRoute$: Observable<string>;
/**
* The route to the page for granting access to the item
*/
grantRoute$: Observable<string>;
constructor(
private router: Router,
private route: ActivatedRoute,
private authService: AuthService,
private itemDataService: ItemDataService,
private nameService: DSONameService,
) {
}
ngOnInit(): void {
this.itemRequestRD$ = this.route.data.pipe(
map((data) => data.request as RemoteData<ItemRequest>),
getFirstCompletedRemoteData(),
redirectOn4xx(this.router, this.authService),
);
this.itemRD$ = this.itemRequestRD$.pipe(
getFirstSucceededRemoteDataPayload(),
switchMap((itemRequest: ItemRequest) => this.itemDataService.findById(itemRequest.itemId)),
);
this.itemName$ = this.itemRD$.pipe(
getFirstSucceededRemoteDataPayload(),
map((item) => this.nameService.getName(item)),
);
this.itemUrl$ = this.itemRD$.pipe(
getFirstSucceededRemoteDataPayload(),
map((item) => getItemPageRoute(item)),
);
this.denyRoute$ = this.itemRequestRD$.pipe(
getFirstSucceededRemoteDataPayload(),
map((itemRequest: ItemRequest) => getRequestCopyDenyRoute(itemRequest.token))
);
this.grantRoute$ = this.itemRequestRD$.pipe(
getFirstSucceededRemoteDataPayload(),
map((itemRequest: ItemRequest) => getRequestCopyGrantRoute(itemRequest.token))
);
}
}

View File

@@ -0,0 +1,17 @@
<div class="container" *ngVar="(itemRequestRD$ | async) as itemRequestRD">
<h3 class="mb-4">{{'grant-request-copy.header' | translate}}</h3>
<div *ngIf="itemRequestRD && itemRequestRD.hasSucceeded">
<p>{{'grant-request-copy.intro' | translate}}</p>
<ds-email-request-copy [subject]="subject$ | async" [message]="message$ | async" (send)="grant($event)">
<p>{{ 'grant-deny-request-copy.email.permissions.info' | translate }}</p>
<form class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" value="" id="permissions" [(ngModel)]="suggestOpenAccess" name="permissions">
<label class="form-check-label" for="permissions">{{ 'grant-deny-request-copy.email.permissions.label' | translate }}</label>
</div>
</form>
</ds-email-request-copy>
</div>
<ds-loading *ngIf="!itemRequestRD || itemRequestRD?.isLoading"></ds-loading>
</div>

View File

@@ -0,0 +1,177 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { VarDirective } from '../../shared/utils/var.directive';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { RouterTestingModule } from '@angular/router/testing';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { AuthService } from '../../core/auth/auth.service';
import { ItemDataService } from '../../core/data/item-data.service';
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
import { ItemRequestDataService } from '../../core/data/item-request-data.service';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { of as observableOf } from 'rxjs';
import {
createFailedRemoteDataObject$,
createSuccessfulRemoteDataObject,
createSuccessfulRemoteDataObject$
} from '../../shared/remote-data.utils';
import { ItemRequest } from '../../core/shared/item-request.model';
import { EPerson } from '../../core/eperson/models/eperson.model';
import { Item } from '../../core/shared/item.model';
import { RequestCopyEmail } from '../email-request-copy/request-copy-email.model';
import { GrantRequestCopyComponent } from './grant-request-copy.component';
describe('GrantRequestCopyComponent', () => {
let component: GrantRequestCopyComponent;
let fixture: ComponentFixture<GrantRequestCopyComponent>;
let router: Router;
let route: ActivatedRoute;
let authService: AuthService;
let translateService: TranslateService;
let itemDataService: ItemDataService;
let nameService: DSONameService;
let itemRequestService: ItemRequestDataService;
let notificationsService: NotificationsService;
let itemRequest: ItemRequest;
let user: EPerson;
let item: Item;
let itemName: string;
let itemUrl: string;
beforeEach(waitForAsync(() => {
itemRequest = Object.assign(new ItemRequest(), {
token: 'item-request-token',
requestName: 'requester name'
});
user = Object.assign(new EPerson(), {
metadata: {
'eperson.firstname': [
{
value: 'first'
}
],
'eperson.lastname': [
{
value: 'last'
}
]
},
email: 'user-email',
});
itemName = 'item-name';
itemUrl = 'item-url';
item = Object.assign(new Item(), {
id: 'item-id',
metadata: {
'dc.identifier.uri': [
{
value: itemUrl
}
],
'dc.title': [
{
value: itemName
}
]
}
});
router = jasmine.createSpyObj('router', {
navigateByUrl: jasmine.createSpy('navigateByUrl'),
});
route = jasmine.createSpyObj('route', {}, {
data: observableOf({
request: createSuccessfulRemoteDataObject(itemRequest),
}),
});
authService = jasmine.createSpyObj('authService', {
isAuthenticated: observableOf(true),
getAuthenticatedUserFromStore: observableOf(user),
});
itemDataService = jasmine.createSpyObj('itemDataService', {
findById: createSuccessfulRemoteDataObject$(item),
});
nameService = jasmine.createSpyObj('nameService', {
getName: itemName,
});
itemRequestService = jasmine.createSpyObj('itemRequestService', {
grant: createSuccessfulRemoteDataObject$(itemRequest),
});
notificationsService = jasmine.createSpyObj('notificationsService', ['success', 'error']);
TestBed.configureTestingModule({
declarations: [GrantRequestCopyComponent, VarDirective],
imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])],
providers: [
{ provide: Router, useValue: router },
{ provide: ActivatedRoute, useValue: route },
{ provide: AuthService, useValue: authService },
{ provide: ItemDataService, useValue: itemDataService },
{ provide: DSONameService, useValue: nameService },
{ provide: ItemRequestDataService, useValue: itemRequestService },
{ provide: NotificationsService, useValue: notificationsService },
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(GrantRequestCopyComponent);
component = fixture.componentInstance;
fixture.detectChanges();
translateService = (component as any).translateService;
spyOn(translateService, 'get').and.returnValue(observableOf('translated-message'));
});
it('message$ should be parameterized correctly', (done) => {
component.message$.subscribe(() => {
expect(translateService.get).toHaveBeenCalledWith(jasmine.anything(), Object.assign({
recipientName: itemRequest.requestName,
itemUrl: itemUrl,
itemName: itemName,
authorName: user.name,
authorEmail: user.email,
}));
done();
});
});
describe('grant', () => {
let email: RequestCopyEmail;
describe('when the request is successful', () => {
beforeEach(() => {
email = new RequestCopyEmail('subject', 'message');
(itemRequestService.grant as jasmine.Spy).and.returnValue(createSuccessfulRemoteDataObject$(itemRequest));
component.grant(email);
});
it('should display a success notification', () => {
expect(notificationsService.success).toHaveBeenCalled();
});
it('should navigate to the homepage', () => {
expect(router.navigateByUrl).toHaveBeenCalledWith('/');
});
});
describe('when the request is unsuccessful', () => {
beforeEach(() => {
email = new RequestCopyEmail('subject', 'message');
(itemRequestService.grant as jasmine.Spy).and.returnValue(createFailedRemoteDataObject$());
component.grant(email);
});
it('should display a success notification', () => {
expect(notificationsService.error).toHaveBeenCalled();
});
it('should not navigate', () => {
expect(router.navigateByUrl).not.toHaveBeenCalled();
});
});
});
});

View File

@@ -0,0 +1,118 @@
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { map, switchMap } from 'rxjs/operators';
import { ItemRequest } from '../../core/shared/item-request.model';
import { Observable } from 'rxjs/internal/Observable';
import {
getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload,
redirectOn4xx
} from '../../core/shared/operators';
import { RemoteData } from '../../core/data/remote-data';
import { AuthService } from '../../core/auth/auth.service';
import { TranslateService } from '@ngx-translate/core';
import { combineLatest as observableCombineLatest } from 'rxjs';
import { ItemDataService } from '../../core/data/item-data.service';
import { EPerson } from '../../core/eperson/models/eperson.model';
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
import { Item } from '../../core/shared/item.model';
import { isNotEmpty } from '../../shared/empty.util';
import { ItemRequestDataService } from '../../core/data/item-request-data.service';
import { RequestCopyEmail } from '../email-request-copy/request-copy-email.model';
import { NotificationsService } from '../../shared/notifications/notifications.service';
@Component({
selector: 'ds-grant-request-copy',
styleUrls: ['./grant-request-copy.component.scss'],
templateUrl: './grant-request-copy.component.html'
})
/**
* Component for granting an item request
*/
export class GrantRequestCopyComponent implements OnInit {
/**
* The item request to accept
*/
itemRequestRD$: Observable<RemoteData<ItemRequest>>;
/**
* The default subject of the message to send to the user requesting the item
*/
subject$: Observable<string>;
/**
* The default contents of the message to send to the user requesting the item
*/
message$: Observable<string>;
/**
* Whether or not the item should be open access, to avoid future requests
* Defaults to false
*/
suggestOpenAccess = false;
constructor(
private router: Router,
private route: ActivatedRoute,
private authService: AuthService,
private translateService: TranslateService,
private itemDataService: ItemDataService,
private nameService: DSONameService,
private itemRequestService: ItemRequestDataService,
private notificationsService: NotificationsService,
) {
}
ngOnInit(): void {
this.itemRequestRD$ = this.route.data.pipe(
map((data) => data.request as RemoteData<ItemRequest>),
getFirstCompletedRemoteData(),
redirectOn4xx(this.router, this.authService),
);
const msgParams$ = observableCombineLatest(
this.itemRequestRD$.pipe(getFirstSucceededRemoteDataPayload()),
this.authService.getAuthenticatedUserFromStore(),
).pipe(
switchMap(([itemRequest, user]: [ItemRequest, EPerson]) => {
return this.itemDataService.findById(itemRequest.itemId).pipe(
getFirstSucceededRemoteDataPayload(),
map((item: Item) => {
const uri = item.firstMetadataValue('dc.identifier.uri');
return Object.assign({
recipientName: itemRequest.requestName,
itemUrl: isNotEmpty(uri) ? uri : item.handle,
itemName: this.nameService.getName(item),
authorName: user.name,
authorEmail: user.email,
});
}),
);
}),
);
this.subject$ = this.translateService.get('grant-request-copy.email.subject');
this.message$ = msgParams$.pipe(
switchMap((params) => this.translateService.get('grant-request-copy.email.message', params)),
);
}
/**
* Grant the item request
* @param email Subject and contents of the message to send back to the user requesting the item
*/
grant(email: RequestCopyEmail) {
this.itemRequestRD$.pipe(
getFirstSucceededRemoteDataPayload(),
switchMap((itemRequest: ItemRequest) => this.itemRequestService.grant(itemRequest.token, email, this.suggestOpenAccess)),
getFirstCompletedRemoteData()
).subscribe((rd) => {
if (rd.hasSucceeded) {
this.notificationsService.success(this.translateService.get('grant-request-copy.success'));
this.router.navigateByUrl('/');
} else {
this.notificationsService.error(this.translateService.get('grant-request-copy.error'), rd.errorMessage);
}
});
}
}

View File

@@ -0,0 +1,18 @@
import { URLCombiner } from '../core/url-combiner/url-combiner';
import { getRequestCopyModulePath } from '../app-routing-paths';
export function getRequestCopyRoute(token: string) {
return new URLCombiner(getRequestCopyModulePath(), token).toString();
}
export const REQUEST_COPY_DENY_PATH = 'deny';
export function getRequestCopyDenyRoute(token: string) {
return new URLCombiner(getRequestCopyRoute(token), REQUEST_COPY_DENY_PATH).toString();
}
export const REQUEST_COPY_GRANT_PATH = 'grant';
export function getRequestCopyGrantRoute(token: string) {
return new URLCombiner(getRequestCopyRoute(token), REQUEST_COPY_GRANT_PATH).toString();
}

View File

@@ -0,0 +1,40 @@
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { RequestCopyResolver } from './request-copy.resolver';
import { GrantDenyRequestCopyComponent } from './grant-deny-request-copy/grant-deny-request-copy.component';
import { REQUEST_COPY_DENY_PATH, REQUEST_COPY_GRANT_PATH } from './request-copy-routing-paths';
import { DenyRequestCopyComponent } from './deny-request-copy/deny-request-copy.component';
import { GrantRequestCopyComponent } from './grant-request-copy/grant-request-copy.component';
@NgModule({
imports: [
RouterModule.forChild([
{
path: ':token',
resolve: {
request: RequestCopyResolver
},
children: [
{
path: '',
component: GrantDenyRequestCopyComponent,
},
{
path: REQUEST_COPY_DENY_PATH,
component: DenyRequestCopyComponent,
},
{
path: REQUEST_COPY_GRANT_PATH,
component: GrantRequestCopyComponent,
},
]
}
])
],
providers: [
RequestCopyResolver,
GrantDenyRequestCopyComponent
]
})
export class RequestCopyRoutingModule {
}

View File

@@ -0,0 +1,30 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { SharedModule } from '../shared/shared.module';
import { GrantDenyRequestCopyComponent } from './grant-deny-request-copy/grant-deny-request-copy.component';
import { RequestCopyRoutingModule } from './request-copy-routing.module';
import { DenyRequestCopyComponent } from './deny-request-copy/deny-request-copy.component';
import { EmailRequestCopyComponent } from './email-request-copy/email-request-copy.component';
import { GrantRequestCopyComponent } from './grant-request-copy/grant-request-copy.component';
@NgModule({
imports: [
CommonModule,
SharedModule,
RequestCopyRoutingModule
],
declarations: [
GrantDenyRequestCopyComponent,
DenyRequestCopyComponent,
EmailRequestCopyComponent,
GrantRequestCopyComponent,
],
providers: []
})
/**
* Module related to components used to grant or deny an item request
*/
export class RequestCopyModule {
}

View File

@@ -0,0 +1,26 @@
import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router';
import { RemoteData } from '../core/data/remote-data';
import { ItemRequest } from '../core/shared/item-request.model';
import { Observable } from 'rxjs/internal/Observable';
import { ItemRequestDataService } from '../core/data/item-request-data.service';
import { Injectable } from '@angular/core';
import { getFirstCompletedRemoteData } from '../core/shared/operators';
/**
* Resolves an {@link ItemRequest} from the token found in the route's parameters
*/
@Injectable()
export class RequestCopyResolver implements Resolve<RemoteData<ItemRequest>> {
constructor(
private itemRequestDataService: ItemRequestDataService,
) {
}
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<RemoteData<ItemRequest>> | Promise<RemoteData<ItemRequest>> | RemoteData<ItemRequest> {
return this.itemRequestDataService.findById(route.params.token).pipe(
getFirstCompletedRemoteData(),
);
}
}

View File

@@ -55,10 +55,4 @@ describe('ConfigurationSearchPageComponent', () => {
expect(routeService.setParameter).toHaveBeenCalledWith('fixedFilterQuery', QUERY);
});
it('should reset route parameters on destroy', () => {
fixture.destroy();
expect(routeService.setParameter).toHaveBeenCalledWith('configuration', undefined);
expect(routeService.setParameter).toHaveBeenCalledWith('fixedFilterQuery', undefined);
});
});

View File

@@ -6,7 +6,6 @@ import {
Component,
Inject,
Input,
OnDestroy,
OnInit
} from '@angular/core';
import { pushInOut } from '../shared/animations/push';
@@ -34,7 +33,7 @@ import { Router } from '@angular/router';
]
})
export class ConfigurationSearchPageComponent extends SearchComponent implements OnInit, OnDestroy {
export class ConfigurationSearchPageComponent extends SearchComponent implements OnInit {
/**
* The configuration to use for the search options
* If empty, the configuration will be determined by the route parameter called 'configuration'
@@ -72,17 +71,4 @@ export class ConfigurationSearchPageComponent extends SearchComponent implements
this.routeService.setParameter('fixedFilterQuery', this.fixedFilterQuery);
}
}
/**
* Reset the updated query/configuration set in ngOnInit()
*/
ngOnDestroy(): void {
super.ngOnDestroy();
if (hasValue(this.configuration)) {
this.routeService.setParameter('configuration', undefined);
}
if (hasValue(this.fixedFilterQuery)) {
this.routeService.setParameter('fixedFilterQuery', undefined);
}
}
}

View File

@@ -52,7 +52,7 @@
[searchPlaceholder]="'search.search-form.placeholder' | translate">
</ds-search-form>
<div class="row mb-3 mb-md-1">
<div class="labels col-sm-9 offset-sm-3">
<div class="labels col-sm-9">
<ds-search-labels *ngIf="searchEnabled" [inPlaceSearch]="inPlaceSearch"></ds-search-labels>
</div>
</div>

View File

@@ -0,0 +1,87 @@
<div class="container">
<h3 class="mb-4">{{'bitstream-request-a-copy.header' | translate}}</h3>
<div *ngIf="canDownload$|async" class="alert alert-success">
<span>{{'bitstream-request-a-copy.alert.canDownload1' | translate}}</span>
<a [routerLink]="getBitstreamLink()">{{'bitstream-request-a-copy.alert.canDownload2'| translate}}</a>
</div>
<div>
<p>{{'bitstream-request-a-copy.intro' | translate}} <a [routerLink]="getItemPath()">{{itemName}}</a></p>
<p *ngIf="bitstream != undefined && allfiles.value === 'false'">{{'bitstream-request-a-copy.intro.bitstream.one' | translate}} {{bitstreamName}}</p>
<p *ngIf="allfiles.value === 'true'">{{'bitstream-request-a-copy.intro.bitstream.all' | translate}}</p>
</div>
<form [class]="'ng-invalid'" [formGroup]="requestCopyForm" (ngSubmit)="onSubmit()">
<div class="form-group">
<div class="row mb-4">
<div class="col-12">
<label for="name">{{'bitstream-request-a-copy.name.label' | translate}}</label>
<input [className]="(name.invalid) && (name.dirty || name.touched) ? 'form-control is-invalid' :'form-control'"
type="text" id="name" formControlName="name"/>
<div *ngIf="name.invalid && (name.dirty || name.touched)"
class="invalid-feedback show-feedback">
<span *ngIf="name.errors && name.errors.required">
{{ 'bitstream-request-a-copy.name.error' | translate }}
</span>
</div>
</div>
</div>
<div class="row mb-4">
<div class="col-12">
<label
for="email">{{'bitstream-request-a-copy.email.label' | translate}}</label>
<input
[className]="(email.invalid) && (email.dirty || email.touched) ? 'form-control is-invalid' :'form-control'"
id="email" formControlName="email">
<div *ngIf="email.invalid && (email.dirty || email.touched)"
class="invalid-feedback show-feedback">
<span *ngIf="email.errors">
{{ 'bitstream-request-a-copy.email.error' | translate }}
</span>
</div>
<small class="text-muted ds-hint">{{'bitstream-request-a-copy.email.hint' |translate}}</small>
</div>
</div>
<div class="row mb-4">
<div class="col-12">
<div>{{'bitstream-request-a-copy.allfiles.label' |translate}}</div>
<div class="ml-4">
<input [className]="'form-check-input'" type="radio"
id="allfiles-true" formControlName="allfiles" value="true">
<label class="form-check-label"
for="allfiles-true">{{'bitstream-request-a-copy.files-all-true.label' | translate}}</label>
</div>
<div class="ml-4">
<input [className]="'form-check-input'" type="radio"
id="allfiles-false" formControlName="allfiles" value="false" [attr.disabled]="bitstream === undefined ? true : null ">
<label class="form-check-label"
for="allfiles-false">{{'bitstream-request-a-copy.files-all-false.label' | translate}}</label>
</div>
</div>
</div>
<div class="row mb-4">
<div class="col-12">
<label
for="message">{{'bitstream-request-a-copy.message.label' | translate}}</label>
<textarea rows="5"
[className]="'form-control'"
id="message" formControlName="message"></textarea>
</div>
</div>
</div>
</form>
<hr>
<div class="row">
<div class="col-12 text-right">
<a (click)="navigateBack()" role="button" class="btn btn-outline-secondary mr-1">
<i class="fas fa-arrow-left"></i> {{'bitstream-request-a-copy.return' | translate}}
</a>
<button
[disabled]="requestCopyForm.invalid"
class="btn btn-default btn-primary"
(click)="onSubmit()">{{'bitstream-request-a-copy.submit' | translate}}</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,289 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { AuthService } from '../../core/auth/auth.service';
import { of as observableOf } from 'rxjs';
import { Bitstream } from '../../core/shared/bitstream.model';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import {
createFailedRemoteDataObject$,
createSuccessfulRemoteDataObject,
createSuccessfulRemoteDataObject$
} from '../remote-data.utils';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { CommonModule } from '@angular/common';
import { BitstreamRequestACopyPageComponent } from './bitstream-request-a-copy-page.component';
import { By } from '@angular/platform-browser';
import { RouterStub } from '../testing/router.stub';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { NotificationsServiceStub } from '../testing/notifications-service.stub';
import { ItemRequestDataService } from '../../core/data/item-request-data.service';
import { NotificationsService } from '../notifications/notifications.service';
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
import { DSONameServiceMock } from '../mocks/dso-name.service.mock';
import { Item } from '../../core/shared/item.model';
import { EPerson } from '../../core/eperson/models/eperson.model';
import { ItemRequest } from '../../core/shared/item-request.model';
import { Location } from '@angular/common';
import { BitstreamDataService } from '../../core/data/bitstream-data.service';
describe('BitstreamRequestACopyPageComponent', () => {
let component: BitstreamRequestACopyPageComponent;
let fixture: ComponentFixture<BitstreamRequestACopyPageComponent>;
let authService: AuthService;
let authorizationService: AuthorizationDataService;
let activatedRoute;
let router;
let itemRequestDataService;
let notificationsService;
let location;
let bitstreamDataService;
let item: Item;
let bitstream: Bitstream;
let eperson;
function init() {
eperson = Object.assign(new EPerson(), {
email: 'test@mail.org',
metadata: {
'eperson.firstname': [{value: 'Test'}],
'eperson.lastname': [{value: 'User'}],
}
});
authService = jasmine.createSpyObj('authService', {
isAuthenticated: observableOf(false),
getAuthenticatedUserFromStore: observableOf(eperson)
});
authorizationService = jasmine.createSpyObj('authorizationSerivice', {
isAuthorized: observableOf(true)
});
itemRequestDataService = jasmine.createSpyObj('itemRequestDataService', {
requestACopy: createSuccessfulRemoteDataObject$({})
});
location = jasmine.createSpyObj('location', {
back: {}
});
notificationsService = new NotificationsServiceStub();
item = Object.assign(new Item(), {uuid: 'item-uuid'});
bitstream = Object.assign(new Bitstream(), {
uuid: 'bitstreamUuid',
_links: {
content: {href: 'bitstream-content-link'},
self: {href: 'bitstream-self-link'},
}
});
activatedRoute = {
data: observableOf({
dso: createSuccessfulRemoteDataObject(
item
)
}),
queryParams: observableOf({
bitstream : bitstream.uuid
})
};
bitstreamDataService = jasmine.createSpyObj('bitstreamDataService', {
findById: createSuccessfulRemoteDataObject$(bitstream)
});
router = new RouterStub();
}
function initTestbed() {
TestBed.configureTestingModule({
imports: [CommonModule, TranslateModule.forRoot(), FormsModule, ReactiveFormsModule],
declarations: [BitstreamRequestACopyPageComponent],
providers: [
{provide: Location, useValue: location},
{provide: ActivatedRoute, useValue: activatedRoute},
{provide: Router, useValue: router},
{provide: AuthorizationDataService, useValue: authorizationService},
{provide: AuthService, useValue: authService},
{provide: ItemRequestDataService, useValue: itemRequestDataService},
{provide: NotificationsService, useValue: notificationsService},
{provide: DSONameService, useValue: new DSONameServiceMock()},
{provide: BitstreamDataService, useValue: bitstreamDataService},
]
})
.compileComponents();
}
describe('init', () => {
beforeEach(waitForAsync(() => {
init();
initTestbed();
}));
beforeEach(() => {
fixture = TestBed.createComponent(BitstreamRequestACopyPageComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should init the comp', () => {
expect(component).toBeTruthy();
});
});
describe('should show a form to request a copy', () => {
describe('when the user is not logged in', () => {
beforeEach(waitForAsync(() => {
init();
initTestbed();
}));
beforeEach(() => {
fixture = TestBed.createComponent(BitstreamRequestACopyPageComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('show the form with no values filled in based on the user', () => {
expect(component.name.value).toEqual('');
expect(component.email.value).toEqual('');
expect(component.allfiles.value).toEqual('false');
expect(component.message.value).toEqual('');
});
});
describe('when the user is logged in', () => {
beforeEach(waitForAsync(() => {
init();
(authService.isAuthenticated as jasmine.Spy).and.returnValue(observableOf(true));
initTestbed();
}));
beforeEach(() => {
fixture = TestBed.createComponent(BitstreamRequestACopyPageComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('show the form with values filled in based on the user', () => {
fixture.detectChanges();
expect(component.name.value).toEqual(eperson.name);
expect(component.email.value).toEqual(eperson.email);
expect(component.allfiles.value).toEqual('false');
expect(component.message.value).toEqual('');
});
});
describe('when no bitstream was provided', () => {
beforeEach(waitForAsync(() => {
init();
activatedRoute = {
data: observableOf({
dso: createSuccessfulRemoteDataObject(
item
)
}),
queryParams: observableOf({
})
};
initTestbed();
}));
beforeEach(() => {
fixture = TestBed.createComponent(BitstreamRequestACopyPageComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should set the all files value to true and disable the false value', () => {
expect(component.name.value).toEqual('');
expect(component.email.value).toEqual('');
expect(component.allfiles.value).toEqual('true');
expect(component.message.value).toEqual('');
const allFilesFalse = fixture.debugElement.query(By.css('#allfiles-false')).nativeElement;
expect(allFilesFalse.getAttribute('disabled')).toBeTruthy();
});
});
describe('when the user has authorization to download the file', () => {
beforeEach(waitForAsync(() => {
init();
(authService.isAuthenticated as jasmine.Spy).and.returnValue(observableOf(true));
initTestbed();
}));
beforeEach(() => {
fixture = TestBed.createComponent(BitstreamRequestACopyPageComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should show an alert indicating the user can download the file', () => {
const alert = fixture.debugElement.query(By.css('.alert')).nativeElement;
expect(alert.innerHTML).toContain('bitstream-request-a-copy.alert.canDownload');
});
});
});
describe('onSubmit', () => {
describe('onSuccess', () => {
beforeEach(waitForAsync(() => {
init();
initTestbed();
}));
beforeEach(() => {
fixture = TestBed.createComponent(BitstreamRequestACopyPageComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should take the current form information and submit it', () => {
component.name.patchValue('User Name');
component.email.patchValue('user@name.org');
component.allfiles.patchValue('false');
component.message.patchValue('I would like to request a copy');
component.onSubmit();
const itemRequest = Object.assign(new ItemRequest(),
{
itemId: item.uuid,
bitstreamId: bitstream.uuid,
allfiles: 'false',
requestEmail: 'user@name.org',
requestName: 'User Name',
requestMessage: 'I would like to request a copy'
});
expect(itemRequestDataService.requestACopy).toHaveBeenCalledWith(itemRequest);
expect(notificationsService.success).toHaveBeenCalled();
expect(location.back).toHaveBeenCalled();
});
});
describe('onFail', () => {
beforeEach(waitForAsync(() => {
init();
(itemRequestDataService.requestACopy as jasmine.Spy).and.returnValue(createFailedRemoteDataObject$());
initTestbed();
}));
beforeEach(() => {
fixture = TestBed.createComponent(BitstreamRequestACopyPageComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should take the current form information and submit it', () => {
component.name.patchValue('User Name');
component.email.patchValue('user@name.org');
component.allfiles.patchValue('false');
component.message.patchValue('I would like to request a copy');
component.onSubmit();
const itemRequest = Object.assign(new ItemRequest(),
{
itemId: item.uuid,
bitstreamId: bitstream.uuid,
allfiles: 'false',
requestEmail: 'user@name.org',
requestName: 'User Name',
requestMessage: 'I would like to request a copy'
});
expect(itemRequestDataService.requestACopy).toHaveBeenCalledWith(itemRequest);
expect(notificationsService.error).toHaveBeenCalled();
expect(location.back).not.toHaveBeenCalled();
});
});
});
});

View File

@@ -0,0 +1,213 @@
import { Component, OnDestroy, OnInit } from '@angular/core';
import { filter, map, switchMap, take } from 'rxjs/operators';
import { ActivatedRoute, Router } from '@angular/router';
import { hasValue, isNotEmpty } from '../empty.util';
import { getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload } from '../../core/shared/operators';
import { Bitstream } from '../../core/shared/bitstream.model';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
import { AuthService } from '../../core/auth/auth.service';
import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs';
import { getBitstreamDownloadRoute, getForbiddenRoute } from '../../app-routing-paths';
import { TranslateService } from '@ngx-translate/core';
import { Subscription } from 'rxjs/internal/Subscription';
import { EPerson } from '../../core/eperson/models/eperson.model';
import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms';
import { ItemRequestDataService } from '../../core/data/item-request-data.service';
import { ItemRequest } from '../../core/shared/item-request.model';
import { Item } from '../../core/shared/item.model';
import { NotificationsService } from '../notifications/notifications.service';
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
import { Location } from '@angular/common';
import { BitstreamDataService } from '../../core/data/bitstream-data.service';
import { getItemPageRoute } from '../../item-page/item-page-routing-paths';
@Component({
selector: 'ds-bitstream-request-a-copy-page',
templateUrl: './bitstream-request-a-copy-page.component.html'
})
/**
* Page component for requesting a copy for a bitstream
*/
export class BitstreamRequestACopyPageComponent implements OnInit, OnDestroy {
item$: Observable<Item>;
canDownload$: Observable<boolean>;
private subs: Subscription[] = [];
requestCopyForm: FormGroup;
item: Item;
itemName: string;
bitstream$: Observable<Bitstream>;
bitstream: Bitstream;
bitstreamName: string;
constructor(private location: Location,
private translateService: TranslateService,
private route: ActivatedRoute,
protected router: Router,
private authorizationService: AuthorizationDataService,
private auth: AuthService,
private formBuilder: FormBuilder,
private itemRequestDataService: ItemRequestDataService,
private notificationsService: NotificationsService,
private dsoNameService: DSONameService,
private bitstreamService: BitstreamDataService,
) {
}
ngOnInit(): void {
this.requestCopyForm = this.formBuilder.group({
name: new FormControl('', {
validators: [Validators.required],
}),
email: new FormControl('', {
validators: [Validators.required,
Validators.pattern('^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,4}$')]
}),
allfiles: new FormControl(''),
message: new FormControl(''),
});
this.item$ = this.route.data.pipe(
map((data) => data.dso),
getFirstSucceededRemoteDataPayload()
);
this.subs.push(this.item$.subscribe((item) => {
this.item = item;
this.itemName = this.dsoNameService.getName(item);
}));
this.bitstream$ = this.route.queryParams.pipe(
filter((params) => hasValue(params) && hasValue(params.bitstream)),
switchMap((params) => this.bitstreamService.findById(params.bitstream)),
getFirstSucceededRemoteDataPayload()
);
this.subs.push(this.bitstream$.subscribe((bitstream) => {
this.bitstream = bitstream;
this.bitstreamName = this.dsoNameService.getName(bitstream);
}));
this.canDownload$ = this.bitstream$.pipe(
switchMap((bitstream) => this.authorizationService.isAuthorized(FeatureID.CanDownload, isNotEmpty(bitstream) ? bitstream.self : undefined))
);
const canRequestCopy$ = this.bitstream$.pipe(
switchMap((bitstream) => this.authorizationService.isAuthorized(FeatureID.CanRequestACopy, isNotEmpty(bitstream) ? bitstream.self : undefined)),
);
this.subs.push(observableCombineLatest([this.canDownload$, canRequestCopy$]).subscribe(([canDownload, canRequestCopy]) => {
if (!canDownload && !canRequestCopy) {
this.router.navigateByUrl(getForbiddenRoute(), {skipLocationChange: true});
}
}));
this.initValues();
}
get name() {
return this.requestCopyForm.get('name');
}
get email() {
return this.requestCopyForm.get('email');
}
get message() {
return this.requestCopyForm.get('message');
}
get allfiles() {
return this.requestCopyForm.get('allfiles');
}
/**
* Initialise the form values based on the current user.
*/
private initValues() {
this.getCurrentUser().pipe(take(1)).subscribe((user) => {
this.requestCopyForm.patchValue({allfiles: 'true'});
if (hasValue(user)) {
this.requestCopyForm.patchValue({name: user.name, email: user.email});
}
});
this.bitstream$.pipe(take(1)).subscribe((bitstream) => {
this.requestCopyForm.patchValue({allfiles: 'false'});
});
}
/**
* Retrieve the current user
*/
private getCurrentUser(): Observable<EPerson> {
return this.auth.isAuthenticated().pipe(
switchMap((authenticated) => {
if (authenticated) {
return this.auth.getAuthenticatedUserFromStore();
} else {
return observableOf(undefined);
}
})
);
}
/**
* Submit the the form values as an item request to the server.
* When the submission is successful, the user will be redirected to the item page and a success notification will be shown.
* When the submission fails, the user will stay on the page and an error notification will be shown
*/
onSubmit() {
const itemRequest = new ItemRequest();
if (hasValue(this.bitstream)) {
itemRequest.bitstreamId = this.bitstream.uuid;
}
itemRequest.itemId = this.item.uuid;
itemRequest.allfiles = this.allfiles.value;
itemRequest.requestEmail = this.email.value;
itemRequest.requestName = this.name.value;
itemRequest.requestMessage = this.message.value;
this.itemRequestDataService.requestACopy(itemRequest).pipe(
getFirstCompletedRemoteData()
).subscribe((rd) => {
if (rd.hasSucceeded) {
this.notificationsService.success(this.translateService.get('bitstream-request-a-copy.submit.success'));
this.navigateBack();
} else {
this.notificationsService.error(this.translateService.get('bitstream-request-a-copy.submit.error'));
}
});
}
ngOnDestroy(): void {
if (hasValue(this.subs)) {
this.subs.forEach((sub) => {
if (hasValue(sub)) {
sub.unsubscribe();
}
});
}
}
/**
* Navigates back to the user's previous location
*/
navigateBack() {
this.location.back();
}
getItemPath() {
return [getItemPageRoute(this.item)];
}
/**
* Retrieves the link to the bistream download page
*/
getBitstreamLink() {
return [getBitstreamDownloadRoute(this.bitstream)];
}
}

View File

@@ -0,0 +1,8 @@
<button *ngIf="isAuthorized$ | async"
class="edit-button btn btn-dark btn-sm"
(click)="createNewVersion()"
[disabled]="disableNewVersionButton$ | async"
[ngbTooltip]="tooltipMsg$ | async | translate"
role="button" [title]="tooltipMsg$ | async |translate" [attr.aria-label]="tooltipMsg$ | async | translate">
<i class="fas fa-code-branch fa-fw"></i>
</button>

View File

@@ -0,0 +1,3 @@
.btn-dark {
background-color: var(--ds-admin-sidebar-bg);
}

View File

@@ -0,0 +1,96 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { DsoPageVersionButtonComponent } from './dso-page-version-button.component';
import { Item } from '../../../core/shared/item.model';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { Observable, of, of as observableOf } from 'rxjs';
import { TranslateModule } from '@ngx-translate/core';
import { RouterTestingModule } from '@angular/router/testing';
import { FeatureID } from '../../../core/data/feature-authorization/feature-id';
import { By } from '@angular/platform-browser';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { VersionHistoryDataService } from '../../../core/data/version-history-data.service';
describe('DsoPageVersionButtonComponent', () => {
let component: DsoPageVersionButtonComponent;
let fixture: ComponentFixture<DsoPageVersionButtonComponent>;
let authorizationService: AuthorizationDataService;
let versionHistoryService: VersionHistoryDataService;
let dso: Item;
let tooltipMsg: Observable<string>;
const authorizationServiceSpy = jasmine.createSpyObj('authorizationService', ['isAuthorized']);
const versionHistoryServiceSpy = jasmine.createSpyObj('versionHistoryService',
['getVersions', 'getLatestVersionFromHistory$', 'isLatest$', 'hasDraftVersion$']
);
beforeEach(waitForAsync(() => {
dso = Object.assign(new Item(), {
id: 'test-item',
_links: {
self: { href: 'test-item-selflink' },
version: { href: 'test-item-version-selflink' },
},
});
tooltipMsg = of('tooltip-msg');
TestBed.configureTestingModule({
declarations: [DsoPageVersionButtonComponent],
imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NgbModule],
providers: [
{ provide: AuthorizationDataService, useValue: authorizationServiceSpy },
{ provide: VersionHistoryDataService, useValue: versionHistoryServiceSpy },
]
}).compileComponents();
authorizationService = TestBed.inject(AuthorizationDataService);
versionHistoryService = TestBed.inject(VersionHistoryDataService);
versionHistoryServiceSpy.hasDraftVersion$.and.returnValue(observableOf(true));
}));
beforeEach(() => {
fixture = TestBed.createComponent(DsoPageVersionButtonComponent);
component = fixture.componentInstance;
component.dso = dso;
component.tooltipMsg$ = tooltipMsg;
fixture.detectChanges();
});
it('should check the authorization of the current user', () => {
expect(authorizationService.isAuthorized).toHaveBeenCalledWith(FeatureID.CanCreateVersion, dso.self);
});
it('should check if the item has a draft version', () => {
expect(versionHistoryServiceSpy.hasDraftVersion$).toHaveBeenCalledWith(dso._links.version.href);
});
describe('when the user is authorized', () => {
beforeEach(() => {
authorizationServiceSpy.isAuthorized.and.returnValue(observableOf(true));
component.ngOnInit();
fixture.detectChanges();
});
it('should render a button', () => {
const button = fixture.debugElement.query(By.css('button'));
expect(button).not.toBeNull();
});
});
describe('when the user is not authorized', () => {
beforeEach(() => {
authorizationServiceSpy.isAuthorized.and.returnValue(observableOf(false));
component.ngOnInit();
fixture.detectChanges();
});
it('should render a button', () => {
const button = fixture.debugElement.query(By.css('button'));
expect(button).toBeNull();
});
});
});

View File

@@ -0,0 +1,78 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { Observable } from 'rxjs/internal/Observable';
import { FeatureID } from '../../../core/data/feature-authorization/feature-id';
import { VersionHistoryDataService } from '../../../core/data/version-history-data.service';
import { Item } from '../../../core/shared/item.model';
import { map, startWith, switchMap } from 'rxjs/operators';
import { of } from 'rxjs';
@Component({
selector: 'ds-dso-page-version-button',
templateUrl: './dso-page-version-button.component.html',
styleUrls: ['./dso-page-version-button.component.scss']
})
/**
* Display a button linking to the edit page of a DSpaceObject
*/
export class DsoPageVersionButtonComponent implements OnInit {
/**
* The item for which display a button to create a new version
*/
@Input() dso: Item;
/**
* A message for the tooltip on the button
* Supports i18n keys
*/
@Input() tooltipMsgCreate: string;
/**
* A message for the tooltip on the button (when is disabled)
* Supports i18n keys
*/
@Input() tooltipMsgHasDraft: string;
/**
* Emits an event that triggers the creation of the new version
*/
@Output() newVersionEvent = new EventEmitter();
/**
* Whether or not the current user is authorized to create a new version of the DSpaceObject
*/
isAuthorized$: Observable<boolean>;
disableNewVersionButton$: Observable<boolean>;
tooltipMsg$: Observable<string>;
constructor(
protected authorizationService: AuthorizationDataService,
protected versionHistoryService: VersionHistoryDataService,
) {
}
/**
* Creates a new version for the current item
*/
createNewVersion() {
this.newVersionEvent.emit();
}
ngOnInit() {
this.isAuthorized$ = this.authorizationService.isAuthorized(FeatureID.CanCreateVersion, this.dso.self);
this.disableNewVersionButton$ = this.versionHistoryService.hasDraftVersion$(this.dso._links.version.href).pipe(
// button is disabled if hasDraftVersion = true, and enabled if hasDraftVersion = false or null
// (hasDraftVersion is null when a version history does not exist)
map((res) => Boolean(res)),
startWith(true),
);
this.tooltipMsg$ = this.disableNewVersionButton$.pipe(
switchMap((hasDraftVersion) => of(hasDraftVersion ? this.tooltipMsgHasDraft : this.tooltipMsgCreate)),
);
}
}

View File

@@ -1,4 +1,5 @@
<a [href]="bitstreamPath" [target]="isBlank ? '_blank': '_self'" [ngClass]="cssClasses">
<a [routerLink]="(bitstreamPath$| async)?.routerLink" [queryParams]="(bitstreamPath$| async)?.queryParams" [target]="isBlank ? '_blank': '_self'" [ngClass]="cssClasses">
<span *ngIf="!(canDownload$ |async)"><i class="fas fa-lock"></i></span>
<ng-container *ngTemplateOutlet="content"></ng-container>
</a>

View File

@@ -1,62 +1,145 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { FileDownloadLinkComponent } from './file-download-link.component';
import { AuthService } from '../../core/auth/auth.service';
import { FileService } from '../../core/shared/file.service';
import { of as observableOf } from 'rxjs';
import { Bitstream } from '../../core/shared/bitstream.model';
import { By } from '@angular/platform-browser';
import { URLCombiner } from '../../core/url-combiner/url-combiner';
import { getBitstreamModuleRoute } from '../../app-routing-paths';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { cold, getTestScheduler } from 'jasmine-marbles';
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
import { Item } from '../../core/shared/item.model';
import { getItemModuleRoute } from '../../item-page/item-page-routing-paths';
import { RouterLinkDirectiveStub } from '../testing/router-link-directive.stub';
describe('FileDownloadLinkComponent', () => {
let component: FileDownloadLinkComponent;
let fixture: ComponentFixture<FileDownloadLinkComponent>;
let authService: AuthService;
let fileService: FileService;
let scheduler;
let authorizationService: AuthorizationDataService;
let bitstream: Bitstream;
let item: Item;
function init() {
authService = jasmine.createSpyObj('authService', {
isAuthenticated: observableOf(true)
authorizationService = jasmine.createSpyObj('authorizationService', {
isAuthorized: cold('-a', {a: true})
});
fileService = jasmine.createSpyObj('fileService', ['downloadFile']);
bitstream = Object.assign(new Bitstream(), {
uuid: 'bitstreamUuid',
_links: {
self: {href: 'obj-selflink'}
}
});
item = Object.assign(new Item(), {
uuid: 'itemUuid',
_links: {
self: {href: 'obj-selflink'}
}
});
}
beforeEach(waitForAsync(() => {
init();
function initTestbed() {
TestBed.configureTestingModule({
declarations: [FileDownloadLinkComponent],
declarations: [FileDownloadLinkComponent, RouterLinkDirectiveStub],
providers: [
{ provide: AuthService, useValue: authService },
{ provide: FileService, useValue: fileService },
{provide: AuthorizationDataService, useValue: authorizationService},
]
})
.compileComponents();
}
describe('init', () => {
describe('getBitstreamPath', () => {
describe('when the user has download rights', () => {
beforeEach(waitForAsync(() => {
scheduler = getTestScheduler();
init();
initTestbed();
}));
beforeEach(() => {
fixture = TestBed.createComponent(FileDownloadLinkComponent);
component = fixture.componentInstance;
component.bitstream = bitstream;
component.item = item;
fixture.detectChanges();
});
it('should return the bitstreamPath based on the input bitstream', () => {
expect(component.bitstreamPath$).toBeObservable(cold('-a', {a: { routerLink: new URLCombiner(getBitstreamModuleRoute(), bitstream.uuid, 'download').toString(), queryParams: {} }}));
expect(component.canDownload$).toBeObservable(cold('--a', {a: true}));
describe('init', () => {
describe('getBitstreamPath', () => {
it('should set the bitstreamPath based on the input bitstream', () => {
expect(component.bitstreamPath).toEqual(new URLCombiner(getBitstreamModuleRoute(), bitstream.uuid, 'download').toString());
});
});
it('should init the component', () => {
const link = fixture.debugElement.query(By.css('a')).nativeElement;
expect(link.href).toContain(new URLCombiner(getBitstreamModuleRoute(), bitstream.uuid, 'download').toString());
scheduler.flush();
fixture.detectChanges();
const link = fixture.debugElement.query(By.css('a'));
expect(link.injector.get(RouterLinkDirectiveStub).routerLink).toContain(new URLCombiner(getBitstreamModuleRoute(), bitstream.uuid, 'download').toString());
const lock = fixture.debugElement.query(By.css('.fa-lock'));
expect(lock).toBeNull();
});
});
describe('when the user has no download rights but has the right to request a copy', () => {
beforeEach(waitForAsync(() => {
scheduler = getTestScheduler();
init();
(authorizationService.isAuthorized as jasmine.Spy).and.callFake((featureId, object) => {
if (featureId === FeatureID.CanDownload) {
return cold('-a', {a: false});
}
return cold('-a', {a: true});
});
initTestbed();
}));
beforeEach(() => {
fixture = TestBed.createComponent(FileDownloadLinkComponent);
component = fixture.componentInstance;
component.item = item;
component.bitstream = bitstream;
fixture.detectChanges();
});
it('should return the bitstreamPath based on the input bitstream', () => {
expect(component.bitstreamPath$).toBeObservable(cold('-a', {a: { routerLink: new URLCombiner(getItemModuleRoute(), item.uuid, 'request-a-copy').toString(), queryParams: { bitstream: bitstream.uuid } }}));
expect(component.canDownload$).toBeObservable(cold('--a', {a: false}));
});
it('should init the component', () => {
scheduler.flush();
fixture.detectChanges();
const link = fixture.debugElement.query(By.css('a'));
expect(link.injector.get(RouterLinkDirectiveStub).routerLink).toContain(new URLCombiner(getItemModuleRoute(), item.uuid, 'request-a-copy').toString());
const lock = fixture.debugElement.query(By.css('.fa-lock')).nativeElement;
expect(lock).toBeTruthy();
});
});
describe('when the user has no download rights and no request a copy rights', () => {
beforeEach(waitForAsync(() => {
scheduler = getTestScheduler();
init();
(authorizationService.isAuthorized as jasmine.Spy).and.returnValue(cold('-a', {a: false}));
initTestbed();
}));
beforeEach(() => {
fixture = TestBed.createComponent(FileDownloadLinkComponent);
component = fixture.componentInstance;
component.bitstream = bitstream;
component.item = item;
fixture.detectChanges();
});
it('should return the bitstreamPath based on the input bitstream', () => {
expect(component.bitstreamPath$).toBeObservable(cold('-a', {a: { routerLink: new URLCombiner(getBitstreamModuleRoute(), bitstream.uuid, 'download').toString(), queryParams: {} }}));
expect(component.canDownload$).toBeObservable(cold('--a', {a: false}));
});
it('should init the component', () => {
scheduler.flush();
fixture.detectChanges();
const link = fixture.debugElement.query(By.css('a'));
expect(link.injector.get(RouterLinkDirectiveStub).routerLink).toContain(new URLCombiner(getBitstreamModuleRoute(), bitstream.uuid, 'download').toString());
const lock = fixture.debugElement.query(By.css('.fa-lock')).nativeElement;
expect(lock).toBeTruthy();
});
});
});
});
});

View File

@@ -1,6 +1,12 @@
import { Component, Input, OnInit } from '@angular/core';
import { Bitstream } from '../../core/shared/bitstream.model';
import { getBitstreamDownloadRoute } from '../../app-routing-paths';
import { getBitstreamDownloadRoute, getBitstreamRequestACopyRoute } from '../../app-routing-paths';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
import { hasValue, isNotEmpty } from '../empty.util';
import { map } from 'rxjs/operators';
import { of as observableOf, combineLatest as observableCombineLatest, Observable } from 'rxjs';
import { Item } from '../../core/shared/item.model';
@Component({
selector: 'ds-file-download-link',
@@ -19,6 +25,8 @@ export class FileDownloadLinkComponent implements OnInit {
*/
@Input() bitstream: Bitstream;
@Input() item: Item;
/**
* Additional css classes to apply to link
*/
@@ -29,13 +37,44 @@ export class FileDownloadLinkComponent implements OnInit {
*/
@Input() isBlank = false;
bitstreamPath: string;
@Input() enableRequestACopy = true;
bitstreamPath$: Observable<{
routerLink: string,
queryParams: any,
}>;
canDownload$: Observable<boolean>;
constructor(
private authorizationService: AuthorizationDataService,
) {
}
ngOnInit() {
this.bitstreamPath = this.getBitstreamPath();
if (this.enableRequestACopy) {
this.canDownload$ = this.authorizationService.isAuthorized(FeatureID.CanDownload, isNotEmpty(this.bitstream) ? this.bitstream.self : undefined);
const canRequestACopy$ = this.authorizationService.isAuthorized(FeatureID.CanRequestACopy, isNotEmpty(this.bitstream) ? this.bitstream.self : undefined);
this.bitstreamPath$ = observableCombineLatest([this.canDownload$, canRequestACopy$]).pipe(
map(([canDownload, canRequestACopy]) => this.getBitstreamPath(canDownload, canRequestACopy))
);
} else {
this.bitstreamPath$ = observableOf(this.getBitstreamDownloadPath());
this.canDownload$ = observableOf(true);
}
}
getBitstreamPath() {
return getBitstreamDownloadRoute(this.bitstream);
getBitstreamPath(canDownload: boolean, canRequestACopy: boolean) {
if (!canDownload && canRequestACopy && hasValue(this.item)) {
return getBitstreamRequestACopyRoute(this.item, this.bitstream);
}
return this.getBitstreamDownloadPath();
}
getBitstreamDownloadPath() {
return {
routerLink: getBitstreamDownloadRoute(this.bitstream),
queryParams: {}
};
}
}

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