mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-16 14:33:03 +00:00
Merge remote-tracking branch 'origin/main' into CST-4504-Adding-new-relationships-in-edit-item-is-difficult-for-entities-with-many-relationships
# Conflicts: # src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.ts
This commit is contained in:
14
README.md
14
README.md
@@ -212,13 +212,17 @@ Once you have tested the Pull Request, please add a comment and/or approval to t
|
||||
|
||||
### Unit Tests
|
||||
|
||||
Unit tests use Karma. You can find the configuration file at the same level of this README file:`./karma.conf.js` If you are going to use a remote test environment you need to edit the `./karma.conf.js`. Follow the instructions you will find inside it. To executing tests whenever any file changes you can modify the 'autoWatch' option to 'true' and 'singleRun' option to 'false'. A coverage report is also available at: http://localhost:9876/ after you run: `yarn run coverage`.
|
||||
Unit tests use the [Jasmine test framework](https://jasmine.github.io/), and are run via [Karma](https://karma-runner.github.io/).
|
||||
|
||||
You can find the Karma configuration file at the same level of this README file:`./karma.conf.js` If you are going to use a remote test environment you need to edit the `./karma.conf.js`. Follow the instructions you will find inside it. To executing tests whenever any file changes you can modify the 'autoWatch' option to 'true' and 'singleRun' option to 'false'. A coverage report is also available at: http://localhost:9876/ after you run: `yarn run coverage`.
|
||||
|
||||
The default browser is Google Chrome.
|
||||
|
||||
Place your tests in the same location of the application source code files that they test.
|
||||
Place your tests in the same location of the application source code files that they test, e.g. ending with `*.component.spec.ts`
|
||||
|
||||
and run: `yarn run test`
|
||||
and run: `yarn test`
|
||||
|
||||
If you run into odd test errors, see the Angular guide to debugging tests: https://angular.io/guide/test-debugging
|
||||
|
||||
### E2E Tests
|
||||
|
||||
@@ -258,6 +262,10 @@ _Hint: Creating e2e tests is easiest in an IDE (like Visual Studio), as it can h
|
||||
|
||||
More Information: [docs.cypress.io](https://docs.cypress.io/) has great guides & documentation helping you learn more about writing/debugging e2e tests in Cypress.
|
||||
|
||||
### Learning how to build tests
|
||||
|
||||
See our [DSpace Code Testing Guide](https://wiki.lyrasis.org/display/DSPACE/Code+Testing+Guide) for more hints/tips.
|
||||
|
||||
Documentation
|
||||
--------------
|
||||
|
||||
|
15
cypress/integration/breadcrumbs.spec.ts
Normal file
15
cypress/integration/breadcrumbs.spec.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { TEST_ENTITY_PUBLICATION } from 'cypress/support';
|
||||
import { testA11y } from 'cypress/support/utils';
|
||||
|
||||
describe('Breadcrumbs', () => {
|
||||
it('should pass accessibility tests', () => {
|
||||
// Visit an Item, as those have more breadcrumbs
|
||||
cy.visit('/entities/publication/' + TEST_ENTITY_PUBLICATION);
|
||||
|
||||
// Wait for breadcrumbs to be visible
|
||||
cy.get('ds-breadcrumbs').should('be.visible');
|
||||
|
||||
// Analyze <ds-breadcrumbs> for accessibility
|
||||
testA11y('ds-breadcrumbs');
|
||||
});
|
||||
});
|
13
cypress/integration/browse-by-author.spec.ts
Normal file
13
cypress/integration/browse-by-author.spec.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { testA11y } from 'cypress/support/utils';
|
||||
|
||||
describe('Browse By Author', () => {
|
||||
it('should pass accessibility tests', () => {
|
||||
cy.visit('/browse/author');
|
||||
|
||||
// Wait for <ds-browse-by-metadata-page> to be visible
|
||||
cy.get('ds-browse-by-metadata-page').should('be.visible');
|
||||
|
||||
// Analyze <ds-browse-by-metadata-page> for accessibility
|
||||
testA11y('ds-browse-by-metadata-page');
|
||||
});
|
||||
});
|
13
cypress/integration/browse-by-dateissued.spec.ts
Normal file
13
cypress/integration/browse-by-dateissued.spec.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { testA11y } from 'cypress/support/utils';
|
||||
|
||||
describe('Browse By Date Issued', () => {
|
||||
it('should pass accessibility tests', () => {
|
||||
cy.visit('/browse/dateissued');
|
||||
|
||||
// Wait for <ds-browse-by-date-page> to be visible
|
||||
cy.get('ds-browse-by-date-page').should('be.visible');
|
||||
|
||||
// Analyze <ds-browse-by-date-page> for accessibility
|
||||
testA11y('ds-browse-by-date-page');
|
||||
});
|
||||
});
|
13
cypress/integration/browse-by-subject.spec.ts
Normal file
13
cypress/integration/browse-by-subject.spec.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { testA11y } from 'cypress/support/utils';
|
||||
|
||||
describe('Browse By Subject', () => {
|
||||
it('should pass accessibility tests', () => {
|
||||
cy.visit('/browse/subject');
|
||||
|
||||
// Wait for <ds-browse-by-metadata-page> to be visible
|
||||
cy.get('ds-browse-by-metadata-page').should('be.visible');
|
||||
|
||||
// Analyze <ds-browse-by-metadata-page> for accessibility
|
||||
testA11y('ds-browse-by-metadata-page');
|
||||
});
|
||||
});
|
13
cypress/integration/browse-by-title.spec.ts
Normal file
13
cypress/integration/browse-by-title.spec.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { testA11y } from 'cypress/support/utils';
|
||||
|
||||
describe('Browse By Title', () => {
|
||||
it('should pass accessibility tests', () => {
|
||||
cy.visit('/browse/title');
|
||||
|
||||
// Wait for <ds-browse-by-title-page> to be visible
|
||||
cy.get('ds-browse-by-title-page').should('be.visible');
|
||||
|
||||
// Analyze <ds-browse-by-title-page> for accessibility
|
||||
testA11y('ds-browse-by-title-page');
|
||||
});
|
||||
});
|
15
cypress/integration/collection-page.spec.ts
Normal file
15
cypress/integration/collection-page.spec.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { TEST_COLLECTION } from 'cypress/support';
|
||||
import { testA11y } from 'cypress/support/utils';
|
||||
|
||||
describe('Collection Page', () => {
|
||||
|
||||
it('should pass accessibility tests', () => {
|
||||
cy.visit('/collections/' + TEST_COLLECTION);
|
||||
|
||||
// <ds-collection-page> tag must be loaded
|
||||
cy.get('ds-collection-page').should('exist');
|
||||
|
||||
// Analyze <ds-collection-page> for accessibility issues
|
||||
testA11y('ds-collection-page');
|
||||
});
|
||||
});
|
32
cypress/integration/collection-statistics.spec.ts
Normal file
32
cypress/integration/collection-statistics.spec.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { TEST_COLLECTION } from 'cypress/support';
|
||||
import { testA11y } from 'cypress/support/utils';
|
||||
|
||||
describe('Collection Statistics Page', () => {
|
||||
const COLLECTIONSTATISTICSPAGE = '/statistics/collections/' + TEST_COLLECTION;
|
||||
|
||||
it('should load if you click on "Statistics" from a Collection page', () => {
|
||||
cy.visit('/collections/' + TEST_COLLECTION);
|
||||
cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click();
|
||||
cy.location('pathname').should('eq', COLLECTIONSTATISTICSPAGE);
|
||||
});
|
||||
|
||||
it('should contain a "Total visits" section', () => {
|
||||
cy.visit(COLLECTIONSTATISTICSPAGE);
|
||||
cy.get('.' + TEST_COLLECTION + '_TotalVisits').should('exist');
|
||||
});
|
||||
|
||||
it('should contain a "Total visits per month" section', () => {
|
||||
cy.visit(COLLECTIONSTATISTICSPAGE);
|
||||
cy.get('.' + TEST_COLLECTION + '_TotalVisitsPerMonth').should('exist');
|
||||
});
|
||||
|
||||
it('should pass accessibility tests', () => {
|
||||
cy.visit(COLLECTIONSTATISTICSPAGE);
|
||||
|
||||
// <ds-collection-statistics-page> tag must be loaded
|
||||
cy.get('ds-collection-statistics-page').should('exist');
|
||||
|
||||
// Analyze <ds-collection-statistics-page> for accessibility issues
|
||||
testA11y('ds-collection-statistics-page');
|
||||
});
|
||||
});
|
25
cypress/integration/community-list.spec.ts
Normal file
25
cypress/integration/community-list.spec.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Options } from 'cypress-axe';
|
||||
import { testA11y } from 'cypress/support/utils';
|
||||
|
||||
describe('Community List Page', () => {
|
||||
|
||||
it('should pass accessibility tests', () => {
|
||||
cy.visit('/community-list');
|
||||
|
||||
// <ds-community-list-page> tag must be loaded
|
||||
cy.get('ds-community-list-page').should('exist');
|
||||
|
||||
// Open first Community (to show Collections)...that way we scan sub-elements as well
|
||||
cy.get('ds-community-list :nth-child(1) > .btn-group > .btn').click();
|
||||
|
||||
// Analyze <ds-community-list-page> for accessibility issues
|
||||
// Disable heading-order checks until it is fixed
|
||||
testA11y('ds-community-list-page',
|
||||
{
|
||||
rules: {
|
||||
'heading-order': { enabled: false }
|
||||
}
|
||||
} as Options
|
||||
);
|
||||
});
|
||||
});
|
15
cypress/integration/community-page.spec.ts
Normal file
15
cypress/integration/community-page.spec.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { TEST_COMMUNITY } from 'cypress/support';
|
||||
import { testA11y } from 'cypress/support/utils';
|
||||
|
||||
describe('Community Page', () => {
|
||||
|
||||
it('should pass accessibility tests', () => {
|
||||
cy.visit('/communities/' + TEST_COMMUNITY);
|
||||
|
||||
// <ds-community-page> tag must be loaded
|
||||
cy.get('ds-community-page').should('exist');
|
||||
|
||||
// Analyze <ds-community-page> for accessibility issues
|
||||
testA11y('ds-community-page',);
|
||||
});
|
||||
});
|
32
cypress/integration/community-statistics.spec.ts
Normal file
32
cypress/integration/community-statistics.spec.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { TEST_COMMUNITY } from 'cypress/support';
|
||||
import { testA11y } from 'cypress/support/utils';
|
||||
|
||||
describe('Community Statistics Page', () => {
|
||||
const COMMUNITYSTATISTICSPAGE = '/statistics/communities/' + TEST_COMMUNITY;
|
||||
|
||||
it('should load if you click on "Statistics" from a Community page', () => {
|
||||
cy.visit('/communities/' + TEST_COMMUNITY);
|
||||
cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click();
|
||||
cy.location('pathname').should('eq', COMMUNITYSTATISTICSPAGE);
|
||||
});
|
||||
|
||||
it('should contain a "Total visits" section', () => {
|
||||
cy.visit(COMMUNITYSTATISTICSPAGE);
|
||||
cy.get('.' + TEST_COMMUNITY + '_TotalVisits').should('exist');
|
||||
});
|
||||
|
||||
it('should contain a "Total visits per month" section', () => {
|
||||
cy.visit(COMMUNITYSTATISTICSPAGE);
|
||||
cy.get('.' + TEST_COMMUNITY + '_TotalVisitsPerMonth').should('exist');
|
||||
});
|
||||
|
||||
it('should pass accessibility tests', () => {
|
||||
cy.visit(COMMUNITYSTATISTICSPAGE);
|
||||
|
||||
// <ds-community-statistics-page> tag must be loaded
|
||||
cy.get('ds-community-statistics-page').should('exist');
|
||||
|
||||
// Analyze <ds-community-statistics-page> for accessibility issues
|
||||
testA11y('ds-community-statistics-page');
|
||||
});
|
||||
});
|
13
cypress/integration/footer.spec.ts
Normal file
13
cypress/integration/footer.spec.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { testA11y } from 'cypress/support/utils';
|
||||
|
||||
describe('Footer', () => {
|
||||
it('should pass accessibility tests', () => {
|
||||
cy.visit('/');
|
||||
|
||||
// Footer must first be visible
|
||||
cy.get('ds-footer').should('be.visible');
|
||||
|
||||
// Analyze <ds-footer> for accessibility
|
||||
testA11y('ds-footer');
|
||||
});
|
||||
});
|
19
cypress/integration/header.spec.ts
Normal file
19
cypress/integration/header.spec.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { testA11y } from 'cypress/support/utils';
|
||||
|
||||
describe('Header', () => {
|
||||
it('should pass accessibility tests', () => {
|
||||
cy.visit('/');
|
||||
|
||||
// Header must first be visible
|
||||
cy.get('ds-header').should('be.visible');
|
||||
|
||||
// Analyze <ds-header> for accessibility
|
||||
testA11y({
|
||||
include: ['ds-header'],
|
||||
exclude: [
|
||||
['#search-navbar-container'], // search in navbar has duplicative ID. Will be fixed in #1174
|
||||
['.dropdownLogin'] // "Log in" link has color contrast issues. Will be fixed in #1149
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
19
cypress/integration/homepage-statistics.spec.ts
Normal file
19
cypress/integration/homepage-statistics.spec.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { testA11y } from 'cypress/support/utils';
|
||||
|
||||
describe('Site Statistics Page', () => {
|
||||
it('should load if you click on "Statistics" from homepage', () => {
|
||||
cy.visit('/');
|
||||
cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click();
|
||||
cy.location('pathname').should('eq', '/statistics');
|
||||
});
|
||||
|
||||
it('should pass accessibility tests', () => {
|
||||
cy.visit('/statistics');
|
||||
|
||||
// <ds-site-statistics-page> tag must be loaded
|
||||
cy.get('ds-site-statistics-page').should('exist');
|
||||
|
||||
// Analyze <ds-site-statistics-page> for accessibility issues
|
||||
testA11y('ds-site-statistics-page');
|
||||
});
|
||||
});
|
@@ -1,3 +1,5 @@
|
||||
import { testA11y } from 'cypress/support/utils';
|
||||
|
||||
describe('Homepage', () => {
|
||||
beforeEach(() => {
|
||||
// All tests start with visiting homepage
|
||||
@@ -20,18 +22,11 @@ describe('Homepage', () => {
|
||||
cy.url().should('include', 'query=' + encodeURI(queryString));
|
||||
});
|
||||
|
||||
// it('should pass accessibility tests', () => {
|
||||
// // first must inject Axe into current page
|
||||
// cy.injectAxe();
|
||||
it('should pass accessibility tests', () => {
|
||||
// Wait for homepage tag to appear
|
||||
cy.get('ds-home-page').should('be.visible');
|
||||
|
||||
// // Analyze entire page for accessibility issues
|
||||
// // NOTE: this test checks accessibility of header/footer as well
|
||||
// cy.checkA11y({
|
||||
// exclude: [
|
||||
// ['#klaro'], // Klaro plugin (privacy policy popup) has color contrast issues
|
||||
// ['#search-navbar-container'], // search in navbar has duplicative ID. Will be fixed in #1174
|
||||
// ['.dropdownLogin'] // "Log in" link in header has color contrast issues
|
||||
// ],
|
||||
// });
|
||||
// });
|
||||
// Analyze <ds-home-page> for accessibility issues
|
||||
testA11y('ds-home-page');
|
||||
});
|
||||
});
|
||||
|
@@ -1,15 +1,31 @@
|
||||
describe('Item Page', () => {
|
||||
const ITEMPAGE = '/items/e98b0f27-5c19-49a0-960d-eb6ad5287067';
|
||||
const ENTITYPAGE = '/entities/publication/e98b0f27-5c19-49a0-960d-eb6ad5287067';
|
||||
import { Options } from 'cypress-axe';
|
||||
import { TEST_ENTITY_PUBLICATION } from 'cypress/support';
|
||||
import { testA11y } from 'cypress/support/utils';
|
||||
|
||||
it('should contain element ds-item-page when navigating to an item page', () => {
|
||||
cy.visit(ENTITYPAGE);
|
||||
cy.get('ds-item-page').should('exist');
|
||||
});
|
||||
describe('Item Page', () => {
|
||||
const ITEMPAGE = '/items/' + TEST_ENTITY_PUBLICATION;
|
||||
const ENTITYPAGE = '/entities/publication/' + TEST_ENTITY_PUBLICATION;
|
||||
|
||||
// Test that entities will redirect to /entities/[type]/[uuid] when accessed via /items/[uuid]
|
||||
it('should redirect to the entity page when navigating to an item page', () => {
|
||||
cy.visit(ITEMPAGE);
|
||||
cy.location('pathname').should('eq', ENTITYPAGE);
|
||||
});
|
||||
|
||||
it('should pass accessibility tests', () => {
|
||||
cy.visit(ENTITYPAGE);
|
||||
|
||||
// <ds-item-page> tag must be loaded
|
||||
cy.get('ds-item-page').should('exist');
|
||||
|
||||
// Analyze <ds-item-page> for accessibility issues
|
||||
// Disable heading-order checks until it is fixed
|
||||
testA11y('ds-item-page',
|
||||
{
|
||||
rules: {
|
||||
'heading-order': { enabled: false }
|
||||
}
|
||||
} as Options
|
||||
);
|
||||
});
|
||||
});
|
||||
|
@@ -1,6 +1,14 @@
|
||||
import { TEST_ENTITY_PUBLICATION } from 'cypress/support';
|
||||
import { testA11y } from 'cypress/support/utils';
|
||||
|
||||
describe('Item Statistics Page', () => {
|
||||
const ITEMUUID = 'e98b0f27-5c19-49a0-960d-eb6ad5287067';
|
||||
const ITEMSTATISTICSPAGE = '/statistics/items/' + ITEMUUID;
|
||||
const ITEMSTATISTICSPAGE = '/statistics/items/' + TEST_ENTITY_PUBLICATION;
|
||||
|
||||
it('should load if you click on "Statistics" from an Item/Entity page', () => {
|
||||
cy.visit('/entities/publication/' + TEST_ENTITY_PUBLICATION);
|
||||
cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click();
|
||||
cy.location('pathname').should('eq', ITEMSTATISTICSPAGE);
|
||||
});
|
||||
|
||||
it('should contain element ds-item-statistics-page when navigating to an item statistics page', () => {
|
||||
cy.visit(ITEMSTATISTICSPAGE);
|
||||
@@ -8,18 +16,23 @@ describe('Item Statistics Page', () => {
|
||||
cy.get('ds-item-page').should('not.exist');
|
||||
});
|
||||
|
||||
it('should contain the item statistics page url when navigating to an item statistics page', () => {
|
||||
cy.visit(ITEMSTATISTICSPAGE);
|
||||
cy.location('pathname').should('eq', ITEMSTATISTICSPAGE);
|
||||
});
|
||||
|
||||
it('should contain a "Total visits" section', () => {
|
||||
cy.visit(ITEMSTATISTICSPAGE);
|
||||
cy.get('.' + ITEMUUID + '_TotalVisits').should('exist');
|
||||
cy.get('.' + TEST_ENTITY_PUBLICATION + '_TotalVisits').should('exist');
|
||||
});
|
||||
|
||||
it('should contain a "Total visits per month" section', () => {
|
||||
cy.visit(ITEMSTATISTICSPAGE);
|
||||
cy.get('.' + ITEMUUID + '_TotalVisitsPerMonth').should('exist');
|
||||
cy.get('.' + TEST_ENTITY_PUBLICATION + '_TotalVisitsPerMonth').should('exist');
|
||||
});
|
||||
|
||||
it('should pass accessibility tests', () => {
|
||||
cy.visit(ITEMSTATISTICSPAGE);
|
||||
|
||||
// <ds-item-statistics-page> tag must be loaded
|
||||
cy.get('ds-item-statistics-page').should('exist');
|
||||
|
||||
// Analyze <ds-item-statistics-page> for accessibility issues
|
||||
testA11y('ds-item-statistics-page');
|
||||
});
|
||||
});
|
||||
|
@@ -1,3 +1,6 @@
|
||||
import { Options } from 'cypress-axe';
|
||||
import { testA11y } from 'cypress/support/utils';
|
||||
|
||||
describe('Search Page', () => {
|
||||
// unique ID of the search form (for selecting specific elements below)
|
||||
const SEARCHFORM_ID = '#search-form';
|
||||
@@ -8,52 +11,6 @@ describe('Search Page', () => {
|
||||
cy.get(SEARCHFORM_ID + ' input[name="query"]').should('have.value', queryString);
|
||||
});
|
||||
|
||||
|
||||
it('should have right scope selected when navigating to page with scope parameter', () => {
|
||||
// First, visit search with no params just to get the set of the scope options
|
||||
cy.visit('/search');
|
||||
cy.get(SEARCHFORM_ID + ' select[name="scope"] > option').as('options');
|
||||
|
||||
// Find length of scope options, select a random index
|
||||
cy.get('@options').its('length')
|
||||
.then(len => Math.floor(Math.random() * Math.floor(len)))
|
||||
.then((index) => {
|
||||
// return the option at that (randomly selected) index
|
||||
return cy.get('@options').eq(index);
|
||||
})
|
||||
.then((option) => {
|
||||
const randomScope: any = option.val();
|
||||
// Visit the search page with the randomly selected option as a pararmeter
|
||||
cy.visit('/search?scope=' + randomScope);
|
||||
// Verify that scope is selected when the page reloads
|
||||
cy.get(SEARCHFORM_ID + ' select[name="scope"]').find('option:selected').should('have.value', randomScope);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('should redirect to the correct url when scope was set and submit button was triggered', () => {
|
||||
// First, visit search with no params just to get the set of scope options
|
||||
cy.visit('/search');
|
||||
cy.get(SEARCHFORM_ID + ' select[name="scope"] > option').as('options');
|
||||
|
||||
// Find length of scope options, select a random index (i.e. a random option in selectbox)
|
||||
cy.get('@options').its('length')
|
||||
.then(len => Math.floor(Math.random() * Math.floor(len)))
|
||||
.then((index) => {
|
||||
// return the option at that (randomly selected) index
|
||||
return cy.get('@options').eq(index);
|
||||
})
|
||||
.then((option) => {
|
||||
const randomScope: any = option.val();
|
||||
// Select the option at our random index & click the search button
|
||||
cy.get(SEARCHFORM_ID + ' select[name="scope"]').select(randomScope);
|
||||
cy.get(SEARCHFORM_ID + ' button.search-button').click();
|
||||
// Result should be the page URL should include that scope & page will reload with scope selected
|
||||
cy.url().should('include', 'scope=' + randomScope);
|
||||
cy.get(SEARCHFORM_ID + ' select[name="scope"]').find('option:selected').should('have.value', randomScope);
|
||||
});
|
||||
});
|
||||
|
||||
it('should redirect to the correct url when query was set and submit button was triggered', () => {
|
||||
const queryString = 'Another interesting query string';
|
||||
cy.visit('/search');
|
||||
@@ -63,4 +20,53 @@ describe('Search Page', () => {
|
||||
cy.url().should('include', 'query=' + encodeURI(queryString));
|
||||
});
|
||||
|
||||
it('should pass accessibility tests', () => {
|
||||
cy.visit('/search');
|
||||
|
||||
// <ds-search-page> tag must be loaded
|
||||
cy.get('ds-search-page').should('exist');
|
||||
|
||||
// Click each filter toggle to open *every* filter
|
||||
// (As we want to scan filter section for accessibility issues as well)
|
||||
cy.get('.filter-toggle').click({ multiple: true });
|
||||
|
||||
// Analyze <ds-search-page> for accessibility issues
|
||||
testA11y(
|
||||
{
|
||||
include: ['ds-search-page'],
|
||||
exclude: [
|
||||
['nouislider'] // Date filter slider is missing ARIA labels. Will be fixed by #1175
|
||||
],
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
// Search filters fail these two "moderate" impact rules
|
||||
'heading-order': { enabled: false },
|
||||
'landmark-unique': { enabled: false }
|
||||
}
|
||||
} as Options
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass accessibility tests in Grid view', () => {
|
||||
cy.visit('/search');
|
||||
|
||||
// Click to display grid view
|
||||
// TODO: These buttons should likely have an easier way to uniquely select
|
||||
cy.get('#search-sidebar-content > ds-view-mode-switch > .btn-group > [href="/search?spc.sf=score&spc.sd=DESC&view=grid"] > .fas').click();
|
||||
|
||||
// <ds-search-page> tag must be loaded
|
||||
cy.get('ds-search-page').should('exist');
|
||||
|
||||
// Analyze <ds-search-page> for accessibility issues
|
||||
testA11y('ds-search-page',
|
||||
{
|
||||
rules: {
|
||||
// Search filters fail these two "moderate" impact rules
|
||||
'heading-order': { enabled: false },
|
||||
'landmark-unique': { enabled: false }
|
||||
}
|
||||
} as Options
|
||||
);
|
||||
});
|
||||
});
|
||||
|
@@ -1,5 +1,16 @@
|
||||
// Plugins enable you to tap into, modify, or extend the internal behavior of Cypress
|
||||
// For more info, visit https://on.cypress.io/plugins-api
|
||||
/* tslint:disable:no-empty */
|
||||
module.exports = (on, config) => { };
|
||||
/* tslint:enable:no-empty */
|
||||
module.exports = (on, config) => {
|
||||
// Define "log" and "table" tasks, used for logging accessibility errors during CI
|
||||
// Borrowed from https://github.com/component-driven/cypress-axe#in-cypress-plugins-file
|
||||
on('task', {
|
||||
log(message: string) {
|
||||
console.log(message);
|
||||
return null;
|
||||
},
|
||||
table(message: string) {
|
||||
console.table(message);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
@@ -19,3 +19,8 @@
|
||||
// Import Cypress Axe tools for all tests
|
||||
// https://github.com/component-driven/cypress-axe
|
||||
import 'cypress-axe';
|
||||
|
||||
// Global constants used in tests
|
||||
export const TEST_COLLECTION = '282164f5-d325-4740-8dd1-fa4d6d3e7200';
|
||||
export const TEST_COMMUNITY = '0958c910-2037-42a9-81c7-dca80e3892b4';
|
||||
export const TEST_ENTITY_PUBLICATION = 'e98b0f27-5c19-49a0-960d-eb6ad5287067';
|
||||
|
44
cypress/support/utils.ts
Normal file
44
cypress/support/utils.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Result } from 'axe-core';
|
||||
import { Options } from 'cypress-axe';
|
||||
|
||||
// Log violations to terminal/commandline in a table format.
|
||||
// Uses 'log' and 'table' tasks defined in ../plugins/index.ts
|
||||
// Borrowed from https://github.com/component-driven/cypress-axe#in-your-spec-file
|
||||
function terminalLog(violations: Result[]) {
|
||||
cy.task(
|
||||
'log',
|
||||
`${violations.length} accessibility violation${violations.length === 1 ? '' : 's'} ${violations.length === 1 ? 'was' : 'were'} detected`
|
||||
);
|
||||
// pluck specific keys to keep the table readable
|
||||
const violationData = violations.map(
|
||||
({ id, impact, description, helpUrl, nodes }) => ({
|
||||
id,
|
||||
impact,
|
||||
description,
|
||||
helpUrl,
|
||||
nodes: nodes.length,
|
||||
html: nodes.map(node => node.html)
|
||||
})
|
||||
);
|
||||
|
||||
// Print violations as an array, since 'node.html' above often breaks table alignment
|
||||
cy.task('log', violationData);
|
||||
// Optionally, uncomment to print as a table
|
||||
// cy.task('table', violationData);
|
||||
|
||||
}
|
||||
|
||||
// Custom "testA11y()" method which checks accessibility using cypress-axe
|
||||
// while also ensuring any violations are logged to the terminal (see terminalLog above)
|
||||
// This method MUST be called after cy.visit(), as cy.injectAxe() must be called after page load
|
||||
export const testA11y = (context?: any, options?: Options) => {
|
||||
cy.injectAxe();
|
||||
cy.configureAxe({
|
||||
rules: [
|
||||
// Disable color contrast checks as they are inaccurate / result in a lot of false positives
|
||||
// See also open issues in axe-core: https://github.com/dequelabs/axe-core/labels/color%20contrast
|
||||
{ id: 'color-contrast', enabled: false },
|
||||
]
|
||||
});
|
||||
cy.checkA11y(context, options, terminalLog);
|
||||
};
|
@@ -6,7 +6,8 @@
|
||||
"compilerOptions": {
|
||||
"types": [
|
||||
"cypress",
|
||||
"cypress-axe"
|
||||
"cypress-axe",
|
||||
"node"
|
||||
]
|
||||
}
|
||||
}
|
@@ -44,6 +44,7 @@
|
||||
"clean": "yarn run clean:prod && yarn run clean:env && yarn run clean:node",
|
||||
"clean:env": "rimraf src/environments/environment.ts",
|
||||
"sync-i18n": "yarn run config:dev && ts-node --project ./tsconfig.ts-node.json scripts/sync-i18n-files.ts",
|
||||
"merge-i18n": "yarn run config:dev && ts-node --project ./tsconfig.ts-node.json scripts/merge-i18n-files.ts",
|
||||
"postinstall": "ngcc",
|
||||
"cypress:open": "cypress open",
|
||||
"cypress:run": "cypress run"
|
||||
@@ -151,7 +152,7 @@
|
||||
"copy-webpack-plugin": "^6.4.1",
|
||||
"css-loader": "3.4.0",
|
||||
"cssnano": "^4.1.10",
|
||||
"cypress": "8.3.1",
|
||||
"cypress": "8.6.0",
|
||||
"cypress-axe": "^0.13.0",
|
||||
"deep-freeze": "0.0.1",
|
||||
"dotenv": "^8.2.0",
|
||||
|
99
scripts/merge-i18n-files.ts
Normal file
99
scripts/merge-i18n-files.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { projectRoot} from '../webpack/helpers';
|
||||
const commander = require('commander');
|
||||
const fs = require('fs');
|
||||
const JSON5 = require('json5');
|
||||
const _cliProgress = require('cli-progress');
|
||||
const _ = require('lodash');
|
||||
|
||||
const program = new commander.Command();
|
||||
program.version('1.0.0', '-v, --version');
|
||||
|
||||
const LANGUAGE_FILES_LOCATION = 'src/assets/i18n';
|
||||
|
||||
parseCliInput();
|
||||
|
||||
/**
|
||||
* Purpose: Allows customization of i18n labels from within themes
|
||||
* e.g. Customize the label "menu.section.browse_global" to display "Browse DSpace" rather than "All of DSpace"
|
||||
*
|
||||
* This script uses the i18n files found in a source directory to override settings in files with the same
|
||||
* name in a destination directory. Only the i18n labels to be overridden need be in the source files.
|
||||
*
|
||||
* Execution (using custom theme):
|
||||
* ```
|
||||
* yarn merge-i18n -s src/themes/custom/assets/i18n
|
||||
* ```
|
||||
*
|
||||
* Input parameters:
|
||||
* * Output directory: The directory in which the original i18n files are stored
|
||||
* - Defaults to src/assets/i18n (the default i18n file location)
|
||||
* - This is where the final output files will be written
|
||||
* * Source directory: The directory with override files
|
||||
* - Required
|
||||
* - Recommended to place override files in the theme directory under assets/i18n (but this is not required)
|
||||
* - Files must have matching names in both source and destination directories, for example:
|
||||
* en.json5 in the source directory will be merged with en.json5 in the destination directory
|
||||
* fr.json5 in the source directory will be merged with fr.json5 in the destination directory
|
||||
*/
|
||||
function parseCliInput() {
|
||||
program
|
||||
.option('-d, --output-dir <output-dir>', 'output dir when running script on all language files', projectRoot(LANGUAGE_FILES_LOCATION))
|
||||
.option('-s, --source-dir <source-dir>', 'source dir of transalations to be merged')
|
||||
.usage('(-s <source-dir> [-d <output-dir>])')
|
||||
.parse(process.argv);
|
||||
|
||||
if (program.outputDir && program.sourceDir) {
|
||||
if (!fs.existsSync(program.outputDir) && !fs.lstatSync(program.outputDir).isDirectory() ) {
|
||||
console.error('Output does not exist or is not a directory.');
|
||||
console.log(program.outputHelp());
|
||||
process.exit(1);
|
||||
}
|
||||
if (!fs.existsSync(program.sourceDir) && !fs.lstatSync(program.sourceDir).isDirectory() ) {
|
||||
console.error('Source does not exist or is not a directory.');
|
||||
console.log(program.outputHelp());
|
||||
process.exit(1);
|
||||
}
|
||||
fs.readdirSync(projectRoot(program.sourceDir)).forEach(file => {
|
||||
if (fs.existsSync(program.outputDir + '/' + file) ) {
|
||||
console.log('Merging: ' + program.outputDir + '/' + file + ' with ' + program.sourceDir + '/' + file);
|
||||
mergeFileWithSource(program.sourceDir + '/' + file, program.outputDir + '/' + file);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.error('Source or Output parameter is missing.');
|
||||
console.log(program.outputHelp());
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads source file and output file to merge the contents
|
||||
* > Iterates over the source file keys
|
||||
* > Updates values for each key and adds new keys as needed
|
||||
* > Updates the output file with the new merged json
|
||||
* @param pathToSourceFile Valid path to source file to merge from
|
||||
* @param pathToOutputFile Valid path to merge and write output
|
||||
*/
|
||||
function mergeFileWithSource(pathToSourceFile, pathToOutputFile) {
|
||||
const progressBar = new _cliProgress.SingleBar({}, _cliProgress.Presets.shades_classic);
|
||||
progressBar.start(100, 0);
|
||||
|
||||
const sourceFile = fs.readFileSync(pathToSourceFile, 'utf8');
|
||||
progressBar.update(10);
|
||||
const outputFile = fs.readFileSync(pathToOutputFile, 'utf8');
|
||||
progressBar.update(20);
|
||||
|
||||
const parsedSource = JSON5.parse(sourceFile);
|
||||
progressBar.update(30);
|
||||
const parsedOutput = JSON5.parse(outputFile);
|
||||
progressBar.update(40);
|
||||
|
||||
for (const key of Object.keys(parsedSource)) {
|
||||
parsedOutput[key] = parsedSource[key];
|
||||
}
|
||||
progressBar.update(80);
|
||||
fs.writeFileSync(pathToOutputFile,JSON5.stringify(parsedOutput,{ space:'\n ', quote: '"' }), { encoding:'utf8' });
|
||||
|
||||
progressBar.update(100);
|
||||
progressBar.stop();
|
||||
}
|
@@ -52,15 +52,17 @@
|
||||
<table id="groups" class="table table-striped table-hover table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{{messagePrefix + '.table.id' | translate}}</th>
|
||||
<th scope="col">{{messagePrefix + '.table.name' | translate}}</th>
|
||||
<th scope="col" class="align-middle">{{messagePrefix + '.table.id' | translate}}</th>
|
||||
<th scope="col" class="align-middle">{{messagePrefix + '.table.name' | translate}}</th>
|
||||
<th scope="col" class="align-middle">{{messagePrefix + '.table.collectionOrCommunity' | translate}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let group of (groups | async)?.payload?.page">
|
||||
<td>{{group.id}}</td>
|
||||
<td><a (click)="groupsDataService.startEditingNewGroup(group)"
|
||||
<td class="align-middle">{{group.id}}</td>
|
||||
<td class="align-middle"><a (click)="groupsDataService.startEditingNewGroup(group)"
|
||||
[routerLink]="[groupsDataService.getGroupEditPageRouterLink(group)]">{{group.name}}</a></td>
|
||||
<td class="align-middle">{{(group.object | async)?.payload?.name}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
@@ -28,6 +28,9 @@ import { createPaginatedList } from '../../../shared/testing/utils.test';
|
||||
import { RequestService } from '../../../core/data/request.service';
|
||||
import { PaginationService } from '../../../core/pagination/pagination.service';
|
||||
import { PaginationServiceStub } from '../../../shared/testing/pagination-service.stub';
|
||||
import { FormArray, FormControl, FormGroup,Validators, NG_VALIDATORS, NG_ASYNC_VALIDATORS } from '@angular/forms';
|
||||
import { ValidateEmailNotTaken } from './validators/email-taken.validator';
|
||||
|
||||
|
||||
describe('EPersonFormComponent', () => {
|
||||
let component: EPersonFormComponent;
|
||||
@@ -99,12 +102,78 @@ describe('EPersonFormComponent', () => {
|
||||
}
|
||||
});
|
||||
return createSuccessfulRemoteDataObject$(ePerson);
|
||||
},
|
||||
getEPersonByEmail(email): Observable<RemoteData<EPerson>> {
|
||||
return createSuccessfulRemoteDataObject$(null);
|
||||
}
|
||||
};
|
||||
builderService = getMockFormBuilderService();
|
||||
builderService = Object.assign(getMockFormBuilderService(),{
|
||||
createFormGroup(formModel, options = null) {
|
||||
const controls = {};
|
||||
formModel.forEach( model => {
|
||||
model.parent = parent;
|
||||
const controlModel = model;
|
||||
const controlState = { value: controlModel.value, disabled: controlModel.disabled };
|
||||
const controlOptions = this.createAbstractControlOptions(controlModel.validators, controlModel.asyncValidators, controlModel.updateOn);
|
||||
controls[model.id] = new FormControl(controlState, controlOptions);
|
||||
});
|
||||
return new FormGroup(controls, options);
|
||||
},
|
||||
createAbstractControlOptions(validatorsConfig = null, asyncValidatorsConfig = null, updateOn = null) {
|
||||
return {
|
||||
validators: validatorsConfig !== null ? this.getValidators(validatorsConfig) : null,
|
||||
};
|
||||
},
|
||||
getValidators(validatorsConfig) {
|
||||
return this.getValidatorFns(validatorsConfig);
|
||||
},
|
||||
getValidatorFns(validatorsConfig, validatorsToken = this._NG_VALIDATORS) {
|
||||
let validatorFns = [];
|
||||
if (this.isObject(validatorsConfig)) {
|
||||
validatorFns = Object.keys(validatorsConfig).map(validatorConfigKey => {
|
||||
const validatorConfigValue = validatorsConfig[validatorConfigKey];
|
||||
if (this.isValidatorDescriptor(validatorConfigValue)) {
|
||||
const descriptor = validatorConfigValue;
|
||||
return this.getValidatorFn(descriptor.name, descriptor.args, validatorsToken);
|
||||
}
|
||||
return this.getValidatorFn(validatorConfigKey, validatorConfigValue, validatorsToken);
|
||||
});
|
||||
}
|
||||
return validatorFns;
|
||||
},
|
||||
getValidatorFn(validatorName, validatorArgs = null, validatorsToken = this._NG_VALIDATORS) {
|
||||
let validatorFn;
|
||||
if (Validators.hasOwnProperty(validatorName)) { // Built-in Angular Validators
|
||||
validatorFn = Validators[validatorName];
|
||||
} else { // Custom Validators
|
||||
if (this._DYNAMIC_VALIDATORS && this._DYNAMIC_VALIDATORS.has(validatorName)) {
|
||||
validatorFn = this._DYNAMIC_VALIDATORS.get(validatorName);
|
||||
} else if (validatorsToken) {
|
||||
validatorFn = validatorsToken.find(validator => validator.name === validatorName);
|
||||
}
|
||||
}
|
||||
if (validatorFn === undefined) { // throw when no validator could be resolved
|
||||
throw new Error(`validator '${validatorName}' is not provided via NG_VALIDATORS, NG_ASYNC_VALIDATORS or DYNAMIC_FORM_VALIDATORS`);
|
||||
}
|
||||
if (validatorArgs !== null) {
|
||||
return validatorFn(validatorArgs);
|
||||
}
|
||||
return validatorFn;
|
||||
},
|
||||
isValidatorDescriptor(value) {
|
||||
if (this.isObject(value)) {
|
||||
return value.hasOwnProperty('name') && value.hasOwnProperty('args');
|
||||
}
|
||||
return false;
|
||||
},
|
||||
isObject(value) {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
});
|
||||
authService = new AuthServiceStub();
|
||||
authorizationService = jasmine.createSpyObj('authorizationService', {
|
||||
isAuthorized: observableOf(true)
|
||||
isAuthorized: observableOf(true),
|
||||
|
||||
});
|
||||
groupsDataService = jasmine.createSpyObj('groupsDataService', {
|
||||
findAllByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])),
|
||||
@@ -146,6 +215,131 @@ describe('EPersonFormComponent', () => {
|
||||
expect(component).toBeDefined();
|
||||
});
|
||||
|
||||
describe('check form validation', () => {
|
||||
let firstName;
|
||||
let lastName;
|
||||
let email;
|
||||
let canLogIn;
|
||||
let requireCertificate;
|
||||
|
||||
let expected;
|
||||
beforeEach(() => {
|
||||
firstName = 'testName';
|
||||
lastName = 'testLastName';
|
||||
email = 'testEmail@test.com';
|
||||
canLogIn = false;
|
||||
requireCertificate = false;
|
||||
|
||||
expected = Object.assign(new EPerson(), {
|
||||
metadata: {
|
||||
'eperson.firstname': [
|
||||
{
|
||||
value: firstName
|
||||
}
|
||||
],
|
||||
'eperson.lastname': [
|
||||
{
|
||||
value: lastName
|
||||
},
|
||||
],
|
||||
},
|
||||
email: email,
|
||||
canLogIn: canLogIn,
|
||||
requireCertificate: requireCertificate,
|
||||
});
|
||||
spyOn(component.submitForm, 'emit');
|
||||
component.canLogIn.value = canLogIn;
|
||||
component.requireCertificate.value = requireCertificate;
|
||||
|
||||
fixture.detectChanges();
|
||||
component.initialisePage();
|
||||
fixture.detectChanges();
|
||||
});
|
||||
describe('firstName, lastName and email should be required', () => {
|
||||
it('form should be invalid because the firstName is required', waitForAsync(() => {
|
||||
fixture.whenStable().then(() => {
|
||||
expect(component.formGroup.controls.firstName.valid).toBeFalse();
|
||||
expect(component.formGroup.controls.firstName.errors.required).toBeTrue();
|
||||
});
|
||||
}));
|
||||
it('form should be invalid because the lastName is required', waitForAsync(() => {
|
||||
fixture.whenStable().then(() => {
|
||||
expect(component.formGroup.controls.lastName.valid).toBeFalse();
|
||||
expect(component.formGroup.controls.lastName.errors.required).toBeTrue();
|
||||
});
|
||||
}));
|
||||
it('form should be invalid because the email is required', waitForAsync(() => {
|
||||
fixture.whenStable().then(() => {
|
||||
expect(component.formGroup.controls.email.valid).toBeFalse();
|
||||
expect(component.formGroup.controls.email.errors.required).toBeTrue();
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
describe('after inserting information firstName,lastName and email not required', () => {
|
||||
beforeEach(() => {
|
||||
component.formGroup.controls.firstName.setValue('test');
|
||||
component.formGroup.controls.lastName.setValue('test');
|
||||
component.formGroup.controls.email.setValue('test@test.com');
|
||||
fixture.detectChanges();
|
||||
});
|
||||
it('firstName should be valid because the firstName is set', waitForAsync(() => {
|
||||
fixture.whenStable().then(() => {
|
||||
expect(component.formGroup.controls.firstName.valid).toBeTrue();
|
||||
expect(component.formGroup.controls.firstName.errors).toBeNull();
|
||||
});
|
||||
}));
|
||||
it('lastName should be valid because the lastName is set', waitForAsync(() => {
|
||||
fixture.whenStable().then(() => {
|
||||
expect(component.formGroup.controls.lastName.valid).toBeTrue();
|
||||
expect(component.formGroup.controls.lastName.errors).toBeNull();
|
||||
});
|
||||
}));
|
||||
it('email should be valid because the email is set', waitForAsync(() => {
|
||||
fixture.whenStable().then(() => {
|
||||
expect(component.formGroup.controls.email.valid).toBeTrue();
|
||||
expect(component.formGroup.controls.email.errors).toBeNull();
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
|
||||
describe('after inserting email wrong should show pattern validation error', () => {
|
||||
beforeEach(() => {
|
||||
component.formGroup.controls.email.setValue('test@test');
|
||||
fixture.detectChanges();
|
||||
});
|
||||
it('email should not be valid because the email pattern', waitForAsync(() => {
|
||||
fixture.whenStable().then(() => {
|
||||
expect(component.formGroup.controls.email.valid).toBeFalse();
|
||||
expect(component.formGroup.controls.email.errors.pattern).toBeTruthy();
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
describe('after already utilized email', () => {
|
||||
beforeEach(() => {
|
||||
const ePersonServiceWithEperson = Object.assign(ePersonDataServiceStub,{
|
||||
getEPersonByEmail(): Observable<RemoteData<EPerson>> {
|
||||
return createSuccessfulRemoteDataObject$(EPersonMock);
|
||||
}
|
||||
});
|
||||
component.formGroup.controls.email.setValue('test@test.com');
|
||||
component.formGroup.controls.email.setAsyncValidators(ValidateEmailNotTaken.createValidator(ePersonServiceWithEperson));
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('email should not be valid because email is already taken', waitForAsync(() => {
|
||||
fixture.whenStable().then(() => {
|
||||
expect(component.formGroup.controls.email.valid).toBeFalse();
|
||||
expect(component.formGroup.controls.email.errors.emailTaken).toBeTruthy();
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
|
||||
|
||||
});
|
||||
describe('when submitting the form', () => {
|
||||
let firstName;
|
||||
let lastName;
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { Component, EventEmitter, OnDestroy, OnInit, Output } from '@angular/core';
|
||||
import { ChangeDetectorRef, Component, EventEmitter, OnDestroy, OnInit, Output } from '@angular/core';
|
||||
import { FormGroup } from '@angular/forms';
|
||||
import {
|
||||
DynamicCheckboxModel,
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from '@ng-dynamic-forms/core';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { combineLatest as observableCombineLatest, Observable, of as observableOf, Subscription } from 'rxjs';
|
||||
import { switchMap, take } from 'rxjs/operators';
|
||||
import { debounceTime, switchMap, take } from 'rxjs/operators';
|
||||
import { PaginatedList } from '../../../core/data/paginated-list.model';
|
||||
import { RemoteData } from '../../../core/data/remote-data';
|
||||
import { EPersonDataService } from '../../../core/eperson/eperson-data.service';
|
||||
@@ -32,10 +32,12 @@ import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { RequestService } from '../../../core/data/request.service';
|
||||
import { NoContent } from '../../../core/shared/NoContent.model';
|
||||
import { PaginationService } from '../../../core/pagination/pagination.service';
|
||||
import { followLink } from '../../../shared/utils/follow-link-config.model';
|
||||
import { ValidateEmailNotTaken } from './validators/email-taken.validator';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-eperson-form',
|
||||
templateUrl: './eperson-form.component.html'
|
||||
templateUrl: './eperson-form.component.html',
|
||||
})
|
||||
/**
|
||||
* A form used for creating and editing EPeople
|
||||
@@ -160,7 +162,13 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
|
||||
*/
|
||||
isImpersonated = false;
|
||||
|
||||
constructor(public epersonService: EPersonDataService,
|
||||
/**
|
||||
* Subscription to email field value change
|
||||
*/
|
||||
emailValueChangeSubscribe: Subscription;
|
||||
|
||||
constructor(protected changeDetectorRef: ChangeDetectorRef,
|
||||
public epersonService: EPersonDataService,
|
||||
public groupsDataService: GroupDataService,
|
||||
private formBuilderService: FormBuilderService,
|
||||
private translateService: TranslateService,
|
||||
@@ -186,6 +194,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
|
||||
* This method will initialise the page
|
||||
*/
|
||||
initialisePage() {
|
||||
|
||||
observableCombineLatest(
|
||||
this.translateService.get(`${this.messagePrefix}.firstName`),
|
||||
this.translateService.get(`${this.messagePrefix}.lastName`),
|
||||
@@ -218,9 +227,13 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
|
||||
name: 'email',
|
||||
validators: {
|
||||
required: null,
|
||||
pattern: '^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,4}$'
|
||||
pattern: '^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,4}$',
|
||||
},
|
||||
required: true,
|
||||
errorMessages: {
|
||||
emailTaken: 'error.validation.emailTaken',
|
||||
pattern: 'error.validation.NotValidEmail'
|
||||
},
|
||||
hint: emailHint
|
||||
});
|
||||
this.canLogIn = new DynamicCheckboxModel(
|
||||
@@ -259,11 +272,18 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
|
||||
canLogIn: eperson != null ? eperson.canLogIn : true,
|
||||
requireCertificate: eperson != null ? eperson.requireCertificate : false
|
||||
});
|
||||
|
||||
if (eperson === null && !!this.formGroup.controls.email) {
|
||||
this.formGroup.controls.email.setAsyncValidators(ValidateEmailNotTaken.createValidator(this.epersonService));
|
||||
this.emailValueChangeSubscribe = this.email.valueChanges.pipe(debounceTime(300)).subscribe(() => {
|
||||
this.changeDetectorRef.detectChanges();
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
||||
const activeEPerson$ = this.epersonService.getActiveEPerson();
|
||||
|
||||
this.groups = activeEPerson$.pipe(
|
||||
this.groups = activeEPerson$.pipe(
|
||||
switchMap((eperson) => {
|
||||
return observableCombineLatest([observableOf(eperson), this.paginationService.getFindListOptions(this.config.id, {
|
||||
currentPage: 1,
|
||||
@@ -272,14 +292,20 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
|
||||
}),
|
||||
switchMap(([eperson, findListOptions]) => {
|
||||
if (eperson != null) {
|
||||
return this.groupsDataService.findAllByHref(eperson._links.groups.href, findListOptions);
|
||||
return this.groupsDataService.findAllByHref(eperson._links.groups.href, findListOptions, true, true, followLink('object'));
|
||||
}
|
||||
return observableOf(undefined);
|
||||
})
|
||||
);
|
||||
|
||||
this.canImpersonate$ = activeEPerson$.pipe(
|
||||
switchMap((eperson) => this.authorizationService.isAuthorized(FeatureID.LoginOnBehalfOf, hasValue(eperson) ? eperson.self : undefined))
|
||||
switchMap((eperson) => {
|
||||
if (hasValue(eperson)) {
|
||||
return this.authorizationService.isAuthorized(FeatureID.LoginOnBehalfOf, eperson.self);
|
||||
} else {
|
||||
return observableOf(false);
|
||||
}
|
||||
})
|
||||
);
|
||||
this.canDelete$ = activeEPerson$.pipe(
|
||||
switchMap((eperson) => this.authorizationService.isAuthorized(FeatureID.CanDelete, hasValue(eperson) ? eperson.self : undefined))
|
||||
@@ -342,10 +368,10 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
|
||||
getFirstCompletedRemoteData()
|
||||
).subscribe((rd: RemoteData<EPerson>) => {
|
||||
if (rd.hasSucceeded) {
|
||||
this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.created.success', {name: ePersonToCreate.name}));
|
||||
this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.created.success', { name: ePersonToCreate.name }));
|
||||
this.submitForm.emit(ePersonToCreate);
|
||||
} else {
|
||||
this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.created.failure', {name: ePersonToCreate.name}));
|
||||
this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.created.failure', { name: ePersonToCreate.name }));
|
||||
this.cancelForm.emit();
|
||||
}
|
||||
});
|
||||
@@ -381,10 +407,10 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
|
||||
const response = this.epersonService.updateEPerson(editedEperson);
|
||||
response.pipe(getFirstCompletedRemoteData()).subscribe((rd: RemoteData<EPerson>) => {
|
||||
if (rd.hasSucceeded) {
|
||||
this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.edited.success', {name: editedEperson.name}));
|
||||
this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.edited.success', { name: editedEperson.name }));
|
||||
this.submitForm.emit(editedEperson);
|
||||
} else {
|
||||
this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.edited.failure', {name: editedEperson.name}));
|
||||
this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.edited.failure', { name: editedEperson.name }));
|
||||
this.cancelForm.emit();
|
||||
}
|
||||
});
|
||||
@@ -394,6 +420,87 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Event triggered when the user changes page
|
||||
* @param event
|
||||
*/
|
||||
onPageChange(event) {
|
||||
this.updateGroups({
|
||||
currentPage: event,
|
||||
elementsPerPage: this.config.pageSize
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start impersonating the EPerson
|
||||
*/
|
||||
impersonate() {
|
||||
this.authService.impersonate(this.epersonInitial.id);
|
||||
this.isImpersonated = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the EPerson from the Repository. The EPerson will be the only that this form is showing.
|
||||
* It'll either show a success or error message depending on whether the delete was successful or not.
|
||||
*/
|
||||
delete() {
|
||||
this.epersonService.getActiveEPerson().pipe(take(1)).subscribe((eperson: EPerson) => {
|
||||
const modalRef = this.modalService.open(ConfirmationModalComponent);
|
||||
modalRef.componentInstance.dso = eperson;
|
||||
modalRef.componentInstance.headerLabel = 'confirmation-modal.delete-eperson.header';
|
||||
modalRef.componentInstance.infoLabel = 'confirmation-modal.delete-eperson.info';
|
||||
modalRef.componentInstance.cancelLabel = 'confirmation-modal.delete-eperson.cancel';
|
||||
modalRef.componentInstance.confirmLabel = 'confirmation-modal.delete-eperson.confirm';
|
||||
modalRef.componentInstance.brandColor = 'danger';
|
||||
modalRef.componentInstance.confirmIcon = 'fas fa-trash';
|
||||
modalRef.componentInstance.response.pipe(take(1)).subscribe((confirm: boolean) => {
|
||||
if (confirm) {
|
||||
if (hasValue(eperson.id)) {
|
||||
this.epersonService.deleteEPerson(eperson).pipe(getFirstCompletedRemoteData()).subscribe((restResponse: RemoteData<NoContent>) => {
|
||||
if (restResponse.hasSucceeded) {
|
||||
this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', { name: eperson.name }));
|
||||
this.submitForm.emit();
|
||||
} else {
|
||||
this.notificationsService.error('Error occured when trying to delete EPerson with id: ' + eperson.id + ' with code: ' + restResponse.statusCode + ' and message: ' + restResponse.errorMessage);
|
||||
}
|
||||
this.cancelForm.emit();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop impersonating the EPerson
|
||||
*/
|
||||
stopImpersonating() {
|
||||
this.authService.stopImpersonatingAndRefresh();
|
||||
this.isImpersonated = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the current edit when component is destroyed & unsub all subscriptions
|
||||
*/
|
||||
ngOnDestroy(): void {
|
||||
this.onCancel();
|
||||
this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe());
|
||||
this.paginationService.clearPagination(this.config.id);
|
||||
if (hasValue(this.emailValueChangeSubscribe)) {
|
||||
this.emailValueChangeSubscribe.unsubscribe();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This method will ensure that the page gets reset and that the cache is cleared
|
||||
*/
|
||||
reset() {
|
||||
this.epersonService.getActiveEPerson().pipe(take(1)).subscribe((eperson: EPerson) => {
|
||||
this.requestService.removeByHrefSubstring(eperson.self);
|
||||
});
|
||||
this.initialisePage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks for the given ePerson if there is already an ePerson in the system with that email
|
||||
* and shows notification if this is the case
|
||||
@@ -416,17 +523,6 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Event triggered when the user changes page
|
||||
* @param event
|
||||
*/
|
||||
onPageChange(event) {
|
||||
this.updateGroups({
|
||||
currentPage: event,
|
||||
elementsPerPage: this.config.pageSize
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the list of groups by fetching it from the rest api or cache
|
||||
*/
|
||||
@@ -435,71 +531,4 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
|
||||
this.groups = this.groupsDataService.findAllByHref(eperson._links.groups.href, options);
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Start impersonating the EPerson
|
||||
*/
|
||||
impersonate() {
|
||||
this.authService.impersonate(this.epersonInitial.id);
|
||||
this.isImpersonated = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the EPerson from the Repository. The EPerson will be the only that this form is showing.
|
||||
* It'll either show a success or error message depending on whether the delete was successful or not.
|
||||
*/
|
||||
delete() {
|
||||
this.epersonService.getActiveEPerson().pipe(take(1)).subscribe((eperson: EPerson) => {
|
||||
const modalRef = this.modalService.open(ConfirmationModalComponent);
|
||||
modalRef.componentInstance.dso = eperson;
|
||||
modalRef.componentInstance.headerLabel = 'confirmation-modal.delete-eperson.header';
|
||||
modalRef.componentInstance.infoLabel = 'confirmation-modal.delete-eperson.info';
|
||||
modalRef.componentInstance.cancelLabel = 'confirmation-modal.delete-eperson.cancel';
|
||||
modalRef.componentInstance.confirmLabel = 'confirmation-modal.delete-eperson.confirm';
|
||||
modalRef.componentInstance.brandColor = 'danger';
|
||||
modalRef.componentInstance.confirmIcon = 'fas fa-trash';
|
||||
modalRef.componentInstance.response.pipe(take(1)).subscribe((confirm: boolean) => {
|
||||
if (confirm) {
|
||||
if (hasValue(eperson.id)) {
|
||||
this.epersonService.deleteEPerson(eperson).pipe(getFirstCompletedRemoteData()).subscribe((restResponse: RemoteData<NoContent>) => {
|
||||
if (restResponse.hasSucceeded) {
|
||||
this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', { name: eperson.name }));
|
||||
this.submitForm.emit();
|
||||
} else {
|
||||
this.notificationsService.error('Error occured when trying to delete EPerson with id: ' + eperson.id + ' with code: ' + restResponse.statusCode + ' and message: ' + restResponse.errorMessage);
|
||||
}
|
||||
this.cancelForm.emit();
|
||||
});
|
||||
}}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop impersonating the EPerson
|
||||
*/
|
||||
stopImpersonating() {
|
||||
this.authService.stopImpersonatingAndRefresh();
|
||||
this.isImpersonated = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the current edit when component is destroyed & unsub all subscriptions
|
||||
*/
|
||||
ngOnDestroy(): void {
|
||||
this.onCancel();
|
||||
this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe());
|
||||
this.paginationService.clearPagination(this.config.id);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* This method will ensure that the page gets reset and that the cache is cleared
|
||||
*/
|
||||
reset() {
|
||||
this.epersonService.getActiveEPerson().pipe(take(1)).subscribe((eperson: EPerson) => {
|
||||
this.requestService.removeByHrefSubstring(eperson.self);
|
||||
});
|
||||
this.initialisePage();
|
||||
}
|
||||
}
|
||||
|
@@ -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;
|
||||
})
|
||||
);
|
||||
};
|
||||
}
|
||||
}
|
@@ -14,9 +14,9 @@ import {
|
||||
combineLatest as observableCombineLatest,
|
||||
Observable,
|
||||
of as observableOf,
|
||||
Subscription
|
||||
Subscription,
|
||||
} from 'rxjs';
|
||||
import { catchError, map, switchMap, take } from 'rxjs/operators';
|
||||
import { catchError, map, switchMap, take, filter } from 'rxjs/operators';
|
||||
import { getCollectionEditRolesRoute } from '../../../collection-page/collection-page-routing-paths';
|
||||
import { getCommunityEditRolesRoute } from '../../../community-page/community-page-routing-paths';
|
||||
import { DSpaceObjectDataService } from '../../../core/data/dspace-object-data.service';
|
||||
@@ -34,7 +34,8 @@ import { DSpaceObject } from '../../../core/shared/dspace-object.model';
|
||||
import {
|
||||
getRemoteDataPayload,
|
||||
getFirstSucceededRemoteData,
|
||||
getFirstCompletedRemoteData
|
||||
getFirstCompletedRemoteData,
|
||||
getFirstSucceededRemoteDataPayload
|
||||
} from '../../../core/shared/operators';
|
||||
import { AlertType } from '../../../shared/alert/aletr-type';
|
||||
import { ConfirmationModalComponent } from '../../../shared/confirmation-modal/confirmation-modal.component';
|
||||
@@ -65,6 +66,7 @@ export class GroupFormComponent implements OnInit, OnDestroy {
|
||||
* Dynamic models for the inputs of form
|
||||
*/
|
||||
groupName: DynamicInputModel;
|
||||
groupCommunity: DynamicInputModel;
|
||||
groupDescription: DynamicTextAreaModel;
|
||||
|
||||
/**
|
||||
@@ -125,16 +127,16 @@ export class GroupFormComponent implements OnInit, OnDestroy {
|
||||
public AlertTypeEnum = AlertType;
|
||||
|
||||
constructor(public groupDataService: GroupDataService,
|
||||
private ePersonDataService: EPersonDataService,
|
||||
private dSpaceObjectDataService: DSpaceObjectDataService,
|
||||
private formBuilderService: FormBuilderService,
|
||||
private translateService: TranslateService,
|
||||
private notificationsService: NotificationsService,
|
||||
private route: ActivatedRoute,
|
||||
protected router: Router,
|
||||
private authorizationService: AuthorizationDataService,
|
||||
private modalService: NgbModal,
|
||||
public requestService: RequestService) {
|
||||
private ePersonDataService: EPersonDataService,
|
||||
private dSpaceObjectDataService: DSpaceObjectDataService,
|
||||
private formBuilderService: FormBuilderService,
|
||||
private translateService: TranslateService,
|
||||
private notificationsService: NotificationsService,
|
||||
private route: ActivatedRoute,
|
||||
protected router: Router,
|
||||
private authorizationService: AuthorizationDataService,
|
||||
private modalService: NgbModal,
|
||||
public requestService: RequestService) {
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
@@ -160,8 +162,9 @@ export class GroupFormComponent implements OnInit, OnDestroy {
|
||||
);
|
||||
observableCombineLatest(
|
||||
this.translateService.get(`${this.messagePrefix}.groupName`),
|
||||
this.translateService.get(`${this.messagePrefix}.groupCommunity`),
|
||||
this.translateService.get(`${this.messagePrefix}.groupDescription`)
|
||||
).subscribe(([groupName, groupDescription]) => {
|
||||
).subscribe(([groupName, groupCommunity, groupDescription]) => {
|
||||
this.groupName = new DynamicInputModel({
|
||||
id: 'groupName',
|
||||
label: groupName,
|
||||
@@ -171,6 +174,13 @@ export class GroupFormComponent implements OnInit, OnDestroy {
|
||||
},
|
||||
required: true,
|
||||
});
|
||||
this.groupCommunity = new DynamicInputModel({
|
||||
id: 'groupCommunity',
|
||||
label: groupCommunity,
|
||||
name: 'groupCommunity',
|
||||
required: false,
|
||||
readOnly: true,
|
||||
});
|
||||
this.groupDescription = new DynamicTextAreaModel({
|
||||
id: 'groupDescription',
|
||||
label: groupDescription,
|
||||
@@ -185,17 +195,36 @@ export class GroupFormComponent implements OnInit, OnDestroy {
|
||||
this.subs.push(
|
||||
observableCombineLatest(
|
||||
this.groupDataService.getActiveGroup(),
|
||||
this.canEdit$
|
||||
).subscribe(([activeGroup, canEdit]) => {
|
||||
this.canEdit$,
|
||||
this.groupDataService.getActiveGroup()
|
||||
.pipe(filter((activeGroup) => hasValue(activeGroup)),switchMap((activeGroup) => this.getLinkedDSO(activeGroup).pipe(getFirstSucceededRemoteDataPayload())))
|
||||
).subscribe(([activeGroup, canEdit, linkedObject]) => {
|
||||
|
||||
if (activeGroup != null) {
|
||||
this.groupBeingEdited = activeGroup;
|
||||
this.formGroup.patchValue({
|
||||
groupName: activeGroup != null ? activeGroup.name : '',
|
||||
groupDescription: activeGroup != null ? activeGroup.firstMetadataValue('dc.description') : '',
|
||||
});
|
||||
if (!canEdit || activeGroup.permanent) {
|
||||
this.formGroup.disable();
|
||||
|
||||
if (linkedObject?.name) {
|
||||
this.formBuilderService.insertFormGroupControl(1, this.formGroup, this.formModel, this.groupCommunity);
|
||||
this.formGroup.patchValue({
|
||||
groupName: activeGroup.name,
|
||||
groupCommunity: linkedObject?.name ?? '',
|
||||
groupDescription: activeGroup.firstMetadataValue('dc.description'),
|
||||
});
|
||||
} else {
|
||||
this.formModel = [
|
||||
this.groupName,
|
||||
this.groupDescription,
|
||||
];
|
||||
this.formGroup.patchValue({
|
||||
groupName: activeGroup.name,
|
||||
groupDescription: activeGroup.firstMetadataValue('dc.description'),
|
||||
});
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (!canEdit || activeGroup.permanent) {
|
||||
this.formGroup.disable();
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -417,11 +446,7 @@ export class GroupFormComponent implements OnInit, OnDestroy {
|
||||
if (hasValue(group) && hasValue(group._links.object.href)) {
|
||||
return this.getLinkedDSO(group).pipe(
|
||||
map((rd: RemoteData<DSpaceObject>) => {
|
||||
if (hasValue(rd) && hasValue(rd.payload)) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
return hasValue(rd) && hasValue(rd.payload);
|
||||
}),
|
||||
catchError(() => observableOf(false)),
|
||||
);
|
||||
|
@@ -38,17 +38,22 @@
|
||||
<table id="epersonsSearch" class="table table-striped table-hover table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{{messagePrefix + '.table.id' | translate}}</th>
|
||||
<th scope="col">{{messagePrefix + '.table.name' | translate}}</th>
|
||||
<th>{{messagePrefix + '.table.edit' | translate}}</th>
|
||||
<th scope="col" class="align-middle">{{messagePrefix + '.table.id' | translate}}</th>
|
||||
<th scope="col" class="align-middle">{{messagePrefix + '.table.name' | translate}}</th>
|
||||
<th scope="col" class="align-middle">{{messagePrefix + '.table.identity' | translate}}</th>
|
||||
<th class="align-middle">{{messagePrefix + '.table.edit' | translate}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let ePerson of (ePeopleSearchDtos | async)?.page">
|
||||
<td>{{ePerson.eperson.id}}</td>
|
||||
<td><a (click)="ePersonDataService.startEditingNewEPerson(ePerson.eperson)"
|
||||
<td class="align-middle">{{ePerson.eperson.id}}</td>
|
||||
<td class="align-middle"><a (click)="ePersonDataService.startEditingNewEPerson(ePerson.eperson)"
|
||||
[routerLink]="[ePersonDataService.getEPeoplePageRouterLink()]">{{ePerson.eperson.name}}</a></td>
|
||||
<td>
|
||||
<td class="align-middle">
|
||||
{{messagePrefix + '.table.email' | translate}}: {{ ePerson.eperson.email ? ePerson.eperson.email : '-' }}<br/>
|
||||
{{messagePrefix + '.table.netid' | translate}}: {{ ePerson.eperson.netid ? ePerson.eperson.netid : '-' }}
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
<div class="btn-group edit-field">
|
||||
<button *ngIf="(ePerson.memberOfGroup)"
|
||||
(click)="deleteMemberFromGroup(ePerson)"
|
||||
@@ -91,17 +96,22 @@
|
||||
<table id="ePeopleMembersOfGroup" class="table table-striped table-hover table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{{messagePrefix + '.table.id' | translate}}</th>
|
||||
<th scope="col">{{messagePrefix + '.table.name' | translate}}</th>
|
||||
<th>{{messagePrefix + '.table.edit' | translate}}</th>
|
||||
<th scope="col" class="align-middle">{{messagePrefix + '.table.id' | translate}}</th>
|
||||
<th scope="col" class="align-middle">{{messagePrefix + '.table.name' | translate}}</th>
|
||||
<th scope="col" class="align-middle">{{messagePrefix + '.table.identity' | translate}}</th>
|
||||
<th class="align-middle">{{messagePrefix + '.table.edit' | translate}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let ePerson of (ePeopleMembersOfGroupDtos | async)?.page">
|
||||
<td>{{ePerson.eperson.id}}</td>
|
||||
<td><a (click)="ePersonDataService.startEditingNewEPerson(ePerson.eperson)"
|
||||
<td class="align-middle">{{ePerson.eperson.id}}</td>
|
||||
<td class="align-middle"><a (click)="ePersonDataService.startEditingNewEPerson(ePerson.eperson)"
|
||||
[routerLink]="[ePersonDataService.getEPeoplePageRouterLink()]">{{ePerson.eperson.name}}</a></td>
|
||||
<td>
|
||||
<td class="align-middle">
|
||||
{{messagePrefix + '.table.email' | translate}}: {{ ePerson.eperson.email ? ePerson.eperson.email : '-' }}<br/>
|
||||
{{messagePrefix + '.table.netid' | translate}}: {{ ePerson.eperson.netid ? ePerson.eperson.netid : '-' }}
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
<div class="btn-group edit-field">
|
||||
<button (click)="deleteMemberFromGroup(ePerson)"
|
||||
class="btn btn-outline-danger btn-sm"
|
||||
|
@@ -35,17 +35,19 @@
|
||||
<table id="groupsSearch" class="table table-striped table-hover table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{{messagePrefix + '.table.id' | translate}}</th>
|
||||
<th scope="col">{{messagePrefix + '.table.name' | translate}}</th>
|
||||
<th>{{messagePrefix + '.table.edit' | translate}}</th>
|
||||
<th scope="col" class="align-middle">{{messagePrefix + '.table.id' | translate}}</th>
|
||||
<th scope="col" class="align-middle">{{messagePrefix + '.table.name' | translate}}</th>
|
||||
<th scope="col" class="align-middle">{{messagePrefix + '.table.collectionOrCommunity' | translate}}</th>
|
||||
<th class="align-middle">{{messagePrefix + '.table.edit' | translate}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let group of (searchResults$ | async)?.payload?.page">
|
||||
<td>{{group.id}}</td>
|
||||
<td><a (click)="groupDataService.startEditingNewGroup(group)"
|
||||
<td class="align-middle">{{group.id}}</td>
|
||||
<td class="align-middle"><a (click)="groupDataService.startEditingNewGroup(group)"
|
||||
[routerLink]="[groupDataService.getGroupEditPageRouterLink(group)]">{{group.name}}</a></td>
|
||||
<td>
|
||||
<td class="align-middle">{{(group.object | async)?.payload?.name}}</td>
|
||||
<td class="align-middle">
|
||||
<div class="btn-group edit-field">
|
||||
<button *ngIf="(isSubgroupOfGroup(group) | async) && !(isActiveGroup(group) | async)"
|
||||
(click)="deleteSubgroupFromGroup(group)"
|
||||
@@ -88,17 +90,19 @@
|
||||
<table id="subgroupsOfGroup" class="table table-striped table-hover table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{{messagePrefix + '.table.id' | translate}}</th>
|
||||
<th scope="col">{{messagePrefix + '.table.name' | translate}}</th>
|
||||
<th scope="col" class="align-middle">{{messagePrefix + '.table.id' | translate}}</th>
|
||||
<th scope="col" class="align-middle">{{messagePrefix + '.table.name' | translate}}</th>
|
||||
<th scope="col" class="align-middle">{{messagePrefix + '.table.collectionOrCommunity' | translate}}</th>
|
||||
<th>{{messagePrefix + '.table.edit' | translate}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let group of (subGroups$ | async)?.payload?.page">
|
||||
<td>{{group.id}}</td>
|
||||
<td><a (click)="groupDataService.startEditingNewGroup(group)"
|
||||
<td class="align-middle">{{group.id}}</td>
|
||||
<td class="align-middle"><a (click)="groupDataService.startEditingNewGroup(group)"
|
||||
[routerLink]="[groupDataService.getGroupEditPageRouterLink(group)]">{{group.name}}</a></td>
|
||||
<td>
|
||||
<td class="align-middle">{{(group.object | async)?.payload?.name}}</td>
|
||||
<td class="align-middle">
|
||||
<div class="btn-group edit-field">
|
||||
<button (click)="deleteSubgroupFromGroup(group)"
|
||||
class="btn btn-outline-danger btn-sm deleteButton"
|
||||
|
@@ -17,6 +17,7 @@ import { NotificationsService } from '../../../../shared/notifications/notificat
|
||||
import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model';
|
||||
import { NoContent } from '../../../../core/shared/NoContent.model';
|
||||
import { PaginationService } from '../../../../core/pagination/pagination.service';
|
||||
import { followLink } from '../../../../shared/utils/follow-link-config.model';
|
||||
|
||||
/**
|
||||
* Keys to keep track of specific subscriptions
|
||||
@@ -117,7 +118,10 @@ export class SubgroupsListComponent implements OnInit, OnDestroy {
|
||||
switchMap((config) => this.groupDataService.findAllByHref(this.groupBeingEdited._links.subgroups.href, {
|
||||
currentPage: config.currentPage,
|
||||
elementsPerPage: config.pageSize
|
||||
}
|
||||
},
|
||||
true,
|
||||
true,
|
||||
followLink('object')
|
||||
))
|
||||
).subscribe((rd: RemoteData<PaginatedList<Group>>) => {
|
||||
this.subGroups$.next(rd);
|
||||
@@ -217,7 +221,8 @@ export class SubgroupsListComponent implements OnInit, OnDestroy {
|
||||
switchMap((config) => this.groupDataService.searchGroups(this.currentSearchQuery, {
|
||||
currentPage: config.currentPage,
|
||||
elementsPerPage: config.pageSize
|
||||
}))
|
||||
}, true, true, followLink('object')
|
||||
))
|
||||
).subscribe((rd: RemoteData<PaginatedList<Group>>) => {
|
||||
this.searchResults$.next(rd);
|
||||
}));
|
||||
|
@@ -48,6 +48,7 @@
|
||||
<tr>
|
||||
<th scope="col">{{messagePrefix + 'table.id' | translate}}</th>
|
||||
<th scope="col">{{messagePrefix + 'table.name' | translate}}</th>
|
||||
<th scope="col">{{messagePrefix + 'table.collectionOrCommunity' | translate}}</th>
|
||||
<th scope="col">{{messagePrefix + 'table.members' | translate}}</th>
|
||||
<th>{{messagePrefix + 'table.edit' | translate}}</th>
|
||||
</tr>
|
||||
@@ -56,6 +57,7 @@
|
||||
<tr *ngFor="let groupDto of (groupsDto$ | async)?.page">
|
||||
<td>{{groupDto.group.id}}</td>
|
||||
<td>{{groupDto.group.name}}</td>
|
||||
<td>{{(groupDto.group.object | async)?.payload?.name}}</td>
|
||||
<td>{{groupDto.epersons?.totalElements + groupDto.subgroups?.totalElements}}</td>
|
||||
<td>
|
||||
<div class="btn-group edit-field">
|
||||
|
@@ -152,6 +152,7 @@ describe('GroupRegistryComponent', () => {
|
||||
return createSuccessfulRemoteDataObject$(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
authorizationService = jasmine.createSpyObj('authorizationService', ['isAuthorized']);
|
||||
setIsAuthorized(true, true);
|
||||
paginationService = new PaginationServiceStub();
|
||||
@@ -200,6 +201,13 @@ describe('GroupRegistryComponent', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should display community/collection name if present', () => {
|
||||
const collectionNamesFound = fixture.debugElement.queryAll(By.css('#groups tr td:nth-child(3)'));
|
||||
expect(collectionNamesFound.length).toEqual(2);
|
||||
expect(collectionNamesFound[0].nativeElement.textContent).toEqual('');
|
||||
expect(collectionNamesFound[1].nativeElement.textContent).toEqual('testgroupid2objectName');
|
||||
});
|
||||
|
||||
describe('edit buttons', () => {
|
||||
describe('when the user is a general admin', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
@@ -213,7 +221,7 @@ describe('GroupRegistryComponent', () => {
|
||||
}));
|
||||
|
||||
it('should be active', () => {
|
||||
const editButtonsFound = fixture.debugElement.queryAll(By.css('#groups tr td:nth-child(4) button.btn-edit'));
|
||||
const editButtonsFound = fixture.debugElement.queryAll(By.css('#groups tr td:nth-child(5) button.btn-edit'));
|
||||
expect(editButtonsFound.length).toEqual(2);
|
||||
editButtonsFound.forEach((editButtonFound) => {
|
||||
expect(editButtonFound.nativeElement.disabled).toBeFalse();
|
||||
@@ -247,7 +255,7 @@ describe('GroupRegistryComponent', () => {
|
||||
}));
|
||||
|
||||
it('should be active', () => {
|
||||
const editButtonsFound = fixture.debugElement.queryAll(By.css('#groups tr td:nth-child(4) button.btn-edit'));
|
||||
const editButtonsFound = fixture.debugElement.queryAll(By.css('#groups tr td:nth-child(5) button.btn-edit'));
|
||||
expect(editButtonsFound.length).toEqual(2);
|
||||
editButtonsFound.forEach((editButtonFound) => {
|
||||
expect(editButtonFound.nativeElement.disabled).toBeFalse();
|
||||
@@ -266,7 +274,7 @@ describe('GroupRegistryComponent', () => {
|
||||
}));
|
||||
|
||||
it('should not be active', () => {
|
||||
const editButtonsFound = fixture.debugElement.queryAll(By.css('#groups tr td:nth-child(4) button.btn-edit'));
|
||||
const editButtonsFound = fixture.debugElement.queryAll(By.css('#groups tr td:nth-child(5) button.btn-edit'));
|
||||
expect(editButtonsFound.length).toEqual(2);
|
||||
editButtonsFound.forEach((editButtonFound) => {
|
||||
expect(editButtonFound.nativeElement.disabled).toBeTrue();
|
||||
|
@@ -35,6 +35,7 @@ import { NotificationsService } from '../../shared/notifications/notifications.s
|
||||
import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
|
||||
import { NoContent } from '../../core/shared/NoContent.model';
|
||||
import { PaginationService } from '../../core/pagination/pagination.service';
|
||||
import { followLink } from '../../shared/utils/follow-link-config.model';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-groups-registry',
|
||||
@@ -132,8 +133,8 @@ export class GroupsRegistryComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
return this.groupService.searchGroups(this.currentSearchQuery.trim(), {
|
||||
currentPage: paginationOptions.currentPage,
|
||||
elementsPerPage: paginationOptions.pageSize
|
||||
});
|
||||
elementsPerPage: paginationOptions.pageSize,
|
||||
}, true, true, followLink('object'));
|
||||
}),
|
||||
getAllSucceededRemoteData(),
|
||||
getRemoteDataPayload(),
|
||||
|
@@ -4,7 +4,7 @@ import { Collection } from './core/shared/collection.model';
|
||||
import { Item } from './core/shared/item.model';
|
||||
import { getCommunityPageRoute } from './community-page/community-page-routing-paths';
|
||||
import { getCollectionPageRoute } from './collection-page/collection-page-routing-paths';
|
||||
import { getItemPageRoute } from './item-page/item-page-routing-paths';
|
||||
import { getItemModuleRoute, getItemPageRoute } from './item-page/item-page-routing-paths';
|
||||
import { hasValue } from './shared/empty.util';
|
||||
import { URLCombiner } from './core/url-combiner/url-combiner';
|
||||
|
||||
@@ -22,6 +22,15 @@ export function getBitstreamModuleRoute() {
|
||||
export function getBitstreamDownloadRoute(bitstream): string {
|
||||
return new URLCombiner(getBitstreamModuleRoute(), bitstream.uuid, 'download').toString();
|
||||
}
|
||||
export function getBitstreamRequestACopyRoute(item, bitstream): { routerLink: string, queryParams: any } {
|
||||
const url = new URLCombiner(getItemModuleRoute(), item.uuid, 'request-a-copy').toString();
|
||||
return {
|
||||
routerLink: url,
|
||||
queryParams: {
|
||||
bitstream: bitstream.uuid
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const ADMIN_MODULE_PATH = 'admin';
|
||||
|
||||
@@ -90,3 +99,8 @@ export const ACCESS_CONTROL_MODULE_PATH = 'access-control';
|
||||
export function getAccessControlModuleRoute() {
|
||||
return `/${ACCESS_CONTROL_MODULE_PATH}`;
|
||||
}
|
||||
|
||||
export const REQUEST_COPY_MODULE_PATH = 'request-a-copy';
|
||||
export function getRequestCopyModulePath() {
|
||||
return `/${REQUEST_COPY_MODULE_PATH}`;
|
||||
}
|
||||
|
@@ -14,7 +14,7 @@ import {
|
||||
PROFILE_MODULE_PATH,
|
||||
REGISTER_PATH,
|
||||
WORKFLOW_ITEM_MODULE_PATH,
|
||||
LEGACY_BITSTREAM_MODULE_PATH,
|
||||
LEGACY_BITSTREAM_MODULE_PATH, REQUEST_COPY_MODULE_PATH,
|
||||
} from './app-routing-paths';
|
||||
import { COLLECTION_MODULE_PATH } from './collection-page/collection-page-routing-paths';
|
||||
import { COMMUNITY_MODULE_PATH } from './community-page/community-page-routing-paths';
|
||||
@@ -180,6 +180,11 @@ import { GroupAdministratorGuard } from './core/data/feature-authorization/featu
|
||||
path: INFO_MODULE_PATH,
|
||||
loadChildren: () => import('./info/info.module').then((m) => m.InfoModule),
|
||||
},
|
||||
{
|
||||
path: REQUEST_COPY_MODULE_PATH,
|
||||
loadChildren: () => import('./request-copy/request-copy.module').then((m) => m.RequestCopyModule),
|
||||
canActivate: [AuthenticatedGuard, EndUserAgreementCurrentUserGuard]
|
||||
},
|
||||
{
|
||||
path: FORBIDDEN_PATH,
|
||||
component: ThemedForbiddenComponent
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { delay, distinctUntilChanged, filter, take, withLatestFrom } from 'rxjs/operators';
|
||||
import { distinctUntilChanged, filter, switchMap, take, withLatestFrom } from 'rxjs/operators';
|
||||
import {
|
||||
AfterViewInit,
|
||||
ChangeDetectionStrategy,
|
||||
@@ -9,7 +9,13 @@ import {
|
||||
Optional,
|
||||
PLATFORM_ID,
|
||||
} from '@angular/core';
|
||||
import { NavigationCancel, NavigationEnd, NavigationStart, Router } from '@angular/router';
|
||||
import {
|
||||
ActivatedRouteSnapshot,
|
||||
NavigationCancel,
|
||||
NavigationEnd,
|
||||
NavigationStart, ResolveEnd,
|
||||
Router,
|
||||
} from '@angular/router';
|
||||
|
||||
import { BehaviorSubject, Observable, of } from 'rxjs';
|
||||
import { select, Store } from '@ngrx/store';
|
||||
@@ -71,6 +77,7 @@ export class AppComponent implements OnInit, AfterViewInit {
|
||||
*/
|
||||
isThemeLoading$: BehaviorSubject<boolean> = new BehaviorSubject(false);
|
||||
|
||||
isThemeCSSLoading$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
|
||||
|
||||
/**
|
||||
* Whether or not the idle modal is is currently open
|
||||
@@ -105,7 +112,7 @@ export class AppComponent implements OnInit, AfterViewInit {
|
||||
this.themeService.getThemeName$().subscribe((themeName: string) => {
|
||||
if (isPlatformBrowser(this.platformId)) {
|
||||
// the theme css will never download server side, so this should only happen on the browser
|
||||
this.isThemeLoading$.next(true);
|
||||
this.isThemeCSSLoading$.next(true);
|
||||
}
|
||||
if (hasValue(themeName)) {
|
||||
this.setThemeCss(themeName);
|
||||
@@ -177,17 +184,33 @@ export class AppComponent implements OnInit, AfterViewInit {
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
this.router.events.pipe(
|
||||
// This fixes an ExpressionChangedAfterItHasBeenCheckedError from being thrown while loading the component
|
||||
// More information on this bug-fix: https://blog.angular-university.io/angular-debugging/
|
||||
delay(0)
|
||||
).subscribe((event) => {
|
||||
let resolveEndFound = false;
|
||||
this.router.events.subscribe((event) => {
|
||||
if (event instanceof NavigationStart) {
|
||||
resolveEndFound = false;
|
||||
this.isRouteLoading$.next(true);
|
||||
this.isThemeLoading$.next(true);
|
||||
} else if (event instanceof ResolveEnd) {
|
||||
resolveEndFound = true;
|
||||
const activatedRouteSnapShot: ActivatedRouteSnapshot = event.state.root;
|
||||
this.themeService.updateThemeOnRouteChange$(event.urlAfterRedirects, activatedRouteSnapShot).pipe(
|
||||
switchMap((changed) => {
|
||||
if (changed) {
|
||||
return this.isThemeCSSLoading$;
|
||||
} else {
|
||||
return [false];
|
||||
}
|
||||
})
|
||||
).subscribe((changed) => {
|
||||
this.isThemeLoading$.next(changed);
|
||||
});
|
||||
} else if (
|
||||
event instanceof NavigationEnd ||
|
||||
event instanceof NavigationCancel
|
||||
) {
|
||||
if (!resolveEndFound) {
|
||||
this.isThemeLoading$.next(false);
|
||||
}
|
||||
this.isRouteLoading$.next(false);
|
||||
}
|
||||
});
|
||||
@@ -237,7 +260,7 @@ export class AppComponent implements OnInit, AfterViewInit {
|
||||
});
|
||||
}
|
||||
// the fact that this callback is used, proves we're on the browser.
|
||||
this.isThemeLoading$.next(false);
|
||||
this.isThemeCSSLoading$.next(false);
|
||||
};
|
||||
head.appendChild(link);
|
||||
}
|
||||
|
@@ -7,7 +7,11 @@ import { EffectsModule } from '@ngrx/effects';
|
||||
import { RouterStateSerializer, StoreRouterConnectingModule } from '@ngrx/router-store';
|
||||
import { MetaReducer, Store, StoreModule, USER_PROVIDED_META_REDUCERS } from '@ngrx/store';
|
||||
import { StoreDevtoolsModule } from '@ngrx/store-devtools';
|
||||
import { DYNAMIC_MATCHER_PROVIDERS } from '@ng-dynamic-forms/core';
|
||||
import {
|
||||
DYNAMIC_ERROR_MESSAGES_MATCHER,
|
||||
DYNAMIC_MATCHER_PROVIDERS,
|
||||
DynamicErrorMessagesMatcher
|
||||
} from '@ng-dynamic-forms/core';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { ScrollToModule } from '@nicky-lenaers/ngx-scroll-to';
|
||||
|
||||
@@ -52,6 +56,7 @@ import { IdleModalComponent } from './shared/idle-modal/idle-modal.component';
|
||||
|
||||
import { UUIDService } from './core/shared/uuid.service';
|
||||
import { CookieService } from './core/services/cookie.service';
|
||||
import { AbstractControl } from '@angular/forms';
|
||||
|
||||
export function getBase() {
|
||||
return environment.ui.nameSpace;
|
||||
@@ -61,6 +66,14 @@ export function getMetaReducers(): MetaReducer<AppState>[] {
|
||||
return environment.debug ? [...appMetaReducers, ...debugMetaReducers] : appMetaReducers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Condition for displaying error messages on email form field
|
||||
*/
|
||||
export const ValidateEmailErrorStateMatcher: DynamicErrorMessagesMatcher =
|
||||
(control: AbstractControl, model: any, hasFocus: boolean) => {
|
||||
return (control.touched && !hasFocus) || (control.errors?.emailTaken && hasFocus);
|
||||
};
|
||||
|
||||
const IMPORTS = [
|
||||
CommonModule,
|
||||
SharedModule,
|
||||
@@ -146,6 +159,10 @@ const PROVIDERS = [
|
||||
multi: true,
|
||||
deps: [ CookieService, UUIDService ]
|
||||
},
|
||||
{
|
||||
provide: DYNAMIC_ERROR_MESSAGES_MATCHER,
|
||||
useValue: ValidateEmailErrorStateMatcher
|
||||
},
|
||||
...DYNAMIC_MATCHER_PROVIDERS,
|
||||
];
|
||||
|
||||
|
@@ -1,18 +1,27 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { Component, Input, OnInit } from '@angular/core';
|
||||
|
||||
import { Observable } from 'rxjs';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import {
|
||||
DynamicFormControlModel,
|
||||
DynamicFormOptionConfig,
|
||||
DynamicFormService,
|
||||
DynamicInputModel,
|
||||
DynamicTextAreaModel
|
||||
DynamicSelectModel
|
||||
} from '@ng-dynamic-forms/core';
|
||||
|
||||
import { Collection } from '../../core/shared/collection.model';
|
||||
import { ComColFormComponent } from '../../shared/comcol-forms/comcol-form/comcol-form.component';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||
import { CommunityDataService } from '../../core/data/community-data.service';
|
||||
import { AuthService } from '../../core/auth/auth.service';
|
||||
import { RequestService } from '../../core/data/request.service';
|
||||
import { ObjectCacheService } from '../../core/cache/object-cache.service';
|
||||
import { EntityTypeService } from '../../core/data/entity-type.service';
|
||||
import { ItemType } from '../../core/shared/item-relationships/item-type.model';
|
||||
import { MetadataValue } from '../../core/shared/metadata.models';
|
||||
import { getFirstSucceededRemoteListPayload } from '../../core/shared/operators';
|
||||
import { collectionFormEntityTypeSelectionConfig, collectionFormModels, } from './collection-form.models';
|
||||
import { NONE_ENTITY_TYPE } from '../../core/shared/item-relationships/item-type.resource-type';
|
||||
|
||||
/**
|
||||
* Form used for creating and editing collections
|
||||
@@ -22,7 +31,7 @@ import { ObjectCacheService } from '../../core/cache/object-cache.service';
|
||||
styleUrls: ['../../shared/comcol-forms/comcol-form/comcol-form.component.scss'],
|
||||
templateUrl: '../../shared/comcol-forms/comcol-form/comcol-form.component.html'
|
||||
})
|
||||
export class CollectionFormComponent extends ComColFormComponent<Collection> {
|
||||
export class CollectionFormComponent extends ComColFormComponent<Collection> implements OnInit {
|
||||
/**
|
||||
* @type {Collection} A new collection when a collection is being created, an existing Input collection when a collection is being edited
|
||||
*/
|
||||
@@ -34,46 +43,16 @@ export class CollectionFormComponent extends ComColFormComponent<Collection> {
|
||||
type = Collection.type;
|
||||
|
||||
/**
|
||||
* The dynamic form fields used for creating/editing a collection
|
||||
* @type {(DynamicInputModel | DynamicTextAreaModel)[]}
|
||||
* The dynamic form field used for entity type selection
|
||||
* @type {DynamicSelectModel<string>}
|
||||
*/
|
||||
formModel: DynamicFormControlModel[] = [
|
||||
new DynamicInputModel({
|
||||
id: 'title',
|
||||
name: 'dc.title',
|
||||
required: true,
|
||||
validators: {
|
||||
required: null
|
||||
},
|
||||
errorMessages: {
|
||||
required: 'Please enter a name for this title'
|
||||
},
|
||||
}),
|
||||
new DynamicTextAreaModel({
|
||||
id: 'description',
|
||||
name: 'dc.description',
|
||||
}),
|
||||
new DynamicTextAreaModel({
|
||||
id: 'abstract',
|
||||
name: 'dc.description.abstract',
|
||||
}),
|
||||
new DynamicTextAreaModel({
|
||||
id: 'rights',
|
||||
name: 'dc.rights',
|
||||
}),
|
||||
new DynamicTextAreaModel({
|
||||
id: 'tableofcontents',
|
||||
name: 'dc.description.tableofcontents',
|
||||
}),
|
||||
new DynamicTextAreaModel({
|
||||
id: 'license',
|
||||
name: 'dc.rights.license',
|
||||
}),
|
||||
new DynamicTextAreaModel({
|
||||
id: 'provenance',
|
||||
name: 'dc.description.provenance',
|
||||
}),
|
||||
];
|
||||
entityTypeSelection: DynamicSelectModel<string> = new DynamicSelectModel(collectionFormEntityTypeSelectionConfig);
|
||||
|
||||
/**
|
||||
* The dynamic form fields used for creating/editing a collection
|
||||
* @type {DynamicFormControlModel[]}
|
||||
*/
|
||||
formModel: DynamicFormControlModel[];
|
||||
|
||||
public constructor(protected formService: DynamicFormService,
|
||||
protected translate: TranslateService,
|
||||
@@ -81,7 +60,43 @@ export class CollectionFormComponent extends ComColFormComponent<Collection> {
|
||||
protected authService: AuthService,
|
||||
protected dsoService: CommunityDataService,
|
||||
protected requestService: RequestService,
|
||||
protected objectCache: ObjectCacheService) {
|
||||
protected objectCache: ObjectCacheService,
|
||||
protected entityTypeService: EntityTypeService) {
|
||||
super(formService, translate, notificationsService, authService, requestService, objectCache);
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
|
||||
let currentRelationshipValue: MetadataValue[];
|
||||
if (this.dso && this.dso.metadata) {
|
||||
currentRelationshipValue = this.dso.metadata['dspace.entity.type'];
|
||||
}
|
||||
|
||||
const entities$: Observable<ItemType[]> = this.entityTypeService.findAll({ elementsPerPage: 100, currentPage: 1 }).pipe(
|
||||
getFirstSucceededRemoteListPayload()
|
||||
);
|
||||
|
||||
// retrieve all entity types to populate the dropdowns selection
|
||||
entities$.subscribe((entityTypes: ItemType[]) => {
|
||||
|
||||
entityTypes
|
||||
.filter((type: ItemType) => type.label !== NONE_ENTITY_TYPE)
|
||||
.forEach((type: ItemType, index: number) => {
|
||||
this.entityTypeSelection.add({
|
||||
disabled: false,
|
||||
label: type.label,
|
||||
value: type.label
|
||||
} as DynamicFormOptionConfig<string>);
|
||||
if (currentRelationshipValue && currentRelationshipValue.length > 0 && currentRelationshipValue[0].value === type.label) {
|
||||
this.entityTypeSelection.select(index);
|
||||
this.entityTypeSelection.disabled = true;
|
||||
}
|
||||
});
|
||||
|
||||
this.formModel = [...collectionFormModels, this.entityTypeSelection];
|
||||
|
||||
super.ngOnInit();
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,46 @@
|
||||
import { DynamicFormControlModel, DynamicInputModel, DynamicTextAreaModel } from '@ng-dynamic-forms/core';
|
||||
import { DynamicSelectModelConfig } from '@ng-dynamic-forms/core/lib/model/select/dynamic-select.model';
|
||||
|
||||
export const collectionFormEntityTypeSelectionConfig: DynamicSelectModelConfig<string> = {
|
||||
id: 'entityType',
|
||||
name: 'dspace.entity.type',
|
||||
disabled: false
|
||||
};
|
||||
|
||||
/**
|
||||
* The dynamic form fields used for creating/editing a collection
|
||||
* @type {(DynamicInputModel | DynamicTextAreaModel)[]}
|
||||
*/
|
||||
export const collectionFormModels: DynamicFormControlModel[] = [
|
||||
new DynamicInputModel({
|
||||
id: 'title',
|
||||
name: 'dc.title',
|
||||
required: true,
|
||||
validators: {
|
||||
required: null
|
||||
},
|
||||
errorMessages: {
|
||||
required: 'Please enter a name for this title'
|
||||
},
|
||||
}),
|
||||
new DynamicTextAreaModel({
|
||||
id: 'description',
|
||||
name: 'dc.description',
|
||||
}),
|
||||
new DynamicTextAreaModel({
|
||||
id: 'abstract',
|
||||
name: 'dc.description.abstract',
|
||||
}),
|
||||
new DynamicTextAreaModel({
|
||||
id: 'rights',
|
||||
name: 'dc.rights',
|
||||
}),
|
||||
new DynamicTextAreaModel({
|
||||
id: 'tableofcontents',
|
||||
name: 'dc.description.tableofcontents',
|
||||
}),
|
||||
new DynamicTextAreaModel({
|
||||
id: 'license',
|
||||
name: 'dc.rights.license',
|
||||
})
|
||||
];
|
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
@@ -1,57 +1,74 @@
|
||||
<div class="container-fluid">
|
||||
<div class="d-inline-block float-right">
|
||||
<button class=" btn btn-danger" *ngIf="!(isReinstatable() | async)"
|
||||
[disabled]="!(hasChanges() | async)"
|
||||
(click)="discard()"><i
|
||||
class="fas fa-times"></i>
|
||||
<span class="d-none d-sm-inline"> {{"item.edit.metadata.discard-button" | translate}}</span>
|
||||
</button>
|
||||
<button class="btn btn-warning" *ngIf="isReinstatable() | async"
|
||||
(click)="reinstate()"><i
|
||||
class="fas fa-undo-alt"></i>
|
||||
<span class="d-none d-sm-inline"> {{"item.edit.metadata.reinstate-button" | translate}}</span>
|
||||
</button>
|
||||
<button class="btn btn-primary" [disabled]="!(hasChanges() | async) || !isValid() || (initialHarvestType === harvestTypeNone && contentSource.harvestType === initialHarvestType)"
|
||||
(click)="onSubmit()"><i
|
||||
class="fas fa-save"></i>
|
||||
<span class="d-none d-sm-inline"> {{"item.edit.metadata.save-button" | translate}}</span>
|
||||
</button>
|
||||
</div>
|
||||
<h4>{{ 'collection.edit.tabs.source.head' | translate }}</h4>
|
||||
<div *ngIf="contentSource" class="form-check mb-4">
|
||||
<input type="checkbox" class="form-check-input" id="externalSourceCheck" [checked]="(contentSource?.harvestType !== harvestTypeNone)" (change)="changeExternalSource()">
|
||||
<label class="form-check-label" for="externalSourceCheck">{{ 'collection.edit.tabs.source.external' | translate }}</label>
|
||||
</div>
|
||||
<ds-loading *ngIf="!contentSource" [message]="'loading.content-source' | translate"></ds-loading>
|
||||
<h4 *ngIf="contentSource && (contentSource?.harvestType !== harvestTypeNone)">{{ 'collection.edit.tabs.source.form.head' | translate }}</h4>
|
||||
<div class="d-inline-block float-right">
|
||||
<button class=" btn btn-danger" *ngIf="!(isReinstatable() | async)"
|
||||
[disabled]="!(hasChanges() | async)"
|
||||
(click)="discard()"><i
|
||||
class="fas fa-times"></i>
|
||||
<span class="d-none d-sm-inline"> {{"item.edit.metadata.discard-button" | translate}}</span>
|
||||
</button>
|
||||
<button class="btn btn-warning" *ngIf="isReinstatable() | async"
|
||||
(click)="reinstate()"><i
|
||||
class="fas fa-undo-alt"></i>
|
||||
<span class="d-none d-sm-inline"> {{"item.edit.metadata.reinstate-button" | translate}}</span>
|
||||
</button>
|
||||
<button class="btn btn-primary"
|
||||
[disabled]="!(hasChanges() | async) || !isValid() || (initialHarvestType === harvestTypeNone && contentSource.harvestType === initialHarvestType)"
|
||||
(click)="onSubmit()"><i
|
||||
class="fas fa-save"></i>
|
||||
<span class="d-none d-sm-inline"> {{"item.edit.metadata.save-button" | translate}}</span>
|
||||
</button>
|
||||
</div>
|
||||
<h4>{{ 'collection.edit.tabs.source.head' | translate }}</h4>
|
||||
<div *ngIf="contentSource" class="form-check mb-4">
|
||||
<input type="checkbox" class="form-check-input" id="externalSourceCheck"
|
||||
[checked]="(contentSource?.harvestType !== harvestTypeNone)" (change)="changeExternalSource()">
|
||||
<label class="form-check-label"
|
||||
for="externalSourceCheck">{{ 'collection.edit.tabs.source.external' | translate }}</label>
|
||||
</div>
|
||||
<ds-loading *ngIf="!contentSource" [message]="'loading.content-source' | translate"></ds-loading>
|
||||
<h4 *ngIf="contentSource && (contentSource?.harvestType !== harvestTypeNone)">{{ 'collection.edit.tabs.source.form.head' | translate }}</h4>
|
||||
</div>
|
||||
<ds-form *ngIf="formGroup && contentSource && (contentSource?.harvestType !== harvestTypeNone)"
|
||||
[formId]="'collection-source-form-id'"
|
||||
[formGroup]="formGroup"
|
||||
[formModel]="formModel"
|
||||
[formLayout]="formLayout"
|
||||
[displaySubmit]="false"
|
||||
[displayCancel]="false"
|
||||
(dfChange)="onChange($event)"
|
||||
(submitForm)="onSubmit()"
|
||||
(cancel)="onCancel()"></ds-form>
|
||||
<div class="container-fluid" *ngIf="(contentSource?.harvestType !== harvestTypeNone)">
|
||||
<div class="d-inline-block float-right">
|
||||
<button class=" btn btn-danger" *ngIf="!(isReinstatable() | async)"
|
||||
[disabled]="!(hasChanges() | async)"
|
||||
(click)="discard()"><i
|
||||
class="fas fa-times"></i>
|
||||
<span class="d-none d-sm-inline"> {{"item.edit.metadata.discard-button" | translate}}</span>
|
||||
</button>
|
||||
<button class="btn btn-warning" *ngIf="isReinstatable() | async"
|
||||
(click)="reinstate()"><i
|
||||
class="fas fa-undo-alt"></i>
|
||||
<span class="d-none d-sm-inline"> {{"item.edit.metadata.reinstate-button" | translate}}</span>
|
||||
</button>
|
||||
<button class="btn btn-primary" [disabled]="!(hasChanges() | async) || !isValid() || (initialHarvestType === harvestTypeNone && contentSource.harvestType === initialHarvestType)"
|
||||
(click)="onSubmit()"><i
|
||||
class="fas fa-save"></i>
|
||||
<span class="d-none d-sm-inline"> {{"item.edit.metadata.save-button" | translate}}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<ds-form *ngIf="formGroup && contentSource && (contentSource?.harvestType !== harvestTypeNone)"
|
||||
[formId]="'collection-source-form-id'"
|
||||
[formGroup]="formGroup"
|
||||
[formModel]="formModel"
|
||||
[formLayout]="formLayout"
|
||||
[displaySubmit]="false"
|
||||
[displayCancel]="false"
|
||||
(dfChange)="onChange($event)"
|
||||
(submitForm)="onSubmit()"
|
||||
(cancel)="onCancel()"></ds-form>
|
||||
</div>
|
||||
<div class="container mt-2" *ngIf="(contentSource?.harvestType !== harvestTypeNone)">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="d-inline-block float-right ml-1">
|
||||
<button class=" btn btn-danger" *ngIf="!(isReinstatable() | async)"
|
||||
[disabled]="!(hasChanges() | async)"
|
||||
(click)="discard()"><i
|
||||
class="fas fa-times"></i>
|
||||
<span class="d-none d-sm-inline"> {{"item.edit.metadata.discard-button" | translate}}</span>
|
||||
</button>
|
||||
<button class="btn btn-warning" *ngIf="isReinstatable() | async"
|
||||
(click)="reinstate()"><i
|
||||
class="fas fa-undo-alt"></i>
|
||||
<span class="d-none d-sm-inline"> {{"item.edit.metadata.reinstate-button" | translate}}</span>
|
||||
</button>
|
||||
<button class="btn btn-primary"
|
||||
[disabled]="!(hasChanges() | async) || !isValid() || (initialHarvestType === harvestTypeNone && contentSource.harvestType === initialHarvestType)"
|
||||
(click)="onSubmit()"><i
|
||||
class="fas fa-save"></i>
|
||||
<span class="d-none d-sm-inline"> {{"item.edit.metadata.save-button" | translate}}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ds-collection-source-controls
|
||||
[isEnabled]="!(hasChanges()|async)"
|
||||
[shouldShow]="contentSource?.harvestType !== harvestTypeNone"
|
||||
[collection]="(collectionRD$ |async)?.payload"
|
||||
>
|
||||
</ds-collection-source-controls>
|
||||
|
||||
|
@@ -62,7 +62,8 @@ describe('CollectionSourceComponent', () => {
|
||||
label: 'DSpace Intermediate Metadata',
|
||||
nameSpace: 'http://www.dspace.org/xmlns/dspace/dim'
|
||||
}
|
||||
]
|
||||
],
|
||||
_links: { self: { href: 'contentsource-selflink' } }
|
||||
});
|
||||
fieldUpdate = {
|
||||
field: contentSource,
|
||||
@@ -115,7 +116,7 @@ describe('CollectionSourceComponent', () => {
|
||||
updateContentSource: observableOf(contentSource),
|
||||
getHarvesterEndpoint: observableOf('harvester-endpoint')
|
||||
});
|
||||
requestService = jasmine.createSpyObj('requestService', ['removeByHrefSubstring']);
|
||||
requestService = jasmine.createSpyObj('requestService', ['removeByHrefSubstring', 'setStaleByHrefSubstring']);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [TranslateModule.forRoot(), RouterTestingModule],
|
||||
|
@@ -380,7 +380,7 @@ export class CollectionSourceComponent extends AbstractTrackableComponent implem
|
||||
switchMap((uuid) => this.collectionService.getHarvesterEndpoint(uuid)),
|
||||
take(1)
|
||||
).subscribe((endpoint) => this.requestService.removeByHrefSubstring(endpoint));
|
||||
|
||||
this.requestService.setStaleByHrefSubstring(this.contentSource._links.self.href);
|
||||
// Update harvester
|
||||
this.collectionRD$.pipe(
|
||||
getFirstSucceededRemoteData(),
|
||||
|
@@ -9,6 +9,7 @@ import { CollectionCurateComponent } from './collection-curate/collection-curate
|
||||
import { CollectionSourceComponent } from './collection-source/collection-source.component';
|
||||
import { CollectionAuthorizationsComponent } from './collection-authorizations/collection-authorizations.component';
|
||||
import { CollectionFormModule } from '../collection-form/collection-form.module';
|
||||
import { CollectionSourceControlsComponent } from './collection-source/collection-source-controls/collection-source-controls.component';
|
||||
|
||||
/**
|
||||
* Module that contains all components related to the Edit Collection page administrator functionality
|
||||
@@ -26,6 +27,8 @@ import { CollectionFormModule } from '../collection-form/collection-form.module'
|
||||
CollectionRolesComponent,
|
||||
CollectionCurateComponent,
|
||||
CollectionSourceComponent,
|
||||
|
||||
CollectionSourceControlsComponent,
|
||||
CollectionAuthorizationsComponent
|
||||
]
|
||||
})
|
||||
|
@@ -17,7 +17,7 @@ import {
|
||||
createFailedRemoteDataObject$,
|
||||
createSuccessfulRemoteDataObject,
|
||||
createSuccessfulRemoteDataObject$
|
||||
} from 'src/app/shared/remote-data.utils';
|
||||
} from '../../shared/remote-data.utils';
|
||||
import { cold, getTestScheduler, hot } from 'jasmine-marbles';
|
||||
import { TestScheduler } from 'rxjs/testing';
|
||||
import { Observable } from 'rxjs';
|
||||
|
@@ -27,12 +27,7 @@ import { CommunityDataService } from './community-data.service';
|
||||
import { DSOChangeAnalyzer } from './dso-change-analyzer.service';
|
||||
import { PaginatedList } from './paginated-list.model';
|
||||
import { RemoteData } from './remote-data';
|
||||
import {
|
||||
ContentSourceRequest,
|
||||
FindListOptions,
|
||||
UpdateContentSourceRequest,
|
||||
RestRequest
|
||||
} from './request.models';
|
||||
import { ContentSourceRequest, FindListOptions, RestRequest, UpdateContentSourceRequest } from './request.models';
|
||||
import { RequestService } from './request.service';
|
||||
import { BitstreamDataService } from './bitstream-data.service';
|
||||
|
||||
@@ -84,16 +79,48 @@ export class CollectionDataService extends ComColDataService<Collection> {
|
||||
filter((collections: RemoteData<PaginatedList<Collection>>) => !collections.isResponsePending));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all collections the user is authorized to submit to
|
||||
*
|
||||
* @param query limit the returned collection to those with metadata values matching the query terms.
|
||||
* @param entityType The entity type used to limit the returned collection
|
||||
* @param options The [[FindListOptions]] object
|
||||
* @param reRequestOnStale Whether or not the request should automatically be re-requested after
|
||||
* the response becomes stale
|
||||
* @param linksToFollow The array of [[FollowLinkConfig]]
|
||||
* @return Observable<RemoteData<PaginatedList<Collection>>>
|
||||
* collection list
|
||||
*/
|
||||
getAuthorizedCollectionByEntityType(
|
||||
query: string,
|
||||
entityType: string,
|
||||
options: FindListOptions = {},
|
||||
reRequestOnStale = true,
|
||||
...linksToFollow: FollowLinkConfig<Collection>[]): Observable<RemoteData<PaginatedList<Collection>>> {
|
||||
const searchHref = 'findSubmitAuthorizedByEntityType';
|
||||
options = Object.assign({}, options, {
|
||||
searchParams: [
|
||||
new RequestParam('query', query),
|
||||
new RequestParam('entityType', entityType)
|
||||
]
|
||||
});
|
||||
|
||||
return this.searchBy(searchHref, options, true, reRequestOnStale, ...linksToFollow).pipe(
|
||||
filter((collections: RemoteData<PaginatedList<Collection>>) => !collections.isResponsePending));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all collections the user is authorized to submit to, by community
|
||||
*
|
||||
* @param communityId The community id
|
||||
* @param query limit the returned collection to those with metadata values matching the query terms.
|
||||
* @param options The [[FindListOptions]] object
|
||||
* @param reRequestOnStale Whether or not the request should automatically be re-
|
||||
* requested after the response becomes stale
|
||||
* @return Observable<RemoteData<PaginatedList<Collection>>>
|
||||
* collection list
|
||||
*/
|
||||
getAuthorizedCollectionByCommunity(communityId: string, query: string, options: FindListOptions = {}): Observable<RemoteData<PaginatedList<Collection>>> {
|
||||
getAuthorizedCollectionByCommunity(communityId: string, query: string, options: FindListOptions = {}, reRequestOnStale = true,): Observable<RemoteData<PaginatedList<Collection>>> {
|
||||
const searchHref = 'findSubmitAuthorizedByCommunity';
|
||||
options = Object.assign({}, options, {
|
||||
searchParams: [
|
||||
@@ -102,7 +129,38 @@ export class CollectionDataService extends ComColDataService<Collection> {
|
||||
]
|
||||
});
|
||||
|
||||
return this.searchBy(searchHref, options).pipe(
|
||||
return this.searchBy(searchHref, options, reRequestOnStale).pipe(
|
||||
filter((collections: RemoteData<PaginatedList<Collection>>) => !collections.isResponsePending));
|
||||
}
|
||||
/**
|
||||
* Get all collections the user is authorized to submit to, by community and has the metadata
|
||||
*
|
||||
* @param communityId The community id
|
||||
* @param entityType The entity type used to limit the returned collection
|
||||
* @param options The [[FindListOptions]] object
|
||||
* @param reRequestOnStale Whether or not the request should automatically be re-requested after
|
||||
* the response becomes stale
|
||||
* @param linksToFollow The array of [[FollowLinkConfig]]
|
||||
* @return Observable<RemoteData<PaginatedList<Collection>>>
|
||||
* collection list
|
||||
*/
|
||||
getAuthorizedCollectionByCommunityAndEntityType(
|
||||
communityId: string,
|
||||
entityType: string,
|
||||
options: FindListOptions = {},
|
||||
reRequestOnStale = true,
|
||||
...linksToFollow: FollowLinkConfig<Collection>[]): Observable<RemoteData<PaginatedList<Collection>>> {
|
||||
const searchHref = 'findSubmitAuthorizedByCommunityAndEntityType';
|
||||
const searchParams = [
|
||||
new RequestParam('uuid', communityId),
|
||||
new RequestParam('entityType', entityType)
|
||||
];
|
||||
|
||||
options = Object.assign({}, options, {
|
||||
searchParams: searchParams
|
||||
});
|
||||
|
||||
return this.searchBy(searchHref, options, true, reRequestOnStale, ...linksToFollow).pipe(
|
||||
filter((collections: RemoteData<PaginatedList<Collection>>) => !collections.isResponsePending));
|
||||
}
|
||||
|
||||
@@ -138,7 +196,7 @@ export class CollectionDataService extends ComColDataService<Collection> {
|
||||
* Get the collection's content harvester
|
||||
* @param collectionId
|
||||
*/
|
||||
getContentSource(collectionId: string): Observable<RemoteData<ContentSource>> {
|
||||
getContentSource(collectionId: string, useCachedVersionIfAvailable = true): Observable<RemoteData<ContentSource>> {
|
||||
const href$ = this.getHarvesterEndpoint(collectionId).pipe(
|
||||
isNotEmptyOperator(),
|
||||
take(1)
|
||||
@@ -146,7 +204,7 @@ export class CollectionDataService extends ComColDataService<Collection> {
|
||||
|
||||
href$.subscribe((href: string) => {
|
||||
const request = new ContentSourceRequest(this.requestService.generateRequestId(), href);
|
||||
this.requestService.send(request, true);
|
||||
this.requestService.send(request, useCachedVersionIfAvailable);
|
||||
});
|
||||
|
||||
return this.rdbService.buildSingle<ContentSource>(href$);
|
||||
@@ -208,10 +266,20 @@ export class CollectionDataService extends ComColDataService<Collection> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns {@link RemoteData} of {@link Collection} that is the owing collection of the given item
|
||||
* Returns {@link RemoteData} of {@link Collection} that is the owning collection of the given item
|
||||
* @param item Item we want the owning collection of
|
||||
*/
|
||||
findOwningCollectionFor(item: Item): Observable<RemoteData<Collection>> {
|
||||
return this.findByHref(item._links.owningCollection.href);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a list of mapped collections for the given item.
|
||||
* @param item Item for which the mapped collections should be retrieved.
|
||||
* @param findListOptions Pagination and search options.
|
||||
*/
|
||||
findMappedCollectionsFor(item: Item, findListOptions?: FindListOptions): Observable<RemoteData<PaginatedList<Collection>>> {
|
||||
return this.findAllByHref(item._links.mappedCollections.href, findListOptions);
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -10,13 +10,14 @@ import { NotificationsService } from '../../shared/notifications/notifications.s
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { DefaultChangeAnalyzer } from './default-change-analyzer.service';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { FindListOptions } from './request.models';
|
||||
import { Observable } from 'rxjs';
|
||||
import { switchMap, take, map } from 'rxjs/operators';
|
||||
import { filter, map, switchMap, take } from 'rxjs/operators';
|
||||
import { RemoteData } from './remote-data';
|
||||
import { RelationshipType } from '../shared/item-relationships/relationship-type.model';
|
||||
import { PaginatedList } from './paginated-list.model';
|
||||
import { ItemType } from '../shared/item-relationships/item-type.model';
|
||||
import { getRemoteDataPayload, getFirstSucceededRemoteData } from '../shared/operators';
|
||||
import { getFirstSucceededRemoteData, getRemoteDataPayload } from '../shared/operators';
|
||||
import { RelationshipTypeService } from './relationship-type.service';
|
||||
|
||||
/**
|
||||
@@ -56,7 +57,7 @@ export class EntityTypeService extends DataService<ItemType> {
|
||||
/**
|
||||
* Check whether a given entity type is the left type of a given relationship type, as an observable boolean
|
||||
* @param relationshipType the relationship type for which to check whether the given entity type is the left type
|
||||
* @param entityType the entity type for which to check whether it is the left type of the given relationship type
|
||||
* @param itemType the entity type for which to check whether it is the left type of the given relationship type
|
||||
*/
|
||||
isLeftType(relationshipType: RelationshipType, itemType: ItemType): Observable<boolean> {
|
||||
|
||||
@@ -67,6 +68,73 @@ export class EntityTypeService extends DataService<ItemType> {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of entity types for which there is at least one collection in which the user is authorized to submit
|
||||
*
|
||||
* @param {FindListOptions} options
|
||||
*/
|
||||
getAllAuthorizedRelationshipType(options: FindListOptions = {}): Observable<RemoteData<PaginatedList<ItemType>>> {
|
||||
const searchHref = 'findAllByAuthorizedCollection';
|
||||
|
||||
return this.searchBy(searchHref, options).pipe(
|
||||
filter((type: RemoteData<PaginatedList<ItemType>>) => !type.isResponsePending));
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to verify if there are one or more entities available
|
||||
*/
|
||||
hasMoreThanOneAuthorized(): Observable<boolean> {
|
||||
const findListOptions: FindListOptions = {
|
||||
elementsPerPage: 2,
|
||||
currentPage: 1
|
||||
};
|
||||
return this.getAllAuthorizedRelationshipType(findListOptions).pipe(
|
||||
map((result: RemoteData<PaginatedList<ItemType>>) => {
|
||||
let output: boolean;
|
||||
if (result.payload) {
|
||||
output = ( result.payload.page.length > 1 );
|
||||
} else {
|
||||
output = false;
|
||||
}
|
||||
return output;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* It returns a list of entity types for which there is at least one collection
|
||||
* in which the user is authorized to submit supported by at least one external data source provider
|
||||
*
|
||||
* @param {FindListOptions} options
|
||||
*/
|
||||
getAllAuthorizedRelationshipTypeImport(options: FindListOptions = {}): Observable<RemoteData<PaginatedList<ItemType>>> {
|
||||
const searchHref = 'findAllByAuthorizedExternalSource';
|
||||
|
||||
return this.searchBy(searchHref, options).pipe(
|
||||
filter((type: RemoteData<PaginatedList<ItemType>>) => !type.isResponsePending));
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to verify if there are one or more entities available. To use with external source import.
|
||||
*/
|
||||
hasMoreThanOneAuthorizedImport(): Observable<boolean> {
|
||||
const findListOptions: FindListOptions = {
|
||||
elementsPerPage: 2,
|
||||
currentPage: 1
|
||||
};
|
||||
return this.getAllAuthorizedRelationshipTypeImport(findListOptions).pipe(
|
||||
map((result: RemoteData<PaginatedList<ItemType>>) => {
|
||||
let output: boolean;
|
||||
if (result.payload) {
|
||||
output = ( result.payload.page.length > 1 );
|
||||
} else {
|
||||
output = false;
|
||||
}
|
||||
return output;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the allowed relationship types for an entity type
|
||||
* @param entityTypeId
|
||||
|
@@ -14,6 +14,7 @@ export enum FeatureID {
|
||||
IsCollectionAdmin = 'isCollectionAdmin',
|
||||
IsCommunityAdmin = 'isCommunityAdmin',
|
||||
CanDownload = 'canDownload',
|
||||
CanRequestACopy = 'canRequestACopy',
|
||||
CanManageVersions = 'canManageVersions',
|
||||
CanManageBitstreamBundles = 'canManageBitstreamBundles',
|
||||
CanManageRelationships = 'canManageRelationships',
|
||||
@@ -21,4 +22,7 @@ export enum FeatureID {
|
||||
CanManagePolicies = 'canManagePolicies',
|
||||
CanMakePrivate = 'canMakePrivate',
|
||||
CanMove = 'canMove',
|
||||
CanEditVersion = 'canEditVersion',
|
||||
CanDeleteVersion = 'canDeleteVersion',
|
||||
CanCreateVersion = 'canCreateVersion',
|
||||
}
|
||||
|
@@ -31,7 +31,7 @@ describe('ItemDataService', () => {
|
||||
},
|
||||
removeByHrefSubstring(href: string) {
|
||||
// Do nothing
|
||||
}
|
||||
},
|
||||
}) as RequestService;
|
||||
const rdbService = getMockRemoteDataBuildService();
|
||||
|
||||
@@ -184,4 +184,14 @@ describe('ItemDataService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('when cache is invalidated', () => {
|
||||
beforeEach(() => {
|
||||
service = initTestService();
|
||||
});
|
||||
it('should call setStaleByHrefSubstring', () => {
|
||||
service.invalidateItemCache('uuid');
|
||||
expect(requestService.setStaleByHrefSubstring).toHaveBeenCalledWith('item/uuid');
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
@@ -59,6 +59,7 @@ export class ItemDataService extends DataService<Item> {
|
||||
* Get the endpoint for browsing items
|
||||
* (When options.sort.field is empty, the default field to browse by will be 'dc.date.issued')
|
||||
* @param {FindListOptions} options
|
||||
* @param linkPath
|
||||
* @returns {Observable<string>}
|
||||
*/
|
||||
public getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable<string> {
|
||||
@@ -287,4 +288,13 @@ export class ItemDataService extends DataService<Item> {
|
||||
switchMap((url: string) => this.halService.getEndpoint('bitstreams', `${url}/${itemId}`))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate the cache of the item
|
||||
* @param itemUUID
|
||||
*/
|
||||
invalidateItemCache(itemUUID: string) {
|
||||
this.requestService.setStaleByHrefSubstring('item/' + itemUUID);
|
||||
}
|
||||
|
||||
}
|
||||
|
95
src/app/core/data/item-request-data.service.spec.ts
Normal file
95
src/app/core/data/item-request-data.service.spec.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { ItemRequestDataService } from './item-request-data.service';
|
||||
import { HALEndpointService } from '../shared/hal-endpoint.service';
|
||||
import { RequestService } from './request.service';
|
||||
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
|
||||
import { of as observableOf } from 'rxjs';
|
||||
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
|
||||
import { ItemRequest } from '../shared/item-request.model';
|
||||
import { PostRequest } from './request.models';
|
||||
import { RequestCopyEmail } from '../../request-copy/email-request-copy/request-copy-email.model';
|
||||
import { RestRequestMethod } from './rest-request-method';
|
||||
|
||||
describe('ItemRequestDataService', () => {
|
||||
let service: ItemRequestDataService;
|
||||
|
||||
let requestService: RequestService;
|
||||
let rdbService: RemoteDataBuildService;
|
||||
let halService: HALEndpointService;
|
||||
|
||||
const restApiEndpoint = 'rest/api/endpoint/';
|
||||
const requestId = 'request-id';
|
||||
let itemRequest: ItemRequest;
|
||||
|
||||
beforeEach(() => {
|
||||
itemRequest = Object.assign(new ItemRequest(), {
|
||||
token: 'item-request-token',
|
||||
});
|
||||
requestService = jasmine.createSpyObj('requestService', {
|
||||
generateRequestId: requestId,
|
||||
send: '',
|
||||
});
|
||||
rdbService = jasmine.createSpyObj('rdbService', {
|
||||
buildFromRequestUUID: createSuccessfulRemoteDataObject$(itemRequest),
|
||||
});
|
||||
halService = jasmine.createSpyObj('halService', {
|
||||
getEndpoint: observableOf(restApiEndpoint),
|
||||
});
|
||||
|
||||
service = new ItemRequestDataService(requestService, rdbService, null, null, halService, null, null, null);
|
||||
});
|
||||
|
||||
describe('requestACopy', () => {
|
||||
it('should send a POST request containing the provided item request', (done) => {
|
||||
service.requestACopy(itemRequest).subscribe(() => {
|
||||
expect(requestService.send).toHaveBeenCalledWith(new PostRequest(requestId, restApiEndpoint, itemRequest));
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('grant', () => {
|
||||
let email: RequestCopyEmail;
|
||||
|
||||
beforeEach(() => {
|
||||
email = new RequestCopyEmail('subject', 'message');
|
||||
});
|
||||
|
||||
it('should send a PUT request containing the correct properties', (done) => {
|
||||
service.grant(itemRequest.token, email, true).subscribe(() => {
|
||||
expect(requestService.send).toHaveBeenCalledWith(jasmine.objectContaining({
|
||||
method: RestRequestMethod.PUT,
|
||||
body: JSON.stringify({
|
||||
acceptRequest: true,
|
||||
responseMessage: email.message,
|
||||
subject: email.subject,
|
||||
suggestOpenAccess: true,
|
||||
}),
|
||||
}));
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('deny', () => {
|
||||
let email: RequestCopyEmail;
|
||||
|
||||
beforeEach(() => {
|
||||
email = new RequestCopyEmail('subject', 'message');
|
||||
});
|
||||
|
||||
it('should send a PUT request containing the correct properties', (done) => {
|
||||
service.deny(itemRequest.token, email).subscribe(() => {
|
||||
expect(requestService.send).toHaveBeenCalledWith(jasmine.objectContaining({
|
||||
method: RestRequestMethod.PUT,
|
||||
body: JSON.stringify({
|
||||
acceptRequest: false,
|
||||
responseMessage: email.message,
|
||||
subject: email.subject,
|
||||
suggestOpenAccess: false,
|
||||
}),
|
||||
}));
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
131
src/app/core/data/item-request-data.service.ts
Normal file
131
src/app/core/data/item-request-data.service.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { distinctUntilChanged, filter, find, map } from 'rxjs/operators';
|
||||
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
|
||||
import { HALEndpointService } from '../shared/hal-endpoint.service';
|
||||
import { getFirstCompletedRemoteData, sendRequest } from '../shared/operators';
|
||||
import { RemoteData } from './remote-data';
|
||||
import { PostRequest, PutRequest } from './request.models';
|
||||
import { RequestService } from './request.service';
|
||||
import { ItemRequest } from '../shared/item-request.model';
|
||||
import { hasValue, isNotEmpty } from '../../shared/empty.util';
|
||||
import { DataService } from './data.service';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { CoreState } from '../core.reducers';
|
||||
import { ObjectCacheService } from '../cache/object-cache.service';
|
||||
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||
import { DefaultChangeAnalyzer } from './default-change-analyzer.service';
|
||||
import { RequestCopyEmail } from '../../request-copy/email-request-copy/request-copy-email.model';
|
||||
import { HttpOptions } from '../dspace-rest/dspace-rest.service';
|
||||
|
||||
/**
|
||||
* A service responsible for fetching/sending data from/to the REST API on the itemrequests endpoint
|
||||
*/
|
||||
@Injectable(
|
||||
{
|
||||
providedIn: 'root',
|
||||
}
|
||||
)
|
||||
export class ItemRequestDataService extends DataService<ItemRequest> {
|
||||
|
||||
protected linkPath = 'itemrequests';
|
||||
|
||||
constructor(
|
||||
protected requestService: RequestService,
|
||||
protected rdbService: RemoteDataBuildService,
|
||||
protected store: Store<CoreState>,
|
||||
protected objectCache: ObjectCacheService,
|
||||
protected halService: HALEndpointService,
|
||||
protected notificationsService: NotificationsService,
|
||||
protected http: HttpClient,
|
||||
protected comparator: DefaultChangeAnalyzer<ItemRequest>,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
getItemRequestEndpoint(): Observable<string> {
|
||||
return this.halService.getEndpoint(this.linkPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the endpoint for an {@link ItemRequest} by their token
|
||||
* @param token
|
||||
*/
|
||||
getItemRequestEndpointByToken(token: string): Observable<string> {
|
||||
return this.halService.getEndpoint(this.linkPath).pipe(
|
||||
filter((href: string) => isNotEmpty(href)),
|
||||
map((href: string) => `${href}/${token}`));
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a copy of an item
|
||||
* @param itemRequest
|
||||
*/
|
||||
requestACopy(itemRequest: ItemRequest): Observable<RemoteData<ItemRequest>> {
|
||||
const requestId = this.requestService.generateRequestId();
|
||||
|
||||
const href$ = this.getItemRequestEndpoint();
|
||||
|
||||
href$.pipe(
|
||||
find((href: string) => hasValue(href)),
|
||||
map((href: string) => {
|
||||
const request = new PostRequest(requestId, href, itemRequest);
|
||||
this.requestService.send(request);
|
||||
})
|
||||
).subscribe();
|
||||
|
||||
return this.rdbService.buildFromRequestUUID<ItemRequest>(requestId).pipe(
|
||||
getFirstCompletedRemoteData()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deny the request of an item
|
||||
* @param token Token of the {@link ItemRequest}
|
||||
* @param email Email to send back to the user requesting the item
|
||||
*/
|
||||
deny(token: string, email: RequestCopyEmail): Observable<RemoteData<ItemRequest>> {
|
||||
return this.process(token, email, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant the request of an item
|
||||
* @param token Token of the {@link ItemRequest}
|
||||
* @param email Email to send back to the user requesting the item
|
||||
* @param suggestOpenAccess Whether or not to suggest the item to become open access
|
||||
*/
|
||||
grant(token: string, email: RequestCopyEmail, suggestOpenAccess = false): Observable<RemoteData<ItemRequest>> {
|
||||
return this.process(token, email, true, suggestOpenAccess);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the request of an item
|
||||
* @param token Token of the {@link ItemRequest}
|
||||
* @param email Email to send back to the user requesting the item
|
||||
* @param grant Grant or deny the request (true = grant, false = deny)
|
||||
* @param suggestOpenAccess Whether or not to suggest the item to become open access
|
||||
*/
|
||||
process(token: string, email: RequestCopyEmail, grant: boolean, suggestOpenAccess = false): Observable<RemoteData<ItemRequest>> {
|
||||
const requestId = this.requestService.generateRequestId();
|
||||
|
||||
this.getItemRequestEndpointByToken(token).pipe(
|
||||
distinctUntilChanged(),
|
||||
map((endpointURL: string) => {
|
||||
const options: HttpOptions = Object.create({});
|
||||
let headers = new HttpHeaders();
|
||||
headers = headers.append('Content-Type', 'application/json');
|
||||
options.headers = headers;
|
||||
return new PutRequest(requestId, endpointURL, JSON.stringify({
|
||||
acceptRequest: grant,
|
||||
responseMessage: email.message,
|
||||
subject: email.subject,
|
||||
suggestOpenAccess,
|
||||
}), options);
|
||||
}),
|
||||
sendRequest(this.requestService)).subscribe();
|
||||
|
||||
return this.rdbService.buildFromRequestUUID(requestId);
|
||||
}
|
||||
|
||||
}
|
181
src/app/core/data/version-data.service.spec.ts
Normal file
181
src/app/core/data/version-data.service.spec.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { of as observableOf } from 'rxjs';
|
||||
import { TestScheduler } from 'rxjs/testing';
|
||||
|
||||
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
|
||||
import { ObjectCacheService } from '../cache/object-cache.service';
|
||||
import { HALEndpointService } from '../shared/hal-endpoint.service';
|
||||
import { RequestService } from './request.service';
|
||||
import { PageInfo } from '../shared/page-info.model';
|
||||
import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
|
||||
import { RequestEntry } from './request.reducer';
|
||||
import { HrefOnlyDataService } from './href-only-data.service';
|
||||
import { getMockHrefOnlyDataService } from '../../shared/mocks/href-only-data.service.mock';
|
||||
|
||||
import { Store } from '@ngrx/store';
|
||||
import { CoreState } from '../core.reducers';
|
||||
import { RestResponse } from '../cache/response.models';
|
||||
import { cold, getTestScheduler, hot } from 'jasmine-marbles';
|
||||
import { Item } from '../shared/item.model';
|
||||
import { VersionDataService } from './version-data.service';
|
||||
import { Version } from '../shared/version.model';
|
||||
import { VersionHistory } from '../shared/version-history.model';
|
||||
import { followLink } from '../../shared/utils/follow-link-config.model';
|
||||
|
||||
|
||||
describe('VersionDataService test', () => {
|
||||
let scheduler: TestScheduler;
|
||||
let service: VersionDataService;
|
||||
let requestService: RequestService;
|
||||
let rdbService: RemoteDataBuildService;
|
||||
let objectCache: ObjectCacheService;
|
||||
let halService: HALEndpointService;
|
||||
let hrefOnlyDataService: HrefOnlyDataService;
|
||||
let responseCacheEntry: RequestEntry;
|
||||
|
||||
const item = Object.assign(new Item(), {
|
||||
id: '1234-1234',
|
||||
uuid: '1234-1234',
|
||||
bundles: observableOf({}),
|
||||
metadata: {
|
||||
'dc.title': [
|
||||
{
|
||||
language: 'en_US',
|
||||
value: 'This is just another title'
|
||||
}
|
||||
],
|
||||
'dc.type': [
|
||||
{
|
||||
language: null,
|
||||
value: 'Article'
|
||||
}
|
||||
],
|
||||
'dc.contributor.author': [
|
||||
{
|
||||
language: 'en_US',
|
||||
value: 'Smith, Donald'
|
||||
}
|
||||
],
|
||||
'dc.date.issued': [
|
||||
{
|
||||
language: null,
|
||||
value: '2015-06-26'
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
const itemRD = createSuccessfulRemoteDataObject(item);
|
||||
|
||||
const versionHistory = Object.assign(new VersionHistory(), {
|
||||
id: '1',
|
||||
draftVersion: true,
|
||||
});
|
||||
|
||||
const mockVersion: Version = Object.assign(new Version(), {
|
||||
item: createSuccessfulRemoteDataObject$(item),
|
||||
versionhistory: createSuccessfulRemoteDataObject$(versionHistory),
|
||||
version: 1,
|
||||
});
|
||||
const mockVersionRD = createSuccessfulRemoteDataObject(mockVersion);
|
||||
|
||||
const endpointURL = `https://rest.api/rest/api/versioning/versions`;
|
||||
const findByIdRequestURL = `https://rest.api/rest/api/versioning/versions/${mockVersion.id}`;
|
||||
const findByIdRequestURL$ = observableOf(findByIdRequestURL);
|
||||
|
||||
const requestUUID = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a';
|
||||
|
||||
objectCache = {} as ObjectCacheService;
|
||||
const notificationsService = {} as NotificationsService;
|
||||
const http = {} as HttpClient;
|
||||
const comparator = {} as any;
|
||||
const comparatorEntry = {} as any;
|
||||
const store = {} as Store<CoreState>;
|
||||
const pageInfo = new PageInfo();
|
||||
|
||||
function initTestService() {
|
||||
hrefOnlyDataService = getMockHrefOnlyDataService();
|
||||
return new VersionDataService(
|
||||
requestService,
|
||||
rdbService,
|
||||
store,
|
||||
objectCache,
|
||||
halService,
|
||||
notificationsService,
|
||||
http,
|
||||
comparatorEntry
|
||||
);
|
||||
}
|
||||
|
||||
describe('', () => {
|
||||
beforeEach(() => {
|
||||
|
||||
scheduler = getTestScheduler();
|
||||
|
||||
halService = jasmine.createSpyObj('halService', {
|
||||
getEndpoint: cold('a', { a: endpointURL })
|
||||
});
|
||||
responseCacheEntry = new RequestEntry();
|
||||
responseCacheEntry.request = { href: 'https://rest.api/' } as any;
|
||||
responseCacheEntry.response = new RestResponse(true, 200, 'Success');
|
||||
|
||||
requestService = jasmine.createSpyObj('requestService', {
|
||||
generateRequestId: requestUUID,
|
||||
send: true,
|
||||
removeByHrefSubstring: {},
|
||||
getByHref: observableOf(responseCacheEntry),
|
||||
getByUUID: observableOf(responseCacheEntry),
|
||||
});
|
||||
rdbService = jasmine.createSpyObj('rdbService', {
|
||||
buildSingle: hot('(a|)', {
|
||||
a: mockVersionRD
|
||||
})
|
||||
});
|
||||
|
||||
service = initTestService();
|
||||
|
||||
spyOn((service as any), 'findByHref').and.callThrough();
|
||||
spyOn((service as any), 'getIDHrefObs').and.returnValue(findByIdRequestURL$);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
service = null;
|
||||
});
|
||||
|
||||
describe('getHistoryFromVersion', () => {
|
||||
it('should proxy the call to DataService.findByHref', () => {
|
||||
scheduler.schedule(() => service.getHistoryFromVersion(mockVersion, true, true));
|
||||
scheduler.flush();
|
||||
|
||||
expect((service as any).findByHref).toHaveBeenCalledWith(findByIdRequestURL$, true, true, followLink('versionhistory'));
|
||||
});
|
||||
|
||||
it('should return a VersionHistory', () => {
|
||||
const result = service.getHistoryFromVersion(mockVersion, true, true);
|
||||
const expected = cold('(a|)', {
|
||||
a: versionHistory
|
||||
});
|
||||
expect(result).toBeObservable(expected);
|
||||
});
|
||||
|
||||
it('should return an EMPTY observable when version is not given', () => {
|
||||
const result = service.getHistoryFromVersion(null);
|
||||
const expected = cold('|');
|
||||
expect(result).toBeObservable(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getHistoryIdFromVersion', () => {
|
||||
it('should return the version history id', () => {
|
||||
spyOn((service as any), 'getHistoryFromVersion').and.returnValue(observableOf(versionHistory));
|
||||
|
||||
const result = service.getHistoryIdFromVersion(mockVersion);
|
||||
const expected = cold('(a|)', {
|
||||
a: versionHistory.id
|
||||
});
|
||||
expect(result).toBeObservable(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
});
|
@@ -10,10 +10,14 @@ import { HALEndpointService } from '../shared/hal-endpoint.service';
|
||||
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { DefaultChangeAnalyzer } from './default-change-analyzer.service';
|
||||
import { FindListOptions } from './request.models';
|
||||
import { Observable } from 'rxjs';
|
||||
import { EMPTY, Observable } from 'rxjs';
|
||||
import { dataService } from '../cache/builders/build-decorators';
|
||||
import { VERSION } from '../shared/version.resource-type';
|
||||
import { VersionHistory } from '../shared/version-history.model';
|
||||
import { followLink } from '../../shared/utils/follow-link-config.model';
|
||||
import { getFirstSucceededRemoteDataPayload } from '../shared/operators';
|
||||
import { map, switchMap } from 'rxjs/operators';
|
||||
import { isNotEmpty } from '../../shared/empty.util';
|
||||
|
||||
/**
|
||||
* Service responsible for handling requests related to the Version object
|
||||
@@ -36,9 +40,29 @@ export class VersionDataService extends DataService<Version> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the endpoint for browsing versions
|
||||
* Get the version history for the given version
|
||||
* @param version
|
||||
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
|
||||
* no valid cached version. Defaults to true
|
||||
* @param reRequestOnStale Whether or not the request should automatically be re-
|
||||
* requested after the response becomes stale
|
||||
*/
|
||||
getBrowseEndpoint(options: FindListOptions = {}, linkPath?: string): Observable<string> {
|
||||
return this.halService.getEndpoint(this.linkPath);
|
||||
getHistoryFromVersion(version: Version, useCachedVersionIfAvailable = false, reRequestOnStale = true): Observable<VersionHistory> {
|
||||
return isNotEmpty(version) ? this.findById(version.id, useCachedVersionIfAvailable, reRequestOnStale, followLink('versionhistory')).pipe(
|
||||
getFirstSucceededRemoteDataPayload(),
|
||||
switchMap((res: Version) => res.versionhistory),
|
||||
getFirstSucceededRemoteDataPayload(),
|
||||
) : EMPTY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ID of the version history for the given version
|
||||
* @param version
|
||||
*/
|
||||
getHistoryIdFromVersion(version: Version): Observable<string> {
|
||||
return this.getHistoryFromVersion(version).pipe(
|
||||
map((versionHistory: VersionHistory) => versionHistory.id),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -6,6 +6,14 @@ import { NotificationsServiceStub } from '../../shared/testing/notifications-ser
|
||||
import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub';
|
||||
import { getMockRequestService } from '../../shared/mocks/request.service.mock';
|
||||
import { VersionDataService } from './version-data.service';
|
||||
import { fakeAsync, waitForAsync } from '@angular/core/testing';
|
||||
import { VersionHistory } from '../shared/version-history.model';
|
||||
import { Version } from '../shared/version.model';
|
||||
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
|
||||
import { createPaginatedList } from '../../shared/testing/utils.test';
|
||||
import { Item } from '../shared/item.model';
|
||||
import { of } from 'rxjs';
|
||||
import SpyObj = jasmine.SpyObj;
|
||||
|
||||
const url = 'fake-url';
|
||||
|
||||
@@ -16,9 +24,97 @@ describe('VersionHistoryDataService', () => {
|
||||
let notificationsService: any;
|
||||
let rdbService: RemoteDataBuildService;
|
||||
let objectCache: ObjectCacheService;
|
||||
let versionService: VersionDataService;
|
||||
let versionService: SpyObj<VersionDataService>;
|
||||
let halService: any;
|
||||
|
||||
const versionHistoryId = 'version-history-id';
|
||||
const versionHistoryDraftId = 'version-history-draft-id';
|
||||
const version1Id = 'version-1-id';
|
||||
const version2Id = 'version-1-id';
|
||||
const item1Uuid = 'item-1-uuid';
|
||||
const item2Uuid = 'item-2-uuid';
|
||||
const versionHistory = Object.assign(new VersionHistory(), {
|
||||
id: versionHistoryId,
|
||||
draftVersion: false,
|
||||
});
|
||||
const versionHistoryDraft = Object.assign(new VersionHistory(), {
|
||||
id: versionHistoryDraftId,
|
||||
draftVersion: true,
|
||||
});
|
||||
const version1 = Object.assign(new Version(), {
|
||||
id: version1Id,
|
||||
version: 1,
|
||||
created: new Date(2020, 1, 1),
|
||||
summary: 'first version',
|
||||
versionhistory: createSuccessfulRemoteDataObject$(versionHistory),
|
||||
_links: {
|
||||
self: {
|
||||
href: 'version1-url',
|
||||
},
|
||||
},
|
||||
});
|
||||
const version2 = Object.assign(new Version(), {
|
||||
id: version2Id,
|
||||
version: 2,
|
||||
summary: 'second version',
|
||||
created: new Date(2020, 1, 2),
|
||||
versionhistory: createSuccessfulRemoteDataObject$(versionHistory),
|
||||
_links: {
|
||||
self: {
|
||||
href: 'version2-url',
|
||||
},
|
||||
},
|
||||
});
|
||||
const versions = [version1, version2];
|
||||
versionHistory.versions = createSuccessfulRemoteDataObject$(createPaginatedList(versions));
|
||||
const item1 = Object.assign(new Item(), {
|
||||
uuid: item1Uuid,
|
||||
handle: '123456789/1',
|
||||
version: createSuccessfulRemoteDataObject$(version1),
|
||||
_links: {
|
||||
self: {
|
||||
href: '/items/' + item2Uuid,
|
||||
}
|
||||
}
|
||||
});
|
||||
const item2 = Object.assign(new Item(), {
|
||||
uuid: item2Uuid,
|
||||
handle: '123456789/2',
|
||||
version: createSuccessfulRemoteDataObject$(version2),
|
||||
_links: {
|
||||
self: {
|
||||
href: '/items/' + item2Uuid,
|
||||
}
|
||||
}
|
||||
});
|
||||
const items = [item1, item2];
|
||||
version1.item = createSuccessfulRemoteDataObject$(item1);
|
||||
version2.item = createSuccessfulRemoteDataObject$(item2);
|
||||
|
||||
/**
|
||||
* Create a VersionHistoryDataService used for testing
|
||||
* @param requestEntry$ Supply a requestEntry to be returned by the REST API (optional)
|
||||
*/
|
||||
function createService(requestEntry$?) {
|
||||
requestService = getMockRequestService(requestEntry$);
|
||||
rdbService = jasmine.createSpyObj('rdbService', {
|
||||
buildList: jasmine.createSpy('buildList'),
|
||||
buildFromRequestUUID: jasmine.createSpy('buildFromRequestUUID'),
|
||||
});
|
||||
objectCache = jasmine.createSpyObj('objectCache', {
|
||||
remove: jasmine.createSpy('remove')
|
||||
});
|
||||
versionService = jasmine.createSpyObj('objectCache', {
|
||||
findByHref: jasmine.createSpy('findByHref'),
|
||||
findAllByHref: jasmine.createSpy('findAllByHref'),
|
||||
getHistoryFromVersion: jasmine.createSpy('getHistoryFromVersion'),
|
||||
});
|
||||
halService = new HALEndpointServiceStub(url);
|
||||
notificationsService = new NotificationsServiceStub();
|
||||
|
||||
service = new VersionHistoryDataService(requestService, rdbService, null, objectCache, halService, notificationsService, versionService, null, null);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
createService();
|
||||
});
|
||||
@@ -35,24 +131,70 @@ describe('VersionHistoryDataService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a VersionHistoryDataService used for testing
|
||||
* @param requestEntry$ Supply a requestEntry to be returned by the REST API (optional)
|
||||
*/
|
||||
function createService(requestEntry$?) {
|
||||
requestService = getMockRequestService(requestEntry$);
|
||||
rdbService = jasmine.createSpyObj('rdbService', {
|
||||
buildList: jasmine.createSpy('buildList')
|
||||
describe('when getVersions is called', () => {
|
||||
beforeEach(waitForAsync(() => {
|
||||
service.getVersions(versionHistoryId);
|
||||
}));
|
||||
it('findAllByHref should have been called', () => {
|
||||
expect(versionService.findAllByHref).toHaveBeenCalled();
|
||||
});
|
||||
objectCache = jasmine.createSpyObj('objectCache', {
|
||||
remove: jasmine.createSpy('remove')
|
||||
});
|
||||
versionService = jasmine.createSpyObj('objectCache', {
|
||||
findAllByHref: jasmine.createSpy('findAllByHref')
|
||||
});
|
||||
halService = new HALEndpointServiceStub(url);
|
||||
notificationsService = new NotificationsServiceStub();
|
||||
});
|
||||
|
||||
describe('when getBrowseEndpoint is called', () => {
|
||||
it('should return the correct value', () => {
|
||||
service.getBrowseEndpoint().subscribe((res) => {
|
||||
expect(res).toBe(url + '/versionhistories');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when getVersionsEndpoint is called', () => {
|
||||
it('should return the correct value', () => {
|
||||
service.getVersionsEndpoint(versionHistoryId).subscribe((res) => {
|
||||
expect(res).toBe(url + '/versions');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when cache is invalidated', () => {
|
||||
it('should call setStaleByHrefSubstring', () => {
|
||||
service.invalidateVersionHistoryCache(versionHistoryId);
|
||||
expect(requestService.setStaleByHrefSubstring).toHaveBeenCalledWith('versioning/versionhistories/' + versionHistoryId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isLatest$', () => {
|
||||
beforeEach(waitForAsync(() => {
|
||||
spyOn(service, 'getLatestVersion$').and.returnValue(of(version2));
|
||||
}));
|
||||
it('should return false for version1', () => {
|
||||
service.isLatest$(version1).subscribe((res) => {
|
||||
expect(res).toBe(false);
|
||||
});
|
||||
});
|
||||
it('should return true for version2', () => {
|
||||
service.isLatest$(version2).subscribe((res) => {
|
||||
expect(res).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasDraftVersion$', () => {
|
||||
beforeEach(waitForAsync(() => {
|
||||
versionService.findByHref.and.returnValue(createSuccessfulRemoteDataObject$<Version>(version1));
|
||||
}));
|
||||
it('should return false if draftVersion is false', fakeAsync(() => {
|
||||
versionService.getHistoryFromVersion.and.returnValue(of(versionHistory));
|
||||
service.hasDraftVersion$('href').subscribe((res) => {
|
||||
expect(res).toBeFalse();
|
||||
});
|
||||
}));
|
||||
it('should return true if draftVersion is true', fakeAsync(() => {
|
||||
versionService.getHistoryFromVersion.and.returnValue(of(versionHistoryDraft));
|
||||
service.hasDraftVersion$('href').subscribe((res) => {
|
||||
expect(res).toBeTrue();
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
service = new VersionHistoryDataService(requestService, rdbService, null, objectCache, halService, notificationsService, versionService, null, null);
|
||||
}
|
||||
});
|
||||
|
@@ -8,19 +8,30 @@ import { CoreState } from '../core.reducers';
|
||||
import { ObjectCacheService } from '../cache/object-cache.service';
|
||||
import { HALEndpointService } from '../shared/hal-endpoint.service';
|
||||
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||
import { DefaultChangeAnalyzer } from './default-change-analyzer.service';
|
||||
import { FindListOptions } from './request.models';
|
||||
import { Observable } from 'rxjs';
|
||||
import { FindListOptions, PostRequest, RestRequest } from './request.models';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { PaginatedSearchOptions } from '../../shared/search/paginated-search-options.model';
|
||||
import { RemoteData } from './remote-data';
|
||||
import { PaginatedList } from './paginated-list.model';
|
||||
import { Version } from '../shared/version.model';
|
||||
import { map, switchMap } from 'rxjs/operators';
|
||||
import { filter, map, switchMap, take } from 'rxjs/operators';
|
||||
import { dataService } from '../cache/builders/build-decorators';
|
||||
import { VERSION_HISTORY } from '../shared/version-history.resource-type';
|
||||
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
|
||||
import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
|
||||
import { VersionDataService } from './version-data.service';
|
||||
import { HttpOptions } from '../dspace-rest/dspace-rest.service';
|
||||
import {
|
||||
getAllSucceededRemoteData,
|
||||
getFirstCompletedRemoteData,
|
||||
getFirstSucceededRemoteDataPayload,
|
||||
getRemoteDataPayload,
|
||||
sendRequest
|
||||
} from '../shared/operators';
|
||||
import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
|
||||
import { hasValueOperator } from '../../shared/empty.util';
|
||||
import { Item } from '../shared/item.model';
|
||||
|
||||
/**
|
||||
* Service responsible for handling requests related to the VersionHistory object
|
||||
@@ -79,4 +90,129 @@ export class VersionHistoryDataService extends DataService<VersionHistory> {
|
||||
|
||||
return this.versionDataService.findAllByHref(hrefObs, undefined, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new version for an item
|
||||
* @param itemHref the item for which create a new version
|
||||
* @param summary the summary of the new version
|
||||
*/
|
||||
createVersion(itemHref: string, summary: string): Observable<RemoteData<Version>> {
|
||||
const requestOptions: HttpOptions = Object.create({});
|
||||
let requestHeaders = new HttpHeaders();
|
||||
requestHeaders = requestHeaders.append('Content-Type', 'text/uri-list');
|
||||
requestOptions.headers = requestHeaders;
|
||||
|
||||
return this.halService.getEndpoint(this.versionsEndpoint).pipe(
|
||||
take(1),
|
||||
map((endpointUrl: string) => (summary?.length > 0) ? `${endpointUrl}?summary=${summary}` : `${endpointUrl}`),
|
||||
map((endpointURL: string) => new PostRequest(this.requestService.generateRequestId(), endpointURL, itemHref, requestOptions)),
|
||||
sendRequest(this.requestService),
|
||||
switchMap((restRequest: RestRequest) => this.rdbService.buildFromRequestUUID(restRequest.uuid)),
|
||||
getFirstCompletedRemoteData()
|
||||
) as Observable<RemoteData<Version>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest version in a version history
|
||||
* @param versionHistory
|
||||
*/
|
||||
getLatestVersionFromHistory$(versionHistory: VersionHistory): Observable<Version> {
|
||||
|
||||
// Pagination options to fetch a single version on the first page (this is the latest version in the history)
|
||||
const latestVersionOptions = Object.assign(new PaginationComponentOptions(), {
|
||||
id: 'item-newest-version-options',
|
||||
currentPage: 1,
|
||||
pageSize: 1
|
||||
});
|
||||
|
||||
const latestVersionSearch = new PaginatedSearchOptions({pagination: latestVersionOptions});
|
||||
|
||||
return this.getVersions(versionHistory.id, latestVersionSearch, false, true, followLink('item')).pipe(
|
||||
getAllSucceededRemoteData(),
|
||||
getRemoteDataPayload(),
|
||||
hasValueOperator(),
|
||||
filter((versions) => versions.page.length > 0),
|
||||
map((versions) => versions.page[0])
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest version (return null if the specified version is null)
|
||||
* @param version
|
||||
*/
|
||||
getLatestVersion$(version: Version): Observable<Version> {
|
||||
// retrieve again version, including with versionHistory
|
||||
return version.id ? this.versionDataService.findById(version.id, false, true, followLink('versionhistory')).pipe(
|
||||
getFirstSucceededRemoteDataPayload(),
|
||||
switchMap((res) => res.versionhistory),
|
||||
getFirstSucceededRemoteDataPayload(),
|
||||
switchMap((versionHistoryRD) => this.getLatestVersionFromHistory$(versionHistoryRD)),
|
||||
) : of(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given version is the latest (return null if `version` is null)
|
||||
* @param version
|
||||
* @returns `true` if the specified version is the latest one, `false` otherwise, or `null` if the specified version is null
|
||||
*/
|
||||
isLatest$(version: Version): Observable<boolean> {
|
||||
return version ? this.getLatestVersion$(version).pipe(
|
||||
take(1),
|
||||
switchMap((latestVersion) => of(version.version === latestVersion.version))
|
||||
) : of(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a worskpace item exists in the version history (return null if there is no version history)
|
||||
* @param versionHref the href of the version
|
||||
* @returns `true` if a workspace item exists, `false` otherwise, or `null` if a version history does not exist
|
||||
*/
|
||||
hasDraftVersion$(versionHref: string): Observable<boolean> {
|
||||
return this.versionDataService.findByHref(versionHref, true, true, followLink('versionhistory')).pipe(
|
||||
getFirstCompletedRemoteData(),
|
||||
switchMap((res) => {
|
||||
if (res.hasSucceeded && !res.hasNoContent) {
|
||||
return of(res).pipe(
|
||||
getFirstSucceededRemoteDataPayload(),
|
||||
switchMap((version) => this.versionDataService.getHistoryFromVersion(version)),
|
||||
map((versionHistory) => versionHistory ? versionHistory.draftVersion : false),
|
||||
);
|
||||
} else {
|
||||
return of(false);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the item of the latest version in a version history
|
||||
* @param versionHistory
|
||||
*/
|
||||
getLatestVersionItemFromHistory$(versionHistory: VersionHistory): Observable<Item> {
|
||||
return this.getLatestVersionFromHistory$(versionHistory).pipe(
|
||||
switchMap((newLatestVersion) => newLatestVersion.item),
|
||||
getFirstSucceededRemoteDataPayload(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the item of the latest version from any version in the version history
|
||||
* @param version
|
||||
*/
|
||||
getVersionHistoryFromVersion$(version: Version): Observable<VersionHistory> {
|
||||
return this.versionDataService.getHistoryIdFromVersion(version).pipe(
|
||||
take(1),
|
||||
switchMap((res) => this.findById(res)),
|
||||
getFirstSucceededRemoteDataPayload(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate the cache of the version history
|
||||
* @param versionHistoryID
|
||||
*/
|
||||
invalidateVersionHistoryCache(versionHistoryID: string) {
|
||||
this.requestService.setStaleByHrefSubstring('versioning/versionhistories/' + versionHistoryID);
|
||||
}
|
||||
}
|
||||
|
@@ -56,11 +56,11 @@ describe('EPersonDataService', () => {
|
||||
}
|
||||
|
||||
function init() {
|
||||
restEndpointURL = 'https://dspace.4science.it/dspace-spring-rest/api/eperson';
|
||||
restEndpointURL = 'https://rest.api/dspace-spring-rest/api/eperson';
|
||||
epersonsEndpoint = `${restEndpointURL}/epersons`;
|
||||
epeople = [EPersonMock, EPersonMock2];
|
||||
epeople$ = createSuccessfulRemoteDataObject$(createPaginatedList([epeople]));
|
||||
rdbService = getMockRemoteDataBuildServiceHrefMap(undefined, { 'https://dspace.4science.it/dspace-spring-rest/api/eperson/epersons': epeople$ });
|
||||
rdbService = getMockRemoteDataBuildServiceHrefMap(undefined, { 'https://rest.api/dspace-spring-rest/api/eperson/epersons': epeople$ });
|
||||
halService = new HALEndpointServiceStub(restEndpointURL);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
|
26
src/app/core/shared/content-source-set-serializer.spec.ts
Normal file
26
src/app/core/shared/content-source-set-serializer.spec.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { ContentSourceSetSerializer } from './content-source-set-serializer';
|
||||
|
||||
describe('ContentSourceSetSerializer', () => {
|
||||
let serializer: ContentSourceSetSerializer;
|
||||
|
||||
beforeEach(() => {
|
||||
serializer = new ContentSourceSetSerializer();
|
||||
});
|
||||
|
||||
describe('Serialize', () => {
|
||||
it('should return all when the value is empty', () => {
|
||||
expect(serializer.Serialize('')).toEqual('all');
|
||||
});
|
||||
it('should return the value when it is not empty', () => {
|
||||
expect(serializer.Serialize('test-value')).toEqual('test-value');
|
||||
});
|
||||
});
|
||||
describe('Deserialize', () => {
|
||||
it('should return an empty value when the value is \'all\'', () => {
|
||||
expect(serializer.Deserialize('all')).toEqual('');
|
||||
});
|
||||
it('should return the value when it is not \'all\'', () => {
|
||||
expect(serializer.Deserialize('test-value')).toEqual('test-value');
|
||||
});
|
||||
});
|
||||
});
|
31
src/app/core/shared/content-source-set-serializer.ts
Normal file
31
src/app/core/shared/content-source-set-serializer.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { isEmpty } from '../../shared/empty.util';
|
||||
|
||||
/**
|
||||
* Serializer to create convert the 'all' value supported by the server to an empty string and vice versa.
|
||||
*/
|
||||
export class ContentSourceSetSerializer {
|
||||
|
||||
/**
|
||||
* Method to serialize a setId
|
||||
* @param {string} setId
|
||||
* @returns {string} the provided set ID, unless when an empty set ID is provided. In that case, 'all' will be returned.
|
||||
*/
|
||||
Serialize(setId: string): any {
|
||||
if (isEmpty(setId)) {
|
||||
return 'all';
|
||||
}
|
||||
return setId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to deserialize a setId
|
||||
* @param {string} setId
|
||||
* @returns {string} the provided set ID. When 'all' is provided, an empty set ID will be returned.
|
||||
*/
|
||||
Deserialize(setId: string): string {
|
||||
if (setId === 'all') {
|
||||
return '';
|
||||
}
|
||||
return setId;
|
||||
}
|
||||
}
|
@@ -1,4 +1,4 @@
|
||||
import { autoserializeAs, deserializeAs, deserialize } from 'cerialize';
|
||||
import { autoserializeAs, deserialize, deserializeAs, serializeAs } from 'cerialize';
|
||||
import { HALLink } from './hal-link.model';
|
||||
import { MetadataConfig } from './metadata-config.model';
|
||||
import { CacheableObject } from '../cache/object-cache.reducer';
|
||||
@@ -6,6 +6,7 @@ import { typedObject } from '../cache/builders/build-decorators';
|
||||
import { CONTENT_SOURCE } from './content-source.resource-type';
|
||||
import { excludeFromEquals } from '../utilities/equals.decorators';
|
||||
import { ResourceType } from './resource-type';
|
||||
import { ContentSourceSetSerializer } from './content-source-set-serializer';
|
||||
|
||||
/**
|
||||
* The type of content harvesting used
|
||||
@@ -49,7 +50,8 @@ export class ContentSource extends CacheableObject {
|
||||
/**
|
||||
* OAI Specific set ID
|
||||
*/
|
||||
@autoserializeAs('oai_set_id')
|
||||
@deserializeAs(new ContentSourceSetSerializer(), 'oai_set_id')
|
||||
@serializeAs(new ContentSourceSetSerializer(), 'oai_set_id')
|
||||
oaiSetId: string;
|
||||
|
||||
/**
|
||||
@@ -70,6 +72,30 @@ export class ContentSource extends CacheableObject {
|
||||
*/
|
||||
metadataConfigs: MetadataConfig[];
|
||||
|
||||
/**
|
||||
* The current harvest status
|
||||
*/
|
||||
@autoserializeAs('harvest_status')
|
||||
harvestStatus: string;
|
||||
|
||||
/**
|
||||
* The last's harvest start time
|
||||
*/
|
||||
@autoserializeAs('harvest_start_time')
|
||||
harvestStartTime: string;
|
||||
|
||||
/**
|
||||
* When the collection was last harvested
|
||||
*/
|
||||
@autoserializeAs('last_harvested')
|
||||
lastHarvested: string;
|
||||
|
||||
/**
|
||||
* The current harvest message
|
||||
*/
|
||||
@autoserializeAs('harvest_message')
|
||||
message: string;
|
||||
|
||||
/**
|
||||
* The {@link HALLink}s for this ContentSource
|
||||
*/
|
||||
|
@@ -1,10 +1,15 @@
|
||||
import { autoserialize, deserialize } from 'cerialize';
|
||||
import { typedObject } from '../cache/builders/build-decorators';
|
||||
import { link, typedObject } from '../cache/builders/build-decorators';
|
||||
import { CacheableObject } from '../cache/object-cache.reducer';
|
||||
import { excludeFromEquals } from '../utilities/equals.decorators';
|
||||
import { EXTERNAL_SOURCE } from './external-source.resource-type';
|
||||
import { HALLink } from './hal-link.model';
|
||||
import { ResourceType } from './resource-type';
|
||||
import { RemoteData } from '../data/remote-data';
|
||||
import { PaginatedList } from '../data/paginated-list.model';
|
||||
import { Observable } from 'rxjs/internal/Observable';
|
||||
import { ITEM_TYPE } from './item-relationships/item-type.resource-type';
|
||||
import { ItemType } from './item-relationships/item-type.model';
|
||||
|
||||
/**
|
||||
* Model class for an external source
|
||||
@@ -38,6 +43,13 @@ export class ExternalSource extends CacheableObject {
|
||||
@autoserialize
|
||||
hierarchical: boolean;
|
||||
|
||||
/**
|
||||
* The list of entity types that are compatible with this external source
|
||||
* Will be undefined unless the entityTypes {@link HALLink} has been resolved.
|
||||
*/
|
||||
@link(ITEM_TYPE, true)
|
||||
entityTypes?: Observable<RemoteData<PaginatedList<ItemType>>>;
|
||||
|
||||
/**
|
||||
* The {@link HALLink}s for this ExternalSource
|
||||
*/
|
||||
@@ -45,5 +57,6 @@ export class ExternalSource extends CacheableObject {
|
||||
_links: {
|
||||
self: HALLink;
|
||||
entries: HALLink;
|
||||
entityTypes: HALLink;
|
||||
};
|
||||
}
|
||||
|
@@ -6,5 +6,9 @@ import { ResourceType } from '../resource-type';
|
||||
* Needs to be in a separate file to prevent circular
|
||||
* dependencies in webpack.
|
||||
*/
|
||||
|
||||
export const ITEM_TYPE = new ResourceType('entitytype');
|
||||
|
||||
/**
|
||||
* The unset entity type
|
||||
*/
|
||||
export const NONE_ENTITY_TYPE = 'none';
|
||||
|
90
src/app/core/shared/item-request.model.ts
Normal file
90
src/app/core/shared/item-request.model.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { autoserialize, deserialize } from 'cerialize';
|
||||
import { typedObject } from '../cache/builders/build-decorators';
|
||||
import { excludeFromEquals } from '../utilities/equals.decorators';
|
||||
import { ResourceType } from './resource-type';
|
||||
import { ITEM_REQUEST } from './item-request.resource-type';
|
||||
import { CacheableObject } from '../cache/object-cache.reducer';
|
||||
import { HALLink } from './hal-link.model';
|
||||
|
||||
/**
|
||||
* Model class for an ItemRequest
|
||||
*/
|
||||
@typedObject
|
||||
export class ItemRequest implements CacheableObject {
|
||||
static type = ITEM_REQUEST;
|
||||
|
||||
/**
|
||||
* The object type
|
||||
*/
|
||||
@excludeFromEquals
|
||||
@autoserialize
|
||||
type: ResourceType;
|
||||
|
||||
/**
|
||||
* opaque string which uniquely identifies this request
|
||||
*/
|
||||
@autoserialize
|
||||
token: string;
|
||||
|
||||
/**
|
||||
* true if the request is for all bitstreams of the item.
|
||||
*/
|
||||
@autoserialize
|
||||
allfiles: boolean;
|
||||
/**
|
||||
* email address of the person requesting the files.
|
||||
*/
|
||||
@autoserialize
|
||||
requestEmail: string;
|
||||
/**
|
||||
* Human-readable name of the person requesting the files.
|
||||
*/
|
||||
@autoserialize
|
||||
requestName: string;
|
||||
/**
|
||||
* arbitrary message provided by the person requesting the files.
|
||||
*/
|
||||
@autoserialize
|
||||
requestMessage: string;
|
||||
/**
|
||||
* date that the request was recorded.
|
||||
*/
|
||||
@autoserialize
|
||||
requestDate: string;
|
||||
/**
|
||||
* true if the request has been granted.
|
||||
*/
|
||||
@autoserialize
|
||||
acceptRequest: boolean;
|
||||
/**
|
||||
* date that the request was granted or denied.
|
||||
*/
|
||||
@autoserialize
|
||||
decisionDate: string;
|
||||
/**
|
||||
* date on which the request is considered expired.
|
||||
*/
|
||||
@autoserialize
|
||||
expires: string;
|
||||
/**
|
||||
* UUID of the requested Item.
|
||||
*/
|
||||
@autoserialize
|
||||
itemId: string;
|
||||
/**
|
||||
* UUID of the requested bitstream.
|
||||
*/
|
||||
@autoserialize
|
||||
bitstreamId: string;
|
||||
|
||||
/**
|
||||
* The {@link HALLink}s for this ItemRequest
|
||||
*/
|
||||
@deserialize
|
||||
_links: {
|
||||
self: HALLink;
|
||||
item: HALLink;
|
||||
bitstream: HALLink;
|
||||
};
|
||||
|
||||
}
|
9
src/app/core/shared/item-request.resource-type.ts
Normal file
9
src/app/core/shared/item-request.resource-type.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { ResourceType } from './resource-type';
|
||||
|
||||
/**
|
||||
* The resource type for ItemRequest.
|
||||
*
|
||||
* Needs to be in a separate file to prevent circular
|
||||
* dependencies in webpack.
|
||||
*/
|
||||
export const ITEM_REQUEST = new ResourceType('itemrequest');
|
@@ -1,8 +1,8 @@
|
||||
import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs';
|
||||
import { combineLatest as observableCombineLatest, Observable } from 'rxjs';
|
||||
import { Injectable, OnDestroy } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { map, switchMap, take } from 'rxjs/operators';
|
||||
import { followLink, FollowLinkConfig } from '../../../shared/utils/follow-link-config.model';
|
||||
import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model';
|
||||
import { LinkService } from '../../cache/builders/link.service';
|
||||
import { PaginatedList } from '../../data/paginated-list.model';
|
||||
import { ResponseParsingService } from '../../data/parsing.service';
|
||||
@@ -13,7 +13,7 @@ import { DSpaceObject } from '../dspace-object.model';
|
||||
import { GenericConstructor } from '../generic-constructor';
|
||||
import { HALEndpointService } from '../hal-endpoint.service';
|
||||
import { URLCombiner } from '../../url-combiner/url-combiner';
|
||||
import { hasValue, isEmpty, isNotEmpty, hasValueOperator } from '../../../shared/empty.util';
|
||||
import { hasValue, hasValueOperator, isNotEmpty } from '../../../shared/empty.util';
|
||||
import { SearchOptions } from '../../../shared/search/search-options.model';
|
||||
import { SearchFilterConfig } from '../../../shared/search/search-filter-config.model';
|
||||
import { SearchResponseParsingService } from '../../data/search-response-parsing.service';
|
||||
@@ -21,16 +21,11 @@ import { SearchObjects } from '../../../shared/search/search-objects.model';
|
||||
import { FacetValueResponseParsingService } from '../../data/facet-value-response-parsing.service';
|
||||
import { FacetConfigResponseParsingService } from '../../data/facet-config-response-parsing.service';
|
||||
import { PaginatedSearchOptions } from '../../../shared/search/paginated-search-options.model';
|
||||
import { Community } from '../community.model';
|
||||
import { CommunityDataService } from '../../data/community-data.service';
|
||||
import { ViewMode } from '../view-mode.model';
|
||||
import { DSpaceObjectDataService } from '../../data/dspace-object-data.service';
|
||||
import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service';
|
||||
import {
|
||||
getFirstSucceededRemoteData,
|
||||
getFirstCompletedRemoteData,
|
||||
getRemoteDataPayload
|
||||
} from '../operators';
|
||||
import { getFirstCompletedRemoteData, getRemoteDataPayload } from '../operators';
|
||||
import { RouteService } from '../../services/route.service';
|
||||
import { SearchResult } from '../../../shared/search/search-result.model';
|
||||
import { ListableObject } from '../../../shared/object-collection/shared/listable-object.model';
|
||||
@@ -395,48 +390,6 @@ export class SearchService implements OnDestroy {
|
||||
return this.rdb.buildFromHref(href);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a list of DSpaceObjects that can be used as a scope, based on the current scope
|
||||
* @param {string} scopeId UUID of the current scope, if the scope is empty, the repository wide scopes will be returned
|
||||
* @returns {Observable<DSpaceObject[]>} Emits a list of DSpaceObjects which represent possible scopes
|
||||
*/
|
||||
getScopes(scopeId?: string): Observable<DSpaceObject[]> {
|
||||
|
||||
if (isEmpty(scopeId)) {
|
||||
const top: Observable<Community[]> = this.communityService.findTop({ elementsPerPage: 9999 }).pipe(
|
||||
getFirstSucceededRemoteData(),
|
||||
map(
|
||||
(communities: RemoteData<PaginatedList<Community>>) => communities.payload.page
|
||||
)
|
||||
);
|
||||
return top;
|
||||
}
|
||||
|
||||
const scopeObject: Observable<RemoteData<DSpaceObject>> = this.dspaceObjectService.findById(scopeId).pipe(getFirstSucceededRemoteData());
|
||||
const scopeList: Observable<DSpaceObject[]> = scopeObject.pipe(
|
||||
switchMap((dsoRD: RemoteData<DSpaceObject>) => {
|
||||
if ((dsoRD.payload as any).type === Community.type.value) {
|
||||
const community: Community = dsoRD.payload as Community;
|
||||
this.linkService.resolveLinks(community, followLink('subcommunities'), followLink('collections'));
|
||||
return observableCombineLatest([
|
||||
community.subcommunities.pipe(getFirstCompletedRemoteData()),
|
||||
community.collections.pipe(getFirstCompletedRemoteData())
|
||||
]).pipe(
|
||||
map(([subCommunities, collections]) => {
|
||||
/*if this is a community, we also need to show the direct children*/
|
||||
return [community, ...subCommunities.payload.page, ...collections.payload.page];
|
||||
})
|
||||
);
|
||||
} else {
|
||||
return observableOf([dsoRD.payload]);
|
||||
}
|
||||
}
|
||||
));
|
||||
|
||||
return scopeList;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests the current view mode based on the current URL
|
||||
* @returns {Observable<ViewMode>} The current view mode
|
||||
|
@@ -22,6 +22,7 @@ export class VersionHistory extends DSpaceObject {
|
||||
_links: {
|
||||
self: HALLink;
|
||||
versions: HALLink;
|
||||
draftVersion: HALLink;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -30,6 +31,24 @@ export class VersionHistory extends DSpaceObject {
|
||||
@autoserialize
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* The summary of this Version History
|
||||
*/
|
||||
@autoserialize
|
||||
summary: string;
|
||||
|
||||
/**
|
||||
* The name of the submitter of this Version History
|
||||
*/
|
||||
@autoserialize
|
||||
submitterName: string;
|
||||
|
||||
/**
|
||||
* Whether exist a workspace item
|
||||
*/
|
||||
@autoserialize
|
||||
draftVersion: boolean;
|
||||
|
||||
/**
|
||||
* The list of versions within this history
|
||||
*/
|
||||
|
150
src/app/core/submission/workflowitem-data.service.spec.ts
Normal file
150
src/app/core/submission/workflowitem-data.service.spec.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { of as observableOf } from 'rxjs';
|
||||
import { TestScheduler } from 'rxjs/testing';
|
||||
|
||||
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
|
||||
import { ObjectCacheService } from '../cache/object-cache.service';
|
||||
import { HALEndpointService } from '../shared/hal-endpoint.service';
|
||||
import { RequestService } from '../data/request.service';
|
||||
import { PageInfo } from '../shared/page-info.model';
|
||||
import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils';
|
||||
import { RequestEntry } from '../data/request.reducer';
|
||||
import { HrefOnlyDataService } from '../data/href-only-data.service';
|
||||
import { getMockHrefOnlyDataService } from '../../shared/mocks/href-only-data.service.mock';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { CoreState } from '../core.reducers';
|
||||
import { RestResponse } from '../cache/response.models';
|
||||
import { cold, getTestScheduler, hot } from 'jasmine-marbles';
|
||||
import { Item } from '../shared/item.model';
|
||||
import { WorkflowItemDataService } from './workflowitem-data.service';
|
||||
import { WorkflowItem } from './models/workflowitem.model';
|
||||
|
||||
describe('WorkflowItemDataService test', () => {
|
||||
let scheduler: TestScheduler;
|
||||
let service: WorkflowItemDataService;
|
||||
let requestService: RequestService;
|
||||
let rdbService: RemoteDataBuildService;
|
||||
let objectCache: ObjectCacheService;
|
||||
let halService: HALEndpointService;
|
||||
let hrefOnlyDataService: HrefOnlyDataService;
|
||||
let responseCacheEntry: RequestEntry;
|
||||
|
||||
const item = Object.assign(new Item(), {
|
||||
id: '1234-1234',
|
||||
uuid: '1234-1234',
|
||||
bundles: observableOf({}),
|
||||
metadata: {
|
||||
'dc.title': [
|
||||
{
|
||||
language: 'en_US',
|
||||
value: 'This is just another title'
|
||||
}
|
||||
],
|
||||
'dc.type': [
|
||||
{
|
||||
language: null,
|
||||
value: 'Article'
|
||||
}
|
||||
],
|
||||
'dc.contributor.author': [
|
||||
{
|
||||
language: 'en_US',
|
||||
value: 'Smith, Donald'
|
||||
}
|
||||
],
|
||||
'dc.date.issued': [
|
||||
{
|
||||
language: null,
|
||||
value: '2015-06-26'
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
const itemRD = createSuccessfulRemoteDataObject(item);
|
||||
const wsi = Object.assign(new WorkflowItem(), { item: observableOf(itemRD), id: '1234', uuid: '1234' });
|
||||
const wsiRD = createSuccessfulRemoteDataObject(wsi);
|
||||
|
||||
const endpointURL = `https://rest.api/rest/api/submission/workspaceitems`;
|
||||
const searchRequestURL = `https://rest.api/rest/api/submission/workspaceitems/search/item?uuid=1234-1234`;
|
||||
const searchRequestURL$ = observableOf(searchRequestURL);
|
||||
|
||||
const requestUUID = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a';
|
||||
|
||||
objectCache = {} as ObjectCacheService;
|
||||
const notificationsService = {} as NotificationsService;
|
||||
const http = {} as HttpClient;
|
||||
const comparator = {} as any;
|
||||
const comparatorEntry = {} as any;
|
||||
const store = {} as Store<CoreState>;
|
||||
const pageInfo = new PageInfo();
|
||||
|
||||
function initTestService() {
|
||||
hrefOnlyDataService = getMockHrefOnlyDataService();
|
||||
return new WorkflowItemDataService(
|
||||
comparatorEntry,
|
||||
halService,
|
||||
http,
|
||||
notificationsService,
|
||||
requestService,
|
||||
rdbService,
|
||||
objectCache,
|
||||
store
|
||||
);
|
||||
}
|
||||
|
||||
describe('', () => {
|
||||
beforeEach(() => {
|
||||
|
||||
scheduler = getTestScheduler();
|
||||
|
||||
halService = jasmine.createSpyObj('halService', {
|
||||
getEndpoint: cold('a', { a: endpointURL })
|
||||
});
|
||||
responseCacheEntry = new RequestEntry();
|
||||
responseCacheEntry.request = { href: 'https://rest.api/' } as any;
|
||||
responseCacheEntry.response = new RestResponse(true, 200, 'Success');
|
||||
|
||||
requestService = jasmine.createSpyObj('requestService', {
|
||||
generateRequestId: requestUUID,
|
||||
send: true,
|
||||
removeByHrefSubstring: {},
|
||||
getByHref: observableOf(responseCacheEntry),
|
||||
getByUUID: observableOf(responseCacheEntry),
|
||||
});
|
||||
rdbService = jasmine.createSpyObj('rdbService', {
|
||||
buildSingle: hot('a|', {
|
||||
a: wsiRD
|
||||
})
|
||||
});
|
||||
|
||||
service = initTestService();
|
||||
|
||||
spyOn((service as any), 'findByHref').and.callThrough();
|
||||
spyOn((service as any), 'getSearchByHref').and.returnValue(searchRequestURL$);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
service = null;
|
||||
});
|
||||
|
||||
describe('findByItem', () => {
|
||||
it('should proxy the call to DataService.findByHref', () => {
|
||||
scheduler.schedule(() => service.findByItem('1234-1234', true, true, pageInfo));
|
||||
scheduler.flush();
|
||||
|
||||
expect((service as any).findByHref).toHaveBeenCalledWith(searchRequestURL$, true, true);
|
||||
});
|
||||
|
||||
it('should return a RemoteData<WorkspaceItem> for the search', () => {
|
||||
const result = service.findByItem('1234-1234', true, true, pageInfo);
|
||||
const expected = cold('a|', {
|
||||
a: wsiRD
|
||||
});
|
||||
expect(result).toBeObservable(expected);
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
});
|
@@ -9,7 +9,7 @@ import { DataService } from '../data/data.service';
|
||||
import { RequestService } from '../data/request.service';
|
||||
import { WorkflowItem } from './models/workflowitem.model';
|
||||
import { HALEndpointService } from '../shared/hal-endpoint.service';
|
||||
import { DeleteByIDRequest } from '../data/request.models';
|
||||
import { DeleteByIDRequest, FindListOptions } from '../data/request.models';
|
||||
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||
import { ObjectCacheService } from '../cache/object-cache.service';
|
||||
import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service';
|
||||
@@ -19,6 +19,9 @@ import { hasValue } from '../../shared/empty.util';
|
||||
import { RemoteData } from '../data/remote-data';
|
||||
import { NoContent } from '../shared/NoContent.model';
|
||||
import { getFirstCompletedRemoteData } from '../shared/operators';
|
||||
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
|
||||
import { WorkspaceItem } from './models/workspaceitem.model';
|
||||
import { RequestParam } from '../cache/models/request-param.model';
|
||||
|
||||
/**
|
||||
* A service that provides methods to make REST requests with workflow items endpoint.
|
||||
@@ -27,6 +30,7 @@ import { getFirstCompletedRemoteData } from '../shared/operators';
|
||||
@dataService(WorkflowItem.type)
|
||||
export class WorkflowItemDataService extends DataService<WorkflowItem> {
|
||||
protected linkPath = 'workflowitems';
|
||||
protected searchByItemLinkPath = 'item';
|
||||
protected responseMsToLive = 10 * 1000;
|
||||
|
||||
constructor(
|
||||
@@ -86,4 +90,23 @@ export class WorkflowItemDataService extends DataService<WorkflowItem> {
|
||||
|
||||
return this.rdbService.buildFromRequestUUID(requestId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the WorkflowItem object found through the UUID of an item
|
||||
*
|
||||
* @param uuid The uuid of the item
|
||||
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
|
||||
* no valid cached version. Defaults to true
|
||||
* @param reRequestOnStale Whether or not the request should automatically be re-
|
||||
* requested after the response becomes stale
|
||||
* @param options The {@link FindListOptions} object
|
||||
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
|
||||
*/
|
||||
public findByItem(uuid: string, useCachedVersionIfAvailable = false, reRequestOnStale = true, options: FindListOptions = {}, ...linksToFollow: FollowLinkConfig<WorkspaceItem>[]): Observable<RemoteData<WorkspaceItem>> {
|
||||
const findListOptions = new FindListOptions();
|
||||
findListOptions.searchParams = [new RequestParam('uuid', encodeURIComponent(uuid))];
|
||||
const href$ = this.getSearchByHref(this.searchByItemLinkPath, findListOptions, ...linksToFollow);
|
||||
return this.findByHref(href$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
|
||||
}
|
||||
|
||||
}
|
||||
|
150
src/app/core/submission/workspaceitem-data.service.spec.ts
Normal file
150
src/app/core/submission/workspaceitem-data.service.spec.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { of as observableOf } from 'rxjs';
|
||||
import { TestScheduler } from 'rxjs/testing';
|
||||
|
||||
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
|
||||
import { ObjectCacheService } from '../cache/object-cache.service';
|
||||
import { HALEndpointService } from '../shared/hal-endpoint.service';
|
||||
import { RequestService } from '../data/request.service';
|
||||
import { PageInfo } from '../shared/page-info.model';
|
||||
import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils';
|
||||
import { RequestEntry } from '../data/request.reducer';
|
||||
import { HrefOnlyDataService } from '../data/href-only-data.service';
|
||||
import { getMockHrefOnlyDataService } from '../../shared/mocks/href-only-data.service.mock';
|
||||
import { WorkspaceitemDataService } from './workspaceitem-data.service';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { CoreState } from '../core.reducers';
|
||||
import { RestResponse } from '../cache/response.models';
|
||||
import { cold, getTestScheduler, hot } from 'jasmine-marbles';
|
||||
import { Item } from '../shared/item.model';
|
||||
import { WorkspaceItem } from './models/workspaceitem.model';
|
||||
|
||||
describe('WorkspaceitemDataService test', () => {
|
||||
let scheduler: TestScheduler;
|
||||
let service: WorkspaceitemDataService;
|
||||
let requestService: RequestService;
|
||||
let rdbService: RemoteDataBuildService;
|
||||
let objectCache: ObjectCacheService;
|
||||
let halService: HALEndpointService;
|
||||
let hrefOnlyDataService: HrefOnlyDataService;
|
||||
let responseCacheEntry: RequestEntry;
|
||||
|
||||
const item = Object.assign(new Item(), {
|
||||
id: '1234-1234',
|
||||
uuid: '1234-1234',
|
||||
bundles: observableOf({}),
|
||||
metadata: {
|
||||
'dc.title': [
|
||||
{
|
||||
language: 'en_US',
|
||||
value: 'This is just another title'
|
||||
}
|
||||
],
|
||||
'dc.type': [
|
||||
{
|
||||
language: null,
|
||||
value: 'Article'
|
||||
}
|
||||
],
|
||||
'dc.contributor.author': [
|
||||
{
|
||||
language: 'en_US',
|
||||
value: 'Smith, Donald'
|
||||
}
|
||||
],
|
||||
'dc.date.issued': [
|
||||
{
|
||||
language: null,
|
||||
value: '2015-06-26'
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
const itemRD = createSuccessfulRemoteDataObject(item);
|
||||
const wsi = Object.assign(new WorkspaceItem(), { item: observableOf(itemRD), id: '1234', uuid: '1234' });
|
||||
const wsiRD = createSuccessfulRemoteDataObject(wsi);
|
||||
|
||||
const endpointURL = `https://rest.api/rest/api/submission/workspaceitems`;
|
||||
const searchRequestURL = `https://rest.api/rest/api/submission/workspaceitems/search/item?uuid=1234-1234`;
|
||||
const searchRequestURL$ = observableOf(searchRequestURL);
|
||||
|
||||
const requestUUID = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a';
|
||||
|
||||
objectCache = {} as ObjectCacheService;
|
||||
const notificationsService = {} as NotificationsService;
|
||||
const http = {} as HttpClient;
|
||||
const comparator = {} as any;
|
||||
const comparatorEntry = {} as any;
|
||||
const store = {} as Store<CoreState>;
|
||||
const pageInfo = new PageInfo();
|
||||
|
||||
function initTestService() {
|
||||
hrefOnlyDataService = getMockHrefOnlyDataService();
|
||||
return new WorkspaceitemDataService(
|
||||
comparatorEntry,
|
||||
halService,
|
||||
http,
|
||||
notificationsService,
|
||||
requestService,
|
||||
rdbService,
|
||||
objectCache,
|
||||
store
|
||||
);
|
||||
}
|
||||
|
||||
describe('', () => {
|
||||
beforeEach(() => {
|
||||
|
||||
scheduler = getTestScheduler();
|
||||
|
||||
halService = jasmine.createSpyObj('halService', {
|
||||
getEndpoint: cold('a', { a: endpointURL })
|
||||
});
|
||||
responseCacheEntry = new RequestEntry();
|
||||
responseCacheEntry.request = { href: 'https://rest.api/' } as any;
|
||||
responseCacheEntry.response = new RestResponse(true, 200, 'Success');
|
||||
|
||||
requestService = jasmine.createSpyObj('requestService', {
|
||||
generateRequestId: requestUUID,
|
||||
send: true,
|
||||
removeByHrefSubstring: {},
|
||||
getByHref: observableOf(responseCacheEntry),
|
||||
getByUUID: observableOf(responseCacheEntry),
|
||||
});
|
||||
rdbService = jasmine.createSpyObj('rdbService', {
|
||||
buildSingle: hot('a|', {
|
||||
a: wsiRD
|
||||
})
|
||||
});
|
||||
|
||||
service = initTestService();
|
||||
|
||||
spyOn((service as any), 'findByHref').and.callThrough();
|
||||
spyOn((service as any), 'getSearchByHref').and.returnValue(searchRequestURL$);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
service = null;
|
||||
});
|
||||
|
||||
describe('findByItem', () => {
|
||||
it('should proxy the call to DataService.findByHref', () => {
|
||||
scheduler.schedule(() => service.findByItem('1234-1234', true, true, pageInfo));
|
||||
scheduler.flush();
|
||||
|
||||
expect((service as any).findByHref).toHaveBeenCalledWith(searchRequestURL$, true, true);
|
||||
});
|
||||
|
||||
it('should return a RemoteData<WorkspaceItem> for the search', () => {
|
||||
const result = service.findByItem('1234-1234', true, true, pageInfo);
|
||||
const expected = cold('a|', {
|
||||
a: wsiRD
|
||||
});
|
||||
expect(result).toBeObservable(expected);
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
});
|
@@ -12,6 +12,11 @@ import { NotificationsService } from '../../shared/notifications/notifications.s
|
||||
import { ObjectCacheService } from '../cache/object-cache.service';
|
||||
import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service';
|
||||
import { WorkspaceItem } from './models/workspaceitem.model';
|
||||
import { Observable } from 'rxjs';
|
||||
import { RemoteData } from '../data/remote-data';
|
||||
import { FindListOptions } from '../data/request.models';
|
||||
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
|
||||
import { RequestParam } from '../cache/models/request-param.model';
|
||||
|
||||
/**
|
||||
* A service that provides methods to make REST requests with workspaceitems endpoint.
|
||||
@@ -20,6 +25,7 @@ import { WorkspaceItem } from './models/workspaceitem.model';
|
||||
@dataService(WorkspaceItem.type)
|
||||
export class WorkspaceitemDataService extends DataService<WorkspaceItem> {
|
||||
protected linkPath = 'workspaceitems';
|
||||
protected searchByItemLinkPath = 'item';
|
||||
|
||||
constructor(
|
||||
protected comparator: DSOChangeAnalyzer<WorkspaceItem>,
|
||||
@@ -33,4 +39,22 @@ export class WorkspaceitemDataService extends DataService<WorkspaceItem> {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the WorkspaceItem object found through the UUID of an item
|
||||
*
|
||||
* @param uuid The uuid of the item
|
||||
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
|
||||
* no valid cached version. Defaults to true
|
||||
* @param reRequestOnStale Whether or not the request should automatically be re-
|
||||
* requested after the response becomes stale
|
||||
* @param options The {@link FindListOptions} object
|
||||
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
|
||||
*/
|
||||
public findByItem(uuid: string, useCachedVersionIfAvailable = false, reRequestOnStale = true, options: FindListOptions = {}, ...linksToFollow: FollowLinkConfig<WorkspaceItem>[]): Observable<RemoteData<WorkspaceItem>> {
|
||||
const findListOptions = new FindListOptions();
|
||||
findListOptions.searchParams = [new RequestParam('uuid', encodeURIComponent(uuid))];
|
||||
const href$ = this.getSearchByHref(this.searchByItemLinkPath, findListOptions, ...linksToFollow);
|
||||
return this.findByHref(href$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -21,6 +21,7 @@ import { createPaginatedList } from '../../../shared/testing/utils.test';
|
||||
import { RouterStub } from '../../../shared/testing/router.stub';
|
||||
import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub';
|
||||
import { AuthServiceStub } from '../../../shared/testing/auth-service.stub';
|
||||
import { getTestScheduler } from 'jasmine-marbles';
|
||||
|
||||
describe('UploadBistreamComponent', () => {
|
||||
let comp: UploadBitstreamComponent;
|
||||
@@ -76,7 +77,8 @@ describe('UploadBistreamComponent', () => {
|
||||
const restEndpoint = 'fake-rest-endpoint';
|
||||
const mockItemDataService = jasmine.createSpyObj('mockItemDataService', {
|
||||
getBitstreamsEndpoint: observableOf(restEndpoint),
|
||||
createBundle: createSuccessfulRemoteDataObject$(createdBundle)
|
||||
createBundle: createSuccessfulRemoteDataObject$(createdBundle),
|
||||
getBundles: createSuccessfulRemoteDataObject$([bundle])
|
||||
});
|
||||
const bundleService = jasmine.createSpyObj('bundleService', {
|
||||
getBitstreamsEndpoint: observableOf(restEndpoint),
|
||||
@@ -92,6 +94,22 @@ describe('UploadBistreamComponent', () => {
|
||||
removeByHrefSubstring: {}
|
||||
});
|
||||
|
||||
|
||||
describe('on init', () => {
|
||||
beforeEach(waitForAsync(() => {
|
||||
createUploadBitstreamTestingModule({
|
||||
bundle: bundle.id
|
||||
});
|
||||
}));
|
||||
beforeEach(() => {
|
||||
loadFixtureAndComp();
|
||||
});
|
||||
it('should initialize the bundles', () => {
|
||||
expect(comp.bundlesRD$).toBeDefined();
|
||||
getTestScheduler().expectObservable(comp.bundlesRD$).toBe('(a|)', {a: createSuccessfulRemoteDataObject([bundle])});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when a file is uploaded', () => {
|
||||
beforeEach(waitForAsync(() => {
|
||||
createUploadBitstreamTestingModule({});
|
||||
|
@@ -2,7 +2,7 @@ import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core';
|
||||
import { Observable, Subscription } from 'rxjs';
|
||||
import { RemoteData } from '../../../core/data/remote-data';
|
||||
import { Item } from '../../../core/shared/item.model';
|
||||
import { map, switchMap, take } from 'rxjs/operators';
|
||||
import { map, take } from 'rxjs/operators';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { UploaderOptions } from '../../../shared/uploader/uploader-options.model';
|
||||
import { hasValue, isEmpty, isNotEmpty } from '../../../shared/empty.util';
|
||||
@@ -108,9 +108,7 @@ export class UploadBitstreamComponent implements OnInit, OnDestroy {
|
||||
this.itemId = this.route.snapshot.params.id;
|
||||
this.entityType = this.route.snapshot.params['entity-type'];
|
||||
this.itemRD$ = this.route.data.pipe(map((data) => data.dso));
|
||||
this.bundlesRD$ = this.itemRD$.pipe(
|
||||
switchMap((itemRD: RemoteData<Item>) => itemRD.payload.bundles)
|
||||
);
|
||||
this.bundlesRD$ = this.itemService.getBundles(this.itemId);
|
||||
this.selectedBundleId = this.route.snapshot.queryParams.bundle;
|
||||
if (isNotEmpty(this.selectedBundleId)) {
|
||||
this.bundleService.findById(this.selectedBundleId).pipe(
|
||||
|
@@ -1,10 +1,10 @@
|
||||
<td>
|
||||
<div class="metadata-field">
|
||||
<div *ngIf="!(editable | async)">
|
||||
<span>{{metadata?.key?.split('.').join('.​')}}</span>
|
||||
<span >{{metadata?.key?.split('.').join('.​')}}</span>
|
||||
</div>
|
||||
<div *ngIf="(editable | async)" class="field-container">
|
||||
<ds-validation-suggestions [suggestions]="(metadataFieldSuggestions | async)"
|
||||
<ds-validation-suggestions [disable]="fieldUpdate.changeType != 1" [suggestions]="(metadataFieldSuggestions | async)"
|
||||
[(ngModel)]="metadata.key"
|
||||
[url]="this.url"
|
||||
[metadata]="this.metadata"
|
||||
|
@@ -463,4 +463,43 @@ describe('EditInPlaceFieldComponent', () => {
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('canEditMetadataField', () => {
|
||||
describe('when the fieldUpdate\'s changeType is currently ADD', () => {
|
||||
beforeEach(() => {
|
||||
objectUpdatesService.isEditable.and.returnValue(observableOf(true));
|
||||
comp.fieldUpdate.changeType = FieldChangeType.ADD;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
it('can edit metadata field', () => {
|
||||
const disabledMetadataField = fixture.debugElement.query(By.css('ds-validation-suggestions'))
|
||||
.componentInstance.disable;
|
||||
expect(disabledMetadataField).toBe(false);
|
||||
});
|
||||
});
|
||||
describe('when the fieldUpdate\'s changeType is currently REMOVE', () => {
|
||||
beforeEach(() => {
|
||||
objectUpdatesService.isEditable.and.returnValue(observableOf(true));
|
||||
comp.fieldUpdate.changeType = FieldChangeType.REMOVE;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
it('can edit metadata field', () => {
|
||||
const disabledMetadataField = fixture.debugElement.query(By.css('ds-validation-suggestions'))
|
||||
.componentInstance.disable;
|
||||
expect(disabledMetadataField).toBe(true);
|
||||
});
|
||||
});
|
||||
describe('when the fieldUpdate\'s changeType is currently UPDATE', () => {
|
||||
beforeEach(() => {
|
||||
objectUpdatesService.isEditable.and.returnValue(observableOf(true));
|
||||
comp.fieldUpdate.changeType = FieldChangeType.UPDATE;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
it('can edit metadata field', () => {
|
||||
const disabledMetadataField = fixture.debugElement.query(By.css('ds-validation-suggestions'))
|
||||
.componentInstance.disable;
|
||||
expect(disabledMetadataField).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@@ -1,66 +1,69 @@
|
||||
<div class="item-metadata">
|
||||
<div class="button-row top d-flex mb-2">
|
||||
<button class="mr-auto btn btn-success"
|
||||
(click)="add()"><i
|
||||
class="fas fa-plus"></i>
|
||||
<span class="d-none d-sm-inline"> {{"item.edit.metadata.add-button" | translate}}</span>
|
||||
</button>
|
||||
<button class="btn btn-warning" *ngIf="isReinstatable() | async"
|
||||
(click)="reinstate()"><i
|
||||
class="fas fa-undo-alt"></i>
|
||||
<span class="d-none d-sm-inline"> {{"item.edit.metadata.reinstate-button" | translate}}</span>
|
||||
</button>
|
||||
<button class="btn btn-primary" [disabled]="!(hasChanges() | async) || !(isValid() | async)"
|
||||
(click)="submit()"><i
|
||||
class="fas fa-save"></i>
|
||||
<span class="d-none d-sm-inline"> {{"item.edit.metadata.save-button" | translate}}</span>
|
||||
</button>
|
||||
<button class="btn btn-danger" *ngIf="!(isReinstatable() | async)"
|
||||
[disabled]="!(hasChanges() | async)"
|
||||
(click)="discard()"><i
|
||||
class="fas fa-times"></i>
|
||||
<span class="d-none d-sm-inline"> {{"item.edit.metadata.discard-button" | translate}}</span>
|
||||
</button>
|
||||
</div>
|
||||
<table class="table table-responsive table-striped table-bordered" *ngIf="((updates$ | async)| dsObjectValues).length > 0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th><span id="fieldName">{{'item.edit.metadata.headers.field' | translate}}</span></th>
|
||||
<th><span id="fieldValue">{{'item.edit.metadata.headers.value' | translate}}</span></th>
|
||||
<th class="text-center"><span id="fieldLang">{{'item.edit.metadata.headers.language' | translate}}</span></th>
|
||||
<th class="text-center">{{'item.edit.metadata.headers.edit' | translate}}</th>
|
||||
</tr>
|
||||
<tr *ngFor="let updateValue of ((updates$ | async)| dsObjectValues); trackBy: trackUpdate"
|
||||
ds-edit-in-place-field
|
||||
[fieldUpdate]="updateValue || {}"
|
||||
[url]="url"
|
||||
[ngClass]="{
|
||||
<div class="button-row top d-flex mb-2">
|
||||
<button class="mr-auto btn btn-success"
|
||||
(click)="add()"><i
|
||||
class="fas fa-plus"></i>
|
||||
<span class="d-none d-sm-inline"> {{"item.edit.metadata.add-button" | translate}}</span>
|
||||
</button>
|
||||
<button class="btn btn-warning" *ngIf="isReinstatable() | async"
|
||||
(click)="reinstate()"><i
|
||||
class="fas fa-undo-alt"></i>
|
||||
<span class="d-none d-sm-inline"> {{"item.edit.metadata.reinstate-button" | translate}}</span>
|
||||
</button>
|
||||
<button class="btn btn-primary" [disabled]="!(hasChanges() | async) || !(isValid() | async)"
|
||||
(click)="submit()"><i
|
||||
class="fas fa-save"></i>
|
||||
<span class="d-none d-sm-inline"> {{"item.edit.metadata.save-button" | translate}}</span>
|
||||
</button>
|
||||
<button class="btn btn-danger" *ngIf="!(isReinstatable() | async)"
|
||||
[disabled]="!(hasChanges() | async)"
|
||||
(click)="discard()"><i
|
||||
class="fas fa-times"></i>
|
||||
<span class="d-none d-sm-inline"> {{"item.edit.metadata.discard-button" | translate}}</span>
|
||||
</button>
|
||||
</div>
|
||||
<table class="table table-responsive table-striped table-bordered"
|
||||
*ngIf="((updates$ | async)| dsObjectValues).length > 0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><span id="fieldName">{{'item.edit.metadata.headers.field' | translate}}</span></th>
|
||||
<th><span id="fieldValue">{{'item.edit.metadata.headers.value' | translate}}</span></th>
|
||||
<th class="text-center"><span id="fieldLang">{{'item.edit.metadata.headers.language' | translate}}</span></th>
|
||||
<th class="text-center">{{'item.edit.metadata.headers.edit' | translate}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let updateValue of ((updates$ | async)| dsObjectValues); trackBy: trackUpdate"
|
||||
ds-edit-in-place-field
|
||||
[fieldUpdate]="updateValue || {}"
|
||||
[url]="url"
|
||||
[ngClass]="{
|
||||
'table-warning': updateValue.changeType === 0,
|
||||
'table-danger': updateValue.changeType === 2,
|
||||
'table-success': updateValue.changeType === 1
|
||||
}">
|
||||
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div *ngIf="((updates$ | async)| dsObjectValues).length == 0">
|
||||
<ds-alert [content]="'item.edit.metadata.empty'" [type]="AlertTypeEnum.Info"></ds-alert>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div *ngIf="((updates$ | async)| dsObjectValues).length == 0">
|
||||
<ds-alert [content]="'item.edit.metadata.empty'" [type]="AlertTypeEnum.Info"></ds-alert>
|
||||
</div>
|
||||
<div class="button-row bottom">
|
||||
<div class="mt-2 float-right">
|
||||
<button class="btn btn-warning" *ngIf="isReinstatable() | async"
|
||||
(click)="reinstate()"><i
|
||||
class="fas fa-undo-alt"></i> {{"item.edit.metadata.reinstate-button" | translate}}
|
||||
</button>
|
||||
<button class="btn btn-primary mr-0" [disabled]="!(hasChanges() | async)"
|
||||
(click)="submit()"><i
|
||||
class="fas fa-save"></i> {{"item.edit.metadata.save-button" | translate}}
|
||||
</button>
|
||||
<button class="btn btn-danger" *ngIf="!(isReinstatable() | async)"
|
||||
[disabled]="!(hasChanges() | async)"
|
||||
(click)="discard()"><i
|
||||
class="fas fa-times"></i> {{"item.edit.metadata.discard-button" | translate}}
|
||||
</button>
|
||||
</div>
|
||||
<div class="button-row bottom">
|
||||
<div class="mt-2 float-right">
|
||||
<button class="btn btn-warning" *ngIf="isReinstatable() | async"
|
||||
(click)="reinstate()"><i
|
||||
class="fas fa-undo-alt"></i> {{"item.edit.metadata.reinstate-button" | translate}}
|
||||
</button>
|
||||
<button class="btn btn-primary mr-0" [disabled]="!(hasChanges() | async)"
|
||||
(click)="submit()"><i
|
||||
class="fas fa-save"></i> {{"item.edit.metadata.save-button" | translate}}
|
||||
</button>
|
||||
<button class="btn btn-danger" *ngIf="!(isReinstatable() | async)"
|
||||
[disabled]="!(hasChanges() | async)"
|
||||
(click)="discard()"><i
|
||||
class="fas fa-times"></i> {{"item.edit.metadata.discard-button" | translate}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -1,6 +1,4 @@
|
||||
<div class="mt-4">
|
||||
<ds-alert [content]="'item.edit.tabs.versionhistory.under-construction'" [type]="AlertTypeEnum.Warning"></ds-alert>
|
||||
</div>
|
||||
<div class="mt-2" *ngVar="(itemRD$ | async)?.payload as item">
|
||||
<ds-item-versions *ngIf="item" [item]="item" [displayWhenEmpty]="true" [displayTitle]="false"></ds-item-versions>
|
||||
<ds-item-versions *ngIf="item" [item]="item" [displayWhenEmpty]="true" [displayTitle]="false"
|
||||
[displayActions]="true"></ds-item-versions>
|
||||
</div>
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import { ItemVersionHistoryComponent } from './item-version-history.component';
|
||||
import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||
import { VarDirective } from '../../../shared/utils/var.directive';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
@@ -18,12 +18,20 @@ describe('ItemVersionHistoryComponent', () => {
|
||||
handle: '123456789/1',
|
||||
});
|
||||
|
||||
const activatedRoute = {
|
||||
parent: {
|
||||
parent: {
|
||||
data: observableOf({dso: createSuccessfulRemoteDataObject(item)})
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
beforeEach(waitForAsync(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [ItemVersionHistoryComponent, VarDirective],
|
||||
imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])],
|
||||
providers: [
|
||||
{ provide: ActivatedRoute, useValue: { parent: { data: observableOf({ dso: createSuccessfulRemoteDataObject(item) }) } } }
|
||||
{ provide: ActivatedRoute, useValue: activatedRoute }
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA]
|
||||
}).compileComponents();
|
||||
|
@@ -30,6 +30,6 @@ export class ItemVersionHistoryComponent {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.itemRD$ = this.route.parent.data.pipe(map((data) => data.dso)).pipe(getFirstSucceededRemoteData()) as Observable<RemoteData<Item>>;
|
||||
this.itemRD$ = this.route.parent.parent.data.pipe(map((data) => data.dso)).pipe(getFirstSucceededRemoteData()) as Observable<RemoteData<Item>>;
|
||||
}
|
||||
}
|
||||
|
@@ -1,7 +1,21 @@
|
||||
<ds-metadata-field-wrapper *ngIf="(this.collectionsRD$ | async)?.hasSucceeded" [label]="label | translate">
|
||||
<ds-metadata-field-wrapper [label]="label | translate">
|
||||
<div class="collections">
|
||||
<a *ngFor="let collection of (this.collectionsRD$ | async)?.payload?.page; let last=last;" [routerLink]="['/collections', collection.id]">
|
||||
<a *ngFor="let collection of (this.collections$ | async); let last=last;" [routerLink]="['/collections', collection.id]">
|
||||
<span>{{collection?.name}}</span><span *ngIf="!last" [innerHTML]="separator"></span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div *ngIf="isLoading$ | async">
|
||||
{{'item.page.collections.loading' | translate}}
|
||||
</div>
|
||||
|
||||
<a
|
||||
*ngIf="!(isLoading$ | async) && (hasMore$ | async)"
|
||||
(click)="$event.preventDefault(); handleLoadMore()"
|
||||
class="load-more-btn btn btn-sm btn-outline-secondary"
|
||||
role="button"
|
||||
href="#"
|
||||
>
|
||||
{{'item.page.collections.load-more' | translate}}
|
||||
</a>
|
||||
</ds-metadata-field-wrapper>
|
||||
|
@@ -9,46 +9,45 @@ import { Item } from '../../../core/shared/item.model';
|
||||
import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock';
|
||||
import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
|
||||
import { CollectionsComponent } from './collections.component';
|
||||
import { FindListOptions } from '../../../core/data/request.models';
|
||||
import { buildPaginatedList, PaginatedList } from '../../../core/data/paginated-list.model';
|
||||
import { PageInfo } from '../../../core/shared/page-info.model';
|
||||
|
||||
let collectionsComponent: CollectionsComponent;
|
||||
let fixture: ComponentFixture<CollectionsComponent>;
|
||||
|
||||
let collectionDataServiceStub;
|
||||
|
||||
const mockCollection1: Collection = Object.assign(new Collection(), {
|
||||
metadata: {
|
||||
'dc.description.abstract': [
|
||||
{
|
||||
language: 'en_US',
|
||||
value: 'Short description'
|
||||
}
|
||||
]
|
||||
},
|
||||
_links: {
|
||||
self: { href: 'collection-selflink' }
|
||||
}
|
||||
const createMockCollection = (id: string) => Object.assign(new Collection(), {
|
||||
id: id,
|
||||
name: `collection-${id}`,
|
||||
});
|
||||
|
||||
const succeededMockItem: Item = Object.assign(new Item(), {owningCollection: createSuccessfulRemoteDataObject$(mockCollection1)});
|
||||
const failedMockItem: Item = Object.assign(new Item(), {owningCollection: createFailedRemoteDataObject$('error', 500)});
|
||||
const mockItem: Item = new Item();
|
||||
|
||||
describe('CollectionsComponent', () => {
|
||||
collectionDataServiceStub = {
|
||||
findOwningCollectionFor(item: Item) {
|
||||
if (item === succeededMockItem) {
|
||||
return createSuccessfulRemoteDataObject$(mockCollection1);
|
||||
} else {
|
||||
return createFailedRemoteDataObject$('error', 500);
|
||||
}
|
||||
}
|
||||
};
|
||||
let collectionDataService;
|
||||
|
||||
let mockCollection1: Collection;
|
||||
let mockCollection2: Collection;
|
||||
let mockCollection3: Collection;
|
||||
let mockCollection4: Collection;
|
||||
|
||||
let component: CollectionsComponent;
|
||||
let fixture: ComponentFixture<CollectionsComponent>;
|
||||
|
||||
beforeEach(waitForAsync(() => {
|
||||
collectionDataService = jasmine.createSpyObj([
|
||||
'findOwningCollectionFor',
|
||||
'findMappedCollectionsFor',
|
||||
]);
|
||||
|
||||
mockCollection1 = createMockCollection('c1');
|
||||
mockCollection2 = createMockCollection('c2');
|
||||
mockCollection3 = createMockCollection('c3');
|
||||
mockCollection4 = createMockCollection('c4');
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [TranslateModule.forRoot()],
|
||||
declarations: [ CollectionsComponent ],
|
||||
providers: [
|
||||
{ provide: RemoteDataBuildService, useValue: getMockRemoteDataBuildService()},
|
||||
{ provide: CollectionDataService, useValue: collectionDataServiceStub },
|
||||
{ provide: CollectionDataService, useValue: collectionDataService },
|
||||
],
|
||||
|
||||
schemas: [ NO_ERRORS_SCHEMA ]
|
||||
@@ -59,33 +58,264 @@ describe('CollectionsComponent', () => {
|
||||
|
||||
beforeEach(waitForAsync(() => {
|
||||
fixture = TestBed.createComponent(CollectionsComponent);
|
||||
collectionsComponent = fixture.componentInstance;
|
||||
collectionsComponent.label = 'test.test';
|
||||
collectionsComponent.separator = '<br/>';
|
||||
|
||||
component = fixture.componentInstance;
|
||||
component.item = mockItem;
|
||||
component.label = 'test.test';
|
||||
component.separator = '<br/>';
|
||||
component.pageSize = 2;
|
||||
}));
|
||||
|
||||
describe('When the requested item request has succeeded', () => {
|
||||
describe('when the item has only an owning collection', () => {
|
||||
let mockPage1: PaginatedList<Collection>;
|
||||
|
||||
beforeEach(() => {
|
||||
collectionsComponent.item = succeededMockItem;
|
||||
mockPage1 = buildPaginatedList(Object.assign(new PageInfo(), {
|
||||
currentPage: 1,
|
||||
elementsPerPage: 2,
|
||||
totalPages: 0,
|
||||
totalElements: 0,
|
||||
}), []);
|
||||
|
||||
collectionDataService.findOwningCollectionFor.and.returnValue(createSuccessfulRemoteDataObject$(mockCollection1));
|
||||
collectionDataService.findMappedCollectionsFor.and.returnValue(createSuccessfulRemoteDataObject$(mockPage1));
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should show the collection', () => {
|
||||
const collectionField = fixture.debugElement.query(By.css('ds-metadata-field-wrapper div.collections'));
|
||||
expect(collectionField).not.toBeNull();
|
||||
it('should display the owning collection', () => {
|
||||
const collectionFields = fixture.debugElement.queryAll(By.css('ds-metadata-field-wrapper div.collections a'));
|
||||
const loadMoreBtn = fixture.debugElement.query(By.css('ds-metadata-field-wrapper .load-more-btn'));
|
||||
|
||||
expect(collectionDataService.findOwningCollectionFor).toHaveBeenCalledOnceWith(mockItem);
|
||||
expect(collectionDataService.findMappedCollectionsFor).toHaveBeenCalledOnceWith(mockItem, Object.assign(new FindListOptions(), {
|
||||
elementsPerPage: 2,
|
||||
currentPage: 1,
|
||||
}));
|
||||
|
||||
expect(collectionFields.length).toBe(1);
|
||||
expect(collectionFields[0].nativeElement.textContent).toEqual('collection-c1');
|
||||
|
||||
expect(component.lastPage$.getValue()).toBe(1);
|
||||
expect(component.hasMore$.getValue()).toBe(false);
|
||||
expect(component.isLoading$.getValue()).toBe(false);
|
||||
|
||||
expect(loadMoreBtn).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('When the requested item request has failed', () => {
|
||||
describe('when the item has an owning collection and one mapped collection', () => {
|
||||
let mockPage1: PaginatedList<Collection>;
|
||||
|
||||
beforeEach(() => {
|
||||
collectionsComponent.item = failedMockItem;
|
||||
mockPage1 = buildPaginatedList(Object.assign(new PageInfo(), {
|
||||
currentPage: 1,
|
||||
elementsPerPage: 2,
|
||||
totalPages: 1,
|
||||
totalElements: 1,
|
||||
}), [mockCollection2]);
|
||||
|
||||
collectionDataService.findOwningCollectionFor.and.returnValue(createSuccessfulRemoteDataObject$(mockCollection1));
|
||||
collectionDataService.findMappedCollectionsFor.and.returnValue(createSuccessfulRemoteDataObject$(mockPage1));
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should not show the collection', () => {
|
||||
const collectionField = fixture.debugElement.query(By.css('ds-metadata-field-wrapper div.collections'));
|
||||
expect(collectionField).toBeNull();
|
||||
it('should display the owning collection and the mapped collection', () => {
|
||||
const collectionFields = fixture.debugElement.queryAll(By.css('ds-metadata-field-wrapper div.collections a'));
|
||||
const loadMoreBtn = fixture.debugElement.query(By.css('ds-metadata-field-wrapper .load-more-btn'));
|
||||
|
||||
expect(collectionDataService.findOwningCollectionFor).toHaveBeenCalledOnceWith(mockItem);
|
||||
expect(collectionDataService.findMappedCollectionsFor).toHaveBeenCalledOnceWith(mockItem, Object.assign(new FindListOptions(), {
|
||||
elementsPerPage: 2,
|
||||
currentPage: 1,
|
||||
}));
|
||||
|
||||
expect(collectionFields.length).toBe(2);
|
||||
expect(collectionFields[0].nativeElement.textContent).toEqual('collection-c1');
|
||||
expect(collectionFields[1].nativeElement.textContent).toEqual('collection-c2');
|
||||
|
||||
expect(component.lastPage$.getValue()).toBe(1);
|
||||
expect(component.hasMore$.getValue()).toBe(false);
|
||||
expect(component.isLoading$.getValue()).toBe(false);
|
||||
|
||||
expect(loadMoreBtn).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the item has an owning collection and multiple mapped collections', () => {
|
||||
let mockPage1: PaginatedList<Collection>;
|
||||
let mockPage2: PaginatedList<Collection>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockPage1 = buildPaginatedList(Object.assign(new PageInfo(), {
|
||||
currentPage: 1,
|
||||
elementsPerPage: 2,
|
||||
totalPages: 2,
|
||||
totalElements: 3,
|
||||
}), [mockCollection2, mockCollection3]);
|
||||
|
||||
mockPage2 = buildPaginatedList(Object.assign(new PageInfo(), {
|
||||
currentPage: 2,
|
||||
elementsPerPage: 2,
|
||||
totalPages: 2,
|
||||
totalElements: 1,
|
||||
}), [mockCollection4]);
|
||||
|
||||
collectionDataService.findOwningCollectionFor.and.returnValue(createSuccessfulRemoteDataObject$(mockCollection1));
|
||||
collectionDataService.findMappedCollectionsFor.and.returnValues(
|
||||
createSuccessfulRemoteDataObject$(mockPage1),
|
||||
createSuccessfulRemoteDataObject$(mockPage2),
|
||||
);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should display the owning collection, two mapped collections and a load more button', () => {
|
||||
const collectionFields = fixture.debugElement.queryAll(By.css('ds-metadata-field-wrapper div.collections a'));
|
||||
const loadMoreBtn = fixture.debugElement.query(By.css('ds-metadata-field-wrapper .load-more-btn'));
|
||||
|
||||
expect(collectionDataService.findOwningCollectionFor).toHaveBeenCalledOnceWith(mockItem);
|
||||
expect(collectionDataService.findMappedCollectionsFor).toHaveBeenCalledOnceWith(mockItem, Object.assign(new FindListOptions(), {
|
||||
elementsPerPage: 2,
|
||||
currentPage: 1,
|
||||
}));
|
||||
|
||||
expect(collectionFields.length).toBe(3);
|
||||
expect(collectionFields[0].nativeElement.textContent).toEqual('collection-c1');
|
||||
expect(collectionFields[1].nativeElement.textContent).toEqual('collection-c2');
|
||||
expect(collectionFields[2].nativeElement.textContent).toEqual('collection-c3');
|
||||
|
||||
expect(component.lastPage$.getValue()).toBe(1);
|
||||
expect(component.hasMore$.getValue()).toBe(true);
|
||||
expect(component.isLoading$.getValue()).toBe(false);
|
||||
|
||||
expect(loadMoreBtn).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('when the load more button is clicked', () => {
|
||||
beforeEach(() => {
|
||||
const loadMoreBtn = fixture.debugElement.query(By.css('ds-metadata-field-wrapper .load-more-btn'));
|
||||
loadMoreBtn.nativeElement.click();
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should display the owning collection and three mapped collections', () => {
|
||||
const collectionFields = fixture.debugElement.queryAll(By.css('ds-metadata-field-wrapper div.collections a'));
|
||||
const loadMoreBtn = fixture.debugElement.query(By.css('ds-metadata-field-wrapper .load-more-btn'));
|
||||
|
||||
expect(collectionDataService.findOwningCollectionFor).toHaveBeenCalledOnceWith(mockItem);
|
||||
expect(collectionDataService.findMappedCollectionsFor).toHaveBeenCalledTimes(2);
|
||||
expect(collectionDataService.findMappedCollectionsFor).toHaveBeenCalledWith(mockItem, Object.assign(new FindListOptions(), {
|
||||
elementsPerPage: 2,
|
||||
currentPage: 1,
|
||||
}));
|
||||
expect(collectionDataService.findMappedCollectionsFor).toHaveBeenCalledWith(mockItem, Object.assign(new FindListOptions(), {
|
||||
elementsPerPage: 2,
|
||||
currentPage: 2,
|
||||
}));
|
||||
|
||||
expect(collectionFields.length).toBe(4);
|
||||
expect(collectionFields[0].nativeElement.textContent).toEqual('collection-c1');
|
||||
expect(collectionFields[1].nativeElement.textContent).toEqual('collection-c2');
|
||||
expect(collectionFields[2].nativeElement.textContent).toEqual('collection-c3');
|
||||
expect(collectionFields[3].nativeElement.textContent).toEqual('collection-c4');
|
||||
|
||||
expect(component.lastPage$.getValue()).toBe(2);
|
||||
expect(component.hasMore$.getValue()).toBe(false);
|
||||
expect(component.isLoading$.getValue()).toBe(false);
|
||||
|
||||
expect(loadMoreBtn).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the request for the owning collection fails', () => {
|
||||
let mockPage1: PaginatedList<Collection>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockPage1 = buildPaginatedList(Object.assign(new PageInfo(), {
|
||||
currentPage: 1,
|
||||
elementsPerPage: 2,
|
||||
totalPages: 1,
|
||||
totalElements: 1,
|
||||
}), [mockCollection2]);
|
||||
|
||||
collectionDataService.findOwningCollectionFor.and.returnValue(createFailedRemoteDataObject$());
|
||||
collectionDataService.findMappedCollectionsFor.and.returnValue(createSuccessfulRemoteDataObject$(mockPage1));
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should display the mapped collection only', () => {
|
||||
const collectionFields = fixture.debugElement.queryAll(By.css('ds-metadata-field-wrapper div.collections a'));
|
||||
const loadMoreBtn = fixture.debugElement.query(By.css('ds-metadata-field-wrapper .load-more-btn'));
|
||||
|
||||
expect(collectionDataService.findOwningCollectionFor).toHaveBeenCalledOnceWith(mockItem);
|
||||
expect(collectionDataService.findMappedCollectionsFor).toHaveBeenCalledOnceWith(mockItem, Object.assign(new FindListOptions(), {
|
||||
elementsPerPage: 2,
|
||||
currentPage: 1,
|
||||
}));
|
||||
|
||||
expect(collectionFields.length).toBe(1);
|
||||
expect(collectionFields[0].nativeElement.textContent).toEqual('collection-c2');
|
||||
|
||||
expect(component.lastPage$.getValue()).toBe(1);
|
||||
expect(component.hasMore$.getValue()).toBe(false);
|
||||
expect(component.isLoading$.getValue()).toBe(false);
|
||||
|
||||
expect(loadMoreBtn).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the request for the mapped collections fails', () => {
|
||||
beforeEach(() => {
|
||||
collectionDataService.findOwningCollectionFor.and.returnValue(createSuccessfulRemoteDataObject$(mockCollection1));
|
||||
collectionDataService.findMappedCollectionsFor.and.returnValue(createFailedRemoteDataObject$());
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should display the owning collection only', () => {
|
||||
const collectionFields = fixture.debugElement.queryAll(By.css('ds-metadata-field-wrapper div.collections a'));
|
||||
const loadMoreBtn = fixture.debugElement.query(By.css('ds-metadata-field-wrapper .load-more-btn'));
|
||||
|
||||
expect(collectionDataService.findOwningCollectionFor).toHaveBeenCalledOnceWith(mockItem);
|
||||
expect(collectionDataService.findMappedCollectionsFor).toHaveBeenCalledOnceWith(mockItem, Object.assign(new FindListOptions(), {
|
||||
elementsPerPage: 2,
|
||||
currentPage: 1,
|
||||
}));
|
||||
|
||||
expect(collectionFields.length).toBe(1);
|
||||
expect(collectionFields[0].nativeElement.textContent).toEqual('collection-c1');
|
||||
|
||||
expect(component.lastPage$.getValue()).toBe(0);
|
||||
expect(component.hasMore$.getValue()).toBe(true);
|
||||
expect(component.isLoading$.getValue()).toBe(false);
|
||||
|
||||
expect(loadMoreBtn).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when both requests fail', () => {
|
||||
beforeEach(() => {
|
||||
collectionDataService.findOwningCollectionFor.and.returnValue(createFailedRemoteDataObject$());
|
||||
collectionDataService.findMappedCollectionsFor.and.returnValue(createFailedRemoteDataObject$());
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should display no collections', () => {
|
||||
const collectionFields = fixture.debugElement.queryAll(By.css('ds-metadata-field-wrapper div.collections a'));
|
||||
const loadMoreBtn = fixture.debugElement.query(By.css('ds-metadata-field-wrapper .load-more-btn'));
|
||||
|
||||
expect(collectionDataService.findOwningCollectionFor).toHaveBeenCalledOnceWith(mockItem);
|
||||
expect(collectionDataService.findMappedCollectionsFor).toHaveBeenCalledOnceWith(mockItem, Object.assign(new FindListOptions(), {
|
||||
elementsPerPage: 2,
|
||||
currentPage: 1,
|
||||
}));
|
||||
|
||||
expect(collectionFields.length).toBe(0);
|
||||
|
||||
expect(component.lastPage$.getValue()).toBe(0);
|
||||
expect(component.hasMore$.getValue()).toBe(true);
|
||||
expect(component.isLoading$.getValue()).toBe(false);
|
||||
|
||||
expect(loadMoreBtn).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
@@ -1,14 +1,19 @@
|
||||
import { Component, Input, OnInit } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { BehaviorSubject, combineLatest, Observable } from 'rxjs';
|
||||
import {map, scan, startWith, switchMap, tap, withLatestFrom} from 'rxjs/operators';
|
||||
import { CollectionDataService } from '../../../core/data/collection-data.service';
|
||||
import { PaginatedList, buildPaginatedList } from '../../../core/data/paginated-list.model';
|
||||
import { RemoteData } from '../../../core/data/remote-data';
|
||||
import { PaginatedList } from '../../../core/data/paginated-list.model';
|
||||
|
||||
import { Collection } from '../../../core/shared/collection.model';
|
||||
import { Item } from '../../../core/shared/item.model';
|
||||
import { PageInfo } from '../../../core/shared/page-info.model';
|
||||
import { hasValue } from '../../../shared/empty.util';
|
||||
import { FindListOptions } from '../../../core/data/request.models';
|
||||
import {
|
||||
getAllCompletedRemoteData,
|
||||
getAllSucceededRemoteDataPayload,
|
||||
getFirstSucceededRemoteDataPayload,
|
||||
getPaginatedListPayload,
|
||||
} from '../../../core/shared/operators';
|
||||
|
||||
/**
|
||||
* This component renders the parent collections section of the item
|
||||
@@ -27,42 +32,92 @@ export class CollectionsComponent implements OnInit {
|
||||
|
||||
separator = '<br/>';
|
||||
|
||||
collectionsRD$: Observable<RemoteData<PaginatedList<Collection>>>;
|
||||
/**
|
||||
* Amount of mapped collections that should be fetched at once.
|
||||
*/
|
||||
pageSize = 5;
|
||||
|
||||
/**
|
||||
* Last page of the mapped collections that has been fetched.
|
||||
*/
|
||||
lastPage$: BehaviorSubject<number> = new BehaviorSubject<number>(0);
|
||||
|
||||
/**
|
||||
* Push an event to this observable to fetch the next page of mapped collections.
|
||||
* Because this observable is a behavior subject, the first page will be requested
|
||||
* immediately after subscription.
|
||||
*/
|
||||
loadMore$: BehaviorSubject<void> = new BehaviorSubject(undefined);
|
||||
|
||||
/**
|
||||
* Whether or not a page of mapped collections is currently being loaded.
|
||||
*/
|
||||
isLoading$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(true);
|
||||
|
||||
/**
|
||||
* Whether or not more pages of mapped collections are available.
|
||||
*/
|
||||
hasMore$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(true);
|
||||
|
||||
/**
|
||||
* All collections that have been retrieved so far. This includes the owning collection,
|
||||
* as well as any number of pages of mapped collections.
|
||||
*/
|
||||
collections$: Observable<Collection[]>;
|
||||
|
||||
constructor(private cds: CollectionDataService) {
|
||||
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
// this.collections = this.item.parents.payload;
|
||||
const owningCollection$: Observable<Collection> = this.cds.findOwningCollectionFor(this.item).pipe(
|
||||
getFirstSucceededRemoteDataPayload(),
|
||||
startWith(null as Collection),
|
||||
);
|
||||
|
||||
// TODO: this should use parents, but the collections
|
||||
// for an Item aren't returned by the REST API yet,
|
||||
// only the owning collection
|
||||
this.collectionsRD$ = this.cds.findOwningCollectionFor(this.item).pipe(
|
||||
map((rd: RemoteData<Collection>) => {
|
||||
if (hasValue(rd.payload)) {
|
||||
return new RemoteData(
|
||||
rd.timeCompleted,
|
||||
rd.msToLive,
|
||||
rd.lastUpdated,
|
||||
rd.state,
|
||||
rd.errorMessage,
|
||||
buildPaginatedList({
|
||||
elementsPerPage: 10,
|
||||
totalPages: 1,
|
||||
currentPage: 1,
|
||||
totalElements: 1,
|
||||
_links: {
|
||||
self: rd.payload._links.self
|
||||
}
|
||||
} as PageInfo, [rd.payload]),
|
||||
rd.statusCode
|
||||
);
|
||||
} else {
|
||||
return rd as any;
|
||||
}
|
||||
})
|
||||
const mappedCollections$: Observable<Collection[]> = this.loadMore$.pipe(
|
||||
// update isLoading$
|
||||
tap(() => this.isLoading$.next(true)),
|
||||
|
||||
// request next batch of mapped collections
|
||||
withLatestFrom(this.lastPage$),
|
||||
switchMap(([_, lastPage]: [void, number]) => {
|
||||
return this.cds.findMappedCollectionsFor(this.item, Object.assign(new FindListOptions(), {
|
||||
elementsPerPage: this.pageSize,
|
||||
currentPage: lastPage + 1,
|
||||
}));
|
||||
}),
|
||||
|
||||
getAllCompletedRemoteData<PaginatedList<Collection>>(),
|
||||
|
||||
// update isLoading$
|
||||
tap(() => this.isLoading$.next(false)),
|
||||
|
||||
getAllSucceededRemoteDataPayload(),
|
||||
|
||||
// update hasMore$
|
||||
tap((response: PaginatedList<Collection>) => this.hasMore$.next(response.currentPage < response.totalPages)),
|
||||
|
||||
// update lastPage$
|
||||
tap((response: PaginatedList<Collection>) => this.lastPage$.next(response.currentPage)),
|
||||
|
||||
getPaginatedListPayload<Collection>(),
|
||||
|
||||
// add current batch to list of collections
|
||||
scan((prev: Collection[], current: Collection[]) => [...prev, ...current], []),
|
||||
|
||||
startWith([]),
|
||||
) as Observable<Collection[]>;
|
||||
|
||||
this.collections$ = combineLatest([owningCollection$, mappedCollections$]).pipe(
|
||||
map(([owningCollection, mappedCollections]: [Collection, Collection[]]) => {
|
||||
return [owningCollection, ...mappedCollections].filter(collection => hasValue(collection));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
handleLoadMore() {
|
||||
this.loadMore$.next();
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -33,7 +33,7 @@
|
||||
</dl>
|
||||
</div>
|
||||
<div class="col-2">
|
||||
<ds-file-download-link [bitstream]="file">
|
||||
<ds-file-download-link [bitstream]="file" [item]="item">
|
||||
{{"item.page.filesection.download" | translate}}
|
||||
</ds-file-download-link>
|
||||
</div>
|
||||
@@ -74,7 +74,7 @@
|
||||
</dl>
|
||||
</div>
|
||||
<div class="col-2">
|
||||
<ds-file-download-link [bitstream]="file">
|
||||
<ds-file-download-link [bitstream]="file" [item]="item">
|
||||
{{"item.page.filesection.download" | translate}}
|
||||
</ds-file-download-link>
|
||||
</div>
|
||||
|
@@ -22,6 +22,10 @@ export function getItemEditRoute(item: Item) {
|
||||
return new URLCombiner(getItemPageRoute(item), ITEM_EDIT_PATH).toString();
|
||||
}
|
||||
|
||||
export function getItemEditVersionhistoryRoute(item: Item) {
|
||||
return new URLCombiner(getItemPageRoute(item), ITEM_EDIT_PATH, ITEM_EDIT_VERSIONHISTORY_PATH).toString();
|
||||
}
|
||||
|
||||
export function getEntityPageRoute(entityType: string, itemId: string) {
|
||||
if (isNotEmpty(entityType)) {
|
||||
return new URLCombiner('/entities', encodeURIComponent(entityType.toLowerCase()), itemId).toString();
|
||||
@@ -34,5 +38,15 @@ export function getEntityEditRoute(entityType: string, itemId: string) {
|
||||
return new URLCombiner(getEntityPageRoute(entityType, itemId), ITEM_EDIT_PATH).toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the route to an item's version
|
||||
* @param versionId the ID of the version for which the route will be retrieved
|
||||
*/
|
||||
export function getItemVersionRoute(versionId: string) {
|
||||
return new URLCombiner(getItemModuleRoute(), ITEM_VERSION_PATH, versionId).toString();
|
||||
}
|
||||
|
||||
export const ITEM_EDIT_PATH = 'edit';
|
||||
export const ITEM_EDIT_VERSIONHISTORY_PATH = 'versionhistory';
|
||||
export const ITEM_VERSION_PATH = 'version';
|
||||
export const UPLOAD_BITSTREAM_PATH = 'bitstreams/new';
|
||||
|
@@ -3,6 +3,7 @@ import { RouterModule } from '@angular/router';
|
||||
import { ItemPageResolver } from './item-page.resolver';
|
||||
import { AuthenticatedGuard } from '../core/auth/authenticated.guard';
|
||||
import { ItemBreadcrumbResolver } from '../core/breadcrumbs/item-breadcrumb.resolver';
|
||||
import { VersionResolver } from './version-page/version.resolver';
|
||||
import { DSOBreadcrumbsService } from '../core/breadcrumbs/dso-breadcrumbs.service';
|
||||
import { LinkService } from '../core/cache/builders/link.service';
|
||||
import { UploadBitstreamComponent } from './bitstreams/upload/upload-bitstream.component';
|
||||
@@ -12,6 +13,8 @@ import { MenuItemType } from '../shared/menu/initial-menus-state';
|
||||
import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model';
|
||||
import { ThemedItemPageComponent } from './simple/themed-item-page.component';
|
||||
import { ThemedFullItemPageComponent } from './full/themed-full-item-page.component';
|
||||
import { VersionPageComponent } from './version-page/version-page/version-page.component';
|
||||
import { BitstreamRequestACopyPageComponent } from '../shared/bitstream-request-a-copy-page/bitstream-request-a-copy-page.component';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
@@ -42,6 +45,10 @@ import { ThemedFullItemPageComponent } from './full/themed-full-item-page.compon
|
||||
path: UPLOAD_BITSTREAM_PATH,
|
||||
component: UploadBitstreamComponent,
|
||||
canActivate: [AuthenticatedGuard]
|
||||
},
|
||||
{
|
||||
path: ':request-a-copy',
|
||||
component: BitstreamRequestACopyPageComponent,
|
||||
}
|
||||
],
|
||||
data: {
|
||||
@@ -58,6 +65,18 @@ import { ThemedFullItemPageComponent } from './full/themed-full-item-page.compon
|
||||
}],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'version',
|
||||
children: [
|
||||
{
|
||||
path: ':id',
|
||||
component: VersionPageComponent,
|
||||
resolve: {
|
||||
dso: VersionResolver,
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
])
|
||||
],
|
||||
@@ -67,6 +86,7 @@ import { ThemedFullItemPageComponent } from './full/themed-full-item-page.compon
|
||||
DSOBreadcrumbsService,
|
||||
LinkService,
|
||||
ItemPageAdministratorGuard,
|
||||
VersionResolver,
|
||||
]
|
||||
|
||||
})
|
||||
|
@@ -31,6 +31,9 @@ import { MediaViewerComponent } from './media-viewer/media-viewer.component';
|
||||
import { MediaViewerVideoComponent } from './media-viewer/media-viewer-video/media-viewer-video.component';
|
||||
import { MediaViewerImageComponent } from './media-viewer/media-viewer-image/media-viewer-image.component';
|
||||
import { NgxGalleryModule } from '@kolkov/ngx-gallery';
|
||||
import { VersionPageComponent } from './version-page/version-page/version-page.component';
|
||||
import { VersionedItemComponent } from './simple/item-types/versioned-item/versioned-item.component';
|
||||
import { ThemedFileSectionComponent} from './simple/field-components/file-section/themed-file-section.component';
|
||||
|
||||
const ENTRY_COMPONENTS = [
|
||||
// put only entry components that use custom decorator
|
||||
@@ -39,6 +42,7 @@ const ENTRY_COMPONENTS = [
|
||||
];
|
||||
|
||||
const DECLARATIONS = [
|
||||
ThemedFileSectionComponent,
|
||||
ItemPageComponent,
|
||||
ThemedItemPageComponent,
|
||||
FullItemPageComponent,
|
||||
@@ -60,7 +64,8 @@ const DECLARATIONS = [
|
||||
AbstractIncrementalListComponent,
|
||||
MediaViewerComponent,
|
||||
MediaViewerVideoComponent,
|
||||
MediaViewerImageComponent
|
||||
MediaViewerImageComponent,
|
||||
VersionPageComponent,
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
@@ -72,10 +77,11 @@ const DECLARATIONS = [
|
||||
StatisticsModule.forRoot(),
|
||||
JournalEntitiesModule.withEntryComponents(),
|
||||
ResearchEntitiesModule.withEntryComponents(),
|
||||
NgxGalleryModule,
|
||||
NgxGalleryModule,
|
||||
],
|
||||
declarations: [
|
||||
...DECLARATIONS
|
||||
...DECLARATIONS,
|
||||
VersionedItemComponent
|
||||
],
|
||||
exports: [
|
||||
...DECLARATIONS
|
||||
|
@@ -1,7 +1,7 @@
|
||||
<ng-container *ngVar="(bitstreams$ | async) as bitstreams">
|
||||
<ds-metadata-field-wrapper *ngIf="bitstreams?.length > 0" [label]="label | translate">
|
||||
<div class="file-section">
|
||||
<ds-file-download-link *ngFor="let file of bitstreams; let last=last;" [bitstream]="file">
|
||||
<ds-file-download-link *ngFor="let file of bitstreams; let last=last;" [bitstream]="file" [item]="item">
|
||||
<span>{{file?.name}}</span>
|
||||
<span>({{(file?.sizeBytes) | dsFileSize }})</span>
|
||||
<span *ngIf="!last" innerHTML="{{separator}}"></span>
|
||||
|
@@ -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`);
|
||||
}
|
||||
|
||||
}
|
@@ -5,7 +5,7 @@
|
||||
<ds-item-versions-notice [item]="item"></ds-item-versions-notice>
|
||||
<ds-view-tracker [object]="item"></ds-view-tracker>
|
||||
<ds-listable-object-component-loader [object]="item" [viewMode]="viewMode"></ds-listable-object-component-loader>
|
||||
<ds-item-versions class="mt-2" [item]="item"></ds-item-versions>
|
||||
<ds-item-versions class="mt-2" [item]="item" [displayActions]="false"></ds-item-versions>
|
||||
</div>
|
||||
</div>
|
||||
<ds-error *ngIf="itemRD?.hasFailed" message="{{'error.item' | translate}}"></ds-error>
|
||||
|
@@ -16,7 +16,7 @@
|
||||
<ng-container *ngIf="mediaViewer.image">
|
||||
<ds-media-viewer [item]="object" [videoOptions]="mediaViewer.video"></ds-media-viewer>
|
||||
</ng-container>
|
||||
<ds-item-page-file-section [item]="object"></ds-item-page-file-section>
|
||||
<ds-themed-item-page-file-section [item]="object"></ds-themed-item-page-file-section>
|
||||
<ds-item-page-date-field [item]="object"></ds-item-page-date-field>
|
||||
<ds-metadata-representation-list class="ds-item-page-mixed-author-field"
|
||||
[parentItem]="object"
|
||||
|
@@ -3,6 +3,9 @@
|
||||
<ds-metadata-values [mdValues]="object?.allMetadata(['dc.title'])"></ds-metadata-values>
|
||||
</h2>
|
||||
<div class="pl-2">
|
||||
<ds-dso-page-version-button (newVersionEvent)="onCreateNewVersion()" [dso]="object"
|
||||
[tooltipMsgCreate]="'item.page.version.create'"
|
||||
[tooltipMsgHasDraft]="'item.page.version.hasDraft'"></ds-dso-page-version-button>
|
||||
<ds-dso-page-edit-button [pageRoute]="itemPageRoute" [dso]="object" [tooltipMsg]="'item.page.edit'"></ds-dso-page-edit-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -16,7 +19,7 @@
|
||||
<ng-container *ngIf="mediaViewer.image">
|
||||
<ds-media-viewer [item]="object" [videoOptions]="mediaViewer.video"></ds-media-viewer>
|
||||
</ng-container>
|
||||
<ds-item-page-file-section [item]="object"></ds-item-page-file-section>
|
||||
<ds-themed-item-page-file-section [item]="object"></ds-themed-item-page-file-section>
|
||||
<ds-item-page-date-field [item]="object"></ds-item-page-date-field>
|
||||
<ds-metadata-representation-list class="ds-item-page-mixed-author-field"
|
||||
[parentItem]="object"
|
||||
|
@@ -29,6 +29,12 @@ import { TruncatePipe } from '../../../../shared/utils/truncate.pipe';
|
||||
import { GenericItemPageFieldComponent } from '../../field-components/specific-field/generic/generic-item-page-field.component';
|
||||
import { createRelationshipsObservable } from '../shared/item.component.spec';
|
||||
import { UntypedItemComponent } from './untyped-item.component';
|
||||
import { VersionHistoryDataService } from '../../../../core/data/version-history-data.service';
|
||||
import { VersionDataService } from '../../../../core/data/version-data.service';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { WorkspaceitemDataService } from '../../../../core/submission/workspaceitem-data.service';
|
||||
import { SearchService } from '../../../../core/shared/search/search.service';
|
||||
import { ItemVersionsSharedService } from '../../../../shared/item/item-versions/item-versions-shared.service';
|
||||
|
||||
const mockItem: Item = Object.assign(new Item(), {
|
||||
bundles: createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])),
|
||||
@@ -47,13 +53,16 @@ describe('UntypedItemComponent', () => {
|
||||
}
|
||||
};
|
||||
TestBed.configureTestingModule({
|
||||
imports: [TranslateModule.forRoot({
|
||||
loader: {
|
||||
provide: TranslateLoader,
|
||||
useClass: TranslateLoaderMock
|
||||
}
|
||||
})],
|
||||
declarations: [UntypedItemComponent, GenericItemPageFieldComponent, TruncatePipe],
|
||||
imports: [
|
||||
TranslateModule.forRoot({
|
||||
loader: {
|
||||
provide: TranslateLoader,
|
||||
useClass: TranslateLoaderMock
|
||||
}
|
||||
}),
|
||||
RouterTestingModule,
|
||||
],
|
||||
declarations: [UntypedItemComponent, GenericItemPageFieldComponent, TruncatePipe ],
|
||||
providers: [
|
||||
{ provide: ItemDataService, useValue: {} },
|
||||
{ provide: TruncatableService, useValue: {} },
|
||||
@@ -68,9 +77,14 @@ describe('UntypedItemComponent', () => {
|
||||
{ provide: HttpClient, useValue: {} },
|
||||
{ provide: DSOChangeAnalyzer, useValue: {} },
|
||||
{ provide: DefaultChangeAnalyzer, useValue: {} },
|
||||
{ provide: VersionHistoryDataService, useValue: {} },
|
||||
{ provide: VersionDataService, useValue: {} },
|
||||
{ provide: BitstreamDataService, useValue: mockBitstreamDataService },
|
||||
{ provide: WorkspaceitemDataService, useValue: {} },
|
||||
{ provide: SearchService, useValue: {} },
|
||||
{ provide: ItemDataService, useValue: {} },
|
||||
{ provide: ItemVersionsSharedService, useValue: {} },
|
||||
],
|
||||
|
||||
schemas: [NO_ERRORS_SCHEMA]
|
||||
}).overrideComponent(UntypedItemComponent, {
|
||||
set: { changeDetection: ChangeDetectionStrategy.Default }
|
||||
|
@@ -1,8 +1,8 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { Item } from '../../../../core/shared/item.model';
|
||||
import { ItemComponent } from '../shared/item.component';
|
||||
import { ViewMode } from '../../../../core/shared/view-mode.model';
|
||||
import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator';
|
||||
import { VersionedItemComponent } from '../versioned-item/versioned-item.component';
|
||||
|
||||
/**
|
||||
* Component that represents a publication Item page
|
||||
@@ -15,6 +15,6 @@ import { listableObjectComponent } from '../../../../shared/object-collection/sh
|
||||
templateUrl: './untyped-item.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class UntypedItemComponent extends ItemComponent {
|
||||
export class UntypedItemComponent extends VersionedItemComponent {
|
||||
|
||||
}
|
||||
|
@@ -0,0 +1,93 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { VersionedItemComponent } from './versioned-item.component';
|
||||
import { VersionHistoryDataService } from '../../../../core/data/version-history-data.service';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { VersionDataService } from '../../../../core/data/version-data.service';
|
||||
import { NotificationsService } from '../../../../shared/notifications/notifications.service';
|
||||
import { ItemVersionsSharedService } from '../../../../shared/item/item-versions/item-versions-shared.service';
|
||||
import { Item } from '../../../../core/shared/item.model';
|
||||
import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils';
|
||||
import { buildPaginatedList } from '../../../../core/data/paginated-list.model';
|
||||
import { PageInfo } from '../../../../core/shared/page-info.model';
|
||||
import { MetadataMap } from '../../../../core/shared/metadata.models';
|
||||
import { createRelationshipsObservable } from '../shared/item.component.spec';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { Component } from '@angular/core';
|
||||
import { WorkspaceitemDataService } from '../../../../core/submission/workspaceitem-data.service';
|
||||
import { SearchService } from '../../../../core/shared/search/search.service';
|
||||
import { ItemDataService } from '../../../../core/data/item-data.service';
|
||||
import { Version } from '../../../../core/shared/version.model';
|
||||
|
||||
const mockItem: Item = Object.assign(new Item(), {
|
||||
bundles: createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])),
|
||||
metadata: new MetadataMap(),
|
||||
relationships: createRelationshipsObservable(),
|
||||
_links: {
|
||||
self: {
|
||||
href: 'item-href'
|
||||
},
|
||||
version: {
|
||||
href: 'version-href'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@Component({template: ''})
|
||||
class DummyComponent {
|
||||
}
|
||||
|
||||
describe('VersionedItemComponent', () => {
|
||||
let component: VersionedItemComponent;
|
||||
let fixture: ComponentFixture<VersionedItemComponent>;
|
||||
|
||||
let versionService: VersionDataService;
|
||||
let versionHistoryService: VersionHistoryDataService;
|
||||
|
||||
const versionServiceSpy = jasmine.createSpyObj('versionService', {
|
||||
findByHref: createSuccessfulRemoteDataObject$<Version>(new Version()),
|
||||
});
|
||||
|
||||
const versionHistoryServiceSpy = jasmine.createSpyObj('versionHistoryService', {
|
||||
createVersion: createSuccessfulRemoteDataObject$<Version>(new Version()),
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [VersionedItemComponent, DummyComponent],
|
||||
imports: [RouterTestingModule],
|
||||
providers: [
|
||||
{ provide: VersionHistoryDataService, useValue: versionHistoryServiceSpy },
|
||||
{ provide: TranslateService, useValue: {} },
|
||||
{ provide: VersionDataService, useValue: versionServiceSpy },
|
||||
{ provide: NotificationsService, useValue: {} },
|
||||
{ provide: ItemVersionsSharedService, useValue: {} },
|
||||
{ provide: WorkspaceitemDataService, useValue: {} },
|
||||
{ provide: SearchService, useValue: {} },
|
||||
{ provide: ItemDataService, useValue: {} },
|
||||
]
|
||||
}).compileComponents();
|
||||
versionService = TestBed.inject(VersionDataService);
|
||||
versionHistoryService = TestBed.inject(VersionHistoryDataService);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(VersionedItemComponent);
|
||||
component = fixture.componentInstance;
|
||||
component.object = mockItem;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('when onCreateNewVersion() is called', () => {
|
||||
it('should call versionService.findByHref', () => {
|
||||
component.onCreateNewVersion();
|
||||
expect(versionService.findByHref).toHaveBeenCalledWith('version-href');
|
||||
});
|
||||
});
|
||||
|
||||
});
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user