forked from hazza/dspace-angular
Merge branch 'main' into w2p-83635_Request-a-copy
This commit is contained in:
15
cypress/integration/breadcrumbs.spec.ts
Normal file
15
cypress/integration/breadcrumbs.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
13
cypress/integration/browse-by-author.spec.ts
Normal file
13
cypress/integration/browse-by-author.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
13
cypress/integration/browse-by-dateissued.spec.ts
Normal file
13
cypress/integration/browse-by-dateissued.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
13
cypress/integration/browse-by-subject.spec.ts
Normal file
13
cypress/integration/browse-by-subject.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
13
cypress/integration/browse-by-title.spec.ts
Normal file
13
cypress/integration/browse-by-title.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
15
cypress/integration/collection-page.spec.ts
Normal file
15
cypress/integration/collection-page.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
32
cypress/integration/collection-statistics.spec.ts
Normal file
32
cypress/integration/collection-statistics.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
25
cypress/integration/community-list.spec.ts
Normal file
25
cypress/integration/community-list.spec.ts
Normal 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
|
||||
);
|
||||
});
|
||||
});
|
15
cypress/integration/community-page.spec.ts
Normal file
15
cypress/integration/community-page.spec.ts
Normal 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',);
|
||||
});
|
||||
});
|
32
cypress/integration/community-statistics.spec.ts
Normal file
32
cypress/integration/community-statistics.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
13
cypress/integration/footer.spec.ts
Normal file
13
cypress/integration/footer.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
19
cypress/integration/header.spec.ts
Normal file
19
cypress/integration/header.spec.ts
Normal 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
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
19
cypress/integration/homepage-statistics.spec.ts
Normal file
19
cypress/integration/homepage-statistics.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
@@ -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');
|
||||
});
|
||||
});
|
||||
|
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
|
@@ -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');
|
||||
});
|
||||
});
|
||||
|
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
|
@@ -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;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
@@ -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
44
cypress/support/utils.ts
Normal 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);
|
||||
};
|
@@ -6,7 +6,8 @@
|
||||
"compilerOptions": {
|
||||
"types": [
|
||||
"cypress",
|
||||
"cypress-axe"
|
||||
"cypress-axe",
|
||||
"node"
|
||||
]
|
||||
}
|
||||
}
|
@@ -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",
|
||||
|
@@ -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;
|
||||
|
@@ -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);
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
@@ -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;
|
||||
})
|
||||
);
|
||||
};
|
||||
}
|
||||
}
|
@@ -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);
|
||||
}
|
||||
|
@@ -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,
|
||||
];
|
||||
|
||||
|
@@ -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"> {{'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"> {{'collection.source.controls.reset.running' | translate}}</span>
|
||||
</button>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
@@ -0,0 +1,3 @@
|
||||
.spinner-button {
|
||||
margin-bottom: calc((var(--bs-line-height-base) * 1rem - var(--bs-font-size-base)) / 2);
|
||||
}
|
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
});
|
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
@@ -11,7 +11,8 @@
|
||||
class="fas fa-undo-alt"></i>
|
||||
<span class="d-none d-sm-inline"> {{"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"> {{"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"> {{"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"> {{"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>
|
||||
|
||||
|
@@ -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],
|
||||
|
@@ -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(),
|
||||
|
@@ -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
|
||||
]
|
||||
})
|
||||
|
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
26
src/app/core/shared/content-source-set-serializer.spec.ts
Normal file
26
src/app/core/shared/content-source-set-serializer.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
31
src/app/core/shared/content-source-set-serializer.ts
Normal file
31
src/app/core/shared/content-source-set-serializer.ts
Normal 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;
|
||||
}
|
||||
}
|
@@ -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
|
||||
*/
|
||||
|
@@ -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
|
||||
|
@@ -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>
|
||||
|
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -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,
|
||||
|
@@ -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`);
|
||||
}
|
||||
|
||||
}
|
@@ -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"
|
||||
|
@@ -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"
|
||||
|
@@ -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>
|
||||
|
@@ -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) => {
|
||||
|
@@ -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>
|
||||
|
@@ -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');
|
||||
}
|
||||
|
@@ -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();
|
||||
|
@@ -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
|
||||
|
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@@ -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);
|
||||
|
@@ -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;
|
||||
}
|
||||
|
@@ -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();
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
@@ -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() {
|
||||
|
@@ -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>
|
||||
|
@@ -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$);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
})
|
||||
|
@@ -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();
|
||||
|
@@ -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(() => {
|
||||
|
@@ -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);
|
||||
}
|
||||
});
|
||||
|
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@@ -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>
|
@@ -0,0 +1,3 @@
|
||||
#create-community-or-separator {
|
||||
top: 0;
|
||||
}
|
@@ -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);
|
||||
});
|
||||
|
||||
});
|
@@ -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);
|
||||
}
|
||||
}
|
@@ -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">
|
||||
|
@@ -3,3 +3,7 @@
|
||||
background-color: var(--bs-input-bg);
|
||||
color: var(--bs-input-color);
|
||||
}
|
||||
|
||||
.scope-button {
|
||||
max-width: $search-form-scope-max-width;
|
||||
}
|
||||
|
@@ -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(), {
|
||||
|
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -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 = [
|
||||
|
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
370
src/app/shared/theme-support/theme.service.spec.ts
Normal file
370
src/app/shared/theme-support/theme.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@@ -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);
|
||||
}
|
||||
}
|
||||
|
@@ -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 */
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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
@@ -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 {
|
||||
|
@@ -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'
|
||||
// },
|
||||
|
@@ -3,3 +3,5 @@
|
||||
|
||||
@import '_bootstrap_variables.scss';
|
||||
@import '../../node_modules/bootstrap/scss/variables.scss';
|
||||
|
||||
$search-form-scope-max-width: 150px;
|
||||
|
@@ -0,0 +1 @@
|
||||
|
@@ -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 {
|
||||
|
||||
}
|
@@ -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,
|
||||
|
14
yarn.lock
14
yarn.lock
@@ -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"
|
||||
|
Reference in New Issue
Block a user