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', () => {
|
describe('Homepage', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// All tests start with visiting homepage
|
// All tests start with visiting homepage
|
||||||
@@ -20,18 +22,11 @@ describe('Homepage', () => {
|
|||||||
cy.url().should('include', 'query=' + encodeURI(queryString));
|
cy.url().should('include', 'query=' + encodeURI(queryString));
|
||||||
});
|
});
|
||||||
|
|
||||||
// it('should pass accessibility tests', () => {
|
it('should pass accessibility tests', () => {
|
||||||
// // first must inject Axe into current page
|
// Wait for homepage tag to appear
|
||||||
// cy.injectAxe();
|
cy.get('ds-home-page').should('be.visible');
|
||||||
|
|
||||||
// // Analyze entire page for accessibility issues
|
// Analyze <ds-home-page> for accessibility issues
|
||||||
// // NOTE: this test checks accessibility of header/footer as well
|
testA11y('ds-home-page');
|
||||||
// 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
|
|
||||||
// ],
|
|
||||||
// });
|
|
||||||
// });
|
|
||||||
});
|
});
|
||||||
|
@@ -1,15 +1,31 @@
|
|||||||
describe('Item Page', () => {
|
import { Options } from 'cypress-axe';
|
||||||
const ITEMPAGE = '/items/e98b0f27-5c19-49a0-960d-eb6ad5287067';
|
import { TEST_ENTITY_PUBLICATION } from 'cypress/support';
|
||||||
const ENTITYPAGE = '/entities/publication/e98b0f27-5c19-49a0-960d-eb6ad5287067';
|
import { testA11y } from 'cypress/support/utils';
|
||||||
|
|
||||||
it('should contain element ds-item-page when navigating to an item page', () => {
|
describe('Item Page', () => {
|
||||||
cy.visit(ENTITYPAGE);
|
const ITEMPAGE = '/items/' + TEST_ENTITY_PUBLICATION;
|
||||||
cy.get('ds-item-page').should('exist');
|
const ENTITYPAGE = '/entities/publication/' + TEST_ENTITY_PUBLICATION;
|
||||||
});
|
|
||||||
|
|
||||||
// Test that entities will redirect to /entities/[type]/[uuid] when accessed via /items/[uuid]
|
// 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', () => {
|
it('should redirect to the entity page when navigating to an item page', () => {
|
||||||
cy.visit(ITEMPAGE);
|
cy.visit(ITEMPAGE);
|
||||||
cy.location('pathname').should('eq', ENTITYPAGE);
|
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', () => {
|
describe('Item Statistics Page', () => {
|
||||||
const ITEMUUID = 'e98b0f27-5c19-49a0-960d-eb6ad5287067';
|
const ITEMSTATISTICSPAGE = '/statistics/items/' + TEST_ENTITY_PUBLICATION;
|
||||||
const ITEMSTATISTICSPAGE = '/statistics/items/' + ITEMUUID;
|
|
||||||
|
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', () => {
|
it('should contain element ds-item-statistics-page when navigating to an item statistics page', () => {
|
||||||
cy.visit(ITEMSTATISTICSPAGE);
|
cy.visit(ITEMSTATISTICSPAGE);
|
||||||
@@ -8,18 +16,23 @@ describe('Item Statistics Page', () => {
|
|||||||
cy.get('ds-item-page').should('not.exist');
|
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', () => {
|
it('should contain a "Total visits" section', () => {
|
||||||
cy.visit(ITEMSTATISTICSPAGE);
|
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', () => {
|
it('should contain a "Total visits per month" section', () => {
|
||||||
cy.visit(ITEMSTATISTICSPAGE);
|
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', () => {
|
describe('Search Page', () => {
|
||||||
// unique ID of the search form (for selecting specific elements below)
|
// unique ID of the search form (for selecting specific elements below)
|
||||||
const SEARCHFORM_ID = '#search-form';
|
const SEARCHFORM_ID = '#search-form';
|
||||||
@@ -8,52 +11,6 @@ describe('Search Page', () => {
|
|||||||
cy.get(SEARCHFORM_ID + ' input[name="query"]').should('have.value', queryString);
|
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', () => {
|
it('should redirect to the correct url when query was set and submit button was triggered', () => {
|
||||||
const queryString = 'Another interesting query string';
|
const queryString = 'Another interesting query string';
|
||||||
cy.visit('/search');
|
cy.visit('/search');
|
||||||
@@ -63,4 +20,53 @@ describe('Search Page', () => {
|
|||||||
cy.url().should('include', 'query=' + encodeURI(queryString));
|
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
|
// Plugins enable you to tap into, modify, or extend the internal behavior of Cypress
|
||||||
// For more info, visit https://on.cypress.io/plugins-api
|
// For more info, visit https://on.cypress.io/plugins-api
|
||||||
/* tslint:disable:no-empty */
|
module.exports = (on, config) => {
|
||||||
module.exports = (on, config) => { };
|
// Define "log" and "table" tasks, used for logging accessibility errors during CI
|
||||||
/* tslint:enable:no-empty */
|
// 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
|
// Import Cypress Axe tools for all tests
|
||||||
// https://github.com/component-driven/cypress-axe
|
// https://github.com/component-driven/cypress-axe
|
||||||
import '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": {
|
"compilerOptions": {
|
||||||
"types": [
|
"types": [
|
||||||
"cypress",
|
"cypress",
|
||||||
"cypress-axe"
|
"cypress-axe",
|
||||||
|
"node"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
@@ -152,7 +152,7 @@
|
|||||||
"copy-webpack-plugin": "^6.4.1",
|
"copy-webpack-plugin": "^6.4.1",
|
||||||
"css-loader": "3.4.0",
|
"css-loader": "3.4.0",
|
||||||
"cssnano": "^4.1.10",
|
"cssnano": "^4.1.10",
|
||||||
"cypress": "8.3.1",
|
"cypress": "8.6.0",
|
||||||
"cypress-axe": "^0.13.0",
|
"cypress-axe": "^0.13.0",
|
||||||
"deep-freeze": "0.0.1",
|
"deep-freeze": "0.0.1",
|
||||||
"dotenv": "^8.2.0",
|
"dotenv": "^8.2.0",
|
||||||
|
@@ -28,6 +28,9 @@ import { createPaginatedList } from '../../../shared/testing/utils.test';
|
|||||||
import { RequestService } from '../../../core/data/request.service';
|
import { RequestService } from '../../../core/data/request.service';
|
||||||
import { PaginationService } from '../../../core/pagination/pagination.service';
|
import { PaginationService } from '../../../core/pagination/pagination.service';
|
||||||
import { PaginationServiceStub } from '../../../shared/testing/pagination-service.stub';
|
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', () => {
|
describe('EPersonFormComponent', () => {
|
||||||
let component: EPersonFormComponent;
|
let component: EPersonFormComponent;
|
||||||
@@ -99,12 +102,78 @@ describe('EPersonFormComponent', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
return createSuccessfulRemoteDataObject$(ePerson);
|
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();
|
authService = new AuthServiceStub();
|
||||||
authorizationService = jasmine.createSpyObj('authorizationService', {
|
authorizationService = jasmine.createSpyObj('authorizationService', {
|
||||||
isAuthorized: observableOf(true)
|
isAuthorized: observableOf(true),
|
||||||
|
|
||||||
});
|
});
|
||||||
groupsDataService = jasmine.createSpyObj('groupsDataService', {
|
groupsDataService = jasmine.createSpyObj('groupsDataService', {
|
||||||
findAllByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])),
|
findAllByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])),
|
||||||
@@ -146,6 +215,131 @@ describe('EPersonFormComponent', () => {
|
|||||||
expect(component).toBeDefined();
|
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', () => {
|
describe('when submitting the form', () => {
|
||||||
let firstName;
|
let firstName;
|
||||||
let lastName;
|
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 { FormGroup } from '@angular/forms';
|
||||||
import {
|
import {
|
||||||
DynamicCheckboxModel,
|
DynamicCheckboxModel,
|
||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
} from '@ng-dynamic-forms/core';
|
} from '@ng-dynamic-forms/core';
|
||||||
import { TranslateService } from '@ngx-translate/core';
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
import { combineLatest as observableCombineLatest, Observable, of as observableOf, Subscription } from 'rxjs';
|
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 { PaginatedList } from '../../../core/data/paginated-list.model';
|
||||||
import { RemoteData } from '../../../core/data/remote-data';
|
import { RemoteData } from '../../../core/data/remote-data';
|
||||||
import { EPersonDataService } from '../../../core/eperson/eperson-data.service';
|
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 { NoContent } from '../../../core/shared/NoContent.model';
|
||||||
import { PaginationService } from '../../../core/pagination/pagination.service';
|
import { PaginationService } from '../../../core/pagination/pagination.service';
|
||||||
import { followLink } from '../../../shared/utils/follow-link-config.model';
|
import { followLink } from '../../../shared/utils/follow-link-config.model';
|
||||||
|
import { ValidateEmailNotTaken } from './validators/email-taken.validator';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'ds-eperson-form',
|
selector: 'ds-eperson-form',
|
||||||
templateUrl: './eperson-form.component.html'
|
templateUrl: './eperson-form.component.html',
|
||||||
})
|
})
|
||||||
/**
|
/**
|
||||||
* A form used for creating and editing EPeople
|
* A form used for creating and editing EPeople
|
||||||
@@ -161,7 +162,13 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
|
|||||||
*/
|
*/
|
||||||
isImpersonated = false;
|
isImpersonated = false;
|
||||||
|
|
||||||
constructor(public epersonService: EPersonDataService,
|
/**
|
||||||
|
* Subscription to email field value change
|
||||||
|
*/
|
||||||
|
emailValueChangeSubscribe: Subscription;
|
||||||
|
|
||||||
|
constructor(protected changeDetectorRef: ChangeDetectorRef,
|
||||||
|
public epersonService: EPersonDataService,
|
||||||
public groupsDataService: GroupDataService,
|
public groupsDataService: GroupDataService,
|
||||||
private formBuilderService: FormBuilderService,
|
private formBuilderService: FormBuilderService,
|
||||||
private translateService: TranslateService,
|
private translateService: TranslateService,
|
||||||
@@ -187,6 +194,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
|
|||||||
* This method will initialise the page
|
* This method will initialise the page
|
||||||
*/
|
*/
|
||||||
initialisePage() {
|
initialisePage() {
|
||||||
|
|
||||||
observableCombineLatest(
|
observableCombineLatest(
|
||||||
this.translateService.get(`${this.messagePrefix}.firstName`),
|
this.translateService.get(`${this.messagePrefix}.firstName`),
|
||||||
this.translateService.get(`${this.messagePrefix}.lastName`),
|
this.translateService.get(`${this.messagePrefix}.lastName`),
|
||||||
@@ -219,9 +227,13 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
|
|||||||
name: 'email',
|
name: 'email',
|
||||||
validators: {
|
validators: {
|
||||||
required: null,
|
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,
|
required: true,
|
||||||
|
errorMessages: {
|
||||||
|
emailTaken: 'error.validation.emailTaken',
|
||||||
|
pattern: 'error.validation.NotValidEmail'
|
||||||
|
},
|
||||||
hint: emailHint
|
hint: emailHint
|
||||||
});
|
});
|
||||||
this.canLogIn = new DynamicCheckboxModel(
|
this.canLogIn = new DynamicCheckboxModel(
|
||||||
@@ -260,6 +272,13 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
|
|||||||
canLogIn: eperson != null ? eperson.canLogIn : true,
|
canLogIn: eperson != null ? eperson.canLogIn : true,
|
||||||
requireCertificate: eperson != null ? eperson.requireCertificate : false
|
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();
|
const activeEPerson$ = this.epersonService.getActiveEPerson();
|
||||||
@@ -280,7 +299,13 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
|
|||||||
);
|
);
|
||||||
|
|
||||||
this.canImpersonate$ = activeEPerson$.pipe(
|
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(
|
this.canDelete$ = activeEPerson$.pipe(
|
||||||
switchMap((eperson) => this.authorizationService.isAuthorized(FeatureID.CanDelete, hasValue(eperson) ? eperson.self : undefined))
|
switchMap((eperson) => this.authorizationService.isAuthorized(FeatureID.CanDelete, hasValue(eperson) ? eperson.self : undefined))
|
||||||
@@ -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
|
* Event triggered when the user changes page
|
||||||
* @param event
|
* @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
|
* Start impersonating the EPerson
|
||||||
*/
|
*/
|
||||||
@@ -471,7 +465,8 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
this.cancelForm.emit();
|
this.cancelForm.emit();
|
||||||
});
|
});
|
||||||
}}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -491,8 +486,10 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
|
|||||||
this.onCancel();
|
this.onCancel();
|
||||||
this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe());
|
this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe());
|
||||||
this.paginationService.clearPagination(this.config.id);
|
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
|
* 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();
|
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 {
|
import {
|
||||||
AfterViewInit,
|
AfterViewInit,
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
@@ -9,7 +9,13 @@ import {
|
|||||||
Optional,
|
Optional,
|
||||||
PLATFORM_ID,
|
PLATFORM_ID,
|
||||||
} from '@angular/core';
|
} 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 { BehaviorSubject, Observable, of } from 'rxjs';
|
||||||
import { select, Store } from '@ngrx/store';
|
import { select, Store } from '@ngrx/store';
|
||||||
@@ -71,6 +77,7 @@ export class AppComponent implements OnInit, AfterViewInit {
|
|||||||
*/
|
*/
|
||||||
isThemeLoading$: BehaviorSubject<boolean> = new BehaviorSubject(false);
|
isThemeLoading$: BehaviorSubject<boolean> = new BehaviorSubject(false);
|
||||||
|
|
||||||
|
isThemeCSSLoading$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether or not the idle modal is is currently open
|
* 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) => {
|
this.themeService.getThemeName$().subscribe((themeName: string) => {
|
||||||
if (isPlatformBrowser(this.platformId)) {
|
if (isPlatformBrowser(this.platformId)) {
|
||||||
// the theme css will never download server side, so this should only happen on the browser
|
// 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)) {
|
if (hasValue(themeName)) {
|
||||||
this.setThemeCss(themeName);
|
this.setThemeCss(themeName);
|
||||||
@@ -177,17 +184,33 @@ export class AppComponent implements OnInit, AfterViewInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngAfterViewInit() {
|
ngAfterViewInit() {
|
||||||
this.router.events.pipe(
|
let resolveEndFound = false;
|
||||||
// This fixes an ExpressionChangedAfterItHasBeenCheckedError from being thrown while loading the component
|
this.router.events.subscribe((event) => {
|
||||||
// More information on this bug-fix: https://blog.angular-university.io/angular-debugging/
|
|
||||||
delay(0)
|
|
||||||
).subscribe((event) => {
|
|
||||||
if (event instanceof NavigationStart) {
|
if (event instanceof NavigationStart) {
|
||||||
|
resolveEndFound = false;
|
||||||
this.isRouteLoading$.next(true);
|
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 (
|
} else if (
|
||||||
event instanceof NavigationEnd ||
|
event instanceof NavigationEnd ||
|
||||||
event instanceof NavigationCancel
|
event instanceof NavigationCancel
|
||||||
) {
|
) {
|
||||||
|
if (!resolveEndFound) {
|
||||||
|
this.isThemeLoading$.next(false);
|
||||||
|
}
|
||||||
this.isRouteLoading$.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.
|
// the fact that this callback is used, proves we're on the browser.
|
||||||
this.isThemeLoading$.next(false);
|
this.isThemeCSSLoading$.next(false);
|
||||||
};
|
};
|
||||||
head.appendChild(link);
|
head.appendChild(link);
|
||||||
}
|
}
|
||||||
|
@@ -7,7 +7,11 @@ import { EffectsModule } from '@ngrx/effects';
|
|||||||
import { RouterStateSerializer, StoreRouterConnectingModule } from '@ngrx/router-store';
|
import { RouterStateSerializer, StoreRouterConnectingModule } from '@ngrx/router-store';
|
||||||
import { MetaReducer, Store, StoreModule, USER_PROVIDED_META_REDUCERS } from '@ngrx/store';
|
import { MetaReducer, Store, StoreModule, USER_PROVIDED_META_REDUCERS } from '@ngrx/store';
|
||||||
import { StoreDevtoolsModule } from '@ngrx/store-devtools';
|
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 { TranslateModule } from '@ngx-translate/core';
|
||||||
import { ScrollToModule } from '@nicky-lenaers/ngx-scroll-to';
|
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 { UUIDService } from './core/shared/uuid.service';
|
||||||
import { CookieService } from './core/services/cookie.service';
|
import { CookieService } from './core/services/cookie.service';
|
||||||
|
import { AbstractControl } from '@angular/forms';
|
||||||
|
|
||||||
export function getBase() {
|
export function getBase() {
|
||||||
return environment.ui.nameSpace;
|
return environment.ui.nameSpace;
|
||||||
@@ -61,6 +66,14 @@ export function getMetaReducers(): MetaReducer<AppState>[] {
|
|||||||
return environment.debug ? [...appMetaReducers, ...debugMetaReducers] : appMetaReducers;
|
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 = [
|
const IMPORTS = [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
SharedModule,
|
SharedModule,
|
||||||
@@ -146,6 +159,10 @@ const PROVIDERS = [
|
|||||||
multi: true,
|
multi: true,
|
||||||
deps: [ CookieService, UUIDService ]
|
deps: [ CookieService, UUIDService ]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: DYNAMIC_ERROR_MESSAGES_MATCHER,
|
||||||
|
useValue: ValidateEmailErrorStateMatcher
|
||||||
|
},
|
||||||
...DYNAMIC_MATCHER_PROVIDERS,
|
...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>
|
class="fas fa-undo-alt"></i>
|
||||||
<span class="d-none d-sm-inline"> {{"item.edit.metadata.reinstate-button" | translate}}</span>
|
<span class="d-none d-sm-inline"> {{"item.edit.metadata.reinstate-button" | translate}}</span>
|
||||||
</button>
|
</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
|
(click)="onSubmit()"><i
|
||||||
class="fas fa-save"></i>
|
class="fas fa-save"></i>
|
||||||
<span class="d-none d-sm-inline"> {{"item.edit.metadata.save-button" | translate}}</span>
|
<span class="d-none d-sm-inline"> {{"item.edit.metadata.save-button" | translate}}</span>
|
||||||
@@ -19,12 +20,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<h4>{{ 'collection.edit.tabs.source.head' | translate }}</h4>
|
<h4>{{ 'collection.edit.tabs.source.head' | translate }}</h4>
|
||||||
<div *ngIf="contentSource" class="form-check mb-4">
|
<div *ngIf="contentSource" class="form-check mb-4">
|
||||||
<input type="checkbox" class="form-check-input" id="externalSourceCheck" [checked]="(contentSource?.harvestType !== harvestTypeNone)" (change)="changeExternalSource()">
|
<input type="checkbox" class="form-check-input" id="externalSourceCheck"
|
||||||
<label class="form-check-label" for="externalSourceCheck">{{ 'collection.edit.tabs.source.external' | translate }}</label>
|
[checked]="(contentSource?.harvestType !== harvestTypeNone)" (change)="changeExternalSource()">
|
||||||
|
<label class="form-check-label"
|
||||||
|
for="externalSourceCheck">{{ 'collection.edit.tabs.source.external' | translate }}</label>
|
||||||
</div>
|
</div>
|
||||||
<ds-loading *ngIf="!contentSource" [message]="'loading.content-source' | translate"></ds-loading>
|
<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>
|
<h4 *ngIf="contentSource && (contentSource?.harvestType !== harvestTypeNone)">{{ 'collection.edit.tabs.source.form.head' | translate }}</h4>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row">
|
||||||
<ds-form *ngIf="formGroup && contentSource && (contentSource?.harvestType !== harvestTypeNone)"
|
<ds-form *ngIf="formGroup && contentSource && (contentSource?.harvestType !== harvestTypeNone)"
|
||||||
[formId]="'collection-source-form-id'"
|
[formId]="'collection-source-form-id'"
|
||||||
[formGroup]="formGroup"
|
[formGroup]="formGroup"
|
||||||
@@ -35,8 +39,11 @@
|
|||||||
(dfChange)="onChange($event)"
|
(dfChange)="onChange($event)"
|
||||||
(submitForm)="onSubmit()"
|
(submitForm)="onSubmit()"
|
||||||
(cancel)="onCancel()"></ds-form>
|
(cancel)="onCancel()"></ds-form>
|
||||||
<div class="container-fluid" *ngIf="(contentSource?.harvestType !== harvestTypeNone)">
|
</div>
|
||||||
<div class="d-inline-block float-right">
|
<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)"
|
<button class=" btn btn-danger" *ngIf="!(isReinstatable() | async)"
|
||||||
[disabled]="!(hasChanges() | async)"
|
[disabled]="!(hasChanges() | async)"
|
||||||
(click)="discard()"><i
|
(click)="discard()"><i
|
||||||
@@ -48,10 +55,20 @@
|
|||||||
class="fas fa-undo-alt"></i>
|
class="fas fa-undo-alt"></i>
|
||||||
<span class="d-none d-sm-inline"> {{"item.edit.metadata.reinstate-button" | translate}}</span>
|
<span class="d-none d-sm-inline"> {{"item.edit.metadata.reinstate-button" | translate}}</span>
|
||||||
</button>
|
</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
|
(click)="onSubmit()"><i
|
||||||
class="fas fa-save"></i>
|
class="fas fa-save"></i>
|
||||||
<span class="d-none d-sm-inline"> {{"item.edit.metadata.save-button" | translate}}</span>
|
<span class="d-none d-sm-inline"> {{"item.edit.metadata.save-button" | translate}}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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',
|
label: 'DSpace Intermediate Metadata',
|
||||||
nameSpace: 'http://www.dspace.org/xmlns/dspace/dim'
|
nameSpace: 'http://www.dspace.org/xmlns/dspace/dim'
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
_links: { self: { href: 'contentsource-selflink' } }
|
||||||
});
|
});
|
||||||
fieldUpdate = {
|
fieldUpdate = {
|
||||||
field: contentSource,
|
field: contentSource,
|
||||||
@@ -115,7 +116,7 @@ describe('CollectionSourceComponent', () => {
|
|||||||
updateContentSource: observableOf(contentSource),
|
updateContentSource: observableOf(contentSource),
|
||||||
getHarvesterEndpoint: observableOf('harvester-endpoint')
|
getHarvesterEndpoint: observableOf('harvester-endpoint')
|
||||||
});
|
});
|
||||||
requestService = jasmine.createSpyObj('requestService', ['removeByHrefSubstring']);
|
requestService = jasmine.createSpyObj('requestService', ['removeByHrefSubstring', 'setStaleByHrefSubstring']);
|
||||||
|
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
imports: [TranslateModule.forRoot(), RouterTestingModule],
|
imports: [TranslateModule.forRoot(), RouterTestingModule],
|
||||||
|
@@ -380,7 +380,7 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem
|
|||||||
switchMap((uuid) => this.collectionService.getHarvesterEndpoint(uuid)),
|
switchMap((uuid) => this.collectionService.getHarvesterEndpoint(uuid)),
|
||||||
take(1)
|
take(1)
|
||||||
).subscribe((endpoint) => this.requestService.removeByHrefSubstring(endpoint));
|
).subscribe((endpoint) => this.requestService.removeByHrefSubstring(endpoint));
|
||||||
|
this.requestService.setStaleByHrefSubstring(this.contentSource._links.self.href);
|
||||||
// Update harvester
|
// Update harvester
|
||||||
this.collectionRD$.pipe(
|
this.collectionRD$.pipe(
|
||||||
getFirstSucceededRemoteData(),
|
getFirstSucceededRemoteData(),
|
||||||
|
@@ -9,6 +9,7 @@ import { CollectionCurateComponent } from './collection-curate/collection-curate
|
|||||||
import { CollectionSourceComponent } from './collection-source/collection-source.component';
|
import { CollectionSourceComponent } from './collection-source/collection-source.component';
|
||||||
import { CollectionAuthorizationsComponent } from './collection-authorizations/collection-authorizations.component';
|
import { CollectionAuthorizationsComponent } from './collection-authorizations/collection-authorizations.component';
|
||||||
import { CollectionFormModule } from '../collection-form/collection-form.module';
|
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
|
* 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,
|
CollectionRolesComponent,
|
||||||
CollectionCurateComponent,
|
CollectionCurateComponent,
|
||||||
CollectionSourceComponent,
|
CollectionSourceComponent,
|
||||||
|
|
||||||
|
CollectionSourceControlsComponent,
|
||||||
CollectionAuthorizationsComponent
|
CollectionAuthorizationsComponent
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
@@ -138,7 +138,7 @@ export class CollectionDataService extends ComColDataService<Collection> {
|
|||||||
* Get the collection's content harvester
|
* Get the collection's content harvester
|
||||||
* @param collectionId
|
* @param collectionId
|
||||||
*/
|
*/
|
||||||
getContentSource(collectionId: string): Observable<RemoteData<ContentSource>> {
|
getContentSource(collectionId: string, useCachedVersionIfAvailable = true): Observable<RemoteData<ContentSource>> {
|
||||||
const href$ = this.getHarvesterEndpoint(collectionId).pipe(
|
const href$ = this.getHarvesterEndpoint(collectionId).pipe(
|
||||||
isNotEmptyOperator(),
|
isNotEmptyOperator(),
|
||||||
take(1)
|
take(1)
|
||||||
@@ -146,7 +146,7 @@ export class CollectionDataService extends ComColDataService<Collection> {
|
|||||||
|
|
||||||
href$.subscribe((href: string) => {
|
href$.subscribe((href: string) => {
|
||||||
const request = new ContentSourceRequest(this.requestService.generateRequestId(), href);
|
const request = new ContentSourceRequest(this.requestService.generateRequestId(), href);
|
||||||
this.requestService.send(request, true);
|
this.requestService.send(request, useCachedVersionIfAvailable);
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.rdbService.buildSingle<ContentSource>(href$);
|
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
|
* @param item Item we want the owning collection of
|
||||||
*/
|
*/
|
||||||
findOwningCollectionFor(item: Item): Observable<RemoteData<Collection>> {
|
findOwningCollectionFor(item: Item): Observable<RemoteData<Collection>> {
|
||||||
return this.findByHref(item._links.owningCollection.href);
|
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 { HALLink } from './hal-link.model';
|
||||||
import { MetadataConfig } from './metadata-config.model';
|
import { MetadataConfig } from './metadata-config.model';
|
||||||
import { CacheableObject } from '../cache/object-cache.reducer';
|
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 { CONTENT_SOURCE } from './content-source.resource-type';
|
||||||
import { excludeFromEquals } from '../utilities/equals.decorators';
|
import { excludeFromEquals } from '../utilities/equals.decorators';
|
||||||
import { ResourceType } from './resource-type';
|
import { ResourceType } from './resource-type';
|
||||||
|
import { ContentSourceSetSerializer } from './content-source-set-serializer';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The type of content harvesting used
|
* The type of content harvesting used
|
||||||
@@ -49,7 +50,8 @@ export class ContentSource extends CacheableObject {
|
|||||||
/**
|
/**
|
||||||
* OAI Specific set ID
|
* OAI Specific set ID
|
||||||
*/
|
*/
|
||||||
@autoserializeAs('oai_set_id')
|
@deserializeAs(new ContentSourceSetSerializer(), 'oai_set_id')
|
||||||
|
@serializeAs(new ContentSourceSetSerializer(), 'oai_set_id')
|
||||||
oaiSetId: string;
|
oaiSetId: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -70,6 +72,30 @@ export class ContentSource extends CacheableObject {
|
|||||||
*/
|
*/
|
||||||
metadataConfigs: MetadataConfig[];
|
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
|
* 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 { Injectable, OnDestroy } from '@angular/core';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { map, switchMap, take } from 'rxjs/operators';
|
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 { LinkService } from '../../cache/builders/link.service';
|
||||||
import { PaginatedList } from '../../data/paginated-list.model';
|
import { PaginatedList } from '../../data/paginated-list.model';
|
||||||
import { ResponseParsingService } from '../../data/parsing.service';
|
import { ResponseParsingService } from '../../data/parsing.service';
|
||||||
@@ -13,7 +13,7 @@ import { DSpaceObject } from '../dspace-object.model';
|
|||||||
import { GenericConstructor } from '../generic-constructor';
|
import { GenericConstructor } from '../generic-constructor';
|
||||||
import { HALEndpointService } from '../hal-endpoint.service';
|
import { HALEndpointService } from '../hal-endpoint.service';
|
||||||
import { URLCombiner } from '../../url-combiner/url-combiner';
|
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 { SearchOptions } from '../../../shared/search/search-options.model';
|
||||||
import { SearchFilterConfig } from '../../../shared/search/search-filter-config.model';
|
import { SearchFilterConfig } from '../../../shared/search/search-filter-config.model';
|
||||||
import { SearchResponseParsingService } from '../../data/search-response-parsing.service';
|
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 { FacetValueResponseParsingService } from '../../data/facet-value-response-parsing.service';
|
||||||
import { FacetConfigResponseParsingService } from '../../data/facet-config-response-parsing.service';
|
import { FacetConfigResponseParsingService } from '../../data/facet-config-response-parsing.service';
|
||||||
import { PaginatedSearchOptions } from '../../../shared/search/paginated-search-options.model';
|
import { PaginatedSearchOptions } from '../../../shared/search/paginated-search-options.model';
|
||||||
import { Community } from '../community.model';
|
|
||||||
import { CommunityDataService } from '../../data/community-data.service';
|
import { CommunityDataService } from '../../data/community-data.service';
|
||||||
import { ViewMode } from '../view-mode.model';
|
import { ViewMode } from '../view-mode.model';
|
||||||
import { DSpaceObjectDataService } from '../../data/dspace-object-data.service';
|
import { DSpaceObjectDataService } from '../../data/dspace-object-data.service';
|
||||||
import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service';
|
import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service';
|
||||||
import {
|
import { getFirstCompletedRemoteData, getRemoteDataPayload } from '../operators';
|
||||||
getFirstSucceededRemoteData,
|
|
||||||
getFirstCompletedRemoteData,
|
|
||||||
getRemoteDataPayload
|
|
||||||
} from '../operators';
|
|
||||||
import { RouteService } from '../../services/route.service';
|
import { RouteService } from '../../services/route.service';
|
||||||
import { SearchResult } from '../../../shared/search/search-result.model';
|
import { SearchResult } from '../../../shared/search/search-result.model';
|
||||||
import { ListableObject } from '../../../shared/object-collection/shared/listable-object.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);
|
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
|
* Requests the current view mode based on the current URL
|
||||||
* @returns {Observable<ViewMode>} The current view mode
|
* @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">
|
<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>
|
<span>{{collection?.name}}</span><span *ngIf="!last" [innerHTML]="separator"></span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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>
|
</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 { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock';
|
||||||
import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
|
import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
|
||||||
import { CollectionsComponent } from './collections.component';
|
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;
|
const createMockCollection = (id: string) => Object.assign(new Collection(), {
|
||||||
let fixture: ComponentFixture<CollectionsComponent>;
|
id: id,
|
||||||
|
name: `collection-${id}`,
|
||||||
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 succeededMockItem: Item = Object.assign(new Item(), {owningCollection: createSuccessfulRemoteDataObject$(mockCollection1)});
|
const mockItem: Item = new Item();
|
||||||
const failedMockItem: Item = Object.assign(new Item(), {owningCollection: createFailedRemoteDataObject$('error', 500)});
|
|
||||||
|
|
||||||
describe('CollectionsComponent', () => {
|
describe('CollectionsComponent', () => {
|
||||||
collectionDataServiceStub = {
|
let collectionDataService;
|
||||||
findOwningCollectionFor(item: Item) {
|
|
||||||
if (item === succeededMockItem) {
|
let mockCollection1: Collection;
|
||||||
return createSuccessfulRemoteDataObject$(mockCollection1);
|
let mockCollection2: Collection;
|
||||||
} else {
|
let mockCollection3: Collection;
|
||||||
return createFailedRemoteDataObject$('error', 500);
|
let mockCollection4: Collection;
|
||||||
}
|
|
||||||
}
|
let component: CollectionsComponent;
|
||||||
};
|
let fixture: ComponentFixture<CollectionsComponent>;
|
||||||
|
|
||||||
beforeEach(waitForAsync(() => {
|
beforeEach(waitForAsync(() => {
|
||||||
|
collectionDataService = jasmine.createSpyObj([
|
||||||
|
'findOwningCollectionFor',
|
||||||
|
'findMappedCollectionsFor',
|
||||||
|
]);
|
||||||
|
|
||||||
|
mockCollection1 = createMockCollection('c1');
|
||||||
|
mockCollection2 = createMockCollection('c2');
|
||||||
|
mockCollection3 = createMockCollection('c3');
|
||||||
|
mockCollection4 = createMockCollection('c4');
|
||||||
|
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
imports: [TranslateModule.forRoot()],
|
imports: [TranslateModule.forRoot()],
|
||||||
declarations: [ CollectionsComponent ],
|
declarations: [ CollectionsComponent ],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: RemoteDataBuildService, useValue: getMockRemoteDataBuildService()},
|
{ provide: RemoteDataBuildService, useValue: getMockRemoteDataBuildService()},
|
||||||
{ provide: CollectionDataService, useValue: collectionDataServiceStub },
|
{ provide: CollectionDataService, useValue: collectionDataService },
|
||||||
],
|
],
|
||||||
|
|
||||||
schemas: [ NO_ERRORS_SCHEMA ]
|
schemas: [ NO_ERRORS_SCHEMA ]
|
||||||
@@ -59,33 +58,264 @@ describe('CollectionsComponent', () => {
|
|||||||
|
|
||||||
beforeEach(waitForAsync(() => {
|
beforeEach(waitForAsync(() => {
|
||||||
fixture = TestBed.createComponent(CollectionsComponent);
|
fixture = TestBed.createComponent(CollectionsComponent);
|
||||||
collectionsComponent = fixture.componentInstance;
|
component = fixture.componentInstance;
|
||||||
collectionsComponent.label = 'test.test';
|
component.item = mockItem;
|
||||||
collectionsComponent.separator = '<br/>';
|
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(() => {
|
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();
|
fixture.detectChanges();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should show the collection', () => {
|
it('should display the owning collection', () => {
|
||||||
const collectionField = fixture.debugElement.query(By.css('ds-metadata-field-wrapper div.collections'));
|
const collectionFields = fixture.debugElement.queryAll(By.css('ds-metadata-field-wrapper div.collections a'));
|
||||||
expect(collectionField).not.toBeNull();
|
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(() => {
|
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();
|
fixture.detectChanges();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not show the collection', () => {
|
it('should display the owning collection and the mapped collection', () => {
|
||||||
const collectionField = fixture.debugElement.query(By.css('ds-metadata-field-wrapper div.collections'));
|
const collectionFields = fixture.debugElement.queryAll(By.css('ds-metadata-field-wrapper div.collections a'));
|
||||||
expect(collectionField).toBeNull();
|
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 { Component, Input, OnInit } from '@angular/core';
|
||||||
import { Observable } from 'rxjs';
|
import { BehaviorSubject, combineLatest, Observable } from 'rxjs';
|
||||||
import { map } from 'rxjs/operators';
|
import {map, scan, startWith, switchMap, tap, withLatestFrom} from 'rxjs/operators';
|
||||||
import { CollectionDataService } from '../../../core/data/collection-data.service';
|
import { CollectionDataService } from '../../../core/data/collection-data.service';
|
||||||
import { PaginatedList, buildPaginatedList } from '../../../core/data/paginated-list.model';
|
import { PaginatedList } from '../../../core/data/paginated-list.model';
|
||||||
import { RemoteData } from '../../../core/data/remote-data';
|
|
||||||
|
|
||||||
import { Collection } from '../../../core/shared/collection.model';
|
import { Collection } from '../../../core/shared/collection.model';
|
||||||
import { Item } from '../../../core/shared/item.model';
|
import { Item } from '../../../core/shared/item.model';
|
||||||
import { PageInfo } from '../../../core/shared/page-info.model';
|
|
||||||
import { hasValue } from '../../../shared/empty.util';
|
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
|
* This component renders the parent collections section of the item
|
||||||
@@ -27,42 +32,92 @@ export class CollectionsComponent implements OnInit {
|
|||||||
|
|
||||||
separator = '<br/>';
|
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) {
|
constructor(private cds: CollectionDataService) {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
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
|
const mappedCollections$: Observable<Collection[]> = this.loadMore$.pipe(
|
||||||
// for an Item aren't returned by the REST API yet,
|
// update isLoading$
|
||||||
// only the owning collection
|
tap(() => this.isLoading$.next(true)),
|
||||||
this.collectionsRD$ = this.cds.findOwningCollectionFor(this.item).pipe(
|
|
||||||
map((rd: RemoteData<Collection>) => {
|
// request next batch of mapped collections
|
||||||
if (hasValue(rd.payload)) {
|
withLatestFrom(this.lastPage$),
|
||||||
return new RemoteData(
|
switchMap(([_, lastPage]: [void, number]) => {
|
||||||
rd.timeCompleted,
|
return this.cds.findMappedCollectionsFor(this.item, Object.assign(new FindListOptions(), {
|
||||||
rd.msToLive,
|
elementsPerPage: this.pageSize,
|
||||||
rd.lastUpdated,
|
currentPage: lastPage + 1,
|
||||||
rd.state,
|
}));
|
||||||
rd.errorMessage,
|
}),
|
||||||
buildPaginatedList({
|
|
||||||
elementsPerPage: 10,
|
getAllCompletedRemoteData<PaginatedList<Collection>>(),
|
||||||
totalPages: 1,
|
|
||||||
currentPage: 1,
|
// update isLoading$
|
||||||
totalElements: 1,
|
tap(() => this.isLoading$.next(false)),
|
||||||
_links: {
|
|
||||||
self: rd.payload._links.self
|
getAllSucceededRemoteDataPayload(),
|
||||||
}
|
|
||||||
} as PageInfo, [rd.payload]),
|
// update hasMore$
|
||||||
rd.statusCode
|
tap((response: PaginatedList<Collection>) => this.hasMore$.next(response.currentPage < response.totalPages)),
|
||||||
);
|
|
||||||
} else {
|
// update lastPage$
|
||||||
return rd as any;
|
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 { MediaViewerVideoComponent } from './media-viewer/media-viewer-video/media-viewer-video.component';
|
||||||
import { MediaViewerImageComponent } from './media-viewer/media-viewer-image/media-viewer-image.component';
|
import { MediaViewerImageComponent } from './media-viewer/media-viewer-image/media-viewer-image.component';
|
||||||
import { NgxGalleryModule } from '@kolkov/ngx-gallery';
|
import { NgxGalleryModule } from '@kolkov/ngx-gallery';
|
||||||
|
import { ThemedFileSectionComponent} from './simple/field-components/file-section/themed-file-section.component';
|
||||||
|
|
||||||
const ENTRY_COMPONENTS = [
|
const ENTRY_COMPONENTS = [
|
||||||
// put only entry components that use custom decorator
|
// put only entry components that use custom decorator
|
||||||
@@ -39,6 +40,7 @@ const ENTRY_COMPONENTS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const DECLARATIONS = [
|
const DECLARATIONS = [
|
||||||
|
ThemedFileSectionComponent,
|
||||||
ItemPageComponent,
|
ItemPageComponent,
|
||||||
ThemedItemPageComponent,
|
ThemedItemPageComponent,
|
||||||
FullItemPageComponent,
|
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">
|
<ng-container *ngIf="mediaViewer.image">
|
||||||
<ds-media-viewer [item]="object" [videoOptions]="mediaViewer.video"></ds-media-viewer>
|
<ds-media-viewer [item]="object" [videoOptions]="mediaViewer.video"></ds-media-viewer>
|
||||||
</ng-container>
|
</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-item-page-date-field [item]="object"></ds-item-page-date-field>
|
||||||
<ds-metadata-representation-list class="ds-item-page-mixed-author-field"
|
<ds-metadata-representation-list class="ds-item-page-mixed-author-field"
|
||||||
[parentItem]="object"
|
[parentItem]="object"
|
||||||
|
@@ -16,7 +16,7 @@
|
|||||||
<ng-container *ngIf="mediaViewer.image">
|
<ng-container *ngIf="mediaViewer.image">
|
||||||
<ds-media-viewer [item]="object" [videoOptions]="mediaViewer.video"></ds-media-viewer>
|
<ds-media-viewer [item]="object" [videoOptions]="mediaViewer.video"></ds-media-viewer>
|
||||||
</ng-container>
|
</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-item-page-date-field [item]="object"></ds-item-page-date-field>
|
||||||
<ds-metadata-representation-list class="ds-item-page-mixed-author-field"
|
<ds-metadata-representation-list class="ds-item-page-mixed-author-field"
|
||||||
[parentItem]="object"
|
[parentItem]="object"
|
||||||
|
@@ -15,7 +15,7 @@
|
|||||||
[query]="(searchOptions$ | async)?.query"
|
[query]="(searchOptions$ | async)?.query"
|
||||||
[scope]="(searchOptions$ | async)?.scope"
|
[scope]="(searchOptions$ | async)?.scope"
|
||||||
[currentUrl]="getSearchLink()"
|
[currentUrl]="getSearchLink()"
|
||||||
[scopes]="(scopeListRD$ | async)"
|
[showScopeSelector]="true"
|
||||||
[inPlaceSearch]="inPlaceSearch"
|
[inPlaceSearch]="inPlaceSearch"
|
||||||
[searchPlaceholder]="'mydspace.search-form.placeholder' | translate">
|
[searchPlaceholder]="'mydspace.search-form.placeholder' | translate">
|
||||||
</ds-search-form>
|
</ds-search-form>
|
||||||
|
@@ -78,11 +78,6 @@ export class MyDSpacePageComponent implements OnInit {
|
|||||||
*/
|
*/
|
||||||
sortOptions$: Observable<SortOptions[]>;
|
sortOptions$: Observable<SortOptions[]>;
|
||||||
|
|
||||||
/**
|
|
||||||
* The current relevant scopes
|
|
||||||
*/
|
|
||||||
scopeListRD$: Observable<DSpaceObject[]>;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Emits true if were on a small screen
|
* Emits true if were on a small screen
|
||||||
*/
|
*/
|
||||||
@@ -144,10 +139,6 @@ export class MyDSpacePageComponent implements OnInit {
|
|||||||
this.resultsRD$.next(results);
|
this.resultsRD$.next(results);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.scopeListRD$ = this.searchConfigService.getCurrentScope('').pipe(
|
|
||||||
switchMap((scopeId) => this.service.getScopes(scopeId))
|
|
||||||
);
|
|
||||||
|
|
||||||
this.context$ = this.searchConfigService.getCurrentConfiguration('workspace')
|
this.context$ = this.searchConfigService.getCurrentConfiguration('workspace')
|
||||||
.pipe(
|
.pipe(
|
||||||
map((configuration: string) => {
|
map((configuration: string) => {
|
||||||
|
@@ -47,7 +47,7 @@
|
|||||||
[query]="(searchOptions$ | async)?.query"
|
[query]="(searchOptions$ | async)?.query"
|
||||||
[scope]="(searchOptions$ | async)?.scope"
|
[scope]="(searchOptions$ | async)?.scope"
|
||||||
[currentUrl]="searchLink"
|
[currentUrl]="searchLink"
|
||||||
[scopes]="(scopeListRD$ | async)"
|
[showScopeSelector]="true"
|
||||||
[inPlaceSearch]="inPlaceSearch"
|
[inPlaceSearch]="inPlaceSearch"
|
||||||
[searchPlaceholder]="'search.search-form.placeholder' | translate">
|
[searchPlaceholder]="'search.search-form.placeholder' | translate">
|
||||||
</ds-search-form>
|
</ds-search-form>
|
||||||
|
@@ -55,11 +55,6 @@ export class SearchComponent implements OnInit {
|
|||||||
*/
|
*/
|
||||||
sortOptions$: Observable<SortOptions[]>;
|
sortOptions$: Observable<SortOptions[]>;
|
||||||
|
|
||||||
/**
|
|
||||||
* The current relevant scopes
|
|
||||||
*/
|
|
||||||
scopeListRD$: Observable<DSpaceObject[]>;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Emits true if were on a small screen
|
* Emits true if were on a small screen
|
||||||
*/
|
*/
|
||||||
@@ -137,9 +132,7 @@ export class SearchComponent implements OnInit {
|
|||||||
).subscribe((results) => {
|
).subscribe((results) => {
|
||||||
this.resultsRD$.next(results);
|
this.resultsRD$.next(results);
|
||||||
});
|
});
|
||||||
this.scopeListRD$ = this.searchConfigService.getCurrentScope('').pipe(
|
|
||||||
switchMap((scopeId) => this.service.getScopes(scopeId))
|
|
||||||
);
|
|
||||||
if (isEmpty(this.configuration$)) {
|
if (isEmpty(this.configuration$)) {
|
||||||
this.configuration$ = this.searchConfigService.getCurrentConfiguration('default');
|
this.configuration$ = this.searchConfigService.getCurrentConfiguration('default');
|
||||||
}
|
}
|
||||||
|
@@ -21,11 +21,14 @@ import { storeModuleConfig } from '../../app.reducer';
|
|||||||
import { FindListOptions } from '../../core/data/request.models';
|
import { FindListOptions } from '../../core/data/request.models';
|
||||||
import { PaginationService } from '../../core/pagination/pagination.service';
|
import { PaginationService } from '../../core/pagination/pagination.service';
|
||||||
import { PaginationServiceStub } from '../testing/pagination-service.stub';
|
import { PaginationServiceStub } from '../testing/pagination-service.stub';
|
||||||
|
import { ThemeService } from '../theme-support/theme.service';
|
||||||
|
|
||||||
describe('BrowseByComponent', () => {
|
describe('BrowseByComponent', () => {
|
||||||
let comp: BrowseByComponent;
|
let comp: BrowseByComponent;
|
||||||
let fixture: ComponentFixture<BrowseByComponent>;
|
let fixture: ComponentFixture<BrowseByComponent>;
|
||||||
|
|
||||||
|
let themeService: ThemeService;
|
||||||
|
|
||||||
const mockItems = [
|
const mockItems = [
|
||||||
Object.assign(new Item(), {
|
Object.assign(new Item(), {
|
||||||
id: 'fakeId-1',
|
id: 'fakeId-1',
|
||||||
@@ -57,6 +60,9 @@ describe('BrowseByComponent', () => {
|
|||||||
const paginationService = new PaginationServiceStub(paginationConfig);
|
const paginationService = new PaginationServiceStub(paginationConfig);
|
||||||
|
|
||||||
beforeEach(waitForAsync(() => {
|
beforeEach(waitForAsync(() => {
|
||||||
|
themeService = jasmine.createSpyObj('themeService', {
|
||||||
|
getThemeName: 'dspace',
|
||||||
|
});
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
@@ -75,7 +81,8 @@ describe('BrowseByComponent', () => {
|
|||||||
],
|
],
|
||||||
declarations: [],
|
declarations: [],
|
||||||
providers: [
|
providers: [
|
||||||
{provide: PaginationService, useValue: paginationService}
|
{provide: PaginationService, useValue: paginationService},
|
||||||
|
{ provide: ThemeService, useValue: themeService },
|
||||||
],
|
],
|
||||||
schemas: [NO_ERRORS_SCHEMA]
|
schemas: [NO_ERRORS_SCHEMA]
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
|
@@ -9,7 +9,8 @@ import { hasValue, isNotEmpty } from '../../empty.util';
|
|||||||
export enum SelectorActionType {
|
export enum SelectorActionType {
|
||||||
CREATE = 'create',
|
CREATE = 'create',
|
||||||
EDIT = 'edit',
|
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
|
* Method called when an object has been selected
|
||||||
* @param dso The selected DSpaceObject
|
* @param dso The selected DSpaceObject
|
||||||
|
@@ -7,12 +7,17 @@ import {
|
|||||||
import { MetadataRepresentationType } from '../../core/shared/metadata-representation/metadata-representation.model';
|
import { MetadataRepresentationType } from '../../core/shared/metadata-representation/metadata-representation.model';
|
||||||
import { Context } from '../../core/shared/context.model';
|
import { Context } from '../../core/shared/context.model';
|
||||||
import * as uuidv4 from 'uuid/v4';
|
import * as uuidv4 from 'uuid/v4';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
|
||||||
|
let ogEnvironmentThemes;
|
||||||
|
|
||||||
describe('MetadataRepresentation decorator function', () => {
|
describe('MetadataRepresentation decorator function', () => {
|
||||||
const type1 = 'TestType';
|
const type1 = 'TestType';
|
||||||
const type2 = 'TestType2';
|
const type2 = 'TestType2';
|
||||||
const type3 = 'TestType3';
|
const type3 = 'TestType3';
|
||||||
const type4 = 'RandomType';
|
const type4 = 'RandomType';
|
||||||
|
const typeAncestor = 'TestTypeAncestor';
|
||||||
|
const typeUnthemed = 'TestTypeUnthemed';
|
||||||
let prefix;
|
let prefix;
|
||||||
|
|
||||||
/* tslint:disable:max-classes-per-file */
|
/* tslint:disable:max-classes-per-file */
|
||||||
@@ -31,6 +36,12 @@ describe('MetadataRepresentation decorator function', () => {
|
|||||||
class Test3ItemSubmission {
|
class Test3ItemSubmission {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class TestAncestorComponent {
|
||||||
|
}
|
||||||
|
|
||||||
|
class TestUnthemedComponent {
|
||||||
|
}
|
||||||
|
|
||||||
/* tslint:enable:max-classes-per-file */
|
/* tslint:enable:max-classes-per-file */
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -46,8 +57,18 @@ describe('MetadataRepresentation decorator function', () => {
|
|||||||
metadataRepresentationComponent(key + type2, MetadataRepresentationType.Item, Context.Workspace)(Test2ItemSubmission);
|
metadataRepresentationComponent(key + type2, MetadataRepresentationType.Item, Context.Workspace)(Test2ItemSubmission);
|
||||||
|
|
||||||
metadataRepresentationComponent(key + type3, MetadataRepresentationType.Item, Context.Workspace)(Test3ItemSubmission);
|
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', () => {
|
describe('If there\'s an exact match', () => {
|
||||||
it('should return the matching class', () => {
|
it('should return the matching class', () => {
|
||||||
const component = getMetadataRepresentationComponent(prefix + type3, MetadataRepresentationType.Item, Context.Workspace);
|
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 { Context } from '../../core/shared/context.model';
|
||||||
import { InjectionToken } from '@angular/core';
|
import { InjectionToken } from '@angular/core';
|
||||||
import { GenericConstructor } from '../../core/shared/generic-constructor';
|
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', {
|
export const METADATA_REPRESENTATION_COMPONENT_FACTORY = new InjectionToken<(entityType: string, mdRepresentationType: MetadataRepresentationType, context: Context, theme: string) => GenericConstructor<any>>('getMetadataRepresentationComponent', {
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
@@ -13,8 +17,6 @@ export const map = new Map();
|
|||||||
|
|
||||||
export const DEFAULT_ENTITY_TYPE = 'Publication';
|
export const DEFAULT_ENTITY_TYPE = 'Publication';
|
||||||
export const DEFAULT_REPRESENTATION_TYPE = MetadataRepresentationType.PlainText;
|
export const DEFAULT_REPRESENTATION_TYPE = MetadataRepresentationType.PlainText;
|
||||||
export const DEFAULT_CONTEXT = Context.Any;
|
|
||||||
export const DEFAULT_THEME = '*';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decorator function to store metadata representation mapping
|
* Decorator function to store metadata representation mapping
|
||||||
@@ -57,8 +59,9 @@ export function getMetadataRepresentationComponent(entityType: string, mdReprese
|
|||||||
if (hasValue(entityAndMDRepMap)) {
|
if (hasValue(entityAndMDRepMap)) {
|
||||||
const contextMap = entityAndMDRepMap.get(context);
|
const contextMap = entityAndMDRepMap.get(context);
|
||||||
if (hasValue(contextMap)) {
|
if (hasValue(contextMap)) {
|
||||||
if (hasValue(contextMap.get(theme))) {
|
const match = resolveTheme(contextMap, theme);
|
||||||
return contextMap.get(theme);
|
if (hasValue(match)) {
|
||||||
|
return match;
|
||||||
}
|
}
|
||||||
if (hasValue(contextMap.get(DEFAULT_THEME))) {
|
if (hasValue(contextMap.get(DEFAULT_THEME))) {
|
||||||
return contextMap.get(DEFAULT_THEME);
|
return contextMap.get(DEFAULT_THEME);
|
||||||
|
@@ -1,9 +1,18 @@
|
|||||||
import { ThemeService } from '../theme-support/theme.service';
|
import { ThemeService } from '../theme-support/theme.service';
|
||||||
import { of as observableOf } from 'rxjs';
|
import { of as observableOf } from 'rxjs';
|
||||||
|
import { ThemeConfig } from '../../../config/theme.model';
|
||||||
|
import { isNotEmpty } from '../empty.util';
|
||||||
|
|
||||||
export function getMockThemeService(themeName = 'base'): ThemeService {
|
export function getMockThemeService(themeName = 'base', themes?: ThemeConfig[]): ThemeService {
|
||||||
return jasmine.createSpyObj('themeService', {
|
const spy = jasmine.createSpyObj('themeService', {
|
||||||
getThemeName: themeName,
|
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 { BrowserModule, By } from '@angular/platform-browser';
|
||||||
import { ChangeDetectorRef, DebugElement } from '@angular/core';
|
import { ChangeDetectorRef, DebugElement } from '@angular/core';
|
||||||
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
|
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 { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||||
import { TranslateLoaderMock } from '../../mocks/translate-loader.mock';
|
import { TranslateLoaderMock } from '../../mocks/translate-loader.mock';
|
||||||
import { storeModuleConfig } from '../../../app.reducer';
|
import { storeModuleConfig } from '../../../app.reducer';
|
||||||
|
import { BehaviorSubject } from 'rxjs';
|
||||||
|
|
||||||
describe('NotificationComponent', () => {
|
describe('NotificationComponent', () => {
|
||||||
|
|
||||||
@@ -83,6 +84,8 @@ describe('NotificationComponent', () => {
|
|||||||
deContent = fixture.debugElement.query(By.css('.notification-content'));
|
deContent = fixture.debugElement.query(By.css('.notification-content'));
|
||||||
elContent = deContent.nativeElement;
|
elContent = deContent.nativeElement;
|
||||||
elType = fixture.debugElement.query(By.css('.notification-icon')).nativeElement;
|
elType = fixture.debugElement.query(By.css('.notification-icon')).nativeElement;
|
||||||
|
|
||||||
|
spyOn(comp, 'remove');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should create component', () => {
|
it('should create component', () => {
|
||||||
@@ -124,4 +127,51 @@ describe('NotificationComponent', () => {
|
|||||||
expect(elContent.innerHTML).toEqual(htmlContent);
|
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 {
|
import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
ChangeDetectorRef,
|
ChangeDetectorRef,
|
||||||
@@ -23,6 +23,7 @@ import { fadeInEnter, fadeInState, fadeOutLeave, fadeOutState } from '../../anim
|
|||||||
import { NotificationAnimationsStatus } from '../models/notification-animations-type';
|
import { NotificationAnimationsStatus } from '../models/notification-animations-type';
|
||||||
import { isNotEmpty } from '../../empty.util';
|
import { isNotEmpty } from '../../empty.util';
|
||||||
import { INotification } from '../models/notification.model';
|
import { INotification } from '../models/notification.model';
|
||||||
|
import { filter, first } from 'rxjs/operators';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'ds-notification',
|
selector: 'ds-notification',
|
||||||
@@ -47,6 +48,11 @@ export class NotificationComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
@Input() public notification = null as INotification;
|
@Input() public notification = null as INotification;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this notification's countdown should be paused
|
||||||
|
*/
|
||||||
|
@Input() public isPaused$: Observable<boolean> = observableOf(false);
|
||||||
|
|
||||||
// Progress bar variables
|
// Progress bar variables
|
||||||
public title: Observable<string>;
|
public title: Observable<string>;
|
||||||
public content: Observable<string>;
|
public content: Observable<string>;
|
||||||
@@ -99,9 +105,12 @@ export class NotificationComponent implements OnInit, OnDestroy {
|
|||||||
private instance = () => {
|
private instance = () => {
|
||||||
this.diff = (new Date().getTime() - this.start) - (this.count * this.speed);
|
this.diff = (new Date().getTime() - this.start) - (this.count * this.speed);
|
||||||
|
|
||||||
|
this.isPaused$.pipe(
|
||||||
|
filter(paused => !paused),
|
||||||
|
first(),
|
||||||
|
).subscribe(() => {
|
||||||
if (this.count++ === this.steps) {
|
if (this.count++ === this.steps) {
|
||||||
this.remove();
|
this.remove();
|
||||||
// this.item.timeoutEnd!.emit();
|
|
||||||
} else if (!this.stopTime) {
|
} else if (!this.stopTime) {
|
||||||
if (this.showProgressBar) {
|
if (this.showProgressBar) {
|
||||||
this.progressWidth += 100 / this.steps;
|
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.timer = setTimeout(this.instance, (this.speed - this.diff));
|
||||||
}
|
}
|
||||||
this.zone.run(() => this.cdr.detectChanges());
|
this.zone.run(() => this.cdr.detectChanges());
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public remove() {
|
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
|
<ds-notification
|
||||||
class="notification"
|
class="notification"
|
||||||
*ngFor="let a of notifications; let i = index"
|
*ngFor="let a of notifications; let i = index"
|
||||||
[notification]="a">
|
[notification]="a" [isPaused$]="isPaused$">
|
||||||
</ds-notification>
|
</ds-notification>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
import { ComponentFixture, inject, TestBed, waitForAsync } from '@angular/core/testing';
|
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 { ChangeDetectorRef } from '@angular/core';
|
||||||
|
|
||||||
import { NotificationsService } from '../notifications.service';
|
import { NotificationsService } from '../notifications.service';
|
||||||
@@ -14,6 +14,9 @@ import { NotificationType } from '../models/notification-type';
|
|||||||
import { uniqueId } from 'lodash';
|
import { uniqueId } from 'lodash';
|
||||||
import { INotificationBoardOptions } from '../../../../config/notifications-config.interfaces';
|
import { INotificationBoardOptions } from '../../../../config/notifications-config.interfaces';
|
||||||
import { NotificationsServiceStub } from '../../testing/notifications-service.stub';
|
import { NotificationsServiceStub } from '../../testing/notifications-service.stub';
|
||||||
|
import { cold } from 'jasmine-marbles';
|
||||||
|
|
||||||
|
export const bools = { f: false, t: true };
|
||||||
|
|
||||||
describe('NotificationsBoardComponent', () => {
|
describe('NotificationsBoardComponent', () => {
|
||||||
let comp: NotificationsBoardComponent;
|
let comp: NotificationsBoardComponent;
|
||||||
@@ -67,6 +70,40 @@ describe('NotificationsBoardComponent', () => {
|
|||||||
|
|
||||||
it('should have two notifications', () => {
|
it('should have two notifications', () => {
|
||||||
expect(comp.notifications.length).toBe(2);
|
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';
|
} from '@angular/core';
|
||||||
|
|
||||||
import { select, Store } from '@ngrx/store';
|
import { select, Store } from '@ngrx/store';
|
||||||
import { Subscription } from 'rxjs';
|
import { BehaviorSubject, Subscription } from 'rxjs';
|
||||||
import { difference } from 'lodash';
|
import { difference } from 'lodash';
|
||||||
|
|
||||||
import { NotificationsService } from '../notifications.service';
|
import { NotificationsService } from '../notifications.service';
|
||||||
@@ -44,6 +44,11 @@ export class NotificationsBoardComponent implements OnInit, OnDestroy {
|
|||||||
public rtl = false;
|
public rtl = false;
|
||||||
public animate: 'fade' | 'fromTop' | 'fromRight' | 'fromBottom' | 'fromLeft' | 'rotate' | 'scale' = 'fromRight';
|
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,
|
constructor(private service: NotificationsService,
|
||||||
private store: Store<AppState>,
|
private store: Store<AppState>,
|
||||||
private cdr: ChangeDetectorRef) {
|
private cdr: ChangeDetectorRef) {
|
||||||
@@ -129,7 +134,6 @@ export class NotificationsBoardComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
if (this.sub) {
|
if (this.sub) {
|
||||||
this.sub.unsubscribe();
|
this.sub.unsubscribe();
|
||||||
|
@@ -11,6 +11,7 @@ import { TranslateModule } from '@ngx-translate/core';
|
|||||||
import { By } from '@angular/platform-browser';
|
import { By } from '@angular/platform-browser';
|
||||||
import { Item } from '../../../../core/shared/item.model';
|
import { Item } from '../../../../core/shared/item.model';
|
||||||
import { provideMockStore } from '@ngrx/store/testing';
|
import { provideMockStore } from '@ngrx/store/testing';
|
||||||
|
import { ThemeService } from '../../../theme-support/theme.service';
|
||||||
|
|
||||||
const testType = 'TestType';
|
const testType = 'TestType';
|
||||||
const testContext = Context.Search;
|
const testContext = Context.Search;
|
||||||
@@ -26,12 +27,20 @@ describe('ListableObjectComponentLoaderComponent', () => {
|
|||||||
let comp: ListableObjectComponentLoaderComponent;
|
let comp: ListableObjectComponentLoaderComponent;
|
||||||
let fixture: ComponentFixture<ListableObjectComponentLoaderComponent>;
|
let fixture: ComponentFixture<ListableObjectComponentLoaderComponent>;
|
||||||
|
|
||||||
|
let themeService: ThemeService;
|
||||||
|
|
||||||
beforeEach(waitForAsync(() => {
|
beforeEach(waitForAsync(() => {
|
||||||
|
themeService = jasmine.createSpyObj('themeService', {
|
||||||
|
getThemeName: 'dspace',
|
||||||
|
});
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
imports: [TranslateModule.forRoot()],
|
imports: [TranslateModule.forRoot()],
|
||||||
declarations: [ListableObjectComponentLoaderComponent, ItemListElementComponent, ListableObjectDirective],
|
declarations: [ListableObjectComponentLoaderComponent, ItemListElementComponent, ListableObjectDirective],
|
||||||
schemas: [NO_ERRORS_SCHEMA],
|
schemas: [NO_ERRORS_SCHEMA],
|
||||||
providers: [provideMockStore({})]
|
providers: [
|
||||||
|
provideMockStore({}),
|
||||||
|
{ provide: ThemeService, useValue: themeService },
|
||||||
|
]
|
||||||
}).overrideComponent(ListableObjectComponentLoaderComponent, {
|
}).overrideComponent(ListableObjectComponentLoaderComponent, {
|
||||||
set: {
|
set: {
|
||||||
changeDetection: ChangeDetectionStrategy.Default,
|
changeDetection: ChangeDetectionStrategy.Default,
|
||||||
@@ -48,6 +57,7 @@ describe('ListableObjectComponentLoaderComponent', () => {
|
|||||||
comp.viewMode = testViewMode;
|
comp.viewMode = testViewMode;
|
||||||
comp.context = testContext;
|
comp.context = testContext;
|
||||||
spyOn(comp, 'getComponent').and.returnValue(ItemListElementComponent as any);
|
spyOn(comp, 'getComponent').and.returnValue(ItemListElementComponent as any);
|
||||||
|
spyOn(comp as any, 'connectInputsAndOutputs').and.callThrough();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
}));
|
}));
|
||||||
@@ -56,6 +66,10 @@ describe('ListableObjectComponentLoaderComponent', () => {
|
|||||||
it('should call the getListableObjectComponent function with the right types, view mode and context', () => {
|
it('should call the getListableObjectComponent function with the right types, view mode and context', () => {
|
||||||
expect(comp.getComponent).toHaveBeenCalledWith([testType], testViewMode, testContext);
|
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', () => {
|
describe('when the object is an item and viewMode is a list', () => {
|
||||||
@@ -121,20 +135,20 @@ describe('ListableObjectComponentLoaderComponent', () => {
|
|||||||
let reloadedObject: any;
|
let reloadedObject: any;
|
||||||
|
|
||||||
beforeEach(() => {
|
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);
|
spyOn((comp as any).contentChange, 'emit').and.returnValue(null);
|
||||||
|
|
||||||
listableComponent = fixture.debugElement.query(By.css('ds-item-list-element')).componentInstance;
|
listableComponent = fixture.debugElement.query(By.css('ds-item-list-element')).componentInstance;
|
||||||
reloadedObject = 'object';
|
reloadedObject = 'object';
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should pass it on connectInputsAndOutputs', fakeAsync(() => {
|
it('should re-instantiate the listable component', fakeAsync(() => {
|
||||||
expect((comp as any).connectInputsAndOutputs).not.toHaveBeenCalled();
|
expect((comp as any).instantiateComponent).not.toHaveBeenCalled();
|
||||||
|
|
||||||
(listableComponent as any).reloadedObject.emit(reloadedObject);
|
(listableComponent as any).reloadedObject.emit(reloadedObject);
|
||||||
tick();
|
tick();
|
||||||
|
|
||||||
expect((comp as any).connectInputsAndOutputs).toHaveBeenCalled();
|
expect((comp as any).instantiateComponent).toHaveBeenCalledWith(reloadedObject);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
it('should re-emit it as a contentChange', fakeAsync(() => {
|
it('should re-emit it as a contentChange', fakeAsync(() => {
|
||||||
|
@@ -184,7 +184,7 @@ export class ListableObjectComponentLoaderComponent implements OnInit, OnChanges
|
|||||||
if (reloadedObject) {
|
if (reloadedObject) {
|
||||||
this.compRef.destroy();
|
this.compRef.destroy();
|
||||||
this.object = reloadedObject;
|
this.object = reloadedObject;
|
||||||
this.connectInputsAndOutputs();
|
this.instantiateComponent(reloadedObject);
|
||||||
this.contentChange.emit(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 { ViewMode } from '../../../../core/shared/view-mode.model';
|
||||||
import { getListableObjectComponent, listableObjectComponent } from './listable-object.decorator';
|
import { getListableObjectComponent, listableObjectComponent } from './listable-object.decorator';
|
||||||
import { Context } from '../../../../core/shared/context.model';
|
import { Context } from '../../../../core/shared/context.model';
|
||||||
|
import { environment } from '../../../../../environments/environment';
|
||||||
|
|
||||||
|
let ogEnvironmentThemes;
|
||||||
|
|
||||||
describe('ListableObject decorator function', () => {
|
describe('ListableObject decorator function', () => {
|
||||||
const type1 = 'TestType';
|
const type1 = 'TestType';
|
||||||
const type2 = 'TestType2';
|
const type2 = 'TestType2';
|
||||||
const type3 = 'TestType3';
|
const type3 = 'TestType3';
|
||||||
|
const typeAncestor = 'TestTypeAncestor';
|
||||||
|
const typeUnthemed = 'TestTypeUnthemed';
|
||||||
|
|
||||||
/* tslint:disable:max-classes-per-file */
|
/* tslint:disable:max-classes-per-file */
|
||||||
class Test1List {
|
class Test1List {
|
||||||
@@ -27,6 +32,12 @@ describe('ListableObject decorator function', () => {
|
|||||||
class Test3DetailedSubmission {
|
class Test3DetailedSubmission {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class TestAncestorComponent {
|
||||||
|
}
|
||||||
|
|
||||||
|
class TestUnthemedComponent {
|
||||||
|
}
|
||||||
|
|
||||||
/* tslint:enable:max-classes-per-file */
|
/* tslint:enable:max-classes-per-file */
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -38,6 +49,16 @@ describe('ListableObject decorator function', () => {
|
|||||||
|
|
||||||
listableObjectComponent(type3, ViewMode.ListElement)(Test3List);
|
listableObjectComponent(type3, ViewMode.ListElement)(Test3List);
|
||||||
listableObjectComponent(type3, ViewMode.DetailedListElement, Context.Workspace)(Test3DetailedSubmission);
|
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);
|
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 { ViewMode } from '../../../../core/shared/view-mode.model';
|
||||||
import { Context } from '../../../../core/shared/context.model';
|
import { Context } from '../../../../core/shared/context.model';
|
||||||
import { hasNoValue, hasValue } from '../../../empty.util';
|
import { hasNoValue, hasValue, isNotEmpty } from '../../../empty.util';
|
||||||
import {
|
|
||||||
DEFAULT_CONTEXT,
|
|
||||||
DEFAULT_THEME
|
|
||||||
} from '../../../metadata-representation/metadata-representation.decorator';
|
|
||||||
import { GenericConstructor } from '../../../../core/shared/generic-constructor';
|
import { GenericConstructor } from '../../../../core/shared/generic-constructor';
|
||||||
import { ListableObject } from '../listable-object.model';
|
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_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();
|
const map = new Map();
|
||||||
|
|
||||||
@@ -54,8 +63,9 @@ export function getListableObjectComponent(types: (string | GenericConstructor<L
|
|||||||
if (hasValue(typeModeMap)) {
|
if (hasValue(typeModeMap)) {
|
||||||
const contextMap = typeModeMap.get(context);
|
const contextMap = typeModeMap.get(context);
|
||||||
if (hasValue(contextMap)) {
|
if (hasValue(contextMap)) {
|
||||||
if (hasValue(contextMap.get(theme))) {
|
const match = resolveTheme(contextMap, theme);
|
||||||
return contextMap.get(theme);
|
if (hasValue(match)) {
|
||||||
|
return match;
|
||||||
}
|
}
|
||||||
if (bestMatchValue < 3 && hasValue(contextMap.get(DEFAULT_THEME))) {
|
if (bestMatchValue < 3 && hasValue(contextMap.get(DEFAULT_THEME))) {
|
||||||
bestMatchValue = 3;
|
bestMatchValue = 3;
|
||||||
@@ -80,3 +90,35 @@ export function getListableObjectComponent(types: (string | GenericConstructor<L
|
|||||||
}
|
}
|
||||||
return bestMatch;
|
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">
|
<form #form="ngForm" (ngSubmit)="onSubmit(form.value)" action="/search">
|
||||||
<div *ngIf="isNotEmpty(scopes)" class="col-12 col-sm-3">
|
<div>
|
||||||
<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">
|
|
||||||
<div class="form-group input-group">
|
<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 }}"
|
<input type="text" [(ngModel)]="query" name="query" class="form-control" attr.aria-label="{{ searchPlaceholder }}"
|
||||||
[placeholder]="searchPlaceholder">
|
[placeholder]="searchPlaceholder">
|
||||||
<span class="input-group-append">
|
<span class="input-group-append">
|
||||||
|
@@ -3,3 +3,7 @@
|
|||||||
background-color: var(--bs-input-bg);
|
background-color: var(--bs-input-bg);
|
||||||
color: var(--bs-input-color);
|
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 { TranslateModule } from '@ngx-translate/core';
|
||||||
import { DSpaceObject } from '../../core/shared/dspace-object.model';
|
import { DSpaceObject } from '../../core/shared/dspace-object.model';
|
||||||
import { SearchService } from '../../core/shared/search/search.service';
|
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 { PaginationService } from '../../core/pagination/pagination.service';
|
||||||
import { SearchConfigurationService } from '../../core/shared/search/search-configuration.service';
|
import { SearchConfigurationService } from '../../core/shared/search/search-configuration.service';
|
||||||
import { PaginationServiceStub } from '../testing/pagination-service.stub';
|
import { PaginationServiceStub } from '../testing/pagination-service.stub';
|
||||||
|
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
|
||||||
|
import { createSuccessfulRemoteDataObject$ } from '../remote-data.utils';
|
||||||
|
|
||||||
describe('SearchFormComponent', () => {
|
describe('SearchFormComponent', () => {
|
||||||
let comp: SearchFormComponent;
|
let comp: SearchFormComponent;
|
||||||
@@ -35,7 +33,8 @@ describe('SearchFormComponent', () => {
|
|||||||
useValue: {}
|
useValue: {}
|
||||||
},
|
},
|
||||||
{ provide: PaginationService, useValue: paginationService },
|
{ provide: PaginationService, useValue: paginationService },
|
||||||
{ provide: SearchConfigurationService, useValue: searchConfigService }
|
{ provide: SearchConfigurationService, useValue: searchConfigService },
|
||||||
|
{ provide: DSpaceObjectDataService, useValue: { findById: () => createSuccessfulRemoteDataObject$(undefined)} }
|
||||||
],
|
],
|
||||||
declarations: [SearchFormComponent]
|
declarations: [SearchFormComponent]
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
@@ -48,24 +47,6 @@ describe('SearchFormComponent', () => {
|
|||||||
el = de.nativeElement;
|
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', () => {
|
it('should not display scopes when empty', () => {
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
const select = de.query(By.css('select'));
|
const select = de.query(By.css('select'));
|
||||||
@@ -84,17 +65,17 @@ describe('SearchFormComponent', () => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
it('should select correct scope option in scope select', fakeAsync(() => {
|
it('should select correct scope option in scope select', fakeAsync(() => {
|
||||||
comp.scopes = objects;
|
|
||||||
fixture.detectChanges();
|
|
||||||
|
|
||||||
|
fixture.detectChanges();
|
||||||
|
comp.showScopeSelector = true;
|
||||||
const testCommunity = objects[1];
|
const testCommunity = objects[1];
|
||||||
comp.scope = testCommunity.id;
|
comp.selectedScope.next(testCommunity);
|
||||||
|
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
tick();
|
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(() => {
|
// it('should call updateSearch when clicking the submit button with correct parameters', fakeAsync(() => {
|
||||||
// comp.query = 'Test String'
|
// comp.query = 'Test String'
|
||||||
|
@@ -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 { DSpaceObject } from '../../core/shared/dspace-object.model';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { isNotEmpty } from '../empty.util';
|
import { isNotEmpty } from '../empty.util';
|
||||||
@@ -6,6 +6,12 @@ import { SearchService } from '../../core/shared/search/search.service';
|
|||||||
import { currentPath } from '../utils/route.utils';
|
import { currentPath } from '../utils/route.utils';
|
||||||
import { PaginationService } from '../../core/pagination/pagination.service';
|
import { PaginationService } from '../../core/pagination/pagination.service';
|
||||||
import { SearchConfigurationService } from '../../core/shared/search/search-configuration.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.
|
* 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
|
* Component that represents the search form
|
||||||
*/
|
*/
|
||||||
export class SearchFormComponent {
|
export class SearchFormComponent implements OnInit {
|
||||||
/**
|
/**
|
||||||
* The search query
|
* The search query
|
||||||
*/
|
*/
|
||||||
@@ -39,12 +45,9 @@ export class SearchFormComponent {
|
|||||||
@Input()
|
@Input()
|
||||||
scope = '';
|
scope = '';
|
||||||
|
|
||||||
@Input() currentUrl: string;
|
selectedScope: BehaviorSubject<DSpaceObject> = new BehaviorSubject<DSpaceObject>(undefined);
|
||||||
|
|
||||||
/**
|
@Input() currentUrl: string;
|
||||||
* The available scopes
|
|
||||||
*/
|
|
||||||
@Input() scopes: DSpaceObject[];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether or not the search button should be displayed large
|
* Whether or not the search button should be displayed large
|
||||||
@@ -61,17 +64,35 @@ export class SearchFormComponent {
|
|||||||
*/
|
*/
|
||||||
@Input() searchPlaceholder: string;
|
@Input() searchPlaceholder: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines whether or not to show the scope selector
|
||||||
|
*/
|
||||||
|
@Input() showScopeSelector = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Output the search data on submit
|
* Output the search data on submit
|
||||||
*/
|
*/
|
||||||
@Output() submitSearch = new EventEmitter<any>();
|
@Output() submitSearch = new EventEmitter<any>();
|
||||||
|
|
||||||
constructor(private router: Router, private searchService: SearchService,
|
constructor(private router: Router,
|
||||||
|
private searchService: SearchService,
|
||||||
private paginationService: PaginationService,
|
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
|
* Updates the search when the form is submitted
|
||||||
* @param data Values submitted using the form
|
* @param data Values submitted using the form
|
||||||
@@ -85,8 +106,8 @@ export class SearchFormComponent {
|
|||||||
* Updates the search when the current scope has been changed
|
* Updates the search when the current scope has been changed
|
||||||
* @param {string} scope The new scope
|
* @param {string} scope The new scope
|
||||||
*/
|
*/
|
||||||
onScopeChange(scope: string) {
|
onScopeChange(scope: DSpaceObject) {
|
||||||
this.updateSearch({ scope });
|
this.updateSearch({ scope: scope ? scope.uuid : undefined });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -131,4 +152,15 @@ export class SearchFormComponent {
|
|||||||
}
|
}
|
||||||
return this.getSearchLink().split('/');
|
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 { TextMenuItemComponent } from './menu/menu-item/text-menu-item.component';
|
||||||
import { ThemedConfigurationSearchPageComponent } from '../search-page/themed-configuration-search-page.component';
|
import { ThemedConfigurationSearchPageComponent } from '../search-page/themed-configuration-search-page.component';
|
||||||
import { SearchNavbarComponent } from '../search-navbar/search-navbar.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';
|
import { BitstreamRequestACopyPageComponent } from './bitstream-request-a-copy-page/bitstream-request-a-copy-page.component';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -460,7 +461,8 @@ const COMPONENTS = [
|
|||||||
PublicationSidebarSearchListElementComponent,
|
PublicationSidebarSearchListElementComponent,
|
||||||
CollectionSidebarSearchListElementComponent,
|
CollectionSidebarSearchListElementComponent,
|
||||||
CommunitySidebarSearchListElementComponent,
|
CommunitySidebarSearchListElementComponent,
|
||||||
SearchNavbarComponent
|
SearchNavbarComponent,
|
||||||
|
ScopeSelectorModalComponent
|
||||||
];
|
];
|
||||||
|
|
||||||
const ENTRY_COMPONENTS = [
|
const ENTRY_COMPONENTS = [
|
||||||
@@ -525,7 +527,8 @@ const ENTRY_COMPONENTS = [
|
|||||||
CommunitySidebarSearchListElementComponent,
|
CommunitySidebarSearchListElementComponent,
|
||||||
LinkMenuItemComponent,
|
LinkMenuItemComponent,
|
||||||
OnClickMenuItemComponent,
|
OnClickMenuItemComponent,
|
||||||
TextMenuItemComponent
|
TextMenuItemComponent,
|
||||||
|
ScopeSelectorModalComponent
|
||||||
];
|
];
|
||||||
|
|
||||||
const SHARED_SEARCH_PAGE_COMPONENTS = [
|
const SHARED_SEARCH_PAGE_COMPONENTS = [
|
||||||
|
@@ -1,75 +1,17 @@
|
|||||||
import { ThemeEffects } from './theme.effects';
|
import { ThemeEffects } from './theme.effects';
|
||||||
import { of as observableOf } from 'rxjs';
|
|
||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { provideMockActions } from '@ngrx/effects/testing';
|
import { provideMockActions } from '@ngrx/effects/testing';
|
||||||
import { LinkService } from '../../core/cache/builders/link.service';
|
|
||||||
import { cold, hot } from 'jasmine-marbles';
|
import { cold, hot } from 'jasmine-marbles';
|
||||||
import { ROOT_EFFECTS_INIT } from '@ngrx/effects';
|
import { ROOT_EFFECTS_INIT } from '@ngrx/effects';
|
||||||
import { SetThemeAction } from './theme.actions';
|
import { SetThemeAction } from './theme.actions';
|
||||||
import { Theme } from '../../../config/theme.model';
|
|
||||||
import { provideMockStore } from '@ngrx/store/testing';
|
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';
|
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', () => {
|
describe('ThemeEffects', () => {
|
||||||
let themeEffects: ThemeEffects;
|
let themeEffects: ThemeEffects;
|
||||||
let linkService: LinkService;
|
|
||||||
let initialState;
|
let initialState;
|
||||||
|
|
||||||
let ancestorDSOs: DSpaceObject[];
|
|
||||||
|
|
||||||
function init() {
|
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 = {
|
initialState = {
|
||||||
theme: {
|
theme: {
|
||||||
currentTheme: 'custom',
|
currentTheme: 'custom',
|
||||||
@@ -82,7 +24,6 @@ describe('ThemeEffects', () => {
|
|||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
ThemeEffects,
|
ThemeEffects,
|
||||||
{ provide: LinkService, useValue: linkService },
|
|
||||||
provideMockStore({ initialState }),
|
provideMockStore({ initialState }),
|
||||||
provideMockActions(() => mockActions)
|
provideMockActions(() => mockActions)
|
||||||
]
|
]
|
||||||
@@ -110,205 +51,4 @@ describe('ThemeEffects', () => {
|
|||||||
expect(themeEffects.initTheme$).toBeObservable(expected);
|
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 { Injectable } from '@angular/core';
|
||||||
import { createEffect, Actions, ofType, ROOT_EFFECTS_INIT } from '@ngrx/effects';
|
import { createEffect, Actions, ofType, ROOT_EFFECTS_INIT } from '@ngrx/effects';
|
||||||
import { ROUTER_NAVIGATED, RouterNavigatedAction } from '@ngrx/router-store';
|
import { map } from 'rxjs/operators';
|
||||||
import { map, withLatestFrom, expand, switchMap, toArray, startWith, filter } from 'rxjs/operators';
|
|
||||||
import { SetThemeAction } from './theme.actions';
|
import { SetThemeAction } from './theme.actions';
|
||||||
import { environment } from '../../../environments/environment';
|
import { environment } from '../../../environments/environment';
|
||||||
import { ThemeConfig, themeFactory, Theme, } from '../../../config/theme.model';
|
import { hasValue, hasNoValue } from '../empty.util';
|
||||||
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 { BASE_THEME_NAME } from './theme.constants';
|
import { BASE_THEME_NAME } from './theme.constants';
|
||||||
|
|
||||||
export const DEFAULT_THEME_CONFIG = environment.themes.find((themeConfig: any) =>
|
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()
|
@Injectable()
|
||||||
export class ThemeEffects {
|
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.
|
* 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(
|
constructor(
|
||||||
private actions$: Actions,
|
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 { Store, createFeatureSelector, createSelector, select } from '@ngrx/store';
|
||||||
import { Observable } from 'rxjs/internal/Observable';
|
import { Observable } from 'rxjs/internal/Observable';
|
||||||
import { ThemeState } from './theme.reducer';
|
import { ThemeState } from './theme.reducer';
|
||||||
import { SetThemeAction } from './theme.actions';
|
import { SetThemeAction, ThemeActionTypes } from './theme.actions';
|
||||||
import { take } from 'rxjs/operators';
|
import { expand, filter, map, switchMap, take, toArray } from 'rxjs/operators';
|
||||||
import { hasValue } from '../empty.util';
|
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');
|
export const themeStateSelector = createFeatureSelector<ThemeState>('theme');
|
||||||
|
|
||||||
@@ -17,9 +33,29 @@ export const currentThemeSelector = createSelector(
|
|||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
export class ThemeService {
|
export class ThemeService {
|
||||||
|
/**
|
||||||
|
* The list of configured themes
|
||||||
|
*/
|
||||||
|
themes: Theme[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if at least one theme depends on the route
|
||||||
|
*/
|
||||||
|
hasDynamicTheme: boolean;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private store: Store<ThemeState>,
|
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) {
|
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 { ThemeService } from './theme.service';
|
||||||
import { getMockThemeService } from '../mocks/theme-service.mock';
|
import { getMockThemeService } from '../mocks/theme-service.mock';
|
||||||
import { TestComponent } from './test/test.component.spec';
|
import { TestComponent } from './test/test.component.spec';
|
||||||
|
import { ThemeConfig } from '../../../config/theme.model';
|
||||||
|
|
||||||
/* tslint:disable:max-classes-per-file */
|
/* tslint:disable:max-classes-per-file */
|
||||||
@Component({
|
@Component({
|
||||||
@@ -32,8 +33,8 @@ describe('ThemedComponent', () => {
|
|||||||
let fixture: ComponentFixture<TestThemedComponent>;
|
let fixture: ComponentFixture<TestThemedComponent>;
|
||||||
let themeService: ThemeService;
|
let themeService: ThemeService;
|
||||||
|
|
||||||
function setupTestingModuleForTheme(theme: string) {
|
function setupTestingModuleForTheme(theme: string, themes?: ThemeConfig[]) {
|
||||||
themeService = getMockThemeService(theme);
|
themeService = getMockThemeService(theme, themes);
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
imports: [],
|
imports: [],
|
||||||
declarations: [TestThemedComponent, VarDirective],
|
declarations: [TestThemedComponent, VarDirective],
|
||||||
@@ -44,17 +45,20 @@ describe('ThemedComponent', () => {
|
|||||||
}).compileComponents();
|
}).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', () => {
|
describe('when the current theme matches a themed component', () => {
|
||||||
beforeEach(waitForAsync(() => {
|
beforeEach(waitForAsync(() => {
|
||||||
setupTestingModuleForTheme('custom');
|
setupTestingModuleForTheme('custom');
|
||||||
}));
|
}));
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(initComponent);
|
||||||
fixture = TestBed.createComponent(TestThemedComponent);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
component.testInput = 'changed';
|
|
||||||
fixture.detectChanges();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should set compRef to the themed component', waitForAsync(() => {
|
it('should set compRef to the themed component', waitForAsync(() => {
|
||||||
fixture.whenStable().then(() => {
|
fixture.whenStable().then(() => {
|
||||||
@@ -70,16 +74,12 @@ describe('ThemedComponent', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('when the current theme doesn\'t match a themed component', () => {
|
describe('when the current theme doesn\'t match a themed component', () => {
|
||||||
|
describe('and it doesn\'t extend another theme', () => {
|
||||||
beforeEach(waitForAsync(() => {
|
beforeEach(waitForAsync(() => {
|
||||||
setupTestingModuleForTheme('non-existing-theme');
|
setupTestingModuleForTheme('non-existing-theme');
|
||||||
}));
|
}));
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(initComponent);
|
||||||
fixture = TestBed.createComponent(TestThemedComponent);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
component.testInput = 'changed';
|
|
||||||
fixture.detectChanges();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should set compRef to the default component', waitForAsync(() => {
|
it('should set compRef to the default component', waitForAsync(() => {
|
||||||
fixture.whenStable().then(() => {
|
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 */
|
/* tslint:enable:max-classes-per-file */
|
||||||
|
@@ -11,7 +11,7 @@ import {
|
|||||||
OnChanges
|
OnChanges
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { hasValue, isNotEmpty } from '../empty.util';
|
import { hasValue, isNotEmpty } from '../empty.util';
|
||||||
import { Subscription } from 'rxjs';
|
import { Observable, of as observableOf, Subscription } from 'rxjs';
|
||||||
import { ThemeService } from './theme.service';
|
import { ThemeService } from './theme.service';
|
||||||
import { fromPromise } from 'rxjs/internal-compatibility';
|
import { fromPromise } from 'rxjs/internal-compatibility';
|
||||||
import { catchError, switchMap, map } from 'rxjs/operators';
|
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.unsubscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.lazyLoadSub =
|
this.lazyLoadSub = this.resolveThemedComponent(this.themeService.getThemeName()).pipe(
|
||||||
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]),
|
|
||||||
switchMap((themedFile: any) => {
|
switchMap((themedFile: any) => {
|
||||||
if (hasValue(themedFile) && hasValue(themedFile[this.getComponentName()])) {
|
if (hasValue(themedFile) && hasValue(themedFile[this.getComponentName()])) {
|
||||||
// if the file is not null, and exports a component with the specified name,
|
// 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.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.",
|
"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.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 }}",
|
"confirmation-modal.export-metadata.header": "Export metadata for {{ dsoName }}",
|
||||||
@@ -1311,6 +1341,11 @@
|
|||||||
|
|
||||||
"error.validation.filerequired": "The file upload is mandatory",
|
"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",
|
"file-section.error.header": "Error obtaining files for this item",
|
||||||
@@ -1972,6 +2007,10 @@
|
|||||||
|
|
||||||
"item.page.collections": "Collections",
|
"item.page.collections": "Collections",
|
||||||
|
|
||||||
|
"item.page.collections.loading": "Loading...",
|
||||||
|
|
||||||
|
"item.page.collections.load-more": "Load more",
|
||||||
|
|
||||||
"item.page.date": "Date",
|
"item.page.date": "Date",
|
||||||
|
|
||||||
"item.page.edit": "Edit this item",
|
"item.page.edit": "Edit this item",
|
||||||
@@ -3199,6 +3238,8 @@
|
|||||||
|
|
||||||
"search.form.search_dspace": "All repository",
|
"search.form.search_dspace": "All repository",
|
||||||
|
|
||||||
|
"search.form.scope.all": "All of DSpace",
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"search.results.head": "Search Results",
|
"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
|
// tslint:disable:max-classes-per-file
|
||||||
export interface NamedThemeConfig extends Config {
|
export interface NamedThemeConfig extends Config {
|
||||||
name: string;
|
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 {
|
export interface RegExThemeConfig extends NamedThemeConfig {
|
||||||
|
@@ -265,6 +265,19 @@ export const environment: GlobalConfig = {
|
|||||||
// uuid: '0958c910-2037-42a9-81c7-dca80e3892b4'
|
// 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
|
// // A theme with only a name will match every route
|
||||||
// name: 'custom'
|
// name: 'custom'
|
||||||
// },
|
// },
|
||||||
|
@@ -3,3 +3,5 @@
|
|||||||
|
|
||||||
@import '_bootstrap_variables.scss';
|
@import '_bootstrap_variables.scss';
|
||||||
@import '../../node_modules/bootstrap/scss/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 { FooterComponent } from './app/footer/footer.component';
|
||||||
import { BreadcrumbsComponent } from './app/breadcrumbs/breadcrumbs.component';
|
import { BreadcrumbsComponent } from './app/breadcrumbs/breadcrumbs.component';
|
||||||
import { HeaderNavbarWrapperComponent } from './app/header-nav-wrapper/header-navbar-wrapper.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 = [
|
const DECLARATIONS = [
|
||||||
|
FileSectionComponent,
|
||||||
HomePageComponent,
|
HomePageComponent,
|
||||||
HomeNewsComponent,
|
HomeNewsComponent,
|
||||||
RootComponent,
|
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"
|
resolved "https://registry.yarnpkg.com/cypress-axe/-/cypress-axe-0.13.0.tgz#3234e1a79a27701f2451fcf2f333eb74204c7966"
|
||||||
integrity sha512-fCIy7RiDCm7t30U3C99gGwQrUO307EYE1QqXNaf9ToK4DVqW8y5on+0a/kUHMrHdlls2rENF6TN9ZPpPpwLrnw==
|
integrity sha512-fCIy7RiDCm7t30U3C99gGwQrUO307EYE1QqXNaf9ToK4DVqW8y5on+0a/kUHMrHdlls2rENF6TN9ZPpPpwLrnw==
|
||||||
|
|
||||||
cypress@8.3.1:
|
cypress@8.6.0:
|
||||||
version "8.3.1"
|
version "8.6.0"
|
||||||
resolved "https://registry.yarnpkg.com/cypress/-/cypress-8.3.1.tgz#c6760dbb907df2570b0e1ac235fa31c30f9260a6"
|
resolved "https://registry.yarnpkg.com/cypress/-/cypress-8.6.0.tgz#8d02fa58878b37cfc45bbfce393aa974fa8a8e22"
|
||||||
integrity sha512-1v6pfx+/5cXhaT5T6QKOvnkawmEHWHLiVzm3MYMoQN1fkX2Ma1C32STd3jBStE9qT5qPSTILjGzypVRxCBi40g==
|
integrity sha512-F7qEK/6Go5FsqTueR+0wEw2vOVKNgk5847Mys8vsWkzPoEKdxs+7N9Y1dit+zhaZCLtMPyrMwjfA53ZFy+lSww==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@cypress/request" "^2.88.6"
|
"@cypress/request" "^2.88.6"
|
||||||
"@cypress/xvfb" "^1.2.4"
|
"@cypress/xvfb" "^1.2.4"
|
||||||
@@ -4192,6 +4192,7 @@ cypress@8.3.1:
|
|||||||
minimist "^1.2.5"
|
minimist "^1.2.5"
|
||||||
ospath "^1.2.2"
|
ospath "^1.2.2"
|
||||||
pretty-bytes "^5.6.0"
|
pretty-bytes "^5.6.0"
|
||||||
|
proxy-from-env "1.0.0"
|
||||||
ramda "~0.27.1"
|
ramda "~0.27.1"
|
||||||
request-progress "^3.0.0"
|
request-progress "^3.0.0"
|
||||||
supports-color "^8.1.1"
|
supports-color "^8.1.1"
|
||||||
@@ -9839,6 +9840,11 @@ proxy-addr@~2.0.5:
|
|||||||
forwarded "~0.1.2"
|
forwarded "~0.1.2"
|
||||||
ipaddr.js "1.9.1"
|
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:
|
prr@~1.0.1:
|
||||||
version "1.0.1"
|
version "1.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/prr/-/prr-1.0.1.tgz#d3fc114ba06995a45ec6893f484ceb1d78f5f476"
|
resolved "https://registry.yarnpkg.com/prr/-/prr-1.0.1.tgz#d3fc114ba06995a45ec6893f484ceb1d78f5f476"
|
||||||
|
Reference in New Issue
Block a user