1
0

Merge branch 'main' into w2p-83635_Request-a-copy

This commit is contained in:
Kristof De Langhe
2021-10-18 16:01:19 +02:00
89 changed files with 8908 additions and 6966 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';
@@ -8,52 +11,6 @@ describe('Search Page', () => {
cy.get(SEARCHFORM_ID + ' input[name="query"]').should('have.value', queryString);
});
it('should have right scope selected when navigating to page with scope parameter', () => {
// First, visit search with no params just to get the set of the scope options
cy.visit('/search');
cy.get(SEARCHFORM_ID + ' select[name="scope"] > option').as('options');
// Find length of scope options, select a random index
cy.get('@options').its('length')
.then(len => Math.floor(Math.random() * Math.floor(len)))
.then((index) => {
// return the option at that (randomly selected) index
return cy.get('@options').eq(index);
})
.then((option) => {
const randomScope: any = option.val();
// Visit the search page with the randomly selected option as a pararmeter
cy.visit('/search?scope=' + randomScope);
// Verify that scope is selected when the page reloads
cy.get(SEARCHFORM_ID + ' select[name="scope"]').find('option:selected').should('have.value', randomScope);
});
});
it('should redirect to the correct url when scope was set and submit button was triggered', () => {
// First, visit search with no params just to get the set of scope options
cy.visit('/search');
cy.get(SEARCHFORM_ID + ' select[name="scope"] > option').as('options');
// Find length of scope options, select a random index (i.e. a random option in selectbox)
cy.get('@options').its('length')
.then(len => Math.floor(Math.random() * Math.floor(len)))
.then((index) => {
// return the option at that (randomly selected) index
return cy.get('@options').eq(index);
})
.then((option) => {
const randomScope: any = option.val();
// Select the option at our random index & click the search button
cy.get(SEARCHFORM_ID + ' select[name="scope"]').select(randomScope);
cy.get(SEARCHFORM_ID + ' button.search-button').click();
// Result should be the page URL should include that scope & page will reload with scope selected
cy.url().should('include', 'scope=' + randomScope);
cy.get(SEARCHFORM_ID + ' select[name="scope"]').find('option:selected').should('have.value', randomScope);
});
});
it('should redirect to the correct url when query was set and submit button was triggered', () => {
const queryString = 'Another interesting query string';
cy.visit('/search');
@@ -63,4 +20,53 @@ describe('Search Page', () => {
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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -138,7 +138,7 @@ export class CollectionDataService extends ComColDataService<Collection> {
* Get the collection's content harvester
* @param collectionId
*/
getContentSource(collectionId: string): Observable<RemoteData<ContentSource>> {
getContentSource(collectionId: string, useCachedVersionIfAvailable = true): Observable<RemoteData<ContentSource>> {
const href$ = this.getHarvesterEndpoint(collectionId).pipe(
isNotEmptyOperator(),
take(1)
@@ -146,7 +146,7 @@ export class CollectionDataService extends ComColDataService<Collection> {
href$.subscribe((href: string) => {
const request = new ContentSourceRequest(this.requestService.generateRequestId(), href);
this.requestService.send(request, true);
this.requestService.send(request, useCachedVersionIfAvailable);
});
return this.rdbService.buildSingle<ContentSource>(href$);
@@ -208,10 +208,20 @@ export class CollectionDataService extends ComColDataService<Collection> {
}
/**
* Returns {@link RemoteData} of {@link Collection} that is the owing collection of the given item
* Returns {@link RemoteData} of {@link Collection} that is the owning collection of the given item
* @param item Item we want the owning collection of
*/
findOwningCollectionFor(item: Item): Observable<RemoteData<Collection>> {
return this.findByHref(item._links.owningCollection.href);
}
/**
* Get a list of mapped collections for the given item.
* @param item Item for which the mapped collections should be retrieved.
* @param findListOptions Pagination and search options.
*/
findMappedCollectionsFor(item: Item, findListOptions?: FindListOptions): Observable<RemoteData<PaginatedList<Collection>>> {
return this.findAllByHref(item._links.mappedCollections.href, findListOptions);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,21 @@
<ds-metadata-field-wrapper *ngIf="(this.collectionsRD$ | async)?.hasSucceeded" [label]="label | translate">
<ds-metadata-field-wrapper [label]="label | translate">
<div class="collections">
<a *ngFor="let collection of (this.collectionsRD$ | async)?.payload?.page; let last=last;" [routerLink]="['/collections', collection.id]">
<a *ngFor="let collection of (this.collections$ | async); let last=last;" [routerLink]="['/collections', collection.id]">
<span>{{collection?.name}}</span><span *ngIf="!last" [innerHTML]="separator"></span>
</a>
</div>
<div *ngIf="isLoading$ | async">
{{'item.page.collections.loading' | translate}}
</div>
<a
*ngIf="!(isLoading$ | async) && (hasMore$ | async)"
(click)="$event.preventDefault(); handleLoadMore()"
class="load-more-btn btn btn-sm btn-outline-secondary"
role="button"
href="#"
>
{{'item.page.collections.load-more' | translate}}
</a>
</ds-metadata-field-wrapper>

View File

@@ -9,46 +9,45 @@ import { Item } from '../../../core/shared/item.model';
import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock';
import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
import { CollectionsComponent } from './collections.component';
import { FindListOptions } from '../../../core/data/request.models';
import { buildPaginatedList, PaginatedList } from '../../../core/data/paginated-list.model';
import { PageInfo } from '../../../core/shared/page-info.model';
let collectionsComponent: CollectionsComponent;
let fixture: ComponentFixture<CollectionsComponent>;
let collectionDataServiceStub;
const mockCollection1: Collection = Object.assign(new Collection(), {
metadata: {
'dc.description.abstract': [
{
language: 'en_US',
value: 'Short description'
}
]
},
_links: {
self: { href: 'collection-selflink' }
}
const createMockCollection = (id: string) => Object.assign(new Collection(), {
id: id,
name: `collection-${id}`,
});
const succeededMockItem: Item = Object.assign(new Item(), {owningCollection: createSuccessfulRemoteDataObject$(mockCollection1)});
const failedMockItem: Item = Object.assign(new Item(), {owningCollection: createFailedRemoteDataObject$('error', 500)});
const mockItem: Item = new Item();
describe('CollectionsComponent', () => {
collectionDataServiceStub = {
findOwningCollectionFor(item: Item) {
if (item === succeededMockItem) {
return createSuccessfulRemoteDataObject$(mockCollection1);
} else {
return createFailedRemoteDataObject$('error', 500);
}
}
};
let collectionDataService;
let mockCollection1: Collection;
let mockCollection2: Collection;
let mockCollection3: Collection;
let mockCollection4: Collection;
let component: CollectionsComponent;
let fixture: ComponentFixture<CollectionsComponent>;
beforeEach(waitForAsync(() => {
collectionDataService = jasmine.createSpyObj([
'findOwningCollectionFor',
'findMappedCollectionsFor',
]);
mockCollection1 = createMockCollection('c1');
mockCollection2 = createMockCollection('c2');
mockCollection3 = createMockCollection('c3');
mockCollection4 = createMockCollection('c4');
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
declarations: [ CollectionsComponent ],
providers: [
{ provide: RemoteDataBuildService, useValue: getMockRemoteDataBuildService()},
{ provide: CollectionDataService, useValue: collectionDataServiceStub },
{ provide: CollectionDataService, useValue: collectionDataService },
],
schemas: [ NO_ERRORS_SCHEMA ]
@@ -59,33 +58,264 @@ describe('CollectionsComponent', () => {
beforeEach(waitForAsync(() => {
fixture = TestBed.createComponent(CollectionsComponent);
collectionsComponent = fixture.componentInstance;
collectionsComponent.label = 'test.test';
collectionsComponent.separator = '<br/>';
component = fixture.componentInstance;
component.item = mockItem;
component.label = 'test.test';
component.separator = '<br/>';
component.pageSize = 2;
}));
describe('When the requested item request has succeeded', () => {
describe('when the item has only an owning collection', () => {
let mockPage1: PaginatedList<Collection>;
beforeEach(() => {
collectionsComponent.item = succeededMockItem;
mockPage1 = buildPaginatedList(Object.assign(new PageInfo(), {
currentPage: 1,
elementsPerPage: 2,
totalPages: 0,
totalElements: 0,
}), []);
collectionDataService.findOwningCollectionFor.and.returnValue(createSuccessfulRemoteDataObject$(mockCollection1));
collectionDataService.findMappedCollectionsFor.and.returnValue(createSuccessfulRemoteDataObject$(mockPage1));
fixture.detectChanges();
});
it('should show the collection', () => {
const collectionField = fixture.debugElement.query(By.css('ds-metadata-field-wrapper div.collections'));
expect(collectionField).not.toBeNull();
it('should display the owning collection', () => {
const collectionFields = fixture.debugElement.queryAll(By.css('ds-metadata-field-wrapper div.collections a'));
const loadMoreBtn = fixture.debugElement.query(By.css('ds-metadata-field-wrapper .load-more-btn'));
expect(collectionDataService.findOwningCollectionFor).toHaveBeenCalledOnceWith(mockItem);
expect(collectionDataService.findMappedCollectionsFor).toHaveBeenCalledOnceWith(mockItem, Object.assign(new FindListOptions(), {
elementsPerPage: 2,
currentPage: 1,
}));
expect(collectionFields.length).toBe(1);
expect(collectionFields[0].nativeElement.textContent).toEqual('collection-c1');
expect(component.lastPage$.getValue()).toBe(1);
expect(component.hasMore$.getValue()).toBe(false);
expect(component.isLoading$.getValue()).toBe(false);
expect(loadMoreBtn).toBeNull();
});
});
describe('When the requested item request has failed', () => {
describe('when the item has an owning collection and one mapped collection', () => {
let mockPage1: PaginatedList<Collection>;
beforeEach(() => {
collectionsComponent.item = failedMockItem;
mockPage1 = buildPaginatedList(Object.assign(new PageInfo(), {
currentPage: 1,
elementsPerPage: 2,
totalPages: 1,
totalElements: 1,
}), [mockCollection2]);
collectionDataService.findOwningCollectionFor.and.returnValue(createSuccessfulRemoteDataObject$(mockCollection1));
collectionDataService.findMappedCollectionsFor.and.returnValue(createSuccessfulRemoteDataObject$(mockPage1));
fixture.detectChanges();
});
it('should not show the collection', () => {
const collectionField = fixture.debugElement.query(By.css('ds-metadata-field-wrapper div.collections'));
expect(collectionField).toBeNull();
it('should display the owning collection and the mapped collection', () => {
const collectionFields = fixture.debugElement.queryAll(By.css('ds-metadata-field-wrapper div.collections a'));
const loadMoreBtn = fixture.debugElement.query(By.css('ds-metadata-field-wrapper .load-more-btn'));
expect(collectionDataService.findOwningCollectionFor).toHaveBeenCalledOnceWith(mockItem);
expect(collectionDataService.findMappedCollectionsFor).toHaveBeenCalledOnceWith(mockItem, Object.assign(new FindListOptions(), {
elementsPerPage: 2,
currentPage: 1,
}));
expect(collectionFields.length).toBe(2);
expect(collectionFields[0].nativeElement.textContent).toEqual('collection-c1');
expect(collectionFields[1].nativeElement.textContent).toEqual('collection-c2');
expect(component.lastPage$.getValue()).toBe(1);
expect(component.hasMore$.getValue()).toBe(false);
expect(component.isLoading$.getValue()).toBe(false);
expect(loadMoreBtn).toBeNull();
});
});
describe('when the item has an owning collection and multiple mapped collections', () => {
let mockPage1: PaginatedList<Collection>;
let mockPage2: PaginatedList<Collection>;
beforeEach(() => {
mockPage1 = buildPaginatedList(Object.assign(new PageInfo(), {
currentPage: 1,
elementsPerPage: 2,
totalPages: 2,
totalElements: 3,
}), [mockCollection2, mockCollection3]);
mockPage2 = buildPaginatedList(Object.assign(new PageInfo(), {
currentPage: 2,
elementsPerPage: 2,
totalPages: 2,
totalElements: 1,
}), [mockCollection4]);
collectionDataService.findOwningCollectionFor.and.returnValue(createSuccessfulRemoteDataObject$(mockCollection1));
collectionDataService.findMappedCollectionsFor.and.returnValues(
createSuccessfulRemoteDataObject$(mockPage1),
createSuccessfulRemoteDataObject$(mockPage2),
);
fixture.detectChanges();
});
it('should display the owning collection, two mapped collections and a load more button', () => {
const collectionFields = fixture.debugElement.queryAll(By.css('ds-metadata-field-wrapper div.collections a'));
const loadMoreBtn = fixture.debugElement.query(By.css('ds-metadata-field-wrapper .load-more-btn'));
expect(collectionDataService.findOwningCollectionFor).toHaveBeenCalledOnceWith(mockItem);
expect(collectionDataService.findMappedCollectionsFor).toHaveBeenCalledOnceWith(mockItem, Object.assign(new FindListOptions(), {
elementsPerPage: 2,
currentPage: 1,
}));
expect(collectionFields.length).toBe(3);
expect(collectionFields[0].nativeElement.textContent).toEqual('collection-c1');
expect(collectionFields[1].nativeElement.textContent).toEqual('collection-c2');
expect(collectionFields[2].nativeElement.textContent).toEqual('collection-c3');
expect(component.lastPage$.getValue()).toBe(1);
expect(component.hasMore$.getValue()).toBe(true);
expect(component.isLoading$.getValue()).toBe(false);
expect(loadMoreBtn).toBeTruthy();
});
describe('when the load more button is clicked', () => {
beforeEach(() => {
const loadMoreBtn = fixture.debugElement.query(By.css('ds-metadata-field-wrapper .load-more-btn'));
loadMoreBtn.nativeElement.click();
fixture.detectChanges();
});
it('should display the owning collection and three mapped collections', () => {
const collectionFields = fixture.debugElement.queryAll(By.css('ds-metadata-field-wrapper div.collections a'));
const loadMoreBtn = fixture.debugElement.query(By.css('ds-metadata-field-wrapper .load-more-btn'));
expect(collectionDataService.findOwningCollectionFor).toHaveBeenCalledOnceWith(mockItem);
expect(collectionDataService.findMappedCollectionsFor).toHaveBeenCalledTimes(2);
expect(collectionDataService.findMappedCollectionsFor).toHaveBeenCalledWith(mockItem, Object.assign(new FindListOptions(), {
elementsPerPage: 2,
currentPage: 1,
}));
expect(collectionDataService.findMappedCollectionsFor).toHaveBeenCalledWith(mockItem, Object.assign(new FindListOptions(), {
elementsPerPage: 2,
currentPage: 2,
}));
expect(collectionFields.length).toBe(4);
expect(collectionFields[0].nativeElement.textContent).toEqual('collection-c1');
expect(collectionFields[1].nativeElement.textContent).toEqual('collection-c2');
expect(collectionFields[2].nativeElement.textContent).toEqual('collection-c3');
expect(collectionFields[3].nativeElement.textContent).toEqual('collection-c4');
expect(component.lastPage$.getValue()).toBe(2);
expect(component.hasMore$.getValue()).toBe(false);
expect(component.isLoading$.getValue()).toBe(false);
expect(loadMoreBtn).toBeNull();
});
});
});
describe('when the request for the owning collection fails', () => {
let mockPage1: PaginatedList<Collection>;
beforeEach(() => {
mockPage1 = buildPaginatedList(Object.assign(new PageInfo(), {
currentPage: 1,
elementsPerPage: 2,
totalPages: 1,
totalElements: 1,
}), [mockCollection2]);
collectionDataService.findOwningCollectionFor.and.returnValue(createFailedRemoteDataObject$());
collectionDataService.findMappedCollectionsFor.and.returnValue(createSuccessfulRemoteDataObject$(mockPage1));
fixture.detectChanges();
});
it('should display the mapped collection only', () => {
const collectionFields = fixture.debugElement.queryAll(By.css('ds-metadata-field-wrapper div.collections a'));
const loadMoreBtn = fixture.debugElement.query(By.css('ds-metadata-field-wrapper .load-more-btn'));
expect(collectionDataService.findOwningCollectionFor).toHaveBeenCalledOnceWith(mockItem);
expect(collectionDataService.findMappedCollectionsFor).toHaveBeenCalledOnceWith(mockItem, Object.assign(new FindListOptions(), {
elementsPerPage: 2,
currentPage: 1,
}));
expect(collectionFields.length).toBe(1);
expect(collectionFields[0].nativeElement.textContent).toEqual('collection-c2');
expect(component.lastPage$.getValue()).toBe(1);
expect(component.hasMore$.getValue()).toBe(false);
expect(component.isLoading$.getValue()).toBe(false);
expect(loadMoreBtn).toBeNull();
});
});
describe('when the request for the mapped collections fails', () => {
beforeEach(() => {
collectionDataService.findOwningCollectionFor.and.returnValue(createSuccessfulRemoteDataObject$(mockCollection1));
collectionDataService.findMappedCollectionsFor.and.returnValue(createFailedRemoteDataObject$());
fixture.detectChanges();
});
it('should display the owning collection only', () => {
const collectionFields = fixture.debugElement.queryAll(By.css('ds-metadata-field-wrapper div.collections a'));
const loadMoreBtn = fixture.debugElement.query(By.css('ds-metadata-field-wrapper .load-more-btn'));
expect(collectionDataService.findOwningCollectionFor).toHaveBeenCalledOnceWith(mockItem);
expect(collectionDataService.findMappedCollectionsFor).toHaveBeenCalledOnceWith(mockItem, Object.assign(new FindListOptions(), {
elementsPerPage: 2,
currentPage: 1,
}));
expect(collectionFields.length).toBe(1);
expect(collectionFields[0].nativeElement.textContent).toEqual('collection-c1');
expect(component.lastPage$.getValue()).toBe(0);
expect(component.hasMore$.getValue()).toBe(true);
expect(component.isLoading$.getValue()).toBe(false);
expect(loadMoreBtn).toBeTruthy();
});
});
describe('when both requests fail', () => {
beforeEach(() => {
collectionDataService.findOwningCollectionFor.and.returnValue(createFailedRemoteDataObject$());
collectionDataService.findMappedCollectionsFor.and.returnValue(createFailedRemoteDataObject$());
fixture.detectChanges();
});
it('should display no collections', () => {
const collectionFields = fixture.debugElement.queryAll(By.css('ds-metadata-field-wrapper div.collections a'));
const loadMoreBtn = fixture.debugElement.query(By.css('ds-metadata-field-wrapper .load-more-btn'));
expect(collectionDataService.findOwningCollectionFor).toHaveBeenCalledOnceWith(mockItem);
expect(collectionDataService.findMappedCollectionsFor).toHaveBeenCalledOnceWith(mockItem, Object.assign(new FindListOptions(), {
elementsPerPage: 2,
currentPage: 1,
}));
expect(collectionFields.length).toBe(0);
expect(component.lastPage$.getValue()).toBe(0);
expect(component.hasMore$.getValue()).toBe(true);
expect(component.isLoading$.getValue()).toBe(false);
expect(loadMoreBtn).toBeTruthy();
});
});
});

View File

@@ -1,14 +1,19 @@
import { Component, Input, OnInit } from '@angular/core';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { BehaviorSubject, combineLatest, Observable } from 'rxjs';
import {map, scan, startWith, switchMap, tap, withLatestFrom} from 'rxjs/operators';
import { CollectionDataService } from '../../../core/data/collection-data.service';
import { PaginatedList, buildPaginatedList } from '../../../core/data/paginated-list.model';
import { RemoteData } from '../../../core/data/remote-data';
import { PaginatedList } from '../../../core/data/paginated-list.model';
import { Collection } from '../../../core/shared/collection.model';
import { Item } from '../../../core/shared/item.model';
import { PageInfo } from '../../../core/shared/page-info.model';
import { hasValue } from '../../../shared/empty.util';
import { FindListOptions } from '../../../core/data/request.models';
import {
getAllCompletedRemoteData,
getAllSucceededRemoteDataPayload,
getFirstSucceededRemoteDataPayload,
getPaginatedListPayload,
} from '../../../core/shared/operators';
/**
* This component renders the parent collections section of the item
@@ -27,42 +32,92 @@ export class CollectionsComponent implements OnInit {
separator = '<br/>';
collectionsRD$: Observable<RemoteData<PaginatedList<Collection>>>;
/**
* Amount of mapped collections that should be fetched at once.
*/
pageSize = 5;
/**
* Last page of the mapped collections that has been fetched.
*/
lastPage$: BehaviorSubject<number> = new BehaviorSubject<number>(0);
/**
* Push an event to this observable to fetch the next page of mapped collections.
* Because this observable is a behavior subject, the first page will be requested
* immediately after subscription.
*/
loadMore$: BehaviorSubject<void> = new BehaviorSubject(undefined);
/**
* Whether or not a page of mapped collections is currently being loaded.
*/
isLoading$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(true);
/**
* Whether or not more pages of mapped collections are available.
*/
hasMore$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(true);
/**
* All collections that have been retrieved so far. This includes the owning collection,
* as well as any number of pages of mapped collections.
*/
collections$: Observable<Collection[]>;
constructor(private cds: CollectionDataService) {
}
ngOnInit(): void {
// this.collections = this.item.parents.payload;
const owningCollection$: Observable<Collection> = this.cds.findOwningCollectionFor(this.item).pipe(
getFirstSucceededRemoteDataPayload(),
startWith(null as Collection),
);
// TODO: this should use parents, but the collections
// for an Item aren't returned by the REST API yet,
// only the owning collection
this.collectionsRD$ = this.cds.findOwningCollectionFor(this.item).pipe(
map((rd: RemoteData<Collection>) => {
if (hasValue(rd.payload)) {
return new RemoteData(
rd.timeCompleted,
rd.msToLive,
rd.lastUpdated,
rd.state,
rd.errorMessage,
buildPaginatedList({
elementsPerPage: 10,
totalPages: 1,
currentPage: 1,
totalElements: 1,
_links: {
self: rd.payload._links.self
}
} as PageInfo, [rd.payload]),
rd.statusCode
);
} else {
return rd as any;
}
})
const mappedCollections$: Observable<Collection[]> = this.loadMore$.pipe(
// update isLoading$
tap(() => this.isLoading$.next(true)),
// request next batch of mapped collections
withLatestFrom(this.lastPage$),
switchMap(([_, lastPage]: [void, number]) => {
return this.cds.findMappedCollectionsFor(this.item, Object.assign(new FindListOptions(), {
elementsPerPage: this.pageSize,
currentPage: lastPage + 1,
}));
}),
getAllCompletedRemoteData<PaginatedList<Collection>>(),
// update isLoading$
tap(() => this.isLoading$.next(false)),
getAllSucceededRemoteDataPayload(),
// update hasMore$
tap((response: PaginatedList<Collection>) => this.hasMore$.next(response.currentPage < response.totalPages)),
// update lastPage$
tap((response: PaginatedList<Collection>) => this.lastPage$.next(response.currentPage)),
getPaginatedListPayload<Collection>(),
// add current batch to list of collections
scan((prev: Collection[], current: Collection[]) => [...prev, ...current], []),
startWith([]),
) as Observable<Collection[]>;
this.collections$ = combineLatest([owningCollection$, mappedCollections$]).pipe(
map(([owningCollection, mappedCollections]: [Collection, Collection[]]) => {
return [owningCollection, ...mappedCollections].filter(collection => hasValue(collection));
}),
);
}
handleLoadMore() {
this.loadMore$.next();
}
}

View File

@@ -31,6 +31,7 @@ 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 { ThemedFileSectionComponent} from './simple/field-components/file-section/themed-file-section.component';
const ENTRY_COMPONENTS = [
// put only entry components that use custom decorator
@@ -39,6 +40,7 @@ const ENTRY_COMPONENTS = [
];
const DECLARATIONS = [
ThemedFileSectionComponent,
ItemPageComponent,
ThemedItemPageComponent,
FullItemPageComponent,

View File

@@ -0,0 +1,28 @@
import { ThemedComponent } from '../../../../shared/theme-support/themed.component';
import { FileSectionComponent } from './file-section.component';
import {Component, Input} from '@angular/core';
import {Item} from '../../../../core/shared/item.model';
@Component({
selector: 'ds-themed-item-page-file-section',
templateUrl: '../../../../shared/theme-support/themed.component.html',
})
export class ThemedFileSectionComponent extends ThemedComponent<FileSectionComponent> {
@Input() item: Item;
protected inAndOutputNames: (keyof FileSectionComponent & keyof this)[] = ['item'];
protected getComponentName(): string {
return 'FileSectionComponent';
}
protected importThemedComponent(themeName: string): Promise<any> {
return import(`../../../../../themes/${themeName}/app/item-page/simple/field-components/file-section/file-section.component`);
}
protected importUnthemedComponent(): Promise<any> {
return import(`./file-section.component`);
}
}

View File

@@ -16,7 +16,7 @@
<ng-container *ngIf="mediaViewer.image">
<ds-media-viewer [item]="object" [videoOptions]="mediaViewer.video"></ds-media-viewer>
</ng-container>
<ds-item-page-file-section [item]="object"></ds-item-page-file-section>
<ds-themed-item-page-file-section [item]="object"></ds-themed-item-page-file-section>
<ds-item-page-date-field [item]="object"></ds-item-page-date-field>
<ds-metadata-representation-list class="ds-item-page-mixed-author-field"
[parentItem]="object"

View File

@@ -16,7 +16,7 @@
<ng-container *ngIf="mediaViewer.image">
<ds-media-viewer [item]="object" [videoOptions]="mediaViewer.video"></ds-media-viewer>
</ng-container>
<ds-item-page-file-section [item]="object"></ds-item-page-file-section>
<ds-themed-item-page-file-section [item]="object"></ds-themed-item-page-file-section>
<ds-item-page-date-field [item]="object"></ds-item-page-date-field>
<ds-metadata-representation-list class="ds-item-page-mixed-author-field"
[parentItem]="object"

View File

@@ -15,7 +15,7 @@
[query]="(searchOptions$ | async)?.query"
[scope]="(searchOptions$ | async)?.scope"
[currentUrl]="getSearchLink()"
[scopes]="(scopeListRD$ | async)"
[showScopeSelector]="true"
[inPlaceSearch]="inPlaceSearch"
[searchPlaceholder]="'mydspace.search-form.placeholder' | translate">
</ds-search-form>

View File

@@ -78,11 +78,6 @@ export class MyDSpacePageComponent implements OnInit {
*/
sortOptions$: Observable<SortOptions[]>;
/**
* The current relevant scopes
*/
scopeListRD$: Observable<DSpaceObject[]>;
/**
* Emits true if were on a small screen
*/
@@ -144,10 +139,6 @@ export class MyDSpacePageComponent implements OnInit {
this.resultsRD$.next(results);
});
this.scopeListRD$ = this.searchConfigService.getCurrentScope('').pipe(
switchMap((scopeId) => this.service.getScopes(scopeId))
);
this.context$ = this.searchConfigService.getCurrentConfiguration('workspace')
.pipe(
map((configuration: string) => {

View File

@@ -47,7 +47,7 @@
[query]="(searchOptions$ | async)?.query"
[scope]="(searchOptions$ | async)?.scope"
[currentUrl]="searchLink"
[scopes]="(scopeListRD$ | async)"
[showScopeSelector]="true"
[inPlaceSearch]="inPlaceSearch"
[searchPlaceholder]="'search.search-form.placeholder' | translate">
</ds-search-form>

View File

@@ -55,11 +55,6 @@ export class SearchComponent implements OnInit {
*/
sortOptions$: Observable<SortOptions[]>;
/**
* The current relevant scopes
*/
scopeListRD$: Observable<DSpaceObject[]>;
/**
* Emits true if were on a small screen
*/
@@ -137,9 +132,7 @@ export class SearchComponent implements OnInit {
).subscribe((results) => {
this.resultsRD$.next(results);
});
this.scopeListRD$ = this.searchConfigService.getCurrentScope('').pipe(
switchMap((scopeId) => this.service.getScopes(scopeId))
);
if (isEmpty(this.configuration$)) {
this.configuration$ = this.searchConfigService.getCurrentConfiguration('default');
}

View File

@@ -21,11 +21,14 @@ import { storeModuleConfig } from '../../app.reducer';
import { FindListOptions } from '../../core/data/request.models';
import { PaginationService } from '../../core/pagination/pagination.service';
import { PaginationServiceStub } from '../testing/pagination-service.stub';
import { ThemeService } from '../theme-support/theme.service';
describe('BrowseByComponent', () => {
let comp: BrowseByComponent;
let fixture: ComponentFixture<BrowseByComponent>;
let themeService: ThemeService;
const mockItems = [
Object.assign(new Item(), {
id: 'fakeId-1',
@@ -57,6 +60,9 @@ describe('BrowseByComponent', () => {
const paginationService = new PaginationServiceStub(paginationConfig);
beforeEach(waitForAsync(() => {
themeService = jasmine.createSpyObj('themeService', {
getThemeName: 'dspace',
});
TestBed.configureTestingModule({
imports: [
CommonModule,
@@ -75,7 +81,8 @@ describe('BrowseByComponent', () => {
],
declarations: [],
providers: [
{provide: PaginationService, useValue: paginationService}
{provide: PaginationService, useValue: paginationService},
{ provide: ThemeService, useValue: themeService },
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();

View File

@@ -9,7 +9,8 @@ import { hasValue, isNotEmpty } from '../../empty.util';
export enum SelectorActionType {
CREATE = 'create',
EDIT = 'edit',
EXPORT_METADATA = 'export-metadata'
EXPORT_METADATA = 'export-metadata',
SET_SCOPE = 'set-scope'
}
/**
@@ -77,6 +78,7 @@ export abstract class DSOSelectorModalWrapperComponent implements OnInit {
}
}
}
/**
* Method called when an object has been selected
* @param dso The selected DSpaceObject

View File

@@ -7,12 +7,17 @@ import {
import { MetadataRepresentationType } from '../../core/shared/metadata-representation/metadata-representation.model';
import { Context } from '../../core/shared/context.model';
import * as uuidv4 from 'uuid/v4';
import { environment } from '../../../environments/environment';
let ogEnvironmentThemes;
describe('MetadataRepresentation decorator function', () => {
const type1 = 'TestType';
const type2 = 'TestType2';
const type3 = 'TestType3';
const type4 = 'RandomType';
const typeAncestor = 'TestTypeAncestor';
const typeUnthemed = 'TestTypeUnthemed';
let prefix;
/* tslint:disable:max-classes-per-file */
@@ -31,6 +36,12 @@ describe('MetadataRepresentation decorator function', () => {
class Test3ItemSubmission {
}
class TestAncestorComponent {
}
class TestUnthemedComponent {
}
/* tslint:enable:max-classes-per-file */
beforeEach(() => {
@@ -46,8 +57,18 @@ describe('MetadataRepresentation decorator function', () => {
metadataRepresentationComponent(key + type2, MetadataRepresentationType.Item, Context.Workspace)(Test2ItemSubmission);
metadataRepresentationComponent(key + type3, MetadataRepresentationType.Item, Context.Workspace)(Test3ItemSubmission);
// Register a metadata representation in the 'ancestor' theme
metadataRepresentationComponent(key + typeAncestor, MetadataRepresentationType.Item, Context.Any, 'ancestor')(TestAncestorComponent);
metadataRepresentationComponent(key + typeUnthemed, MetadataRepresentationType.Item, Context.Any)(TestUnthemedComponent);
ogEnvironmentThemes = environment.themes;
}
afterEach(() => {
environment.themes = ogEnvironmentThemes;
});
describe('If there\'s an exact match', () => {
it('should return the matching class', () => {
const component = getMetadataRepresentationComponent(prefix + type3, MetadataRepresentationType.Item, Context.Workspace);
@@ -76,4 +97,55 @@ describe('MetadataRepresentation decorator function', () => {
});
});
});
describe('With theme extensions', () => {
// We're only interested in the cases that the requested theme doesn't match the requested entityType,
// as the cases where it does are already covered by the tests above
describe('If requested theme has no match', () => {
beforeEach(() => {
environment.themes = [
{
name: 'requested', // Doesn't match any entityType
extends: 'intermediate',
},
{
name: 'intermediate', // Doesn't match any entityType
extends: 'ancestor',
},
{
name: 'ancestor', // Matches typeAncestor, but not typeUnthemed
}
];
});
it('should return component from the first ancestor theme that matches its entityType', () => {
const component = getMetadataRepresentationComponent(prefix + typeAncestor, MetadataRepresentationType.Item, Context.Any, 'requested');
expect(component).toEqual(TestAncestorComponent);
});
it('should return default component if none of the ancestor themes match its entityType', () => {
const component = getMetadataRepresentationComponent(prefix + typeUnthemed, MetadataRepresentationType.Item, Context.Any, 'requested');
expect(component).toEqual(TestUnthemedComponent);
});
});
describe('If there is a theme extension cycle', () => {
beforeEach(() => {
environment.themes = [
{ name: 'extension-cycle', extends: 'broken1' },
{ name: 'broken1', extends: 'broken2' },
{ name: 'broken2', extends: 'broken3' },
{ name: 'broken3', extends: 'broken1' },
];
});
it('should throw an error', () => {
expect(() => {
getMetadataRepresentationComponent(prefix + typeAncestor, MetadataRepresentationType.Item, Context.Any, 'extension-cycle');
}).toThrowError(
'Theme extension cycle detected: extension-cycle -> broken1 -> broken2 -> broken3 -> broken1'
);
});
});
});
});

View File

@@ -3,6 +3,10 @@ import { hasNoValue, hasValue } from '../empty.util';
import { Context } from '../../core/shared/context.model';
import { InjectionToken } from '@angular/core';
import { GenericConstructor } from '../../core/shared/generic-constructor';
import {
resolveTheme,
DEFAULT_THEME, DEFAULT_CONTEXT
} from '../object-collection/shared/listable-object/listable-object.decorator';
export const METADATA_REPRESENTATION_COMPONENT_FACTORY = new InjectionToken<(entityType: string, mdRepresentationType: MetadataRepresentationType, context: Context, theme: string) => GenericConstructor<any>>('getMetadataRepresentationComponent', {
providedIn: 'root',
@@ -13,8 +17,6 @@ export const map = new Map();
export const DEFAULT_ENTITY_TYPE = 'Publication';
export const DEFAULT_REPRESENTATION_TYPE = MetadataRepresentationType.PlainText;
export const DEFAULT_CONTEXT = Context.Any;
export const DEFAULT_THEME = '*';
/**
* Decorator function to store metadata representation mapping
@@ -57,8 +59,9 @@ export function getMetadataRepresentationComponent(entityType: string, mdReprese
if (hasValue(entityAndMDRepMap)) {
const contextMap = entityAndMDRepMap.get(context);
if (hasValue(contextMap)) {
if (hasValue(contextMap.get(theme))) {
return contextMap.get(theme);
const match = resolveTheme(contextMap, theme);
if (hasValue(match)) {
return match;
}
if (hasValue(contextMap.get(DEFAULT_THEME))) {
return contextMap.get(DEFAULT_THEME);

View File

@@ -1,9 +1,18 @@
import { ThemeService } from '../theme-support/theme.service';
import { of as observableOf } from 'rxjs';
import { ThemeConfig } from '../../../config/theme.model';
import { isNotEmpty } from '../empty.util';
export function getMockThemeService(themeName = 'base'): ThemeService {
return jasmine.createSpyObj('themeService', {
export function getMockThemeService(themeName = 'base', themes?: ThemeConfig[]): ThemeService {
const spy = jasmine.createSpyObj('themeService', {
getThemeName: themeName,
getThemeName$: observableOf(themeName)
getThemeName$: observableOf(themeName),
getThemeConfigFor: undefined,
});
if (isNotEmpty(themes)) {
spy.getThemeConfigFor.and.callFake((name: string) => themes.find(theme => theme.name === name));
}
return spy;
}

View File

@@ -1,4 +1,4 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing';
import { BrowserModule, By } from '@angular/platform-browser';
import { ChangeDetectorRef, DebugElement } from '@angular/core';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
@@ -16,6 +16,7 @@ import { Notification } from '../models/notification.model';
import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core';
import { TranslateLoaderMock } from '../../mocks/translate-loader.mock';
import { storeModuleConfig } from '../../../app.reducer';
import { BehaviorSubject } from 'rxjs';
describe('NotificationComponent', () => {
@@ -83,6 +84,8 @@ describe('NotificationComponent', () => {
deContent = fixture.debugElement.query(By.css('.notification-content'));
elContent = deContent.nativeElement;
elType = fixture.debugElement.query(By.css('.notification-icon')).nativeElement;
spyOn(comp, 'remove');
});
it('should create component', () => {
@@ -124,4 +127,51 @@ describe('NotificationComponent', () => {
expect(elContent.innerHTML).toEqual(htmlContent);
});
describe('dismiss countdown', () => {
const TIMEOUT = 5000;
let isPaused$: BehaviorSubject<boolean>;
beforeEach(() => {
isPaused$ = new BehaviorSubject<boolean>(false);
comp.isPaused$ = isPaused$;
comp.notification = {
id: '1',
type: NotificationType.Info,
title: 'Notif. title',
content: 'test',
options: Object.assign(
new NotificationOptions(),
{ timeout: TIMEOUT }
),
html: true
};
});
it('should remove notification after timeout', fakeAsync(() => {
comp.ngOnInit();
tick(TIMEOUT);
expect(comp.remove).toHaveBeenCalled();
}));
describe('isPaused$', () => {
it('should pause countdown on true', fakeAsync(() => {
comp.ngOnInit();
tick(TIMEOUT / 2);
isPaused$.next(true);
tick(TIMEOUT);
expect(comp.remove).not.toHaveBeenCalled();
}));
it('should resume paused countdown on false', fakeAsync(() => {
comp.ngOnInit();
tick(TIMEOUT / 4);
isPaused$.next(true);
tick(TIMEOUT / 4);
isPaused$.next(false);
tick(TIMEOUT);
expect(comp.remove).toHaveBeenCalled();
}));
});
});
});

View File

@@ -1,4 +1,4 @@
import {of as observableOf, Observable } from 'rxjs';
import { Observable, of as observableOf } from 'rxjs';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
@@ -23,6 +23,7 @@ import { fadeInEnter, fadeInState, fadeOutLeave, fadeOutState } from '../../anim
import { NotificationAnimationsStatus } from '../models/notification-animations-type';
import { isNotEmpty } from '../../empty.util';
import { INotification } from '../models/notification.model';
import { filter, first } from 'rxjs/operators';
@Component({
selector: 'ds-notification',
@@ -47,6 +48,11 @@ export class NotificationComponent implements OnInit, OnDestroy {
@Input() public notification = null as INotification;
/**
* Whether this notification's countdown should be paused
*/
@Input() public isPaused$: Observable<boolean> = observableOf(false);
// Progress bar variables
public title: Observable<string>;
public content: Observable<string>;
@@ -99,9 +105,12 @@ export class NotificationComponent implements OnInit, OnDestroy {
private instance = () => {
this.diff = (new Date().getTime() - this.start) - (this.count * this.speed);
this.isPaused$.pipe(
filter(paused => !paused),
first(),
).subscribe(() => {
if (this.count++ === this.steps) {
this.remove();
// this.item.timeoutEnd!.emit();
} else if (!this.stopTime) {
if (this.showProgressBar) {
this.progressWidth += 100 / this.steps;
@@ -110,6 +119,7 @@ export class NotificationComponent implements OnInit, OnDestroy {
this.timer = setTimeout(this.instance, (this.speed - this.diff));
}
this.zone.run(() => this.cdr.detectChanges());
});
}
public remove() {

View File

@@ -1,7 +1,10 @@
<div class="notifications-wrapper position-fixed" [ngClass]="position">
<div class="notifications-wrapper position-fixed"
[ngClass]="position"
(mouseenter)="this.isPaused$.next(true);"
(mouseleave)="this.isPaused$.next(false);">
<ds-notification
class="notification"
*ngFor="let a of notifications; let i = index"
[notification]="a">
[notification]="a" [isPaused$]="isPaused$">
</ds-notification>
</div>

View File

@@ -1,5 +1,5 @@
import { ComponentFixture, inject, TestBed, waitForAsync } from '@angular/core/testing';
import { BrowserModule } from '@angular/platform-browser';
import { BrowserModule, By } from '@angular/platform-browser';
import { ChangeDetectorRef } from '@angular/core';
import { NotificationsService } from '../notifications.service';
@@ -14,6 +14,9 @@ import { NotificationType } from '../models/notification-type';
import { uniqueId } from 'lodash';
import { INotificationBoardOptions } from '../../../../config/notifications-config.interfaces';
import { NotificationsServiceStub } from '../../testing/notifications-service.stub';
import { cold } from 'jasmine-marbles';
export const bools = { f: false, t: true };
describe('NotificationsBoardComponent', () => {
let comp: NotificationsBoardComponent;
@@ -67,6 +70,40 @@ describe('NotificationsBoardComponent', () => {
it('should have two notifications', () => {
expect(comp.notifications.length).toBe(2);
expect(fixture.debugElement.queryAll(By.css('ds-notification')).length).toBe(2);
});
describe('notification countdown', () => {
let wrapper;
beforeEach(() => {
wrapper = fixture.debugElement.query(By.css('div.notifications-wrapper'));
});
it('should not be paused by default', () => {
expect(comp.isPaused$).toBeObservable(cold('f', bools));
});
it('should pause on mouseenter', () => {
wrapper.triggerEventHandler('mouseenter');
expect(comp.isPaused$).toBeObservable(cold('t', bools));
});
it('should resume on mouseleave', () => {
wrapper.triggerEventHandler('mouseenter');
wrapper.triggerEventHandler('mouseleave');
expect(comp.isPaused$).toBeObservable(cold('f', bools));
});
it('should be passed to all notifications', () => {
fixture.debugElement.queryAll(By.css('ds-notification'))
.map(node => node.componentInstance)
.forEach(notification => {
expect(notification.isPaused$).toEqual(comp.isPaused$);
});
});
});
})

View File

@@ -9,7 +9,7 @@ import {
} from '@angular/core';
import { select, Store } from '@ngrx/store';
import { Subscription } from 'rxjs';
import { BehaviorSubject, Subscription } from 'rxjs';
import { difference } from 'lodash';
import { NotificationsService } from '../notifications.service';
@@ -44,6 +44,11 @@ export class NotificationsBoardComponent implements OnInit, OnDestroy {
public rtl = false;
public animate: 'fade' | 'fromTop' | 'fromRight' | 'fromBottom' | 'fromLeft' | 'rotate' | 'scale' = 'fromRight';
/**
* Whether to pause the dismiss countdown of all notifications on the board
*/
public isPaused$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
constructor(private service: NotificationsService,
private store: Store<AppState>,
private cdr: ChangeDetectorRef) {
@@ -129,7 +134,6 @@ export class NotificationsBoardComponent implements OnInit, OnDestroy {
}
});
}
ngOnDestroy(): void {
if (this.sub) {
this.sub.unsubscribe();

View File

@@ -11,6 +11,7 @@ import { TranslateModule } from '@ngx-translate/core';
import { By } from '@angular/platform-browser';
import { Item } from '../../../../core/shared/item.model';
import { provideMockStore } from '@ngrx/store/testing';
import { ThemeService } from '../../../theme-support/theme.service';
const testType = 'TestType';
const testContext = Context.Search;
@@ -26,12 +27,20 @@ describe('ListableObjectComponentLoaderComponent', () => {
let comp: ListableObjectComponentLoaderComponent;
let fixture: ComponentFixture<ListableObjectComponentLoaderComponent>;
let themeService: ThemeService;
beforeEach(waitForAsync(() => {
themeService = jasmine.createSpyObj('themeService', {
getThemeName: 'dspace',
});
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
declarations: [ListableObjectComponentLoaderComponent, ItemListElementComponent, ListableObjectDirective],
schemas: [NO_ERRORS_SCHEMA],
providers: [provideMockStore({})]
providers: [
provideMockStore({}),
{ provide: ThemeService, useValue: themeService },
]
}).overrideComponent(ListableObjectComponentLoaderComponent, {
set: {
changeDetection: ChangeDetectionStrategy.Default,
@@ -48,6 +57,7 @@ describe('ListableObjectComponentLoaderComponent', () => {
comp.viewMode = testViewMode;
comp.context = testContext;
spyOn(comp, 'getComponent').and.returnValue(ItemListElementComponent as any);
spyOn(comp as any, 'connectInputsAndOutputs').and.callThrough();
fixture.detectChanges();
}));
@@ -56,6 +66,10 @@ describe('ListableObjectComponentLoaderComponent', () => {
it('should call the getListableObjectComponent function with the right types, view mode and context', () => {
expect(comp.getComponent).toHaveBeenCalledWith([testType], testViewMode, testContext);
});
it('should connectInputsAndOutputs of loaded component', () => {
expect((comp as any).connectInputsAndOutputs).toHaveBeenCalled();
});
});
describe('when the object is an item and viewMode is a list', () => {
@@ -121,20 +135,20 @@ describe('ListableObjectComponentLoaderComponent', () => {
let reloadedObject: any;
beforeEach(() => {
spyOn((comp as any), 'connectInputsAndOutputs').and.returnValue(null);
spyOn((comp as any), 'instantiateComponent').and.returnValue(null);
spyOn((comp as any).contentChange, 'emit').and.returnValue(null);
listableComponent = fixture.debugElement.query(By.css('ds-item-list-element')).componentInstance;
reloadedObject = 'object';
});
it('should pass it on connectInputsAndOutputs', fakeAsync(() => {
expect((comp as any).connectInputsAndOutputs).not.toHaveBeenCalled();
it('should re-instantiate the listable component', fakeAsync(() => {
expect((comp as any).instantiateComponent).not.toHaveBeenCalled();
(listableComponent as any).reloadedObject.emit(reloadedObject);
tick();
expect((comp as any).connectInputsAndOutputs).toHaveBeenCalled();
expect((comp as any).instantiateComponent).toHaveBeenCalledWith(reloadedObject);
}));
it('should re-emit it as a contentChange', fakeAsync(() => {

View File

@@ -184,7 +184,7 @@ export class ListableObjectComponentLoaderComponent implements OnInit, OnChanges
if (reloadedObject) {
this.compRef.destroy();
this.object = reloadedObject;
this.connectInputsAndOutputs();
this.instantiateComponent(reloadedObject);
this.contentChange.emit(reloadedObject);
}
});

View File

@@ -2,11 +2,16 @@ import { Item } from '../../../../core/shared/item.model';
import { ViewMode } from '../../../../core/shared/view-mode.model';
import { getListableObjectComponent, listableObjectComponent } from './listable-object.decorator';
import { Context } from '../../../../core/shared/context.model';
import { environment } from '../../../../../environments/environment';
let ogEnvironmentThemes;
describe('ListableObject decorator function', () => {
const type1 = 'TestType';
const type2 = 'TestType2';
const type3 = 'TestType3';
const typeAncestor = 'TestTypeAncestor';
const typeUnthemed = 'TestTypeUnthemed';
/* tslint:disable:max-classes-per-file */
class Test1List {
@@ -27,6 +32,12 @@ describe('ListableObject decorator function', () => {
class Test3DetailedSubmission {
}
class TestAncestorComponent {
}
class TestUnthemedComponent {
}
/* tslint:enable:max-classes-per-file */
beforeEach(() => {
@@ -38,6 +49,16 @@ describe('ListableObject decorator function', () => {
listableObjectComponent(type3, ViewMode.ListElement)(Test3List);
listableObjectComponent(type3, ViewMode.DetailedListElement, Context.Workspace)(Test3DetailedSubmission);
// Register a metadata representation in the 'ancestor' theme
listableObjectComponent(typeAncestor, ViewMode.ListElement, Context.Any, 'ancestor')(TestAncestorComponent);
listableObjectComponent(typeUnthemed, ViewMode.ListElement, Context.Any)(TestUnthemedComponent);
ogEnvironmentThemes = environment.themes;
});
afterEach(() => {
environment.themes = ogEnvironmentThemes;
});
const gridDecorator = listableObjectComponent('Item', ViewMode.GridElement);
@@ -80,4 +101,55 @@ describe('ListableObject decorator function', () => {
});
});
});
describe('With theme extensions', () => {
// We're only interested in the cases that the requested theme doesn't match the requested objectType,
// as the cases where it does are already covered by the tests above
describe('If requested theme has no match', () => {
beforeEach(() => {
environment.themes = [
{
name: 'requested', // Doesn't match any objectType
extends: 'intermediate',
},
{
name: 'intermediate', // Doesn't match any objectType
extends: 'ancestor',
},
{
name: 'ancestor', // Matches typeAncestor, but not typeUnthemed
}
];
});
it('should return component from the first ancestor theme that matches its objectType', () => {
const component = getListableObjectComponent([typeAncestor], ViewMode.ListElement, Context.Any, 'requested');
expect(component).toEqual(TestAncestorComponent);
});
it('should return default component if none of the ancestor themes match its objectType', () => {
const component = getListableObjectComponent([typeUnthemed], ViewMode.ListElement, Context.Any, 'requested');
expect(component).toEqual(TestUnthemedComponent);
});
});
describe('If there is a theme extension cycle', () => {
beforeEach(() => {
environment.themes = [
{ name: 'extension-cycle', extends: 'broken1' },
{ name: 'broken1', extends: 'broken2' },
{ name: 'broken2', extends: 'broken3' },
{ name: 'broken3', extends: 'broken1' },
];
});
it('should throw an error', () => {
expect(() => {
getListableObjectComponent([typeAncestor], ViewMode.ListElement, Context.Any, 'extension-cycle');
}).toThrowError(
'Theme extension cycle detected: extension-cycle -> broken1 -> broken2 -> broken3 -> broken1'
);
});
});
});
});

View File

@@ -1,14 +1,23 @@
import { ViewMode } from '../../../../core/shared/view-mode.model';
import { Context } from '../../../../core/shared/context.model';
import { hasNoValue, hasValue } from '../../../empty.util';
import {
DEFAULT_CONTEXT,
DEFAULT_THEME
} from '../../../metadata-representation/metadata-representation.decorator';
import { hasNoValue, hasValue, isNotEmpty } from '../../../empty.util';
import { GenericConstructor } from '../../../../core/shared/generic-constructor';
import { ListableObject } from '../listable-object.model';
import { environment } from '../../../../../environments/environment';
import { ThemeConfig } from '../../../../../config/theme.model';
import { InjectionToken } from '@angular/core';
export const DEFAULT_VIEW_MODE = ViewMode.ListElement;
export const DEFAULT_CONTEXT = Context.Any;
export const DEFAULT_THEME = '*';
/**
* Factory to allow us to inject getThemeConfigFor so we can mock it in tests
*/
export const GET_THEME_CONFIG_FOR_FACTORY = new InjectionToken<(str) => ThemeConfig>('getThemeConfigFor', {
providedIn: 'root',
factory: () => getThemeConfigFor
});
const map = new Map();
@@ -54,8 +63,9 @@ export function getListableObjectComponent(types: (string | GenericConstructor<L
if (hasValue(typeModeMap)) {
const contextMap = typeModeMap.get(context);
if (hasValue(contextMap)) {
if (hasValue(contextMap.get(theme))) {
return contextMap.get(theme);
const match = resolveTheme(contextMap, theme);
if (hasValue(match)) {
return match;
}
if (bestMatchValue < 3 && hasValue(contextMap.get(DEFAULT_THEME))) {
bestMatchValue = 3;
@@ -80,3 +90,35 @@ export function getListableObjectComponent(types: (string | GenericConstructor<L
}
return bestMatch;
}
/**
* Searches for a ThemeConfig by its name;
*/
export const getThemeConfigFor = (themeName: string): ThemeConfig => {
return environment.themes.find(theme => theme.name === themeName);
};
/**
* Find a match in the given map for the given theme name, taking theme extension into account
*
* @param contextMap A map of theme names to components
* @param themeName The name of the theme to check
* @param checkedThemeNames The list of theme names that are already checked
*/
export const resolveTheme = (contextMap: Map<any, any>, themeName: string, checkedThemeNames: string[] = []): any => {
const match = contextMap.get(themeName);
if (hasValue(match)) {
return match;
} else {
const cfg = getThemeConfigFor(themeName);
if (hasValue(cfg) && isNotEmpty(cfg.extends)) {
const nextTheme = cfg.extends;
const nextCheckedThemeNames = [...checkedThemeNames, themeName];
if (checkedThemeNames.includes(nextTheme)) {
throw new Error('Theme extension cycle detected: ' + [...nextCheckedThemeNames, nextTheme].join(' -> '));
} else {
return resolveTheme(contextMap, nextTheme, nextCheckedThemeNames);
}
}
}
};

View File

@@ -0,0 +1,19 @@
<div>
<div class="modal-header">{{'dso-selector.'+ action + '.' + objectType.toString().toLowerCase() + '.head' | translate}}
<button type="button" class="close" (click)="selectObject(undefined)" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
</div>
<div class="modal-body">
<button class="btn btn-outline-primary btn-lg btn-block" (click)="selectObject(undefined)">{{'dso-selector.' + action + '.' + objectType.toString().toLowerCase() + '.button'| translate }}</button>
<h3 class="position-relative py-1 my-3 font-weight-normal">
<hr>
<div id="create-community-or-separator" class="text-center position-absolute w-100">
<span class="px-4 bg-white">or</span>
</div>
</h3>
<h5 class="px-2">{{'dso-selector.' + action + '.' + objectType.toString().toLowerCase() + '.input-header' | translate}}</h5>
<ds-dso-selector [currentDSOId]="dsoRD?.payload.uuid" [types]="selectorTypes" (onSelect)="selectObject($event)"></ds-dso-selector>
</div>
</div>

View File

@@ -0,0 +1,3 @@
#create-community-or-separator {
top: 0;
}

View File

@@ -0,0 +1,73 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { ActivatedRoute, Router } from '@angular/router';
import { ScopeSelectorModalComponent } from './scope-selector-modal.component';
import { Community } from '../../../core/shared/community.model';
import { MetadataValue } from '../../../core/shared/metadata.models';
import { createSuccessfulRemoteDataObject } from '../../remote-data.utils';
import { RouterStub } from '../../testing/router.stub';
describe('ScopeSelectorModalComponent', () => {
let component: ScopeSelectorModalComponent;
let fixture: ComponentFixture<ScopeSelectorModalComponent>;
let debugElement: DebugElement;
const community = new Community();
community.uuid = '1234-1234-1234-1234';
community.metadata = {
'dc.title': [Object.assign(new MetadataValue(), {
value: 'Community title',
language: undefined
})]
};
const router = new RouterStub();
const communityRD = createSuccessfulRemoteDataObject(community);
const modalStub = jasmine.createSpyObj('modalStub', ['close']);
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
declarations: [ScopeSelectorModalComponent],
providers: [
{ provide: NgbActiveModal, useValue: modalStub },
{
provide: ActivatedRoute,
useValue: {
root: {
snapshot: {
data: {
dso: communityRD,
},
},
}
},
},
{
provide: Router, useValue: router
}
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ScopeSelectorModalComponent);
component = fixture.componentInstance;
debugElement = fixture.debugElement;
fixture.detectChanges();
spyOn(component.scopeChange, 'emit');
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should call navigate on the router with the correct edit path when navigate is called', () => {
component.navigate(community);
expect(component.scopeChange.emit).toHaveBeenCalledWith(community);
});
});

View File

@@ -0,0 +1,44 @@
import { Component, EventEmitter, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { DSpaceObjectType } from '../../../core/shared/dspace-object-type.model';
import { DSOSelectorModalWrapperComponent, SelectorActionType } from '../../dso-selector/modal-wrappers/dso-selector-modal-wrapper.component';
import { DSpaceObject } from '../../../core/shared/dspace-object.model';
/**
* Component to wrap a button - to select the entire repository -
* and a list of parent communities - for scope selection
* inside a modal
* Used to select a scope
*/
@Component({
selector: 'ds-scope-selector-modal',
styleUrls: ['./scope-selector-modal.component.scss'],
templateUrl: './scope-selector-modal.component.html',
})
export class ScopeSelectorModalComponent extends DSOSelectorModalWrapperComponent implements OnInit {
objectType = DSpaceObjectType.COMMUNITY;
/**
* The types of DSO that can be selected from this list
*/
selectorTypes = [DSpaceObjectType.COMMUNITY, DSpaceObjectType.COLLECTION];
/**
* The type of action to perform
*/
action = SelectorActionType.SET_SCOPE;
/**
* Emits the selected scope as a DSpaceObject when a user clicks one
*/
scopeChange = new EventEmitter<DSpaceObject>();
constructor(protected activeModal: NgbActiveModal, protected route: ActivatedRoute) {
super(activeModal, route);
}
navigate(dso: DSpaceObject) {
/* Handle complex search navigation in underlying component */
this.scopeChange.emit(dso);
}
}

View File

@@ -1,12 +1,9 @@
<form #form="ngForm" (ngSubmit)="onSubmit(form.value)" class="row" action="/search">
<div *ngIf="isNotEmpty(scopes)" class="col-12 col-sm-3">
<select [(ngModel)]="scope" name="scope" class="form-control" aria-label="Search scope" (change)="onScopeChange($event.target.value)" tabindex="0">
<option value>{{'search.form.search_dspace' | translate}}</option>
<option *ngFor="let scopeOption of scopes" [value]="scopeOption.id">{{scopeOption?.name ? scopeOption.name : 'search.form.search_dspace' | translate}}</option>
</select>
</div>
<div [ngClass]="{'col-sm-9': isNotEmpty(scopes)}" class="col-12">
<form #form="ngForm" (ngSubmit)="onSubmit(form.value)" action="/search">
<div>
<div class="form-group input-group">
<div *ngIf="showScopeSelector === true" class="input-group-prepend">
<button class="scope-button btn btn-outline-secondary text-truncate" [ngbTooltip]="(selectedScope | async)?.name" type="button" (click)="openScopeModal()">{{(selectedScope | async)?.name || ('search.form.scope.all' | translate)}}</button>
</div>
<input type="text" [(ngModel)]="query" name="query" class="form-control" attr.aria-label="{{ searchPlaceholder }}"
[placeholder]="searchPlaceholder">
<span class="input-group-append">

View File

@@ -3,3 +3,7 @@
background-color: var(--bs-input-bg);
color: var(--bs-input-color);
}
.scope-button {
max-width: $search-form-scope-max-width;
}

View File

@@ -8,13 +8,11 @@ import { Community } from '../../core/shared/community.model';
import { TranslateModule } from '@ngx-translate/core';
import { DSpaceObject } from '../../core/shared/dspace-object.model';
import { SearchService } from '../../core/shared/search/search.service';
import { PaginationComponentOptions } from '../pagination/pagination-component-options.model';
import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model';
import { FindListOptions } from '../../core/data/request.models';
import { of as observableOf } from 'rxjs';
import { PaginationService } from '../../core/pagination/pagination.service';
import { SearchConfigurationService } from '../../core/shared/search/search-configuration.service';
import { PaginationServiceStub } from '../testing/pagination-service.stub';
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
import { createSuccessfulRemoteDataObject$ } from '../remote-data.utils';
describe('SearchFormComponent', () => {
let comp: SearchFormComponent;
@@ -35,7 +33,8 @@ describe('SearchFormComponent', () => {
useValue: {}
},
{ provide: PaginationService, useValue: paginationService },
{ provide: SearchConfigurationService, useValue: searchConfigService }
{ provide: SearchConfigurationService, useValue: searchConfigService },
{ provide: DSpaceObjectDataService, useValue: { findById: () => createSuccessfulRemoteDataObject$(undefined)} }
],
declarations: [SearchFormComponent]
}).compileComponents();
@@ -48,24 +47,6 @@ describe('SearchFormComponent', () => {
el = de.nativeElement;
});
it('should display scopes when available with default and all scopes', () => {
comp.scopes = objects;
fixture.detectChanges();
const select: HTMLElement = de.query(By.css('select')).nativeElement;
expect(select).toBeDefined();
const options: HTMLCollection = select.children;
const defOption: Element = options.item(0);
expect(defOption.getAttribute('value')).toBe('');
let index = 1;
objects.forEach((object) => {
expect(options.item(index).textContent).toBe(object.name);
expect(options.item(index).getAttribute('value')).toBe(object.uuid);
index++;
});
});
it('should not display scopes when empty', () => {
fixture.detectChanges();
const select = de.query(By.css('select'));
@@ -84,17 +65,17 @@ describe('SearchFormComponent', () => {
}));
it('should select correct scope option in scope select', fakeAsync(() => {
comp.scopes = objects;
fixture.detectChanges();
fixture.detectChanges();
comp.showScopeSelector = true;
const testCommunity = objects[1];
comp.scope = testCommunity.id;
comp.selectedScope.next(testCommunity);
fixture.detectChanges();
tick();
const scopeSelect = de.query(By.css('select')).nativeElement;
const scopeSelect = de.query(By.css('.scope-button')).nativeElement;
expect(scopeSelect.value).toBe(testCommunity.id);
expect(scopeSelect.textContent).toBe(testCommunity.name);
}));
// it('should call updateSearch when clicking the submit button with correct parameters', fakeAsync(() => {
// comp.query = 'Test String'
@@ -118,7 +99,7 @@ describe('SearchFormComponent', () => {
//
// expect(comp.updateSearch).toHaveBeenCalledWith({ scope: scope, query: query });
// }));
});
});
export const objects: DSpaceObject[] = [
Object.assign(new Community(), {

View File

@@ -1,4 +1,4 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { DSpaceObject } from '../../core/shared/dspace-object.model';
import { Router } from '@angular/router';
import { isNotEmpty } from '../empty.util';
@@ -6,6 +6,12 @@ import { SearchService } from '../../core/shared/search/search.service';
import { currentPath } from '../utils/route.utils';
import { PaginationService } from '../../core/pagination/pagination.service';
import { SearchConfigurationService } from '../../core/shared/search/search-configuration.service';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { ScopeSelectorModalComponent } from './scope-selector-modal/scope-selector-modal.component';
import { take } from 'rxjs/operators';
import { BehaviorSubject } from 'rxjs';
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
import { getFirstSucceededRemoteDataPayload } from '../../core/shared/operators';
/**
* This component renders a simple item page.
@@ -22,7 +28,7 @@ import { SearchConfigurationService } from '../../core/shared/search/search-conf
/**
* Component that represents the search form
*/
export class SearchFormComponent {
export class SearchFormComponent implements OnInit {
/**
* The search query
*/
@@ -39,12 +45,9 @@ export class SearchFormComponent {
@Input()
scope = '';
@Input() currentUrl: string;
selectedScope: BehaviorSubject<DSpaceObject> = new BehaviorSubject<DSpaceObject>(undefined);
/**
* The available scopes
*/
@Input() scopes: DSpaceObject[];
@Input() currentUrl: string;
/**
* Whether or not the search button should be displayed large
@@ -61,17 +64,35 @@ export class SearchFormComponent {
*/
@Input() searchPlaceholder: string;
/**
* Defines whether or not to show the scope selector
*/
@Input() showScopeSelector = false;
/**
* Output the search data on submit
*/
@Output() submitSearch = new EventEmitter<any>();
constructor(private router: Router, private searchService: SearchService,
constructor(private router: Router,
private searchService: SearchService,
private paginationService: PaginationService,
private searchConfig: SearchConfigurationService
private searchConfig: SearchConfigurationService,
private modalService: NgbModal,
private dsoService: DSpaceObjectDataService
) {
}
/**
* Retrieve the scope object from the URL so we can show its name
*/
ngOnInit(): void {
if (isNotEmpty(this.scope)) {
this.dsoService.findById(this.scope).pipe(getFirstSucceededRemoteDataPayload())
.subscribe((scope: DSpaceObject) => this.selectedScope.next(scope));
}
}
/**
* Updates the search when the form is submitted
* @param data Values submitted using the form
@@ -85,8 +106,8 @@ export class SearchFormComponent {
* Updates the search when the current scope has been changed
* @param {string} scope The new scope
*/
onScopeChange(scope: string) {
this.updateSearch({ scope });
onScopeChange(scope: DSpaceObject) {
this.updateSearch({ scope: scope ? scope.uuid : undefined });
}
/**
@@ -131,4 +152,15 @@ export class SearchFormComponent {
}
return this.getSearchLink().split('/');
}
/**
* Open the scope modal so the user can select DSO as scope
*/
openScopeModal() {
const ref = this.modalService.open(ScopeSelectorModalComponent);
ref.componentInstance.scopeChange.pipe(take(1)).subscribe((scope: DSpaceObject) => {
this.selectedScope.next(scope);
this.onScopeChange(scope);
});
}
}

View File

@@ -233,6 +233,7 @@ import { OnClickMenuItemComponent } from './menu/menu-item/onclick-menu-item.com
import { TextMenuItemComponent } from './menu/menu-item/text-menu-item.component';
import { ThemedConfigurationSearchPageComponent } from '../search-page/themed-configuration-search-page.component';
import { SearchNavbarComponent } from '../search-navbar/search-navbar.component';
import { ScopeSelectorModalComponent } from './search-form/scope-selector-modal/scope-selector-modal.component';
import { BitstreamRequestACopyPageComponent } from './bitstream-request-a-copy-page/bitstream-request-a-copy-page.component';
/**
@@ -460,7 +461,8 @@ const COMPONENTS = [
PublicationSidebarSearchListElementComponent,
CollectionSidebarSearchListElementComponent,
CommunitySidebarSearchListElementComponent,
SearchNavbarComponent
SearchNavbarComponent,
ScopeSelectorModalComponent
];
const ENTRY_COMPONENTS = [
@@ -525,7 +527,8 @@ const ENTRY_COMPONENTS = [
CommunitySidebarSearchListElementComponent,
LinkMenuItemComponent,
OnClickMenuItemComponent,
TextMenuItemComponent
TextMenuItemComponent,
ScopeSelectorModalComponent
];
const SHARED_SEARCH_PAGE_COMPONENTS = [

View File

@@ -1,75 +1,17 @@
import { ThemeEffects } from './theme.effects';
import { of as observableOf } from 'rxjs';
import { TestBed } from '@angular/core/testing';
import { provideMockActions } from '@ngrx/effects/testing';
import { LinkService } from '../../core/cache/builders/link.service';
import { cold, hot } from 'jasmine-marbles';
import { ROOT_EFFECTS_INIT } from '@ngrx/effects';
import { SetThemeAction } from './theme.actions';
import { Theme } from '../../../config/theme.model';
import { provideMockStore } from '@ngrx/store/testing';
import { ROUTER_NAVIGATED } from '@ngrx/router-store';
import { ResolverActionTypes } from '../../core/resolving/resolver.actions';
import { Community } from '../../core/shared/community.model';
import { COMMUNITY } from '../../core/shared/community.resource-type';
import { NoOpAction } from '../ngrx/no-op.action';
import { ITEM } from '../../core/shared/item.resource-type';
import { DSpaceObject } from '../../core/shared/dspace-object.model';
import { Item } from '../../core/shared/item.model';
import { Collection } from '../../core/shared/collection.model';
import { COLLECTION } from '../../core/shared/collection.resource-type';
import {
createNoContentRemoteDataObject$,
createSuccessfulRemoteDataObject$
} from '../remote-data.utils';
import { BASE_THEME_NAME } from './theme.constants';
/**
* LinkService able to mock recursively resolving DSO parent links
* Every time resolveLinkWithoutAttaching is called, it returns the next object in the array of ancestorDSOs until
* none are left, after which it returns a no-content remote-date
*/
class MockLinkService {
index = -1;
constructor(private ancestorDSOs: DSpaceObject[]) {
}
resolveLinkWithoutAttaching() {
if (this.index >= this.ancestorDSOs.length - 1) {
return createNoContentRemoteDataObject$();
} else {
this.index++;
return createSuccessfulRemoteDataObject$(this.ancestorDSOs[this.index]);
}
}
}
describe('ThemeEffects', () => {
let themeEffects: ThemeEffects;
let linkService: LinkService;
let initialState;
let ancestorDSOs: DSpaceObject[];
function init() {
ancestorDSOs = [
Object.assign(new Collection(), {
type: COLLECTION.value,
uuid: 'collection-uuid',
_links: { owningCommunity: { href: 'owning-community-link' } }
}),
Object.assign(new Community(), {
type: COMMUNITY.value,
uuid: 'sub-community-uuid',
_links: { parentCommunity: { href: 'parent-community-link' } }
}),
Object.assign(new Community(), {
type: COMMUNITY.value,
uuid: 'top-community-uuid',
}),
];
linkService = new MockLinkService(ancestorDSOs) as any;
initialState = {
theme: {
currentTheme: 'custom',
@@ -82,7 +24,6 @@ describe('ThemeEffects', () => {
TestBed.configureTestingModule({
providers: [
ThemeEffects,
{ provide: LinkService, useValue: linkService },
provideMockStore({ initialState }),
provideMockActions(() => mockActions)
]
@@ -110,205 +51,4 @@ describe('ThemeEffects', () => {
expect(themeEffects.initTheme$).toBeObservable(expected);
});
});
describe('updateThemeOnRouteChange$', () => {
const url = '/test/route';
const dso = Object.assign(new Community(), {
type: COMMUNITY.value,
uuid: '0958c910-2037-42a9-81c7-dca80e3892b4',
});
function spyOnPrivateMethods() {
spyOn((themeEffects as any), 'getAncestorDSOs').and.returnValue(() => observableOf([dso]));
spyOn((themeEffects as any), 'matchThemeToDSOs').and.returnValue(new Theme({ name: 'custom' }));
spyOn((themeEffects as any), 'getActionForMatch').and.returnValue(new SetThemeAction('custom'));
}
describe('when a resolved action is present', () => {
beforeEach(() => {
setupEffectsWithActions(
hot('--ab-', {
a: {
type: ROUTER_NAVIGATED,
payload: { routerState: { url } },
},
b: {
type: ResolverActionTypes.RESOLVED,
payload: { url, dso },
}
})
);
spyOnPrivateMethods();
});
it('should set the theme it receives from the DSO', () => {
const expected = cold('--b-', {
b: new SetThemeAction('custom')
});
expect(themeEffects.updateThemeOnRouteChange$).toBeObservable(expected);
});
});
describe('when no resolved action is present', () => {
beforeEach(() => {
setupEffectsWithActions(
hot('--a-', {
a: {
type: ROUTER_NAVIGATED,
payload: { routerState: { url } },
},
})
);
spyOnPrivateMethods();
});
it('should set the theme it receives from the route url', () => {
const expected = cold('--b-', {
b: new SetThemeAction('custom')
});
expect(themeEffects.updateThemeOnRouteChange$).toBeObservable(expected);
});
});
describe('when no themes are present', () => {
beforeEach(() => {
setupEffectsWithActions(
hot('--a-', {
a: {
type: ROUTER_NAVIGATED,
payload: { routerState: { url } },
},
})
);
(themeEffects as any).themes = [];
});
it('should return an empty action', () => {
const expected = cold('--b-', {
b: new NoOpAction()
});
expect(themeEffects.updateThemeOnRouteChange$).toBeObservable(expected);
});
});
});
describe('private functions', () => {
beforeEach(() => {
setupEffectsWithActions(hot('-', {}));
});
describe('getActionForMatch', () => {
it('should return a SET action if the new theme differs from the current theme', () => {
const theme = new Theme({ name: 'new-theme' });
expect((themeEffects as any).getActionForMatch(theme, 'old-theme')).toEqual(new SetThemeAction('new-theme'));
});
it('should return an empty action if the new theme equals the current theme', () => {
const theme = new Theme({ name: 'old-theme' });
expect((themeEffects as any).getActionForMatch(theme, 'old-theme')).toEqual(new NoOpAction());
});
});
describe('matchThemeToDSOs', () => {
let themes: Theme[];
let nonMatchingTheme: Theme;
let itemMatchingTheme: Theme;
let communityMatchingTheme: Theme;
let dsos: DSpaceObject[];
beforeEach(() => {
nonMatchingTheme = Object.assign(new Theme({ name: 'non-matching-theme' }), {
matches: () => false
});
itemMatchingTheme = Object.assign(new Theme({ name: 'item-matching-theme' }), {
matches: (url, dso) => (dso as any).type === ITEM.value
});
communityMatchingTheme = Object.assign(new Theme({ name: 'community-matching-theme' }), {
matches: (url, dso) => (dso as any).type === COMMUNITY.value
});
dsos = [
Object.assign(new Item(), {
type: ITEM.value,
uuid: 'item-uuid',
}),
Object.assign(new Collection(), {
type: COLLECTION.value,
uuid: 'collection-uuid',
}),
Object.assign(new Community(), {
type: COMMUNITY.value,
uuid: 'community-uuid',
}),
];
});
describe('when no themes match any of the DSOs', () => {
beforeEach(() => {
themes = [ nonMatchingTheme ];
themeEffects.themes = themes;
});
it('should return undefined', () => {
expect((themeEffects as any).matchThemeToDSOs(dsos, '')).toBeUndefined();
});
});
describe('when one of the themes match a DSOs', () => {
beforeEach(() => {
themes = [ nonMatchingTheme, itemMatchingTheme ];
themeEffects.themes = themes;
});
it('should return the matching theme', () => {
expect((themeEffects as any).matchThemeToDSOs(dsos, '')).toEqual(itemMatchingTheme);
});
});
describe('when multiple themes match some of the DSOs', () => {
it('should return the first matching theme', () => {
themes = [ nonMatchingTheme, itemMatchingTheme, communityMatchingTheme ];
themeEffects.themes = themes;
expect((themeEffects as any).matchThemeToDSOs(dsos, '')).toEqual(itemMatchingTheme);
themes = [ nonMatchingTheme, communityMatchingTheme, itemMatchingTheme ];
themeEffects.themes = themes;
expect((themeEffects as any).matchThemeToDSOs(dsos, '')).toEqual(communityMatchingTheme);
});
});
});
describe('getAncestorDSOs', () => {
it('should return an array of the provided DSO and its ancestors', (done) => {
const dso = Object.assign(new Item(), {
type: ITEM.value,
uuid: 'item-uuid',
_links: { owningCollection: { href: 'owning-collection-link' } },
});
observableOf(dso).pipe(
(themeEffects as any).getAncestorDSOs()
).subscribe((result) => {
expect(result).toEqual([dso, ...ancestorDSOs]);
done();
});
});
it('should return an array of just the provided DSO if it doesn\'t have any parents', (done) => {
const dso = {
type: ITEM.value,
uuid: 'item-uuid',
};
observableOf(dso).pipe(
(themeEffects as any).getAncestorDSOs()
).subscribe((result) => {
expect(result).toEqual([dso]);
done();
});
});
});
});
});

View File

@@ -1,22 +1,9 @@
import { Injectable } from '@angular/core';
import { createEffect, Actions, ofType, ROOT_EFFECTS_INIT } from '@ngrx/effects';
import { ROUTER_NAVIGATED, RouterNavigatedAction } from '@ngrx/router-store';
import { map, withLatestFrom, expand, switchMap, toArray, startWith, filter } from 'rxjs/operators';
import { map } from 'rxjs/operators';
import { SetThemeAction } from './theme.actions';
import { environment } from '../../../environments/environment';
import { ThemeConfig, themeFactory, Theme, } from '../../../config/theme.model';
import { hasValue, isNotEmpty, hasNoValue } from '../empty.util';
import { NoOpAction } from '../ngrx/no-op.action';
import { Store, select } from '@ngrx/store';
import { ThemeState } from './theme.reducer';
import { currentThemeSelector } from './theme.service';
import { of as observableOf, EMPTY, Observable } from 'rxjs';
import { ResolverActionTypes, ResolvedAction } from '../../core/resolving/resolver.actions';
import { followLink } from '../utils/follow-link-config.model';
import { RemoteData } from '../../core/data/remote-data';
import { DSpaceObject } from '../../core/shared/dspace-object.model';
import { getFirstCompletedRemoteData } from '../../core/shared/operators';
import { LinkService } from '../../core/cache/builders/link.service';
import { hasValue, hasNoValue } from '../empty.util';
import { BASE_THEME_NAME } from './theme.constants';
export const DEFAULT_THEME_CONFIG = environment.themes.find((themeConfig: any) =>
@@ -27,16 +14,6 @@ export const DEFAULT_THEME_CONFIG = environment.themes.find((themeConfig: any) =
@Injectable()
export class ThemeEffects {
/**
* The list of configured themes
*/
themes: Theme[];
/**
* True if at least one theme depends on the route
*/
hasDynamicTheme: boolean;
/**
* Initialize with a theme that doesn't depend on the route.
*/
@@ -53,133 +30,8 @@ export class ThemeEffects {
)
);
/**
* An effect that fires when a route change completes,
* and determines whether or not the theme should change
*/
updateThemeOnRouteChange$ = createEffect(() => this.actions$.pipe(
// Listen for when a route change ends
ofType(ROUTER_NAVIGATED),
withLatestFrom(
// Pull in the latest resolved action, or undefined if none was dispatched yet
this.actions$.pipe(ofType(ResolverActionTypes.RESOLVED), startWith(undefined)),
// and the current theme from the store
this.store.pipe(select(currentThemeSelector))
),
switchMap(([navigatedAction, resolvedAction, currentTheme]: [RouterNavigatedAction, ResolvedAction, string]) => {
if (this.hasDynamicTheme === true && isNotEmpty(this.themes)) {
const currentRouteUrl = navigatedAction.payload.routerState.url;
// If resolvedAction exists, and deals with the current url
if (hasValue(resolvedAction) && resolvedAction.payload.url === currentRouteUrl) {
// Start with the resolved dso and go recursively through its parents until you reach the top-level community
return observableOf(resolvedAction.payload.dso).pipe(
this.getAncestorDSOs(),
map((dsos: DSpaceObject[]) => {
const dsoMatch = this.matchThemeToDSOs(dsos, currentRouteUrl);
return this.getActionForMatch(dsoMatch, currentTheme);
})
);
}
// check whether the route itself matches
const routeMatch = this.themes.find((theme: Theme) => theme.matches(currentRouteUrl, undefined));
return [this.getActionForMatch(routeMatch, currentTheme)];
}
// If there are no themes configured, do nothing
return [new NoOpAction()];
})
)
);
/**
* return the action to dispatch based on the given matching theme
*
* @param newTheme The theme to create an action for
* @param currentThemeName The name of the currently active theme
* @private
*/
private getActionForMatch(newTheme: Theme, currentThemeName: string): SetThemeAction | NoOpAction {
if (hasValue(newTheme) && newTheme.config.name !== currentThemeName) {
// If we have a match, and it isn't already the active theme, set it as the new theme
return new SetThemeAction(newTheme.config.name);
} else {
// Otherwise, do nothing
return new NoOpAction();
}
}
/**
* Check the given DSpaceObjects in order to see if they match the configured themes in order.
* If a match is found, the matching theme is returned
*
* @param dsos The DSpaceObjects to check
* @param currentRouteUrl The url for the current route
* @private
*/
private matchThemeToDSOs(dsos: DSpaceObject[], currentRouteUrl: string): Theme {
// iterate over the themes in order, and return the first one that matches
return this.themes.find((theme: Theme) => {
// iterate over the dsos's in order (most specific one first, so Item, Collection,
// Community), and return the first one that matches the current theme
const match = dsos.find((dso: DSpaceObject) => theme.matches(currentRouteUrl, dso));
return hasValue(match);
});
}
/**
* An rxjs operator that will return an array of all the ancestors of the DSpaceObject used as
* input. The initial DSpaceObject will be the first element of the output array, followed by
* its parent, its grandparent etc
*
* @private
*/
private getAncestorDSOs() {
return (source: Observable<DSpaceObject>): Observable<DSpaceObject[]> =>
source.pipe(
expand((dso: DSpaceObject) => {
// Check if the dso exists and has a parent link
if (hasValue(dso) && typeof (dso as any).getParentLinkKey === 'function') {
const linkName = (dso as any).getParentLinkKey();
// If it does, retrieve it.
return this.linkService.resolveLinkWithoutAttaching<DSpaceObject, DSpaceObject>(dso, followLink(linkName)).pipe(
getFirstCompletedRemoteData(),
map((rd: RemoteData<DSpaceObject>) => {
if (hasValue(rd.payload)) {
// If there's a parent, use it for the next iteration
return rd.payload;
} else {
// If there's no parent, or an error, return null, which will stop recursion
// in the next iteration
return null;
}
}),
);
}
// The current dso has no value, or no parent. Return EMPTY to stop recursion
return EMPTY;
}),
// only allow through DSOs that have a value
filter((dso: DSpaceObject) => hasValue(dso)),
// Wait for recursion to complete, and emit all results at once, in an array
toArray()
);
}
constructor(
private actions$: Actions,
private store: Store<ThemeState>,
private linkService: LinkService,
) {
// Create objects from the theme configs in the environment file
this.themes = environment.themes.map((themeConfig: ThemeConfig) => themeFactory(themeConfig));
this.hasDynamicTheme = environment.themes.some((themeConfig: any) =>
hasValue(themeConfig.regex) ||
hasValue(themeConfig.handle) ||
hasValue(themeConfig.uuid)
);
}
}

View File

@@ -0,0 +1,370 @@
import { of as observableOf } from 'rxjs';
import { TestBed } from '@angular/core/testing';
import { provideMockActions } from '@ngrx/effects/testing';
import { LinkService } from '../../core/cache/builders/link.service';
import { cold, hot } from 'jasmine-marbles';
import { SetThemeAction } from './theme.actions';
import { Theme } from '../../../config/theme.model';
import { provideMockStore } from '@ngrx/store/testing';
import { Community } from '../../core/shared/community.model';
import { COMMUNITY } from '../../core/shared/community.resource-type';
import { NoOpAction } from '../ngrx/no-op.action';
import { ITEM } from '../../core/shared/item.resource-type';
import { DSpaceObject } from '../../core/shared/dspace-object.model';
import { Item } from '../../core/shared/item.model';
import { Collection } from '../../core/shared/collection.model';
import { COLLECTION } from '../../core/shared/collection.resource-type';
import {
createNoContentRemoteDataObject$, createSuccessfulRemoteDataObject,
createSuccessfulRemoteDataObject$
} from '../remote-data.utils';
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
import { ThemeService } from './theme.service';
import { ROUTER_NAVIGATED } from '@ngrx/router-store';
import { ActivatedRouteSnapshot } from '@angular/router';
/**
* LinkService able to mock recursively resolving DSO parent links
* Every time resolveLinkWithoutAttaching is called, it returns the next object in the array of ancestorDSOs until
* none are left, after which it returns a no-content remote-date
*/
class MockLinkService {
index = -1;
constructor(private ancestorDSOs: DSpaceObject[]) {
}
resolveLinkWithoutAttaching() {
if (this.index >= this.ancestorDSOs.length - 1) {
return createNoContentRemoteDataObject$();
} else {
this.index++;
return createSuccessfulRemoteDataObject$(this.ancestorDSOs[this.index]);
}
}
}
describe('ThemeService', () => {
let themeService: ThemeService;
let linkService: LinkService;
let initialState;
let ancestorDSOs: DSpaceObject[];
const mockCommunity = Object.assign(new Community(), {
type: COMMUNITY.value,
uuid: 'top-community-uuid',
});
function init() {
ancestorDSOs = [
Object.assign(new Collection(), {
type: COLLECTION.value,
uuid: 'collection-uuid',
_links: { owningCommunity: { href: 'owning-community-link' } }
}),
Object.assign(new Community(), {
type: COMMUNITY.value,
uuid: 'sub-community-uuid',
_links: { parentCommunity: { href: 'parent-community-link' } }
}),
mockCommunity,
];
linkService = new MockLinkService(ancestorDSOs) as any;
initialState = {
theme: {
currentTheme: 'custom',
},
};
}
function setupServiceWithActions(mockActions) {
init();
const mockDsoService = {
findById: () => createSuccessfulRemoteDataObject$(mockCommunity)
};
TestBed.configureTestingModule({
providers: [
ThemeService,
{ provide: LinkService, useValue: linkService },
provideMockStore({ initialState }),
provideMockActions(() => mockActions),
{ provide: DSpaceObjectDataService, useValue: mockDsoService }
]
});
themeService = TestBed.inject(ThemeService);
spyOn((themeService as any).store, 'dispatch').and.stub();
}
describe('updateThemeOnRouteChange$', () => {
const url = '/test/route';
const dso = Object.assign(new Community(), {
type: COMMUNITY.value,
uuid: '0958c910-2037-42a9-81c7-dca80e3892b4',
});
function spyOnPrivateMethods() {
spyOn((themeService as any), 'getAncestorDSOs').and.returnValue(() => observableOf([dso]));
spyOn((themeService as any), 'matchThemeToDSOs').and.returnValue(new Theme({ name: 'custom' }));
spyOn((themeService as any), 'getActionForMatch').and.returnValue(new SetThemeAction('custom'));
}
describe('when no resolved action is present', () => {
beforeEach(() => {
setupServiceWithActions(
hot('--a-', {
a: {
type: ROUTER_NAVIGATED,
payload: { routerState: { url } },
},
})
);
spyOnPrivateMethods();
});
it('should set the theme it receives from the route url', (done) => {
themeService.updateThemeOnRouteChange$(url, {} as ActivatedRouteSnapshot).subscribe(() => {
expect((themeService as any).store.dispatch).toHaveBeenCalledWith(new SetThemeAction('custom') as any);
done();
});
});
it('should return true', (done) => {
themeService.updateThemeOnRouteChange$(url, {} as ActivatedRouteSnapshot).subscribe((result) => {
expect(result).toEqual(true);
done();
});
});
});
describe('when no themes are present', () => {
beforeEach(() => {
setupServiceWithActions(
hot('--a-', {
a: {
type: ROUTER_NAVIGATED,
payload: { routerState: { url } },
},
})
);
(themeService as any).themes = [];
});
it('should not dispatch any action', (done) => {
themeService.updateThemeOnRouteChange$(url, {} as ActivatedRouteSnapshot).subscribe(() => {
expect((themeService as any).store.dispatch).not.toHaveBeenCalled();
done();
});
});
it('should return false', (done) => {
themeService.updateThemeOnRouteChange$(url, {} as ActivatedRouteSnapshot).subscribe((result) => {
expect(result).toEqual(false);
done();
});
});
});
describe('when a dso is present in the snapshot\'s data', () => {
let snapshot;
beforeEach(() => {
setupServiceWithActions(
hot('--a-', {
a: {
type: ROUTER_NAVIGATED,
payload: { routerState: { url } },
},
})
);
spyOnPrivateMethods();
snapshot = Object.assign({
data: {
dso: createSuccessfulRemoteDataObject(dso)
}
});
});
it('should match the theme to the dso', (done) => {
themeService.updateThemeOnRouteChange$(url, snapshot).subscribe(() => {
expect((themeService as any).matchThemeToDSOs).toHaveBeenCalled();
done();
});
});
it('should set the theme it receives from the data dso', (done) => {
themeService.updateThemeOnRouteChange$(url, snapshot).subscribe(() => {
expect((themeService as any).store.dispatch).toHaveBeenCalledWith(new SetThemeAction('custom') as any);
done();
});
});
it('should return true', (done) => {
themeService.updateThemeOnRouteChange$(url, snapshot).subscribe((result) => {
expect(result).toEqual(true);
done();
});
});
});
describe('when a scope is present in the snapshot\'s parameters', () => {
let snapshot;
beforeEach(() => {
setupServiceWithActions(
hot('--a-', {
a: {
type: ROUTER_NAVIGATED,
payload: { routerState: { url } },
},
})
);
spyOnPrivateMethods();
snapshot = Object.assign({
queryParams: {
scope: mockCommunity.uuid
}
});
});
it('should match the theme to the dso found through the scope', (done) => {
themeService.updateThemeOnRouteChange$(url, snapshot).subscribe(() => {
expect((themeService as any).matchThemeToDSOs).toHaveBeenCalled();
done();
});
});
it('should set the theme it receives from the dso found through the scope', (done) => {
themeService.updateThemeOnRouteChange$(url, snapshot).subscribe(() => {
expect((themeService as any).store.dispatch).toHaveBeenCalledWith(new SetThemeAction('custom') as any);
done();
});
});
it('should return true', (done) => {
themeService.updateThemeOnRouteChange$(url, snapshot).subscribe((result) => {
expect(result).toEqual(true);
done();
});
});
});
});
describe('private functions', () => {
beforeEach(() => {
setupServiceWithActions(hot('-', {}));
});
describe('getActionForMatch', () => {
it('should return a SET action if the new theme differs from the current theme', () => {
const theme = new Theme({ name: 'new-theme' });
expect((themeService as any).getActionForMatch(theme, 'old-theme')).toEqual(new SetThemeAction('new-theme'));
});
it('should return an empty action if the new theme equals the current theme', () => {
const theme = new Theme({ name: 'old-theme' });
expect((themeService as any).getActionForMatch(theme, 'old-theme')).toEqual(new NoOpAction());
});
});
describe('matchThemeToDSOs', () => {
let themes: Theme[];
let nonMatchingTheme: Theme;
let itemMatchingTheme: Theme;
let communityMatchingTheme: Theme;
let dsos: DSpaceObject[];
beforeEach(() => {
nonMatchingTheme = Object.assign(new Theme({ name: 'non-matching-theme' }), {
matches: () => false
});
itemMatchingTheme = Object.assign(new Theme({ name: 'item-matching-theme' }), {
matches: (url, dso) => (dso as any).type === ITEM.value
});
communityMatchingTheme = Object.assign(new Theme({ name: 'community-matching-theme' }), {
matches: (url, dso) => (dso as any).type === COMMUNITY.value
});
dsos = [
Object.assign(new Item(), {
type: ITEM.value,
uuid: 'item-uuid',
}),
Object.assign(new Collection(), {
type: COLLECTION.value,
uuid: 'collection-uuid',
}),
Object.assign(new Community(), {
type: COMMUNITY.value,
uuid: 'community-uuid',
}),
];
});
describe('when no themes match any of the DSOs', () => {
beforeEach(() => {
themes = [ nonMatchingTheme ];
themeService.themes = themes;
});
it('should return undefined', () => {
expect((themeService as any).matchThemeToDSOs(dsos, '')).toBeUndefined();
});
});
describe('when one of the themes match a DSOs', () => {
beforeEach(() => {
themes = [ nonMatchingTheme, itemMatchingTheme ];
themeService.themes = themes;
});
it('should return the matching theme', () => {
expect((themeService as any).matchThemeToDSOs(dsos, '')).toEqual(itemMatchingTheme);
});
});
describe('when multiple themes match some of the DSOs', () => {
it('should return the first matching theme', () => {
themes = [ nonMatchingTheme, itemMatchingTheme, communityMatchingTheme ];
themeService.themes = themes;
expect((themeService as any).matchThemeToDSOs(dsos, '')).toEqual(itemMatchingTheme);
themes = [ nonMatchingTheme, communityMatchingTheme, itemMatchingTheme ];
themeService.themes = themes;
expect((themeService as any).matchThemeToDSOs(dsos, '')).toEqual(communityMatchingTheme);
});
});
});
describe('getAncestorDSOs', () => {
it('should return an array of the provided DSO and its ancestors', (done) => {
const dso = Object.assign(new Item(), {
type: ITEM.value,
uuid: 'item-uuid',
_links: { owningCollection: { href: 'owning-collection-link' } },
});
observableOf(dso).pipe(
(themeService as any).getAncestorDSOs()
).subscribe((result) => {
expect(result).toEqual([dso, ...ancestorDSOs]);
done();
});
});
it('should return an array of just the provided DSO if it doesn\'t have any parents', (done) => {
const dso = {
type: ITEM.value,
uuid: 'item-uuid',
};
observableOf(dso).pipe(
(themeService as any).getAncestorDSOs()
).subscribe((result) => {
expect(result).toEqual([dso]);
done();
});
});
});
});
});

View File

@@ -1,10 +1,26 @@
import { Injectable } from '@angular/core';
import { Injectable, Inject } from '@angular/core';
import { Store, createFeatureSelector, createSelector, select } from '@ngrx/store';
import { Observable } from 'rxjs/internal/Observable';
import { ThemeState } from './theme.reducer';
import { SetThemeAction } from './theme.actions';
import { take } from 'rxjs/operators';
import { hasValue } from '../empty.util';
import { SetThemeAction, ThemeActionTypes } from './theme.actions';
import { expand, filter, map, switchMap, take, toArray } from 'rxjs/operators';
import { hasValue, isNotEmpty } from '../empty.util';
import { RemoteData } from '../../core/data/remote-data';
import { DSpaceObject } from '../../core/shared/dspace-object.model';
import {
getFirstCompletedRemoteData,
getFirstSucceededRemoteData,
getRemoteDataPayload
} from '../../core/shared/operators';
import { EMPTY, of as observableOf } from 'rxjs';
import { Theme, ThemeConfig, themeFactory } from '../../../config/theme.model';
import { NO_OP_ACTION_TYPE, NoOpAction } from '../ngrx/no-op.action';
import { followLink } from '../utils/follow-link-config.model';
import { LinkService } from '../../core/cache/builders/link.service';
import { environment } from '../../../environments/environment';
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
import { ActivatedRouteSnapshot } from '@angular/router';
import { GET_THEME_CONFIG_FOR_FACTORY } from '../object-collection/shared/listable-object/listable-object.decorator';
export const themeStateSelector = createFeatureSelector<ThemeState>('theme');
@@ -17,9 +33,29 @@ export const currentThemeSelector = createSelector(
providedIn: 'root'
})
export class ThemeService {
/**
* The list of configured themes
*/
themes: Theme[];
/**
* True if at least one theme depends on the route
*/
hasDynamicTheme: boolean;
constructor(
private store: Store<ThemeState>,
private linkService: LinkService,
private dSpaceObjectDataService: DSpaceObjectDataService,
@Inject(GET_THEME_CONFIG_FOR_FACTORY) private gtcf: (str) => ThemeConfig
) {
// Create objects from the theme configs in the environment file
this.themes = environment.themes.map((themeConfig: ThemeConfig) => themeFactory(themeConfig));
this.hasDynamicTheme = environment.themes.some((themeConfig: any) =>
hasValue(themeConfig.regex) ||
hasValue(themeConfig.handle) ||
hasValue(themeConfig.uuid)
);
}
setTheme(newName: string) {
@@ -43,4 +79,174 @@ export class ThemeService {
);
}
/**
* Determine whether or not the theme needs to change depending on the current route's URL and snapshot data
* If the snapshot contains a dso, this will be used to match a theme
* If the snapshot contains a scope parameters, this will be used to match a theme
* Otherwise the URL is matched against
* If none of the above find a match, the theme doesn't change
* @param currentRouteUrl
* @param activatedRouteSnapshot
* @return Observable boolean emitting whether or not the theme has been changed
*/
updateThemeOnRouteChange$(currentRouteUrl: string, activatedRouteSnapshot: ActivatedRouteSnapshot): Observable<boolean> {
// and the current theme from the store
const currentTheme$: Observable<string> = this.store.pipe(select(currentThemeSelector));
const action$ = currentTheme$.pipe(
switchMap((currentTheme: string) => {
const snapshotWithData = this.findRouteData(activatedRouteSnapshot);
if (this.hasDynamicTheme === true && isNotEmpty(this.themes)) {
if (hasValue(snapshotWithData) && hasValue(snapshotWithData.data) && hasValue(snapshotWithData.data.dso)) {
const dsoRD: RemoteData<DSpaceObject> = snapshotWithData.data.dso;
if (dsoRD.hasSucceeded) {
// Start with the resolved dso and go recursively through its parents until you reach the top-level community
return observableOf(dsoRD.payload).pipe(
this.getAncestorDSOs(),
map((dsos: DSpaceObject[]) => {
const dsoMatch = this.matchThemeToDSOs(dsos, currentRouteUrl);
return this.getActionForMatch(dsoMatch, currentTheme);
})
);
}
}
if (hasValue(activatedRouteSnapshot.queryParams) && hasValue(activatedRouteSnapshot.queryParams.scope)) {
const dsoFromScope$: Observable<RemoteData<DSpaceObject>> = this.dSpaceObjectDataService.findById(activatedRouteSnapshot.queryParams.scope);
// Start with the resolved dso and go recursively through its parents until you reach the top-level community
return dsoFromScope$.pipe(
getFirstSucceededRemoteData(),
getRemoteDataPayload(),
this.getAncestorDSOs(),
map((dsos: DSpaceObject[]) => {
const dsoMatch = this.matchThemeToDSOs(dsos, currentRouteUrl);
return this.getActionForMatch(dsoMatch, currentTheme);
})
);
}
// check whether the route itself matches
const routeMatch = this.themes.find((theme: Theme) => theme.matches(currentRouteUrl, undefined));
return [this.getActionForMatch(routeMatch, currentTheme)];
}
// If there are no themes configured, do nothing
return [new NoOpAction()];
}),
take(1),
);
action$.pipe(
filter((action) => action.type !== NO_OP_ACTION_TYPE),
).subscribe((action) => {
this.store.dispatch(action);
});
return action$.pipe(
map((action) => action.type === ThemeActionTypes.SET),
);
}
/**
* Find a DSpaceObject in one of the provided route snapshots their data
* Recursively looks for the dso in the routes their child routes until it reaches a dead end or finds one
* @param routes
*/
findRouteData(...routes: ActivatedRouteSnapshot[]) {
const result = routes.find((route) => hasValue(route.data) && hasValue(route.data.dso));
if (hasValue(result)) {
return result;
} else {
const nextLevelRoutes = routes
.map((route: ActivatedRouteSnapshot) => route.children)
.reduce((combined: ActivatedRouteSnapshot[], current: ActivatedRouteSnapshot[]) => [...combined, ...current]);
if (isNotEmpty(nextLevelRoutes)) {
return this.findRouteData(...nextLevelRoutes);
} else {
return undefined;
}
}
}
/**
* An rxjs operator that will return an array of all the ancestors of the DSpaceObject used as
* input. The initial DSpaceObject will be the first element of the output array, followed by
* its parent, its grandparent etc
*
* @private
*/
private getAncestorDSOs() {
return (source: Observable<DSpaceObject>): Observable<DSpaceObject[]> =>
source.pipe(
expand((dso: DSpaceObject) => {
// Check if the dso exists and has a parent link
if (hasValue(dso) && typeof (dso as any).getParentLinkKey === 'function') {
const linkName = (dso as any).getParentLinkKey();
// If it does, retrieve it.
return this.linkService.resolveLinkWithoutAttaching<DSpaceObject, DSpaceObject>(dso, followLink(linkName)).pipe(
getFirstCompletedRemoteData(),
map((rd: RemoteData<DSpaceObject>) => {
if (hasValue(rd.payload)) {
// If there's a parent, use it for the next iteration
return rd.payload;
} else {
// If there's no parent, or an error, return null, which will stop recursion
// in the next iteration
return null;
}
}),
);
}
// The current dso has no value, or no parent. Return EMPTY to stop recursion
return EMPTY;
}),
// only allow through DSOs that have a value
filter((dso: DSpaceObject) => hasValue(dso)),
// Wait for recursion to complete, and emit all results at once, in an array
toArray()
);
}
/**
* return the action to dispatch based on the given matching theme
*
* @param newTheme The theme to create an action for
* @param currentThemeName The name of the currently active theme
* @private
*/
private getActionForMatch(newTheme: Theme, currentThemeName: string): SetThemeAction | NoOpAction {
if (hasValue(newTheme) && newTheme.config.name !== currentThemeName) {
// If we have a match, and it isn't already the active theme, set it as the new theme
return new SetThemeAction(newTheme.config.name);
} else {
// Otherwise, do nothing
return new NoOpAction();
}
}
/**
* Check the given DSpaceObjects in order to see if they match the configured themes in order.
* If a match is found, the matching theme is returned
*
* @param dsos The DSpaceObjects to check
* @param currentRouteUrl The url for the current route
* @private
*/
private matchThemeToDSOs(dsos: DSpaceObject[], currentRouteUrl: string): Theme {
// iterate over the themes in order, and return the first one that matches
return this.themes.find((theme: Theme) => {
// iterate over the dsos's in order (most specific one first, so Item, Collection,
// Community), and return the first one that matches the current theme
const match = dsos.find((dso: DSpaceObject) => theme.matches(currentRouteUrl, dso));
return hasValue(match);
});
}
/**
* Searches for a ThemeConfig by its name;
*/
getThemeConfigFor(themeName: string): ThemeConfig {
return this.gtcf(themeName);
}
}

View File

@@ -5,6 +5,7 @@ import { VarDirective } from '../utils/var.directive';
import { ThemeService } from './theme.service';
import { getMockThemeService } from '../mocks/theme-service.mock';
import { TestComponent } from './test/test.component.spec';
import { ThemeConfig } from '../../../config/theme.model';
/* tslint:disable:max-classes-per-file */
@Component({
@@ -32,8 +33,8 @@ describe('ThemedComponent', () => {
let fixture: ComponentFixture<TestThemedComponent>;
let themeService: ThemeService;
function setupTestingModuleForTheme(theme: string) {
themeService = getMockThemeService(theme);
function setupTestingModuleForTheme(theme: string, themes?: ThemeConfig[]) {
themeService = getMockThemeService(theme, themes);
TestBed.configureTestingModule({
imports: [],
declarations: [TestThemedComponent, VarDirective],
@@ -44,17 +45,20 @@ describe('ThemedComponent', () => {
}).compileComponents();
}
function initComponent() {
fixture = TestBed.createComponent(TestThemedComponent);
component = fixture.componentInstance;
spyOn(component as any, 'importThemedComponent').and.callThrough();
component.testInput = 'changed';
fixture.detectChanges();
}
describe('when the current theme matches a themed component', () => {
beforeEach(waitForAsync(() => {
setupTestingModuleForTheme('custom');
}));
beforeEach(() => {
fixture = TestBed.createComponent(TestThemedComponent);
component = fixture.componentInstance;
component.testInput = 'changed';
fixture.detectChanges();
});
beforeEach(initComponent);
it('should set compRef to the themed component', waitForAsync(() => {
fixture.whenStable().then(() => {
@@ -70,16 +74,12 @@ describe('ThemedComponent', () => {
});
describe('when the current theme doesn\'t match a themed component', () => {
describe('and it doesn\'t extend another theme', () => {
beforeEach(waitForAsync(() => {
setupTestingModuleForTheme('non-existing-theme');
}));
beforeEach(() => {
fixture = TestBed.createComponent(TestThemedComponent);
component = fixture.componentInstance;
component.testInput = 'changed';
fixture.detectChanges();
});
beforeEach(initComponent);
it('should set compRef to the default component', waitForAsync(() => {
fixture.whenStable().then(() => {
@@ -93,5 +93,108 @@ describe('ThemedComponent', () => {
});
}));
});
describe('and it extends another theme', () => {
describe('that doesn\'t match it either', () => {
beforeEach(waitForAsync(() => {
setupTestingModuleForTheme('current-theme', [
{ name: 'current-theme', extends: 'non-existing-theme' },
]);
}));
beforeEach(initComponent);
it('should set compRef to the default component', waitForAsync(() => {
fixture.whenStable().then(() => {
expect((component as any).importThemedComponent).toHaveBeenCalledWith('current-theme');
expect((component as any).importThemedComponent).toHaveBeenCalledWith('non-existing-theme');
expect((component as any).compRef.instance.type).toEqual('default');
});
}));
it('should sync up this component\'s input with the default component', waitForAsync(() => {
fixture.whenStable().then(() => {
expect((component as any).compRef.instance.testInput).toEqual('changed');
});
}));
});
describe('that does match it', () => {
beforeEach(waitForAsync(() => {
setupTestingModuleForTheme('current-theme', [
{ name: 'current-theme', extends: 'custom' },
]);
}));
beforeEach(initComponent);
it('should set compRef to the themed component', waitForAsync(() => {
fixture.whenStable().then(() => {
expect((component as any).importThemedComponent).toHaveBeenCalledWith('current-theme');
expect((component as any).importThemedComponent).toHaveBeenCalledWith('custom');
expect((component as any).compRef.instance.type).toEqual('themed');
});
}));
it('should sync up this component\'s input with the themed component', waitForAsync(() => {
fixture.whenStable().then(() => {
expect((component as any).compRef.instance.testInput).toEqual('changed');
});
}));
});
describe('that extends another theme that doesn\'t match it either', () => {
beforeEach(waitForAsync(() => {
setupTestingModuleForTheme('current-theme', [
{ name: 'current-theme', extends: 'parent-theme' },
{ name: 'parent-theme', extends: 'non-existing-theme' },
]);
}));
beforeEach(initComponent);
it('should set compRef to the default component', waitForAsync(() => {
fixture.whenStable().then(() => {
expect((component as any).importThemedComponent).toHaveBeenCalledWith('current-theme');
expect((component as any).importThemedComponent).toHaveBeenCalledWith('parent-theme');
expect((component as any).importThemedComponent).toHaveBeenCalledWith('non-existing-theme');
expect((component as any).compRef.instance.type).toEqual('default');
});
}));
it('should sync up this component\'s input with the default component', waitForAsync(() => {
fixture.whenStable().then(() => {
expect((component as any).compRef.instance.testInput).toEqual('changed');
});
}));
});
describe('that extends another theme that does match it', () => {
beforeEach(waitForAsync(() => {
setupTestingModuleForTheme('current-theme', [
{ name: 'current-theme', extends: 'parent-theme' },
{ name: 'parent-theme', extends: 'custom' },
]);
}));
beforeEach(initComponent);
it('should set compRef to the themed component', waitForAsync(() => {
fixture.whenStable().then(() => {
expect((component as any).importThemedComponent).toHaveBeenCalledWith('current-theme');
expect((component as any).importThemedComponent).toHaveBeenCalledWith('parent-theme');
expect((component as any).importThemedComponent).toHaveBeenCalledWith('custom');
expect((component as any).compRef.instance.type).toEqual('themed');
});
}));
it('should sync up this component\'s input with the themed component', waitForAsync(() => {
fixture.whenStable().then(() => {
expect((component as any).compRef.instance.testInput).toEqual('changed');
});
}));
});
});
});
});
/* tslint:enable:max-classes-per-file */

View File

@@ -11,7 +11,7 @@ import {
OnChanges
} from '@angular/core';
import { hasValue, isNotEmpty } from '../empty.util';
import { Subscription } from 'rxjs';
import { Observable, of as observableOf, Subscription } from 'rxjs';
import { ThemeService } from './theme.service';
import { fromPromise } from 'rxjs/internal-compatibility';
import { catchError, switchMap, map } from 'rxjs/operators';
@@ -69,11 +69,7 @@ export abstract class ThemedComponent<T> implements OnInit, OnDestroy, OnChanges
this.lazyLoadSub.unsubscribe();
}
this.lazyLoadSub =
fromPromise(this.importThemedComponent(this.themeService.getThemeName())).pipe(
// if there is no themed version of the component an exception is thrown,
// catch it and return null instead
catchError(() => [null]),
this.lazyLoadSub = this.resolveThemedComponent(this.themeService.getThemeName()).pipe(
switchMap((themedFile: any) => {
if (hasValue(themedFile) && hasValue(themedFile[this.getComponentName()])) {
// if the file is not null, and exports a component with the specified name,
@@ -113,4 +109,32 @@ export abstract class ThemedComponent<T> implements OnInit, OnDestroy, OnChanges
});
}
}
/**
* Attempt to import this component from the current theme or a theme it {@link NamedThemeConfig.extends}.
* Recurse until we succeed or when until we run out of themes to fall back to.
*
* @param themeName The name of the theme to check
* @param checkedThemeNames The list of theme names that are already checked
* @private
*/
private resolveThemedComponent(themeName?: string, checkedThemeNames: string[] = []): Observable<any> {
if (isNotEmpty(themeName)) {
return fromPromise(this.importThemedComponent(themeName)).pipe(
catchError(() => {
// Try the next ancestor theme instead
const nextTheme = this.themeService.getThemeConfigFor(themeName)?.extends;
const nextCheckedThemeNames = [...checkedThemeNames, themeName];
if (checkedThemeNames.includes(nextTheme)) {
throw new Error('Theme extension cycle detected: ' + [...nextCheckedThemeNames, nextTheme].join(' -> '));
} else {
return this.resolveThemedComponent(nextTheme, nextCheckedThemeNames);
}
}),
);
} else {
// If we got here, we've failed to import this component from any ancestor theme → fall back to unthemed
return observableOf(null);
}
}
}

View File

@@ -932,6 +932,30 @@
"collection.select.table.title": "Title",
"collection.source.controls.head": "Harvest Controls",
"collection.source.controls.test.submit.error": "Something went wrong with initiating the testing of the settings",
"collection.source.controls.test.failed": "The script to test the settings has failed",
"collection.source.controls.test.completed": "The script to test the settings has successfully finished",
"collection.source.controls.test.submit": "Test configuration",
"collection.source.controls.test.running": "Testing configuration...",
"collection.source.controls.import.submit.success": "The import has been successfully initiated",
"collection.source.controls.import.submit.error": "Something went wrong with initiating the import",
"collection.source.controls.import.submit": "Import now",
"collection.source.controls.import.running": "Importing...",
"collection.source.controls.import.failed": "An error occurred during the import",
"collection.source.controls.import.completed": "The import completed",
"collection.source.controls.reset.submit.success": "The reset and reimport has been successfully initiated",
"collection.source.controls.reset.submit.error": "Something went wrong with initiating the reset and reimport",
"collection.source.controls.reset.failed": "An error occurred during the reset and reimport",
"collection.source.controls.reset.completed": "The reset and reimport completed",
"collection.source.controls.reset.submit": "Reset and reimport",
"collection.source.controls.reset.running": "Resetting and reimporting...",
"collection.source.controls.harvest.status": "Harvest status:",
"collection.source.controls.harvest.start": "Harvest start time:",
"collection.source.controls.harvest.last": "Last time harvested:",
"collection.source.controls.harvest.message": "Harvest info:",
"collection.source.controls.harvest.no-information": "N/A",
"collection.source.update.notifications.error.content": "The provided settings have been tested and didn't work.",
@@ -1254,6 +1278,12 @@
"dso-selector.placeholder": "Search for a {{ type }}",
"dso-selector.set-scope.community.head": "Select a search scope",
"dso-selector.set-scope.community.button": "Search all of DSpace",
"dso-selector.set-scope.community.input-header": "Search for a community or collection",
"confirmation-modal.export-metadata.header": "Export metadata for {{ dsoName }}",
@@ -1311,6 +1341,11 @@
"error.validation.filerequired": "The file upload is mandatory",
"error.validation.required": "This field is required",
"error.validation.NotValidEmail": "This E-mail is not a valid email",
"error.validation.emailTaken": "This E-mail is already taken",
"file-section.error.header": "Error obtaining files for this item",
@@ -1972,6 +2007,10 @@
"item.page.collections": "Collections",
"item.page.collections.loading": "Loading...",
"item.page.collections.load-more": "Load more",
"item.page.date": "Date",
"item.page.edit": "Edit this item",
@@ -3199,6 +3238,8 @@
"search.form.search_dspace": "All repository",
"search.form.scope.all": "All of DSpace",
"search.results.head": "Search Results",

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,12 @@ import { getDSORoute } from '../app/app-routing-paths';
// tslint:disable:max-classes-per-file
export interface NamedThemeConfig extends Config {
name: string;
/**
* Specify another theme to build upon: whenever a themed component is not found in the current theme,
* its ancestor theme(s) will be checked recursively before falling back to the default theme.
*/
extends?: string;
}
export interface RegExThemeConfig extends NamedThemeConfig {

View File

@@ -265,6 +265,19 @@ export const environment: GlobalConfig = {
// uuid: '0958c910-2037-42a9-81c7-dca80e3892b4'
// },
// {
// // The extends property specifies an ancestor theme (by name). Whenever a themed component is not found
// // in the current theme, its ancestor theme(s) will be checked recursively before falling back to default.
// name: 'custom-A',
// extends: 'custom-B',
// // Any of the matching properties above can be used
// handle: '10673/34',
// },
// {
// name: 'custom-B',
// extends: 'custom',
// handle: '10673/12',
// },
// {
// // A theme with only a name will match every route
// name: 'custom'
// },

View File

@@ -3,3 +3,5 @@
@import '_bootstrap_variables.scss';
@import '../../node_modules/bootstrap/scss/variables.scss';
$search-form-scope-max-width: 150px;

View File

@@ -0,0 +1,13 @@
import { Component } from '@angular/core';
import { slideSidebarPadding } from '../../../../../../../app/shared/animations/slide';
import { FileSectionComponent as BaseComponent } from '../../../../../../../app/item-page/simple/field-components/file-section/file-section.component';
@Component({
selector: 'ds-item-page-file-section',
// templateUrl: './file-section.component.html',
templateUrl: '../../../../../../../app/item-page/simple/field-components/file-section/file-section.component.html',
animations: [slideSidebarPadding],
})
export class FileSectionComponent extends BaseComponent {
}

View File

@@ -79,8 +79,10 @@ import { HeaderComponent } from './app/header/header.component';
import { FooterComponent } from './app/footer/footer.component';
import { BreadcrumbsComponent } from './app/breadcrumbs/breadcrumbs.component';
import { HeaderNavbarWrapperComponent } from './app/header-nav-wrapper/header-navbar-wrapper.component';
import { FileSectionComponent} from './app/item-page/simple/field-components/file-section/file-section.component';
const DECLARATIONS = [
FileSectionComponent,
HomePageComponent,
HomeNewsComponent,
RootComponent,

View File

@@ -4153,10 +4153,10 @@ cypress-axe@^0.13.0:
resolved "https://registry.yarnpkg.com/cypress-axe/-/cypress-axe-0.13.0.tgz#3234e1a79a27701f2451fcf2f333eb74204c7966"
integrity sha512-fCIy7RiDCm7t30U3C99gGwQrUO307EYE1QqXNaf9ToK4DVqW8y5on+0a/kUHMrHdlls2rENF6TN9ZPpPpwLrnw==
cypress@8.3.1:
version "8.3.1"
resolved "https://registry.yarnpkg.com/cypress/-/cypress-8.3.1.tgz#c6760dbb907df2570b0e1ac235fa31c30f9260a6"
integrity sha512-1v6pfx+/5cXhaT5T6QKOvnkawmEHWHLiVzm3MYMoQN1fkX2Ma1C32STd3jBStE9qT5qPSTILjGzypVRxCBi40g==
cypress@8.6.0:
version "8.6.0"
resolved "https://registry.yarnpkg.com/cypress/-/cypress-8.6.0.tgz#8d02fa58878b37cfc45bbfce393aa974fa8a8e22"
integrity sha512-F7qEK/6Go5FsqTueR+0wEw2vOVKNgk5847Mys8vsWkzPoEKdxs+7N9Y1dit+zhaZCLtMPyrMwjfA53ZFy+lSww==
dependencies:
"@cypress/request" "^2.88.6"
"@cypress/xvfb" "^1.2.4"
@@ -4192,6 +4192,7 @@ cypress@8.3.1:
minimist "^1.2.5"
ospath "^1.2.2"
pretty-bytes "^5.6.0"
proxy-from-env "1.0.0"
ramda "~0.27.1"
request-progress "^3.0.0"
supports-color "^8.1.1"
@@ -9839,6 +9840,11 @@ proxy-addr@~2.0.5:
forwarded "~0.1.2"
ipaddr.js "1.9.1"
proxy-from-env@1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.0.0.tgz#33c50398f70ea7eb96d21f7b817630a55791c7ee"
integrity sha1-M8UDmPcOp+uW0h97gXYwpVeRx+4=
prr@~1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/prr/-/prr-1.0.1.tgz#d3fc114ba06995a45ec6893f484ceb1d78f5f476"