diff --git a/README.md b/README.md index 0e98bc7cf9..69b6132478 100644 --- a/README.md +++ b/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 -------------- diff --git a/cypress/integration/breadcrumbs.spec.ts b/cypress/integration/breadcrumbs.spec.ts new file mode 100644 index 0000000000..62b9a8ad1d --- /dev/null +++ b/cypress/integration/breadcrumbs.spec.ts @@ -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 for accessibility + testA11y('ds-breadcrumbs'); + }); +}); diff --git a/cypress/integration/browse-by-author.spec.ts b/cypress/integration/browse-by-author.spec.ts new file mode 100644 index 0000000000..07c20ad7c9 --- /dev/null +++ b/cypress/integration/browse-by-author.spec.ts @@ -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 to be visible + cy.get('ds-browse-by-metadata-page').should('be.visible'); + + // Analyze for accessibility + testA11y('ds-browse-by-metadata-page'); + }); +}); diff --git a/cypress/integration/browse-by-dateissued.spec.ts b/cypress/integration/browse-by-dateissued.spec.ts new file mode 100644 index 0000000000..4d22420227 --- /dev/null +++ b/cypress/integration/browse-by-dateissued.spec.ts @@ -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 to be visible + cy.get('ds-browse-by-date-page').should('be.visible'); + + // Analyze for accessibility + testA11y('ds-browse-by-date-page'); + }); +}); diff --git a/cypress/integration/browse-by-subject.spec.ts b/cypress/integration/browse-by-subject.spec.ts new file mode 100644 index 0000000000..89b791f03c --- /dev/null +++ b/cypress/integration/browse-by-subject.spec.ts @@ -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 to be visible + cy.get('ds-browse-by-metadata-page').should('be.visible'); + + // Analyze for accessibility + testA11y('ds-browse-by-metadata-page'); + }); +}); diff --git a/cypress/integration/browse-by-title.spec.ts b/cypress/integration/browse-by-title.spec.ts new file mode 100644 index 0000000000..e4e027586a --- /dev/null +++ b/cypress/integration/browse-by-title.spec.ts @@ -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 to be visible + cy.get('ds-browse-by-title-page').should('be.visible'); + + // Analyze for accessibility + testA11y('ds-browse-by-title-page'); + }); +}); diff --git a/cypress/integration/collection-page.spec.ts b/cypress/integration/collection-page.spec.ts new file mode 100644 index 0000000000..a0140d8faf --- /dev/null +++ b/cypress/integration/collection-page.spec.ts @@ -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); + + // tag must be loaded + cy.get('ds-collection-page').should('exist'); + + // Analyze for accessibility issues + testA11y('ds-collection-page'); + }); +}); diff --git a/cypress/integration/collection-statistics.spec.ts b/cypress/integration/collection-statistics.spec.ts new file mode 100644 index 0000000000..90b569c824 --- /dev/null +++ b/cypress/integration/collection-statistics.spec.ts @@ -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); + + // tag must be loaded + cy.get('ds-collection-statistics-page').should('exist'); + + // Analyze for accessibility issues + testA11y('ds-collection-statistics-page'); + }); +}); diff --git a/cypress/integration/community-list.spec.ts b/cypress/integration/community-list.spec.ts new file mode 100644 index 0000000000..a7ba72b74a --- /dev/null +++ b/cypress/integration/community-list.spec.ts @@ -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'); + + // 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 for accessibility issues + // Disable heading-order checks until it is fixed + testA11y('ds-community-list-page', + { + rules: { + 'heading-order': { enabled: false } + } + } as Options + ); + }); +}); diff --git a/cypress/integration/community-page.spec.ts b/cypress/integration/community-page.spec.ts new file mode 100644 index 0000000000..79e21431ad --- /dev/null +++ b/cypress/integration/community-page.spec.ts @@ -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); + + // tag must be loaded + cy.get('ds-community-page').should('exist'); + + // Analyze for accessibility issues + testA11y('ds-community-page',); + }); +}); diff --git a/cypress/integration/community-statistics.spec.ts b/cypress/integration/community-statistics.spec.ts new file mode 100644 index 0000000000..cbf1783c0b --- /dev/null +++ b/cypress/integration/community-statistics.spec.ts @@ -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); + + // tag must be loaded + cy.get('ds-community-statistics-page').should('exist'); + + // Analyze for accessibility issues + testA11y('ds-community-statistics-page'); + }); +}); diff --git a/cypress/integration/footer.spec.ts b/cypress/integration/footer.spec.ts new file mode 100644 index 0000000000..656e9d4701 --- /dev/null +++ b/cypress/integration/footer.spec.ts @@ -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 for accessibility + testA11y('ds-footer'); + }); +}); diff --git a/cypress/integration/header.spec.ts b/cypress/integration/header.spec.ts new file mode 100644 index 0000000000..236208db68 --- /dev/null +++ b/cypress/integration/header.spec.ts @@ -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 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 + ], + }); + }); +}); diff --git a/cypress/integration/homepage-statistics.spec.ts b/cypress/integration/homepage-statistics.spec.ts new file mode 100644 index 0000000000..fe0311f87e --- /dev/null +++ b/cypress/integration/homepage-statistics.spec.ts @@ -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'); + + // tag must be loaded + cy.get('ds-site-statistics-page').should('exist'); + + // Analyze for accessibility issues + testA11y('ds-site-statistics-page'); + }); +}); diff --git a/cypress/integration/homepage.spec.ts b/cypress/integration/homepage.spec.ts index e6c28156bb..ddde260bc7 100644 --- a/cypress/integration/homepage.spec.ts +++ b/cypress/integration/homepage.spec.ts @@ -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 for accessibility issues + testA11y('ds-home-page'); + }); }); diff --git a/cypress/integration/item-page.spec.ts b/cypress/integration/item-page.spec.ts index bd91b6506c..6a454b678d 100644 --- a/cypress/integration/item-page.spec.ts +++ b/cypress/integration/item-page.spec.ts @@ -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); + + // tag must be loaded + cy.get('ds-item-page').should('exist'); + + // Analyze for accessibility issues + // Disable heading-order checks until it is fixed + testA11y('ds-item-page', + { + rules: { + 'heading-order': { enabled: false } + } + } as Options + ); + }); }); diff --git a/cypress/integration/item-statistics.spec.ts b/cypress/integration/item-statistics.spec.ts index f90195c9fa..66ebc228db 100644 --- a/cypress/integration/item-statistics.spec.ts +++ b/cypress/integration/item-statistics.spec.ts @@ -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); + + // tag must be loaded + cy.get('ds-item-statistics-page').should('exist'); + + // Analyze for accessibility issues + testA11y('ds-item-statistics-page'); }); }); diff --git a/cypress/integration/search-page.spec.ts b/cypress/integration/search-page.spec.ts index 6de32f8c40..a2bfbe6a5b 100644 --- a/cypress/integration/search-page.spec.ts +++ b/cypress/integration/search-page.spec.ts @@ -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'); + + // 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 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(); + + // tag must be loaded + cy.get('ds-search-page').should('exist'); + + // Analyze 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 + ); + }); }); diff --git a/cypress/plugins/index.ts b/cypress/plugins/index.ts index c106e08011..c6eb874232 100644 --- a/cypress/plugins/index.ts +++ b/cypress/plugins/index.ts @@ -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; + } + }); +}; diff --git a/cypress/support/index.ts b/cypress/support/index.ts index a1456bfcd4..e8b10b9cfb 100644 --- a/cypress/support/index.ts +++ b/cypress/support/index.ts @@ -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'; diff --git a/cypress/support/utils.ts b/cypress/support/utils.ts new file mode 100644 index 0000000000..96575969e8 --- /dev/null +++ b/cypress/support/utils.ts @@ -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); +}; diff --git a/cypress/tsconfig.json b/cypress/tsconfig.json index 39f88f8210..58083003cd 100644 --- a/cypress/tsconfig.json +++ b/cypress/tsconfig.json @@ -6,7 +6,8 @@ "compilerOptions": { "types": [ "cypress", - "cypress-axe" + "cypress-axe", + "node" ] } } \ No newline at end of file diff --git a/package.json b/package.json index 00c1ad7b24..f6d7314243 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/merge-i18n-files.ts b/scripts/merge-i18n-files.ts new file mode 100644 index 0000000000..e790828c0d --- /dev/null +++ b/scripts/merge-i18n-files.ts @@ -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 when running script on all language files', projectRoot(LANGUAGE_FILES_LOCATION)) + .option('-s, --source-dir ', 'source dir of transalations to be merged') + .usage('(-s [-d ])') + .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(); +} diff --git a/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.html b/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.html index c613cfdcad..45326c1abc 100644 --- a/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.html +++ b/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.html @@ -52,15 +52,17 @@ - - + + + - - + +
{{messagePrefix + '.table.id' | translate}}{{messagePrefix + '.table.name' | translate}}{{messagePrefix + '.table.id' | translate}}{{messagePrefix + '.table.name' | translate}}{{messagePrefix + '.table.collectionOrCommunity' | translate}}
{{group.id}}{{group.id}}{{group.name}}{{(group.object | async)?.payload?.name}}
diff --git a/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.spec.ts b/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.spec.ts index 832f4f6ce5..1f4a106bfa 100644 --- a/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.spec.ts +++ b/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.spec.ts @@ -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> { + 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> { + 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; diff --git a/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.ts b/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.ts index 11c117ef55..723939df77 100644 --- a/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.ts +++ b/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.ts @@ -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) => { 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) => { 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) => { + 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) => { - 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(); - } } diff --git a/src/app/access-control/epeople-registry/eperson-form/validators/email-taken.validator.ts b/src/app/access-control/epeople-registry/eperson-form/validators/email-taken.validator.ts new file mode 100644 index 0000000000..5153abae7c --- /dev/null +++ b/src/app/access-control/epeople-registry/eperson-form/validators/email-taken.validator.ts @@ -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 | Observable => { + return ePersonDataService.getEPersonByEmail(control.value) + .pipe( + getFirstSucceededRemoteData(), + map(res => { + return !!res.payload ? { emailTaken: true } : null; + }) + ); + }; + } +} diff --git a/src/app/access-control/group-registry/group-form/group-form.component.ts b/src/app/access-control/group-registry/group-form/group-form.component.ts index 7e0329f54f..b2b9fab58d 100644 --- a/src/app/access-control/group-registry/group-form/group-form.component.ts +++ b/src/app/access-control/group-registry/group-form/group-form.component.ts @@ -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) => { - if (hasValue(rd) && hasValue(rd.payload)) { - return true; - } else { - return false; - } + return hasValue(rd) && hasValue(rd.payload); }), catchError(() => observableOf(false)), ); diff --git a/src/app/access-control/group-registry/group-form/members-list/members-list.component.html b/src/app/access-control/group-registry/group-form/members-list/members-list.component.html index 51282b49c0..e5932edf05 100644 --- a/src/app/access-control/group-registry/group-form/members-list/members-list.component.html +++ b/src/app/access-control/group-registry/group-form/members-list/members-list.component.html @@ -38,17 +38,22 @@ - - - + + + + - - + - + - - - + + + + - - + - + - - - + + + + - - + - + - - + + + - - + - + + @@ -56,6 +57,7 @@ + + +
{{messagePrefix + '.table.id' | translate}}{{messagePrefix + '.table.name' | translate}}{{messagePrefix + '.table.edit' | translate}}{{messagePrefix + '.table.id' | translate}}{{messagePrefix + '.table.name' | translate}}{{messagePrefix + '.table.identity' | translate}}{{messagePrefix + '.table.edit' | translate}}
{{ePerson.eperson.id}}{{ePerson.eperson.id}}{{ePerson.eperson.name}} + + {{messagePrefix + '.table.email' | translate}}: {{ ePerson.eperson.email ? ePerson.eperson.email : '-' }}
+ {{messagePrefix + '.table.netid' | translate}}: {{ ePerson.eperson.netid ? ePerson.eperson.netid : '-' }} +
{{messagePrefix + '.table.id' | translate}}{{messagePrefix + '.table.name' | translate}}{{messagePrefix + '.table.edit' | translate}}{{messagePrefix + '.table.id' | translate}}{{messagePrefix + '.table.name' | translate}}{{messagePrefix + '.table.identity' | translate}}{{messagePrefix + '.table.edit' | translate}}
{{ePerson.eperson.id}}{{ePerson.eperson.id}}{{ePerson.eperson.name}} + + {{messagePrefix + '.table.email' | translate}}: {{ ePerson.eperson.email ? ePerson.eperson.email : '-' }}
+ {{messagePrefix + '.table.netid' | translate}}: {{ ePerson.eperson.netid ? ePerson.eperson.netid : '-' }} +
{{messagePrefix + '.table.id' | translate}}{{messagePrefix + '.table.name' | translate}}{{messagePrefix + '.table.edit' | translate}}{{messagePrefix + '.table.id' | translate}}{{messagePrefix + '.table.name' | translate}}{{messagePrefix + '.table.collectionOrCommunity' | translate}}{{messagePrefix + '.table.edit' | translate}}
{{group.id}}{{group.id}}{{group.name}} + {{(group.object | async)?.payload?.name}}
{{messagePrefix + '.table.id' | translate}}{{messagePrefix + '.table.name' | translate}}{{messagePrefix + '.table.id' | translate}}{{messagePrefix + '.table.name' | translate}}{{messagePrefix + '.table.collectionOrCommunity' | translate}} {{messagePrefix + '.table.edit' | translate}}
{{group.id}}{{group.id}}{{group.name}} + {{(group.object | async)?.payload?.name}}
{{messagePrefix + 'table.id' | translate}} {{messagePrefix + 'table.name' | translate}}{{messagePrefix + 'table.collectionOrCommunity' | translate}} {{messagePrefix + 'table.members' | translate}} {{messagePrefix + 'table.edit' | translate}}
{{groupDto.group.id}} {{groupDto.group.name}}{{(groupDto.group.object | async)?.payload?.name}} {{groupDto.epersons?.totalElements + groupDto.subgroups?.totalElements}}
diff --git a/src/app/access-control/group-registry/groups-registry.component.spec.ts b/src/app/access-control/group-registry/groups-registry.component.spec.ts index 245044f5c9..0b30a551fd 100644 --- a/src/app/access-control/group-registry/groups-registry.component.spec.ts +++ b/src/app/access-control/group-registry/groups-registry.component.spec.ts @@ -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(); diff --git a/src/app/access-control/group-registry/groups-registry.component.ts b/src/app/access-control/group-registry/groups-registry.component.ts index ce1b7dedd9..da861518da 100644 --- a/src/app/access-control/group-registry/groups-registry.component.ts +++ b/src/app/access-control/group-registry/groups-registry.component.ts @@ -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(), diff --git a/src/app/app-routing-paths.ts b/src/app/app-routing-paths.ts index 81b0755d11..db6b22a023 100644 --- a/src/app/app-routing-paths.ts +++ b/src/app/app-routing-paths.ts @@ -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}`; +} diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 52a07b89f5..157ada622d 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -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 diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 356025da9e..6f06a84144 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -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 = new BehaviorSubject(false); + isThemeCSSLoading$: BehaviorSubject = new BehaviorSubject(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); } diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 131e6c6b58..e2cb10691b 100755 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -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[] { 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, ]; diff --git a/src/app/collection-page/collection-form/collection-form.component.ts b/src/app/collection-page/collection-form/collection-form.component.ts index e8b368a25f..7835ccc8e5 100644 --- a/src/app/collection-page/collection-form/collection-form.component.ts +++ b/src/app/collection-page/collection-form/collection-form.component.ts @@ -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 { +export class CollectionFormComponent extends ComColFormComponent 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 { 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} */ - 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 = 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 { 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 = 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); + 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(); + }); + + } } diff --git a/src/app/collection-page/collection-form/collection-form.models.ts b/src/app/collection-page/collection-form/collection-form.models.ts new file mode 100644 index 0000000000..37e9d8a9a0 --- /dev/null +++ b/src/app/collection-page/collection-form/collection-form.models.ts @@ -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 = { + 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', + }) +]; diff --git a/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.html b/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.html new file mode 100644 index 0000000000..7dc93e8adf --- /dev/null +++ b/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.html @@ -0,0 +1,54 @@ +
+
+

{{ 'collection.source.controls.head' | translate }}

+
+ {{'collection.source.controls.harvest.status' | translate}} + {{contentSource?.harvestStatus}} +
+
+ {{'collection.source.controls.harvest.start' | translate}} + {{contentSource?.harvestStartTime ? contentSource?.harvestStartTime : 'collection.source.controls.harvest.no-information'|translate }} +
+
+ {{'collection.source.controls.harvest.last' | translate}} + {{contentSource?.message ? contentSource?.message : 'collection.source.controls.harvest.no-information'|translate }} +
+
+ {{'collection.source.controls.harvest.message' | translate}} + {{contentSource?.lastHarvested ? contentSource?.lastHarvested : 'collection.source.controls.harvest.no-information'|translate }} +
+ + + + + + + + + +
+
\ No newline at end of file diff --git a/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.scss b/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.scss new file mode 100644 index 0000000000..98f634e66b --- /dev/null +++ b/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.scss @@ -0,0 +1,3 @@ +.spinner-button { + margin-bottom: calc((var(--bs-line-height-base) * 1rem - var(--bs-font-size-base)) / 2); +} \ No newline at end of file diff --git a/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.spec.ts b/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.spec.ts new file mode 100644 index 0000000000..3eb83ebe8a --- /dev/null +++ b/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.spec.ts @@ -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; + + 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(); + }); + }); + + +}); diff --git a/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.ts b/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.ts new file mode 100644 index 0000000000..abc5fe3083 --- /dev/null +++ b/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.ts @@ -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; + 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(); + } + }); + } +} diff --git a/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.html b/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.html index de7f0b4708..b67ee9a1bd 100644 --- a/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.html +++ b/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.html @@ -1,57 +1,74 @@
-
- - - -
-

{{ 'collection.edit.tabs.source.head' | translate }}

-
- - -
- -

{{ 'collection.edit.tabs.source.form.head' | translate }}

+
+ + + +
+

{{ 'collection.edit.tabs.source.head' | translate }}

+
+ + +
+ +

{{ 'collection.edit.tabs.source.form.head' | translate }}

- -
-
- - - -
+
+
+
+
+
+
+ + + +
+
+
+
+ + + diff --git a/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.spec.ts b/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.spec.ts index 869238b956..3fb1a50bf1 100644 --- a/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.spec.ts +++ b/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.spec.ts @@ -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], diff --git a/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.ts b/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.ts index c4b42d028d..ae48b9309e 100644 --- a/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.ts +++ b/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.ts @@ -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(), diff --git a/src/app/collection-page/edit-collection-page/edit-collection-page.module.ts b/src/app/collection-page/edit-collection-page/edit-collection-page.module.ts index b743032c8c..0b09542fa0 100644 --- a/src/app/collection-page/edit-collection-page/edit-collection-page.module.ts +++ b/src/app/collection-page/edit-collection-page/edit-collection-page.module.ts @@ -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 ] }) diff --git a/src/app/core/data/collection-data.service.spec.ts b/src/app/core/data/collection-data.service.spec.ts index 00aede27a6..031e5ecf47 100644 --- a/src/app/core/data/collection-data.service.spec.ts +++ b/src/app/core/data/collection-data.service.spec.ts @@ -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'; diff --git a/src/app/core/data/collection-data.service.ts b/src/app/core/data/collection-data.service.ts index f58f36450f..470c036df2 100644 --- a/src/app/core/data/collection-data.service.ts +++ b/src/app/core/data/collection-data.service.ts @@ -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 { filter((collections: RemoteData>) => !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>> + * collection list + */ + getAuthorizedCollectionByEntityType( + query: string, + entityType: string, + options: FindListOptions = {}, + reRequestOnStale = true, + ...linksToFollow: FollowLinkConfig[]): Observable>> { + 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>) => !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>> * collection list */ - getAuthorizedCollectionByCommunity(communityId: string, query: string, options: FindListOptions = {}): Observable>> { + getAuthorizedCollectionByCommunity(communityId: string, query: string, options: FindListOptions = {}, reRequestOnStale = true,): Observable>> { const searchHref = 'findSubmitAuthorizedByCommunity'; options = Object.assign({}, options, { searchParams: [ @@ -102,7 +129,38 @@ export class CollectionDataService extends ComColDataService { ] }); - return this.searchBy(searchHref, options).pipe( + return this.searchBy(searchHref, options, reRequestOnStale).pipe( + filter((collections: RemoteData>) => !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>> + * collection list + */ + getAuthorizedCollectionByCommunityAndEntityType( + communityId: string, + entityType: string, + options: FindListOptions = {}, + reRequestOnStale = true, + ...linksToFollow: FollowLinkConfig[]): Observable>> { + 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>) => !collections.isResponsePending)); } @@ -138,7 +196,7 @@ export class CollectionDataService extends ComColDataService { * Get the collection's content harvester * @param collectionId */ - getContentSource(collectionId: string): Observable> { + getContentSource(collectionId: string, useCachedVersionIfAvailable = true): Observable> { const href$ = this.getHarvesterEndpoint(collectionId).pipe( isNotEmptyOperator(), take(1) @@ -146,7 +204,7 @@ export class CollectionDataService extends ComColDataService { 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(href$); @@ -208,10 +266,20 @@ export class CollectionDataService extends ComColDataService { } /** - * 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> { 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>> { + return this.findAllByHref(item._links.mappedCollections.href, findListOptions); + } + } diff --git a/src/app/core/data/entity-type.service.ts b/src/app/core/data/entity-type.service.ts index ca9ea15bc6..40b9373107 100644 --- a/src/app/core/data/entity-type.service.ts +++ b/src/app/core/data/entity-type.service.ts @@ -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 { /** * 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 { @@ -67,6 +68,73 @@ export class EntityTypeService extends DataService { ); } + /** + * 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>> { + const searchHref = 'findAllByAuthorizedCollection'; + + return this.searchBy(searchHref, options).pipe( + filter((type: RemoteData>) => !type.isResponsePending)); + } + + /** + * Used to verify if there are one or more entities available + */ + hasMoreThanOneAuthorized(): Observable { + const findListOptions: FindListOptions = { + elementsPerPage: 2, + currentPage: 1 + }; + return this.getAllAuthorizedRelationshipType(findListOptions).pipe( + map((result: RemoteData>) => { + 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>> { + const searchHref = 'findAllByAuthorizedExternalSource'; + + return this.searchBy(searchHref, options).pipe( + filter((type: RemoteData>) => !type.isResponsePending)); + } + + /** + * Used to verify if there are one or more entities available. To use with external source import. + */ + hasMoreThanOneAuthorizedImport(): Observable { + const findListOptions: FindListOptions = { + elementsPerPage: 2, + currentPage: 1 + }; + return this.getAllAuthorizedRelationshipTypeImport(findListOptions).pipe( + map((result: RemoteData>) => { + 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 diff --git a/src/app/core/data/feature-authorization/feature-id.ts b/src/app/core/data/feature-authorization/feature-id.ts index ac045b93b0..15eba0e5db 100644 --- a/src/app/core/data/feature-authorization/feature-id.ts +++ b/src/app/core/data/feature-authorization/feature-id.ts @@ -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', } diff --git a/src/app/core/data/item-data.service.spec.ts b/src/app/core/data/item-data.service.spec.ts index 30a132aeae..26a6b52cc3 100644 --- a/src/app/core/data/item-data.service.spec.ts +++ b/src/app/core/data/item-data.service.spec.ts @@ -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'); + }); + }); + }); diff --git a/src/app/core/data/item-data.service.ts b/src/app/core/data/item-data.service.ts index 7a0116fe86..c31b6b3c97 100644 --- a/src/app/core/data/item-data.service.ts +++ b/src/app/core/data/item-data.service.ts @@ -59,6 +59,7 @@ export class ItemDataService extends DataService { * 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} */ public getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable { @@ -287,4 +288,13 @@ export class ItemDataService extends DataService { 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); + } + } diff --git a/src/app/core/data/item-request-data.service.spec.ts b/src/app/core/data/item-request-data.service.spec.ts new file mode 100644 index 0000000000..0d99ca5cd4 --- /dev/null +++ b/src/app/core/data/item-request-data.service.spec.ts @@ -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(); + }); + }); + }); +}); diff --git a/src/app/core/data/item-request-data.service.ts b/src/app/core/data/item-request-data.service.ts new file mode 100644 index 0000000000..41ad19211a --- /dev/null +++ b/src/app/core/data/item-request-data.service.ts @@ -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 { + + protected linkPath = 'itemrequests'; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DefaultChangeAnalyzer, + ) { + super(); + } + + getItemRequestEndpoint(): Observable { + return this.halService.getEndpoint(this.linkPath); + } + + /** + * Get the endpoint for an {@link ItemRequest} by their token + * @param token + */ + getItemRequestEndpointByToken(token: string): Observable { + 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> { + 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(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> { + 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> { + 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> { + 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); + } + +} diff --git a/src/app/core/data/version-data.service.spec.ts b/src/app/core/data/version-data.service.spec.ts new file mode 100644 index 0000000000..5a8caf31be --- /dev/null +++ b/src/app/core/data/version-data.service.spec.ts @@ -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; + 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); + }); + }); + }); + +}); diff --git a/src/app/core/data/version-data.service.ts b/src/app/core/data/version-data.service.ts index 11a3838eb0..70231122c3 100644 --- a/src/app/core/data/version-data.service.ts +++ b/src/app/core/data/version-data.service.ts @@ -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 { } /** - * 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 { - return this.halService.getEndpoint(this.linkPath); + getHistoryFromVersion(version: Version, useCachedVersionIfAvailable = false, reRequestOnStale = true): Observable { + 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 { + return this.getHistoryFromVersion(version).pipe( + map((versionHistory: VersionHistory) => versionHistory.id), + ); + } + } diff --git a/src/app/core/data/version-history-data.service.spec.ts b/src/app/core/data/version-history-data.service.spec.ts index 3a816936de..207093b4d5 100644 --- a/src/app/core/data/version-history-data.service.spec.ts +++ b/src/app/core/data/version-history-data.service.spec.ts @@ -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; 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$(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); - } }); diff --git a/src/app/core/data/version-history-data.service.ts b/src/app/core/data/version-history-data.service.ts index 8f148f168d..4268516e6b 100644 --- a/src/app/core/data/version-history-data.service.ts +++ b/src/app/core/data/version-history-data.service.ts @@ -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 { 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> { + 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>; + } + + /** + * Get the latest version in a version history + * @param versionHistory + */ + getLatestVersionFromHistory$(versionHistory: VersionHistory): Observable { + + // 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 { + // 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 { + 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 { + 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 { + 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 { + 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); + } } diff --git a/src/app/core/eperson/eperson-data.service.spec.ts b/src/app/core/eperson/eperson-data.service.spec.ts index cd7b664379..ac08bda589 100644 --- a/src/app/core/eperson/eperson-data.service.spec.ts +++ b/src/app/core/eperson/eperson-data.service.spec.ts @@ -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({ diff --git a/src/app/core/shared/content-source-set-serializer.spec.ts b/src/app/core/shared/content-source-set-serializer.spec.ts new file mode 100644 index 0000000000..2203481250 --- /dev/null +++ b/src/app/core/shared/content-source-set-serializer.spec.ts @@ -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'); + }); + }); +}); diff --git a/src/app/core/shared/content-source-set-serializer.ts b/src/app/core/shared/content-source-set-serializer.ts new file mode 100644 index 0000000000..ec0baec5a6 --- /dev/null +++ b/src/app/core/shared/content-source-set-serializer.ts @@ -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; + } +} diff --git a/src/app/core/shared/content-source.model.ts b/src/app/core/shared/content-source.model.ts index 326407822f..40cf43ad0c 100644 --- a/src/app/core/shared/content-source.model.ts +++ b/src/app/core/shared/content-source.model.ts @@ -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 */ diff --git a/src/app/core/shared/external-source.model.ts b/src/app/core/shared/external-source.model.ts index e28c8953e9..9f20a732f4 100644 --- a/src/app/core/shared/external-source.model.ts +++ b/src/app/core/shared/external-source.model.ts @@ -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>>; + /** * The {@link HALLink}s for this ExternalSource */ @@ -45,5 +57,6 @@ export class ExternalSource extends CacheableObject { _links: { self: HALLink; entries: HALLink; + entityTypes: HALLink; }; } diff --git a/src/app/core/shared/item-relationships/item-type.resource-type.ts b/src/app/core/shared/item-relationships/item-type.resource-type.ts index 616dc23b73..e820bc22f4 100644 --- a/src/app/core/shared/item-relationships/item-type.resource-type.ts +++ b/src/app/core/shared/item-relationships/item-type.resource-type.ts @@ -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'; diff --git a/src/app/core/shared/item-request.model.ts b/src/app/core/shared/item-request.model.ts new file mode 100644 index 0000000000..08b65abebf --- /dev/null +++ b/src/app/core/shared/item-request.model.ts @@ -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; + }; + +} diff --git a/src/app/core/shared/item-request.resource-type.ts b/src/app/core/shared/item-request.resource-type.ts new file mode 100644 index 0000000000..0535ef1948 --- /dev/null +++ b/src/app/core/shared/item-request.resource-type.ts @@ -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'); diff --git a/src/app/core/shared/search/search.service.ts b/src/app/core/shared/search/search.service.ts index 75723366bc..91916a35ac 100644 --- a/src/app/core/shared/search/search.service.ts +++ b/src/app/core/shared/search/search.service.ts @@ -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} Emits a list of DSpaceObjects which represent possible scopes - */ - getScopes(scopeId?: string): Observable { - - if (isEmpty(scopeId)) { - const top: Observable = this.communityService.findTop({ elementsPerPage: 9999 }).pipe( - getFirstSucceededRemoteData(), - map( - (communities: RemoteData>) => communities.payload.page - ) - ); - return top; - } - - const scopeObject: Observable> = this.dspaceObjectService.findById(scopeId).pipe(getFirstSucceededRemoteData()); - const scopeList: Observable = scopeObject.pipe( - switchMap((dsoRD: RemoteData) => { - 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} The current view mode diff --git a/src/app/core/shared/version-history.model.ts b/src/app/core/shared/version-history.model.ts index 85578f20fc..1e75b8f321 100644 --- a/src/app/core/shared/version-history.model.ts +++ b/src/app/core/shared/version-history.model.ts @@ -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 */ diff --git a/src/app/core/submission/workflowitem-data.service.spec.ts b/src/app/core/submission/workflowitem-data.service.spec.ts new file mode 100644 index 0000000000..8a5177118d --- /dev/null +++ b/src/app/core/submission/workflowitem-data.service.spec.ts @@ -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; + 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 for the search', () => { + const result = service.findByItem('1234-1234', true, true, pageInfo); + const expected = cold('a|', { + a: wsiRD + }); + expect(result).toBeObservable(expected); + }); + + }); + }); + +}); diff --git a/src/app/core/submission/workflowitem-data.service.ts b/src/app/core/submission/workflowitem-data.service.ts index 099cfa8627..384d477110 100644 --- a/src/app/core/submission/workflowitem-data.service.ts +++ b/src/app/core/submission/workflowitem-data.service.ts @@ -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 { protected linkPath = 'workflowitems'; + protected searchByItemLinkPath = 'item'; protected responseMsToLive = 10 * 1000; constructor( @@ -86,4 +90,23 @@ export class WorkflowItemDataService extends DataService { 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[]): Observable> { + 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); + } + } diff --git a/src/app/core/submission/workspaceitem-data.service.spec.ts b/src/app/core/submission/workspaceitem-data.service.spec.ts new file mode 100644 index 0000000000..da7edccda7 --- /dev/null +++ b/src/app/core/submission/workspaceitem-data.service.spec.ts @@ -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; + 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 for the search', () => { + const result = service.findByItem('1234-1234', true, true, pageInfo); + const expected = cold('a|', { + a: wsiRD + }); + expect(result).toBeObservable(expected); + }); + + }); + }); + +}); diff --git a/src/app/core/submission/workspaceitem-data.service.ts b/src/app/core/submission/workspaceitem-data.service.ts index 2fc95bdd00..2813398bb5 100644 --- a/src/app/core/submission/workspaceitem-data.service.ts +++ b/src/app/core/submission/workspaceitem-data.service.ts @@ -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 { protected linkPath = 'workspaceitems'; + protected searchByItemLinkPath = 'item'; constructor( protected comparator: DSOChangeAnalyzer, @@ -33,4 +39,22 @@ export class WorkspaceitemDataService extends DataService { 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[]): Observable> { + 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); + } + } diff --git a/src/app/item-page/bitstreams/upload/upload-bitstream.component.spec.ts b/src/app/item-page/bitstreams/upload/upload-bitstream.component.spec.ts index 35546a63d6..8fa475a2ce 100644 --- a/src/app/item-page/bitstreams/upload/upload-bitstream.component.spec.ts +++ b/src/app/item-page/bitstreams/upload/upload-bitstream.component.spec.ts @@ -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({}); diff --git a/src/app/item-page/bitstreams/upload/upload-bitstream.component.ts b/src/app/item-page/bitstreams/upload/upload-bitstream.component.ts index 7635758463..f68aca2252 100644 --- a/src/app/item-page/bitstreams/upload/upload-bitstream.component.ts +++ b/src/app/item-page/bitstreams/upload/upload-bitstream.component.ts @@ -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) => 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( diff --git a/src/app/item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.html b/src/app/item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.html index 5277fa1a3a..fe46906f47 100644 --- a/src/app/item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.html +++ b/src/app/item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.html @@ -1,10 +1,10 @@
+
+ +
+
+
+ + +
-
-
- - - -
-
+
diff --git a/src/app/item-page/edit-item-page/item-version-history/item-version-history.component.html b/src/app/item-page/edit-item-page/item-version-history/item-version-history.component.html index acabbd1010..ec0c345d82 100644 --- a/src/app/item-page/edit-item-page/item-version-history/item-version-history.component.html +++ b/src/app/item-page/edit-item-page/item-version-history/item-version-history.component.html @@ -1,6 +1,4 @@ -
- -
- +
diff --git a/src/app/item-page/edit-item-page/item-version-history/item-version-history.component.spec.ts b/src/app/item-page/edit-item-page/item-version-history/item-version-history.component.spec.ts index c68dcfeba3..f4ed04c2c8 100644 --- a/src/app/item-page/edit-item-page/item-version-history/item-version-history.component.spec.ts +++ b/src/app/item-page/edit-item-page/item-version-history/item-version-history.component.spec.ts @@ -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(); diff --git a/src/app/item-page/edit-item-page/item-version-history/item-version-history.component.ts b/src/app/item-page/edit-item-page/item-version-history/item-version-history.component.ts index 99ef6b3933..18878109c2 100644 --- a/src/app/item-page/edit-item-page/item-version-history/item-version-history.component.ts +++ b/src/app/item-page/edit-item-page/item-version-history/item-version-history.component.ts @@ -30,6 +30,6 @@ export class ItemVersionHistoryComponent { } ngOnInit(): void { - this.itemRD$ = this.route.parent.data.pipe(map((data) => data.dso)).pipe(getFirstSucceededRemoteData()) as Observable>; + this.itemRD$ = this.route.parent.parent.data.pipe(map((data) => data.dso)).pipe(getFirstSucceededRemoteData()) as Observable>; } } diff --git a/src/app/item-page/field-components/collections/collections.component.html b/src/app/item-page/field-components/collections/collections.component.html index e0f963b5bc..e8f682a182 100644 --- a/src/app/item-page/field-components/collections/collections.component.html +++ b/src/app/item-page/field-components/collections/collections.component.html @@ -1,7 +1,21 @@ - + + +
+ {{'item.page.collections.loading' | translate}} +
+ + + {{'item.page.collections.load-more' | translate}} +
diff --git a/src/app/item-page/field-components/collections/collections.component.spec.ts b/src/app/item-page/field-components/collections/collections.component.spec.ts index 70ce5db760..d5278706da 100644 --- a/src/app/item-page/field-components/collections/collections.component.spec.ts +++ b/src/app/item-page/field-components/collections/collections.component.spec.ts @@ -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; - -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; + 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 = '
'; - + component = fixture.componentInstance; + component.item = mockItem; + component.label = 'test.test'; + component.separator = '
'; + component.pageSize = 2; })); - describe('When the requested item request has succeeded', () => { + describe('when the item has only an owning collection', () => { + let mockPage1: PaginatedList; + 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; + 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; + let mockPage2: PaginatedList; + + 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; + + 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(); + }); + }); + }); diff --git a/src/app/item-page/field-components/collections/collections.component.ts b/src/app/item-page/field-components/collections/collections.component.ts index 32dc8dfb73..23aff80160 100644 --- a/src/app/item-page/field-components/collections/collections.component.ts +++ b/src/app/item-page/field-components/collections/collections.component.ts @@ -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 = '
'; - collectionsRD$: Observable>>; + /** + * Amount of mapped collections that should be fetched at once. + */ + pageSize = 5; + + /** + * Last page of the mapped collections that has been fetched. + */ + lastPage$: BehaviorSubject = new BehaviorSubject(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 = new BehaviorSubject(undefined); + + /** + * Whether or not a page of mapped collections is currently being loaded. + */ + isLoading$: BehaviorSubject = new BehaviorSubject(true); + + /** + * Whether or not more pages of mapped collections are available. + */ + hasMore$: BehaviorSubject = new BehaviorSubject(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; constructor(private cds: CollectionDataService) { } ngOnInit(): void { - // this.collections = this.item.parents.payload; + const owningCollection$: Observable = 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) => { - 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 = 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>(), + + // update isLoading$ + tap(() => this.isLoading$.next(false)), + + getAllSucceededRemoteDataPayload(), + + // update hasMore$ + tap((response: PaginatedList) => this.hasMore$.next(response.currentPage < response.totalPages)), + + // update lastPage$ + tap((response: PaginatedList) => this.lastPage$.next(response.currentPage)), + + getPaginatedListPayload(), + + // add current batch to list of collections + scan((prev: Collection[], current: Collection[]) => [...prev, ...current], []), + + startWith([]), + ) as Observable; + + this.collections$ = combineLatest([owningCollection$, mappedCollections$]).pipe( + map(([owningCollection, mappedCollections]: [Collection, Collection[]]) => { + return [owningCollection, ...mappedCollections].filter(collection => hasValue(collection)); + }), ); } + + handleLoadMore() { + this.loadMore$.next(); + } + } diff --git a/src/app/item-page/full/field-components/file-section/full-file-section.component.html b/src/app/item-page/full/field-components/file-section/full-file-section.component.html index c5393055df..33acd6650b 100644 --- a/src/app/item-page/full/field-components/file-section/full-file-section.component.html +++ b/src/app/item-page/full/field-components/file-section/full-file-section.component.html @@ -33,7 +33,7 @@
- + {{"item.page.filesection.download" | translate}}
@@ -74,7 +74,7 @@
- + {{"item.page.filesection.download" | translate}}
diff --git a/src/app/item-page/item-page-routing-paths.ts b/src/app/item-page/item-page-routing-paths.ts index 43b37f954a..74ad0aae07 100644 --- a/src/app/item-page/item-page-routing-paths.ts +++ b/src/app/item-page/item-page-routing-paths.ts @@ -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'; diff --git a/src/app/item-page/item-page-routing.module.ts b/src/app/item-page/item-page-routing.module.ts index f2d0a23935..2c6631dd1a 100644 --- a/src/app/item-page/item-page-routing.module.ts +++ b/src/app/item-page/item-page-routing.module.ts @@ -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, ] }) diff --git a/src/app/item-page/item-page.module.ts b/src/app/item-page/item-page.module.ts index 1c6cd83e9c..eecd0412f8 100644 --- a/src/app/item-page/item-page.module.ts +++ b/src/app/item-page/item-page.module.ts @@ -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 diff --git a/src/app/item-page/simple/field-components/file-section/file-section.component.html b/src/app/item-page/simple/field-components/file-section/file-section.component.html index 0fa5daa012..3d093f83c9 100644 --- a/src/app/item-page/simple/field-components/file-section/file-section.component.html +++ b/src/app/item-page/simple/field-components/file-section/file-section.component.html @@ -1,7 +1,7 @@
- + {{file?.name}} ({{(file?.sizeBytes) | dsFileSize }}) diff --git a/src/app/item-page/simple/field-components/file-section/themed-file-section.component.ts b/src/app/item-page/simple/field-components/file-section/themed-file-section.component.ts new file mode 100644 index 0000000000..ba5a9e87c0 --- /dev/null +++ b/src/app/item-page/simple/field-components/file-section/themed-file-section.component.ts @@ -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 { + + @Input() item: Item; + + protected inAndOutputNames: (keyof FileSectionComponent & keyof this)[] = ['item']; + + protected getComponentName(): string { + return 'FileSectionComponent'; + } + + protected importThemedComponent(themeName: string): Promise { + return import(`../../../../../themes/${themeName}/app/item-page/simple/field-components/file-section/file-section.component`); + } + + protected importUnthemedComponent(): Promise { + return import(`./file-section.component`); + } + +} diff --git a/src/app/item-page/simple/item-page.component.html b/src/app/item-page/simple/item-page.component.html index e843155d10..74b61fd976 100644 --- a/src/app/item-page/simple/item-page.component.html +++ b/src/app/item-page/simple/item-page.component.html @@ -5,7 +5,7 @@ - +
diff --git a/src/app/item-page/simple/item-types/publication/publication.component.html b/src/app/item-page/simple/item-types/publication/publication.component.html index 9e61f00e48..ba8ce6e25d 100644 --- a/src/app/item-page/simple/item-types/publication/publication.component.html +++ b/src/app/item-page/simple/item-types/publication/publication.component.html @@ -16,7 +16,7 @@ - +
+
@@ -16,7 +19,7 @@ - + { } }; 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 } diff --git a/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.ts b/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.ts index 3183c42a28..3ce33dc90a 100644 --- a/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.ts +++ b/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.ts @@ -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 { } diff --git a/src/app/item-page/simple/item-types/versioned-item/versioned-item.component.html b/src/app/item-page/simple/item-types/versioned-item/versioned-item.component.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/item-page/simple/item-types/versioned-item/versioned-item.component.scss b/src/app/item-page/simple/item-types/versioned-item/versioned-item.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/item-page/simple/item-types/versioned-item/versioned-item.component.spec.ts b/src/app/item-page/simple/item-types/versioned-item/versioned-item.component.spec.ts new file mode 100644 index 0000000000..c4dc82f0d9 --- /dev/null +++ b/src/app/item-page/simple/item-types/versioned-item/versioned-item.component.spec.ts @@ -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; + + let versionService: VersionDataService; + let versionHistoryService: VersionHistoryDataService; + + const versionServiceSpy = jasmine.createSpyObj('versionService', { + findByHref: createSuccessfulRemoteDataObject$(new Version()), + }); + + const versionHistoryServiceSpy = jasmine.createSpyObj('versionHistoryService', { + createVersion: createSuccessfulRemoteDataObject$(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'); + }); + }); + +}); diff --git a/src/app/item-page/simple/item-types/versioned-item/versioned-item.component.ts b/src/app/item-page/simple/item-types/versioned-item/versioned-item.component.ts new file mode 100644 index 0000000000..45c15177e7 --- /dev/null +++ b/src/app/item-page/simple/item-types/versioned-item/versioned-item.component.ts @@ -0,0 +1,78 @@ +import { Component } from '@angular/core'; +import { ItemComponent } from '../shared/item.component'; +import { ItemVersionsSummaryModalComponent } from '../../../../shared/item/item-versions/item-versions-summary-modal/item-versions-summary-modal.component'; +import { getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload } from '../../../../core/shared/operators'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { Version } from '../../../../core/shared/version.model'; +import { switchMap, tap } from 'rxjs/operators'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { VersionHistoryDataService } from '../../../../core/data/version-history-data.service'; +import { TranslateService } from '@ngx-translate/core'; +import { VersionDataService } from '../../../../core/data/version-data.service'; +import { ItemVersionsSharedService } from '../../../../shared/item/item-versions/item-versions-shared.service'; +import { Router } from '@angular/router'; +import { WorkspaceitemDataService } from '../../../../core/submission/workspaceitem-data.service'; +import { SearchService } from '../../../../core/shared/search/search.service'; +import { Item } from '../../../../core/shared/item.model'; +import { ItemDataService } from '../../../../core/data/item-data.service'; +import { WorkspaceItem } from '../../../../core/submission/models/workspaceitem.model'; + +@Component({ + selector: 'ds-versioned-item', + templateUrl: './versioned-item.component.html', + styleUrls: ['./versioned-item.component.scss'] +}) +export class VersionedItemComponent extends ItemComponent { + + constructor( + private modalService: NgbModal, + private versionHistoryService: VersionHistoryDataService, + private translateService: TranslateService, + private versionService: VersionDataService, + private itemVersionShared: ItemVersionsSharedService, + private router: Router, + private workspaceItemDataService: WorkspaceitemDataService, + private searchService: SearchService, + private itemService: ItemDataService, + ) { + super(); + } + + /** + * Open a modal that allows to create a new version starting from the specified item, with optional summary + */ + onCreateNewVersion(): void { + + const item = this.object; + const versionHref = item._links.version.href; + + // Open modal + const activeModal = this.modalService.open(ItemVersionsSummaryModalComponent); + + // Show current version in modal + this.versionService.findByHref(versionHref).pipe(getFirstCompletedRemoteData()).subscribe((res: RemoteData) => { + // if res.hasNoContent then the item is unversioned + activeModal.componentInstance.firstVersion = res.hasNoContent; + activeModal.componentInstance.versionNumber = (res.hasNoContent ? undefined : res.payload.version); + }); + + // On createVersionEvent emitted create new version and notify + activeModal.componentInstance.createVersionEvent.pipe( + switchMap((summary: string) => this.versionHistoryService.createVersion(item._links.self.href, summary)), + getFirstCompletedRemoteData(), + // show success/failure notification + tap((res: RemoteData) => { this.itemVersionShared.notifyCreateNewVersion(res); }), + // get workspace item + getFirstSucceededRemoteDataPayload(), + switchMap((newVersion: Version) => this.itemService.findByHref(newVersion._links.item.href)), + getFirstSucceededRemoteDataPayload(), + switchMap((newVersionItem: Item) => this.workspaceItemDataService.findByItem(newVersionItem.uuid, true, false)), + getFirstSucceededRemoteDataPayload(), + ).subscribe((wsItem) => { + const wsiId = wsItem.id; + const route = 'workspaceitems/' + wsiId + '/edit'; + this.router.navigateByUrl(route); + }); + + } +} diff --git a/src/app/item-page/version-page/version-page/version-page.component.html b/src/app/item-page/version-page/version-page/version-page.component.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/item-page/version-page/version-page/version-page.component.scss b/src/app/item-page/version-page/version-page/version-page.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/item-page/version-page/version-page/version-page.component.spec.ts b/src/app/item-page/version-page/version-page/version-page.component.spec.ts new file mode 100644 index 0000000000..b1dd8bc161 --- /dev/null +++ b/src/app/item-page/version-page/version-page/version-page.component.spec.ts @@ -0,0 +1,68 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { VersionPageComponent } from './version-page.component'; +import { ActivatedRoute } from '@angular/router'; +import { ActivatedRouteStub } from '../../../shared/testing/active-router.stub'; +import { of as observableOf } from 'rxjs'; +import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { Item } from '../../../core/shared/item.model'; +import { createPaginatedList } from '../../../shared/testing/utils.test'; +import { createRelationshipsObservable } from '../../simple/item-types/shared/item.component.spec'; +import { VersionDataService } from '../../../core/data/version-data.service'; +import { AuthService } from '../../../core/auth/auth.service'; +import { Version } from '../../../core/shared/version.model'; +import { RouterTestingModule } from '@angular/router/testing'; +import { Component } from '@angular/core'; + +const mockItem: Item = Object.assign(new Item(), { + bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])), + metadata: [], + relationships: createRelationshipsObservable(), + uuid: 'item-uuid', +}); + +const mockVersion: Version = Object.assign(new Version(), { + item: createSuccessfulRemoteDataObject$(mockItem), + version: 1, +}); + +@Component({ template: '' }) +class DummyComponent { +} + +describe('VersionPageComponent', () => { + let component: VersionPageComponent; + let fixture: ComponentFixture; + let authService: AuthService; + + const mockRoute = Object.assign(new ActivatedRouteStub(), { + data: observableOf({dso: createSuccessfulRemoteDataObject(mockVersion)}) + }); + + beforeEach(waitForAsync(() => { + authService = jasmine.createSpyObj('authService', { + isAuthenticated: observableOf(true), + setRedirectUrl: {} + }); + TestBed.configureTestingModule({ + declarations: [VersionPageComponent, DummyComponent], + imports: [RouterTestingModule.withRoutes([{ path: 'items/item-uuid', component: DummyComponent, pathMatch: 'full' }])], + providers: [ + { provide: ActivatedRoute, useValue: mockRoute }, + { provide: VersionDataService, useValue: {} }, + { provide: AuthService, useValue: authService }, + ], + }).compileComponents(); + })); + + + + beforeEach(() => { + fixture = TestBed.createComponent(VersionPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/item-page/version-page/version-page/version-page.component.ts b/src/app/item-page/version-page/version-page/version-page.component.ts new file mode 100644 index 0000000000..0a2021e06d --- /dev/null +++ b/src/app/item-page/version-page/version-page/version-page.component.ts @@ -0,0 +1,56 @@ +import { Component, OnInit } from '@angular/core'; +import { Observable } from 'rxjs'; +import { RemoteData } from '../../../core/data/remote-data'; +import { ActivatedRoute, Router } from '@angular/router'; +import { AuthService } from '../../../core/auth/auth.service'; +import { map, switchMap } from 'rxjs/operators'; +import { getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload, redirectOn4xx } from '../../../core/shared/operators'; +import { VersionDataService } from '../../../core/data/version-data.service'; +import { Version } from '../../../core/shared/version.model'; +import { Item } from '../../../core/shared/item.model'; +import { getItemPageRoute } from '../../item-page-routing-paths'; +import { getPageNotFoundRoute } from '../../../app-routing-paths'; + +@Component({ + selector: 'ds-version-page', + templateUrl: './version-page.component.html', + styleUrls: ['./version-page.component.scss'] +}) +export class VersionPageComponent implements OnInit { + + versionRD$: Observable>; + itemRD$: Observable>; + + constructor( + protected route: ActivatedRoute, + private router: Router, + private versionService: VersionDataService, + private authService: AuthService, + ) { + } + + ngOnInit(): void { + /* Retrieve version from resolver or redirect on 4xx */ + this.versionRD$ = this.route.data.pipe( + map((data) => data.dso as RemoteData), + redirectOn4xx(this.router, this.authService), + ); + + /* Retrieve item from version and reroute to item's page or handle missing item */ + this.versionRD$.pipe( + getFirstSucceededRemoteDataPayload(), + switchMap((version) => version.item), + redirectOn4xx(this.router, this.authService), + getFirstCompletedRemoteData(), + ).subscribe((itemRD) => { + if (itemRD.hasNoContent) { + this.router.navigateByUrl(getPageNotFoundRoute(), { skipLocationChange: true }); + } else { + const itemUrl = getItemPageRoute(itemRD.payload); + this.router.navigateByUrl(itemUrl); + } + }); + + } + +} diff --git a/src/app/item-page/version-page/version.resolver.ts b/src/app/item-page/version-page/version.resolver.ts new file mode 100644 index 0000000000..8341052468 --- /dev/null +++ b/src/app/item-page/version-page/version.resolver.ts @@ -0,0 +1,54 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, Resolve, Router, RouterStateSnapshot } from '@angular/router'; +import { Observable } from 'rxjs'; +import { RemoteData } from '../../core/data/remote-data'; +import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { getFirstCompletedRemoteData } from '../../core/shared/operators'; +import { Store } from '@ngrx/store'; +import { ResolvedAction } from '../../core/resolving/resolver.actions'; +import { Version } from '../../core/shared/version.model'; +import { VersionDataService } from '../../core/data/version-data.service'; + +/** + * The self links defined in this list are expected to be requested somewhere in the near future + * Requesting them as embeds will limit the number of requests + */ +export const VERSION_PAGE_LINKS_TO_FOLLOW: FollowLinkConfig[] = [ + followLink('item'), +]; + +/** + * This class represents a resolver that requests a specific version before the route is activated + */ +@Injectable() +export class VersionResolver implements Resolve> { + constructor( + protected versionService: VersionDataService, + protected store: Store, + protected router: Router + ) { + } + + /** + * Method for resolving a version based on the parameters in the current route + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + * @param {RouterStateSnapshot} state The current RouterStateSnapshot + * @returns Observable<> Emits the found item based on the parameters in the current route, + * or an error if something went wrong + */ + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable> { + const versionRD$ = this.versionService.findById(route.params.id, + true, + false, + ...VERSION_PAGE_LINKS_TO_FOLLOW + ).pipe( + getFirstCompletedRemoteData(), + ); + + versionRD$.subscribe((versionRD: RemoteData) => { + this.store.dispatch(new ResolvedAction(state.url, versionRD.payload)); + }); + + return versionRD$; + } +} diff --git a/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-external-dropdown/my-dspace-new-external-dropdown.component.html b/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-external-dropdown/my-dspace-new-external-dropdown.component.html new file mode 100644 index 0000000000..141e628ac0 --- /dev/null +++ b/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-external-dropdown/my-dspace-new-external-dropdown.component.html @@ -0,0 +1,24 @@ +
+ +
+
+ + +
diff --git a/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-external-dropdown/my-dspace-new-external-dropdown.component.scss b/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-external-dropdown/my-dspace-new-external-dropdown.component.scss new file mode 100644 index 0000000000..a156132e3f --- /dev/null +++ b/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-external-dropdown/my-dspace-new-external-dropdown.component.scss @@ -0,0 +1,16 @@ +.parent { + display: flex; +} + +.upload { + flex: auto; +} + +.add { + flex: initial; +} + +#entityControlsDropdownMenu { + min-width: 18rem; + box-shadow: $btn-focus-box-shadow; +} diff --git a/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-external-dropdown/my-dspace-new-external-dropdown.component.spec.ts b/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-external-dropdown/my-dspace-new-external-dropdown.component.spec.ts new file mode 100644 index 0000000000..aa223fc10e --- /dev/null +++ b/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-external-dropdown/my-dspace-new-external-dropdown.component.spec.ts @@ -0,0 +1,189 @@ +import { Component, DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { By } from '@angular/platform-browser'; +import { CommonModule } from '@angular/common'; +import { TranslateModule } from '@ngx-translate/core'; +import { of as observableOf } from 'rxjs'; +import { createPaginatedList, createTestComponent } from '../../../shared/testing/utils.test'; +import { MyDSpaceNewExternalDropdownComponent } from './my-dspace-new-external-dropdown.component'; +import { EntityTypeService } from '../../../core/data/entity-type.service'; +import { ItemType } from '../../../core/shared/item-relationships/item-type.model'; +import { ResourceType } from '../../../core/shared/resource-type'; +import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { PageInfo } from '../../../core/shared/page-info.model'; +import { RouterStub } from '../../../shared/testing/router.stub'; + +export function getMockEntityTypeService(): EntityTypeService { + const pageInfo = { elementsPerPage: 20, totalElements: 4, totalPages: 1, currentPage: 0 } as PageInfo; + const type1: ItemType = { + id: '1', + label: 'Publication', + uuid: '1', + type: new ResourceType('entitytype'), + _links: undefined + }; + const type2: ItemType = { + id: '2', + label: 'Journal', + uuid: '2', + type: new ResourceType('entitytype'), + _links: undefined + }; + const type3: ItemType = { + id: '2', + label: 'DataPackage', + uuid: '2', + type: new ResourceType('entitytype'), + _links: undefined + }; + const rd$ = createSuccessfulRemoteDataObject$(createPaginatedList([type1, type2, type3])); + return jasmine.createSpyObj('entityTypeService', { + getAllAuthorizedRelationshipTypeImport: rd$, + hasMoreThanOneAuthorizedImport: observableOf(true) + }); +} + +export function getMockEmptyEntityTypeService(): EntityTypeService { + const pageInfo = { elementsPerPage: 20, totalElements: 1, totalPages: 1, currentPage: 0 } as PageInfo; + const type1: ItemType = { + id: '1', + label: 'Publication', + uuid: '1', + type: new ResourceType('entitytype'), + _links: undefined + }; + const rd$ = createSuccessfulRemoteDataObject$(createPaginatedList([type1])); + return jasmine.createSpyObj('entityTypeService', { + getAllAuthorizedRelationshipTypeImport: rd$, + hasMoreThanOneAuthorizedImport: observableOf(false) + }); +} + +describe('MyDSpaceNewExternalDropdownComponent test', () => { + let testComp: TestComponent; + let testFixture: ComponentFixture; + let submissionComponent: MyDSpaceNewExternalDropdownComponent; + let submissionComponentFixture: ComponentFixture; + + const entityType1: ItemType = { + id: '1', + label: 'Publication', + uuid: '1', + type: new ResourceType('entitytype'), + _links: undefined + }; + + describe('With only one Entity', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + CommonModule, + TranslateModule.forRoot(), + ], + declarations: [ + MyDSpaceNewExternalDropdownComponent, + TestComponent + ], + providers: [ + { provide: EntityTypeService, useValue: getMockEmptyEntityTypeService() }, + { provide: Router, useValue: new RouterStub() }, + MyDSpaceNewExternalDropdownComponent + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + + const html = ``; + + testFixture = createTestComponent(html, TestComponent) as ComponentFixture; + testComp = testFixture.componentInstance; + + submissionComponentFixture = TestBed.createComponent(MyDSpaceNewExternalDropdownComponent); + submissionComponent = submissionComponentFixture.componentInstance; + submissionComponentFixture.detectChanges(); + })); + + afterEach(() => { + testFixture.destroy(); + submissionComponentFixture.destroy(); + }); + + it('should create MyDSpaceNewExternalDropdownComponent', inject([MyDSpaceNewExternalDropdownComponent], (app: MyDSpaceNewExternalDropdownComponent) => { + expect(app).toBeDefined(); + })); + + it('should be a single button', inject([MyDSpaceNewExternalDropdownComponent], (app: MyDSpaceNewExternalDropdownComponent) => { + submissionComponentFixture.detectChanges(); + const addDivElement: DebugElement = submissionComponentFixture.debugElement.query(By.css('.add')); + const addDiv = addDivElement.nativeElement; + expect(addDiv.innerHTML).toBeDefined(); + const buttonElement: DebugElement = addDivElement.query(By.css('.btn')); + const button = buttonElement.nativeElement; + expect(button.innerHTML).toBeDefined(); + const dropdownElement: DebugElement = submissionComponentFixture.debugElement.query(By.css('.dropdown-menu')); + expect(dropdownElement).toBeNull(); + })); + }); + + describe('With more than one Entity', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + CommonModule, + TranslateModule.forRoot(), + ], + declarations: [ + MyDSpaceNewExternalDropdownComponent, + TestComponent + ], + providers: [ + { provide: EntityTypeService, useValue: getMockEntityTypeService() }, + { provide: Router, useValue: new RouterStub() }, + MyDSpaceNewExternalDropdownComponent + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + + const html = ``; + + testFixture = createTestComponent(html, TestComponent) as ComponentFixture; + testComp = testFixture.componentInstance; + + submissionComponentFixture = TestBed.createComponent(MyDSpaceNewExternalDropdownComponent); + submissionComponent = submissionComponentFixture.componentInstance; + submissionComponentFixture.detectChanges(); + })); + + afterEach(() => { + testFixture.destroy(); + submissionComponentFixture.destroy(); + }); + + it('should create MyDSpaceNewExternalDropdownComponent', inject([MyDSpaceNewExternalDropdownComponent], (app: MyDSpaceNewExternalDropdownComponent) => { + expect(app).toBeDefined(); + })); + + it('should be a dropdown button', inject([MyDSpaceNewExternalDropdownComponent], (app: MyDSpaceNewExternalDropdownComponent) => { + const dropdownElement: DebugElement = submissionComponentFixture.debugElement.query(By.css('.dropdown-menu')); + const dropdown = dropdownElement.nativeElement; + expect(dropdown.innerHTML).toBeDefined(); + })); + + it('should invoke modalService.open', () => { + submissionComponent.openPage(entityType1); + + expect((submissionComponent as any).router.navigate).toHaveBeenCalled(); + }); + }); +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { + reload = (event) => { + return; + } +} diff --git a/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-external-dropdown/my-dspace-new-external-dropdown.component.ts b/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-external-dropdown/my-dspace-new-external-dropdown.component.ts new file mode 100644 index 0000000000..651178e7a1 --- /dev/null +++ b/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-external-dropdown/my-dspace-new-external-dropdown.component.ts @@ -0,0 +1,110 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { Router } from '@angular/router'; + +import { Observable, of as observableOf, Subscription } from 'rxjs'; +import { map, mergeMap, take } from 'rxjs/operators'; + +import { EntityTypeService } from '../../../core/data/entity-type.service'; +import { ItemType } from '../../../core/shared/item-relationships/item-type.model'; +import { FindListOptions } from '../../../core/data/request.models'; +import { hasValue } from '../../../shared/empty.util'; +import { RemoteData } from '../../../core/data/remote-data'; +import { PaginatedList } from '../../../core/data/paginated-list.model'; + +/** + * This component represents the 'Import metadata from external source' dropdown menu + */ +@Component({ + selector: 'ds-my-dspace-new-external-dropdown', + styleUrls: ['./my-dspace-new-external-dropdown.component.scss'], + templateUrl: './my-dspace-new-external-dropdown.component.html' +}) +export class MyDSpaceNewExternalDropdownComponent implements OnInit, OnDestroy { + + /** + * Used to verify if there are one or more entities available + */ + public moreThanOne$: Observable; + + /** + * The entity observble (only if there is only one entity available) + */ + public singleEntity$: Observable; + + /** + * The entity object (only if there is only one entity available) + */ + public singleEntity: ItemType; + + /** + * TRUE if the page is initialized + */ + public initialized$: Observable; + + /** + * Array to track all subscriptions and unsubscribe them onDestroy + * @type {Array} + */ + public subs: Subscription[] = []; + + /** + * Initialize instance variables + * + * @param {EntityTypeService} entityTypeService + * @param {Router} router + */ + constructor(private entityTypeService: EntityTypeService, + private router: Router) { } + + /** + * Initialize entity type list + */ + ngOnInit() { + this.initialized$ = observableOf(false); + this.moreThanOne$ = this.entityTypeService.hasMoreThanOneAuthorizedImport(); + this.singleEntity$ = this.moreThanOne$.pipe( + mergeMap((response: boolean) => { + if (!response) { + const findListOptions: FindListOptions = { + elementsPerPage: 1, + currentPage: 1 + }; + return this.entityTypeService.getAllAuthorizedRelationshipTypeImport(findListOptions).pipe( + map((entities: RemoteData>) => { + this.initialized$ = observableOf(true); + return entities.payload.page[0]; + }), + take(1) + ); + } else { + this.initialized$ = observableOf(true); + return observableOf(null); + } + }), + take(1) + ); + this.subs.push( + this.singleEntity$.subscribe((result) => this.singleEntity = result ) + ); + } + + /** + * Method called on clicking the button 'Import metadata from external source'. It opens the page of the external import. + */ + openPage(entity: ItemType) { + const params = Object.create({}); + if (entity) { + params.entity = entity.label; + } + this.router.navigate(['/import-external'], { queryParams: params }); + } + + /** + * Unsubscribe from the subscription + */ + ngOnDestroy(): void { + this.subs + .filter((subscription) => hasValue(subscription)) + .forEach((subscription) => subscription.unsubscribe()); + } +} diff --git a/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission-dropdown/my-dspace-new-submission-dropdown.component.html b/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission-dropdown/my-dspace-new-submission-dropdown.component.html new file mode 100644 index 0000000000..ac40bbb005 --- /dev/null +++ b/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission-dropdown/my-dspace-new-submission-dropdown.component.html @@ -0,0 +1,20 @@ +
+ +
+
+ + +
diff --git a/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission-dropdown/my-dspace-new-submission-dropdown.component.scss b/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission-dropdown/my-dspace-new-submission-dropdown.component.scss new file mode 100644 index 0000000000..a156132e3f --- /dev/null +++ b/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission-dropdown/my-dspace-new-submission-dropdown.component.scss @@ -0,0 +1,16 @@ +.parent { + display: flex; +} + +.upload { + flex: auto; +} + +.add { + flex: initial; +} + +#entityControlsDropdownMenu { + min-width: 18rem; + box-shadow: $btn-focus-box-shadow; +} diff --git a/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission-dropdown/my-dspace-new-submission-dropdown.component.spec.ts b/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission-dropdown/my-dspace-new-submission-dropdown.component.spec.ts new file mode 100644 index 0000000000..2e7361c560 --- /dev/null +++ b/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission-dropdown/my-dspace-new-submission-dropdown.component.spec.ts @@ -0,0 +1,194 @@ +import { Component, DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { CommonModule } from '@angular/common'; +import { TranslateModule } from '@ngx-translate/core'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { of as observableOf } from 'rxjs'; +import { createPaginatedList, createTestComponent } from '../../../shared/testing/utils.test'; +import { MyDSpaceNewSubmissionDropdownComponent } from './my-dspace-new-submission-dropdown.component'; +import { EntityTypeService } from '../../../core/data/entity-type.service'; +import { ItemType } from '../../../core/shared/item-relationships/item-type.model'; +import { ResourceType } from '../../../core/shared/resource-type'; +import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { PageInfo } from '../../../core/shared/page-info.model'; + +export function getMockEntityTypeService(): EntityTypeService { + const type1: ItemType = { + id: '1', + label: 'Publication', + uuid: '1', + type: new ResourceType('entitytype'), + _links: undefined + }; + const type2: ItemType = { + id: '2', + label: 'Journal', + uuid: '2', + type: new ResourceType('entitytype'), + _links: undefined + }; + const type3: ItemType = { + id: '2', + label: 'DataPackage', + uuid: '2', + type: new ResourceType('entitytype'), + _links: undefined + }; + const rd$ = createSuccessfulRemoteDataObject$(createPaginatedList([type1, type2, type3])); + return jasmine.createSpyObj('entityTypeService', { + getAllAuthorizedRelationshipType: rd$, + hasMoreThanOneAuthorized: observableOf(true) + }); +} + +export function getMockEmptyEntityTypeService(): EntityTypeService { + const pageInfo = { elementsPerPage: 20, totalElements: 1, totalPages: 1, currentPage: 0 } as PageInfo; + const type1: ItemType = { + id: '1', + label: 'Publication', + uuid: '1', + type: new ResourceType('entitytype'), + _links: undefined + }; + const rd$ = createSuccessfulRemoteDataObject$(createPaginatedList([type1])); + return jasmine.createSpyObj('entityTypeService', { + getAllAuthorizedRelationshipType: rd$, + hasMoreThanOneAuthorized: observableOf(false) + }); +} + +describe('MyDSpaceNewSubmissionDropdownComponent test', () => { + let testComp: TestComponent; + let testFixture: ComponentFixture; + let submissionComponent: MyDSpaceNewSubmissionDropdownComponent; + let submissionComponentFixture: ComponentFixture; + + const entityType1: ItemType = { + id: '1', + label: 'Publication', + uuid: '1', + type: new ResourceType('entitytype'), + _links: undefined + }; + + const modalStub = { + open: () => null, + close: () => null, + dismiss: () => null + }; + + describe('With only one Entity', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + CommonModule, + TranslateModule.forRoot(), + ], + declarations: [ + MyDSpaceNewSubmissionDropdownComponent, + TestComponent + ], + providers: [ + { provide: EntityTypeService, useValue: getMockEmptyEntityTypeService() }, + { provide: NgbModal, useValue: modalStub }, + MyDSpaceNewSubmissionDropdownComponent + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + + const html = ``; + + testFixture = createTestComponent(html, TestComponent) as ComponentFixture; + testComp = testFixture.componentInstance; + + submissionComponentFixture = TestBed.createComponent(MyDSpaceNewSubmissionDropdownComponent); + submissionComponent = submissionComponentFixture.componentInstance; + submissionComponentFixture.detectChanges(); + })); + + afterEach(() => { + testFixture.destroy(); + submissionComponentFixture.destroy(); + }); + + it('should create MyDSpaceNewSubmissionDropdownComponent', inject([MyDSpaceNewSubmissionDropdownComponent], (app: MyDSpaceNewSubmissionDropdownComponent) => { + expect(app).toBeDefined(); + })); + + it('should be a single button', inject([MyDSpaceNewSubmissionDropdownComponent], (app: MyDSpaceNewSubmissionDropdownComponent) => { + submissionComponentFixture.detectChanges(); + const addDivElement: DebugElement = submissionComponentFixture.debugElement.query(By.css('.add')); + const addDiv = addDivElement.nativeElement; + expect(addDiv.innerHTML).toBeDefined(); + const buttonElement: DebugElement = addDivElement.query(By.css('.btn')); + const button = buttonElement.nativeElement; + expect(button.innerHTML).toBeDefined(); + const dropdownElement: DebugElement = submissionComponentFixture.debugElement.query(By.css('.dropdown-menu')); + expect(dropdownElement).toBeNull(); + })); + }); + + describe('With more than one Entity', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + CommonModule, + TranslateModule.forRoot(), + ], + declarations: [ + MyDSpaceNewSubmissionDropdownComponent, + TestComponent + ], + providers: [ + { provide: EntityTypeService, useValue: getMockEntityTypeService() }, + { provide: NgbModal, useValue: modalStub }, + MyDSpaceNewSubmissionDropdownComponent + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + + const html = ``; + + testFixture = createTestComponent(html, TestComponent) as ComponentFixture; + testComp = testFixture.componentInstance; + + submissionComponentFixture = TestBed.createComponent(MyDSpaceNewSubmissionDropdownComponent); + submissionComponent = submissionComponentFixture.componentInstance; + submissionComponentFixture.detectChanges(); + })); + + afterEach(() => { + testFixture.destroy(); + submissionComponentFixture.destroy(); + }); + + it('should create MyDSpaceNewSubmissionDropdownComponent', inject([MyDSpaceNewSubmissionDropdownComponent], (app: MyDSpaceNewSubmissionDropdownComponent) => { + expect(app).toBeDefined(); + })); + + it('should be a dropdown button', inject([MyDSpaceNewSubmissionDropdownComponent], (app: MyDSpaceNewSubmissionDropdownComponent) => { + const dropdownElement: DebugElement = submissionComponentFixture.debugElement.query(By.css('.dropdown-menu')); + const dropdown = dropdownElement.nativeElement; + expect(dropdown.innerHTML).toBeDefined(); + })); + + it('should invoke modalService.open', () => { + spyOn((submissionComponent as any).modalService, 'open').and.returnValue({ componentInstance: { } }); + submissionComponent.openDialog(entityType1); + + expect((submissionComponent as any).modalService.open).toHaveBeenCalled(); + }); + }); +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { + reload = (event) => { + return; + } +} diff --git a/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission-dropdown/my-dspace-new-submission-dropdown.component.ts b/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission-dropdown/my-dspace-new-submission-dropdown.component.ts new file mode 100644 index 0000000000..0ff363b164 --- /dev/null +++ b/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission-dropdown/my-dspace-new-submission-dropdown.component.ts @@ -0,0 +1,109 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; + +import { Observable, of as observableOf, Subscription } from 'rxjs'; +import { map, mergeMap, take } from 'rxjs/operators'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; + +import { EntityTypeService } from '../../../core/data/entity-type.service'; +import { ItemType } from '../../../core/shared/item-relationships/item-type.model'; +import { FindListOptions } from '../../../core/data/request.models'; +import { hasValue } from '../../../shared/empty.util'; +import { CreateItemParentSelectorComponent } from '../../../shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component'; +import { RemoteData } from '../../../core/data/remote-data'; +import { PaginatedList } from '../../../core/data/paginated-list.model'; + +/** + * This component represents the new submission dropdown + */ +@Component({ + selector: 'ds-my-dspace-new-submission-dropdown', + styleUrls: ['./my-dspace-new-submission-dropdown.component.scss'], + templateUrl: './my-dspace-new-submission-dropdown.component.html' +}) +export class MyDSpaceNewSubmissionDropdownComponent implements OnInit, OnDestroy { + + /** + * Used to verify if there are one or more entities available + */ + public moreThanOne$: Observable; + + /** + * The entity observble (only if there is only one entity available) + */ + public singleEntity$: Observable; + + /** + * The entity object (only if there is only one entity available) + */ + public singleEntity: ItemType; + + /** + * TRUE if the page is initialized + */ + public initialized$: Observable; + + /** + * Array to track all subscriptions and unsubscribe them onDestroy + * @type {Array} + */ + public subs: Subscription[] = []; + + /** + * Initialize instance variables + * + * @param {EntityTypeService} entityTypeService + * @param {NgbModal} modalService + */ + constructor(private entityTypeService: EntityTypeService, + private modalService: NgbModal) { } + + /** + * Initialize entity type list + */ + ngOnInit() { + this.initialized$ = observableOf(false); + this.moreThanOne$ = this.entityTypeService.hasMoreThanOneAuthorized(); + this.singleEntity$ = this.moreThanOne$.pipe( + mergeMap((response: boolean) => { + if (!response) { + const findListOptions: FindListOptions = { + elementsPerPage: 1, + currentPage: 1 + }; + return this.entityTypeService.getAllAuthorizedRelationshipType(findListOptions).pipe( + map((entities: RemoteData>) => { + this.initialized$ = observableOf(true); + return entities.payload.page[0]; + }), + take(1) + ); + } else { + this.initialized$ = observableOf(true); + return observableOf(null); + } + }), + take(1) + ); + this.subs.push( + this.singleEntity$.subscribe((result) => this.singleEntity = result ) + ); + } + + /** + * Method called on clicking the button "New Submition", It opens a dialog for + * select a collection. + */ + openDialog(entity: ItemType) { + const modalRef = this.modalService.open(CreateItemParentSelectorComponent); + modalRef.componentInstance.entityType = entity.label; + } + + /** + * Unsubscribe from the subscription + */ + ngOnDestroy(): void { + this.subs + .filter((subscription) => hasValue(subscription)) + .forEach((subscription) => subscription.unsubscribe()); + } +} diff --git a/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.html b/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.html index 028b7df5a5..d0052b9355 100644 --- a/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.html +++ b/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.html @@ -8,14 +8,10 @@
- +
- - - +
diff --git a/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.spec.ts b/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.spec.ts index 7c6d8918cb..fb8ecbf65c 100644 --- a/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.spec.ts +++ b/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.spec.ts @@ -1,7 +1,6 @@ import { ChangeDetectorRef, Component, NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, inject, TestBed, waitForAsync } from '@angular/core/testing'; import { RouterTestingModule } from '@angular/router/testing'; -import { By } from '@angular/platform-browser'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { ScrollToService } from '@nicky-lenaers/ngx-scroll-to'; @@ -25,6 +24,8 @@ import { HttpXsrfTokenExtractor } from '@angular/common/http'; import { CookieService } from '../../core/services/cookie.service'; import { CookieServiceMock } from '../../shared/mocks/cookie.service.mock'; import { HttpXsrfTokenExtractorMock } from '../../shared/mocks/http-xsrf-token-extractor.mock'; +import { getMockEntityTypeService } from './my-dspace-new-submission-dropdown/my-dspace-new-submission-dropdown.component.spec'; +import { EntityTypeService } from '../../core/data/entity-type.service'; describe('MyDSpaceNewSubmissionComponent test', () => { @@ -62,6 +63,7 @@ describe('MyDSpaceNewSubmissionComponent test', () => { { provide: HttpXsrfTokenExtractor, useValue: new HttpXsrfTokenExtractorMock('mock-token') }, { provide: CookieService, useValue: new CookieServiceMock() }, { provide: HostWindowService, useValue: new HostWindowServiceStub(800) }, + { provide: EntityTypeService, useValue: getMockEntityTypeService() }, ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); @@ -104,20 +106,6 @@ describe('MyDSpaceNewSubmissionComponent test', () => { comp.uploaderComponent.uploader = uploader; }); - it('should call app.openDialog', (done) => { - spyOn(comp, 'openDialog'); - const submissionButton = fixture.debugElement.query(By.css('button.btn-primary')); - submissionButton.triggerEventHandler('click', null); - - fixture.detectChanges(); - - fixture.whenStable().then(() => { - expect(comp.openDialog).toHaveBeenCalled(); - done(); - }); - - }); - it('should show a collection selector if only one file are uploaded', (done) => { spyOn((comp as any).modalService, 'open').and.returnValue({ result: new Promise((res, rej) => {/****/}) }); comp.afterFileLoaded(['']); diff --git a/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.ts b/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.ts index c1e67561b2..580afd8ad4 100644 --- a/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.ts +++ b/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.ts @@ -14,7 +14,6 @@ import { HALEndpointService } from '../../core/shared/hal-endpoint.service'; import { NotificationType } from '../../shared/notifications/models/notification-type'; import { hasValue } from '../../shared/empty.util'; import { SearchResult } from '../../shared/search/search-result.model'; -import { CreateItemParentSelectorComponent } from '../../shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component'; import { CollectionSelectorComponent } from '../collection-selector/collection-selector.component'; import { UploaderComponent } from '../../shared/uploader/uploader.component'; import { UploaderError } from '../../shared/uploader/uploader-error.model'; @@ -118,14 +117,6 @@ export class MyDSpaceNewSubmissionComponent implements OnDestroy, OnInit { this.notificationsService.error(null, this.translate.get(errorMessageKey)); } - /** - * Method called on clicking the button "New Submition", It opens a dialog for - * select a collection. - */ - openDialog() { - this.modalService.open(CreateItemParentSelectorComponent); - } - /** * Method invoked after all file are loaded from upload plugin */ diff --git a/src/app/my-dspace-page/my-dspace-page.component.html b/src/app/my-dspace-page/my-dspace-page.component.html index 32e3a0d710..4aadb16255 100644 --- a/src/app/my-dspace-page/my-dspace-page.component.html +++ b/src/app/my-dspace-page/my-dspace-page.component.html @@ -15,7 +15,7 @@ [query]="(searchOptions$ | async)?.query" [scope]="(searchOptions$ | async)?.scope" [currentUrl]="getSearchLink()" - [scopes]="(scopeListRD$ | async)" + [showScopeSelector]="true" [inPlaceSearch]="inPlaceSearch" [searchPlaceholder]="'mydspace.search-form.placeholder' | translate"> diff --git a/src/app/my-dspace-page/my-dspace-page.component.ts b/src/app/my-dspace-page/my-dspace-page.component.ts index 3ded17191e..90163abc5e 100644 --- a/src/app/my-dspace-page/my-dspace-page.component.ts +++ b/src/app/my-dspace-page/my-dspace-page.component.ts @@ -78,11 +78,6 @@ export class MyDSpacePageComponent implements OnInit { */ sortOptions$: Observable; - /** - * The current relevant scopes - */ - scopeListRD$: Observable; - /** * Emits true if were on a small screen */ @@ -144,10 +139,6 @@ export class MyDSpacePageComponent implements OnInit { this.resultsRD$.next(results); }); - this.scopeListRD$ = this.searchConfigService.getCurrentScope('').pipe( - switchMap((scopeId) => this.service.getScopes(scopeId)) - ); - this.context$ = this.searchConfigService.getCurrentConfiguration('workspace') .pipe( map((configuration: string) => { diff --git a/src/app/my-dspace-page/my-dspace-page.module.ts b/src/app/my-dspace-page/my-dspace-page.module.ts index 52c80c90b0..a5a18effbc 100644 --- a/src/app/my-dspace-page/my-dspace-page.module.ts +++ b/src/app/my-dspace-page/my-dspace-page.module.ts @@ -11,6 +11,8 @@ import { MyDSpaceGuard } from './my-dspace.guard'; import { MyDSpaceConfigurationService } from './my-dspace-configuration.service'; import { CollectionSelectorComponent } from './collection-selector/collection-selector.component'; import { MyDspaceSearchModule } from './my-dspace-search.module'; +import { MyDSpaceNewSubmissionDropdownComponent } from './my-dspace-new-submission/my-dspace-new-submission-dropdown/my-dspace-new-submission-dropdown.component'; +import { MyDSpaceNewExternalDropdownComponent } from './my-dspace-new-submission/my-dspace-new-external-dropdown/my-dspace-new-external-dropdown.component'; import { ThemedMyDSpacePageComponent } from './themed-my-dspace-page.component'; const DECLARATIONS = [ @@ -18,7 +20,9 @@ const DECLARATIONS = [ ThemedMyDSpacePageComponent, MyDSpaceResultsComponent, MyDSpaceNewSubmissionComponent, - CollectionSelectorComponent + CollectionSelectorComponent, + MyDSpaceNewSubmissionDropdownComponent, + MyDSpaceNewExternalDropdownComponent ]; @NgModule({ diff --git a/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.html b/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.html index cb96435b77..08d1b54fab 100644 --- a/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.html +++ b/src/app/navbar/expandable-navbar-section/expandable-navbar-section.component.html @@ -1,5 +1,8 @@ diff --git a/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.spec.ts b/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.spec.ts index 3afb3c5e9a..90bd07c52b 100644 --- a/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.spec.ts +++ b/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.spec.ts @@ -69,4 +69,10 @@ describe('CreateItemParentSelectorComponent', () => { expect(router.navigate).toHaveBeenCalledWith(['/submit'], { queryParams: { collection: collection.uuid } }); }); + it('should call navigate on the router with entityType parameter', () => { + const entityType = 'Person'; + component.entityType = entityType; + component.navigate(collection); + expect(router.navigate).toHaveBeenCalledWith(['/submit'], { queryParams: { collection: collection.uuid, entityType: entityType } }); + }); }); diff --git a/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.ts b/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.ts index 03d7732fb0..b109be0af2 100644 --- a/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.ts +++ b/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, Input, OnInit } from '@angular/core'; import { ActivatedRoute, NavigationExtras, Router } from '@angular/router'; import { DSpaceObjectType } from '../../../../core/shared/dspace-object-type.model'; import { DSpaceObject } from '../../../../core/shared/dspace-object.model'; @@ -22,6 +22,11 @@ export class CreateItemParentSelectorComponent extends DSOSelectorModalWrapperCo action = SelectorActionType.CREATE; header = 'dso-selector.create.item.sub-level'; + /** + * If present this value is used to filter collection list by entity type + */ + @Input() entityType: string; + constructor(protected activeModal: NgbActiveModal, protected route: ActivatedRoute, private router: Router) { super(activeModal, route); } @@ -35,6 +40,9 @@ export class CreateItemParentSelectorComponent extends DSOSelectorModalWrapperCo ['collection']: dso.uuid, } }; + if (this.entityType) { + navigationExtras.queryParams.entityType = this.entityType; + } this.router.navigate(['/submit'], navigationExtras); } } diff --git a/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.ts b/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.ts index 8a8f02d72f..ca8343cfad 100644 --- a/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.ts +++ b/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.ts @@ -9,7 +9,8 @@ import { hasValue, isNotEmpty } from '../../empty.util'; export enum SelectorActionType { CREATE = 'create', EDIT = 'edit', - EXPORT_METADATA = 'export-metadata' + EXPORT_METADATA = 'export-metadata', + SET_SCOPE = 'set-scope' } /** @@ -77,6 +78,7 @@ export abstract class DSOSelectorModalWrapperComponent implements OnInit { } } } + /** * Method called when an object has been selected * @param dso The selected DSpaceObject diff --git a/src/app/shared/entity-dropdown/entity-dropdown.component.html b/src/app/shared/entity-dropdown/entity-dropdown.component.html new file mode 100644 index 0000000000..59c242ef97 --- /dev/null +++ b/src/app/shared/entity-dropdown/entity-dropdown.component.html @@ -0,0 +1,28 @@ +
+ + + + +
diff --git a/src/app/shared/entity-dropdown/entity-dropdown.component.scss b/src/app/shared/entity-dropdown/entity-dropdown.component.scss new file mode 100644 index 0000000000..a5f43f359b --- /dev/null +++ b/src/app/shared/entity-dropdown/entity-dropdown.component.scss @@ -0,0 +1,19 @@ +.list-item:active { + color: white !important; +} + +.scrollable-menu { + height: auto; + max-height: var(--ds-dropdown-menu-max-height); + overflow-x: hidden; +} + +.entity-item { + border-bottom: var(--bs-dropdown-border-width) solid var(--bs-dropdown-border-color); +} + +#entityControlsDropdownMenu { + outline: 0; + left: 0 !important; + box-shadow: var(--bs-btn-focus-box-shadow); +} diff --git a/src/app/shared/entity-dropdown/entity-dropdown.component.spec.ts b/src/app/shared/entity-dropdown/entity-dropdown.component.spec.ts new file mode 100644 index 0000000000..0cc14cae22 --- /dev/null +++ b/src/app/shared/entity-dropdown/entity-dropdown.component.spec.ts @@ -0,0 +1,167 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { EntityDropdownComponent } from './entity-dropdown.component'; +import { getTestScheduler } from 'jasmine-marbles'; +import { createSuccessfulRemoteDataObject$ } from '../remote-data.utils'; +import { ItemType } from '../../core/shared/item-relationships/item-type.model'; +import { ChangeDetectorRef, NO_ERRORS_SCHEMA, Pipe, PipeTransform } from '@angular/core'; +import { EntityTypeService } from '../../core/data/entity-type.service'; +import { TestScheduler } from 'rxjs/testing'; +import { By } from '@angular/platform-browser'; +import { createPaginatedList } from '../testing/utils.test'; + +// tslint:disable-next-line:pipe-prefix +@Pipe({ name: 'translate' }) +class MockTranslatePipe implements PipeTransform { + transform(value: string): string { + return value; + } +} + +const entities: ItemType[] = [ + Object.assign(new ItemType(), { + id: 'ce64f48e-2c9b-411a-ac36-ee429c0e6a88', + label: 'Entity_1', + uuid: 'UUID-ce64f48e-2c9b-411a-ac36-ee429c0e6a88' + }), + Object.assign(new ItemType(), { + id: '59ee713b-ee53-4220-8c3f-9860dc84fe33', + label: 'Entity_2', + uuid: 'UUID-59ee713b-ee53-4220-8c3f-9860dc84fe33' + }), + Object.assign(new ItemType(), { + id: 'e9dbf393-7127-415f-8919-55be34a6e9ed', + label: 'Entity_3', + uuid: 'UUID-7127-415f-8919-55be34a6e9ed' + }), + Object.assign(new ItemType(), { + id: '59da2ff0-9bf4-45bf-88be-e35abd33f304', + label: 'Entity_4', + uuid: 'UUID-59da2ff0-9bf4-45bf-88be-e35abd33f304' + }), + Object.assign(new ItemType(), { + id: 'a5159760-f362-4659-9e81-e3253ad91ede', + label: 'Entity_5', + uuid: 'UUID-a5159760-f362-4659-9e81-e3253ad91ede' + }), +]; + +const listElementMock: ItemType = Object.assign( + new ItemType(), { + id: 'ce64f48e-2c9b-411a-ac36-ee429c0e6a88', + label: 'Entity_1', + uuid: 'UUID-ce64f48e-2c9b-411a-ac36-ee429c0e6a88' +} +); + +describe('EntityDropdownComponent', () => { + let component: EntityDropdownComponent; + let componentAsAny: any; + let fixture: ComponentFixture; + let scheduler: TestScheduler; + + const entityTypeServiceMock: any = jasmine.createSpyObj('EntityTypeService', { + getAllAuthorizedRelationshipType: jasmine.createSpy('getAllAuthorizedRelationshipType'), + getAllAuthorizedRelationshipTypeImport: jasmine.createSpy('getAllAuthorizedRelationshipTypeImport') + }); + + + let translatePipeSpy: jasmine.Spy; + + const paginatedEntities = createPaginatedList(entities); + const paginatedEntitiesRD$ = createSuccessfulRemoteDataObject$(paginatedEntities); + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [], + declarations: [EntityDropdownComponent, MockTranslatePipe], + providers: [ + { provide: EntityTypeService, useValue: entityTypeServiceMock }, + ChangeDetectorRef + ], + schemas: [NO_ERRORS_SCHEMA] + }) + .compileComponents(); + })); + + beforeEach(() => { + scheduler = getTestScheduler(); + fixture = TestBed.createComponent(EntityDropdownComponent); + component = fixture.componentInstance; + componentAsAny = fixture.componentInstance; + componentAsAny.entityTypeService.getAllAuthorizedRelationshipType.and.returnValue(paginatedEntitiesRD$); + componentAsAny.entityTypeService.getAllAuthorizedRelationshipTypeImport.and.returnValue(paginatedEntitiesRD$); + component.isSubmission = true; + + translatePipeSpy = spyOn(MockTranslatePipe.prototype, 'transform'); + }); + + it('should translate entries', () => { + scheduler.schedule(() => fixture.detectChanges()); + scheduler.flush(); + + expect(translatePipeSpy).toHaveBeenCalledWith('entity_1.listelement.badge'); + }); + + it('should init component with entities list', () => { + spyOn(component.subs, 'push'); + spyOn(component, 'resetPagination'); + spyOn(component, 'populateEntityList').and.callThrough(); + + scheduler.schedule(() => fixture.detectChanges()); + scheduler.flush(); + const elements = fixture.debugElement.queryAll(By.css('.entity-item')); + + expect(elements.length).toEqual(5); + expect(component.subs.push).toHaveBeenCalled(); + expect(component.resetPagination).toHaveBeenCalled(); + expect(component.populateEntityList).toHaveBeenCalled(); + expect((component as any).entityTypeService.getAllAuthorizedRelationshipType).toHaveBeenCalled(); + }); + + it('should trigger onSelect method when select a new entity from list', () => { + scheduler.schedule(() => fixture.detectChanges()); + scheduler.flush(); + + spyOn(component, 'onSelect'); + const entityItem = fixture.debugElement.query(By.css('.entity-item:nth-child(2)')); + entityItem.triggerEventHandler('click', null); + + scheduler.schedule(() => fixture.detectChanges()); + scheduler.flush(); + + expect(component.onSelect).toHaveBeenCalled(); + }); + + it('should emit selectionChange event when selecting a new entity', () => { + spyOn(component.selectionChange, 'emit').and.callThrough(); + component.ngOnInit(); + component.onSelect(listElementMock as any); + fixture.detectChanges(); + + expect(component.selectionChange.emit).toHaveBeenCalledWith(listElementMock as any); + }); + + it('should change loader status', () => { + spyOn(component.isLoadingList, 'next').and.callThrough(); + component.hideShowLoader(true); + + expect(component.isLoadingList.next).toHaveBeenCalledWith(true); + }); + + it('reset pagination fields', () => { + component.resetPagination(); + + expect(component.currentPage).toEqual(1); + expect(component.hasNextPage).toEqual(true); + expect(component.searchListEntity).toEqual([]); + }); + + it('should invoke the method getAllAuthorizedRelationshipTypeImport of EntityTypeService when isSubmission is false', () => { + component.isSubmission = false; + + scheduler.schedule(() => fixture.detectChanges()); + scheduler.flush(); + + expect((component as any).entityTypeService.getAllAuthorizedRelationshipTypeImport).toHaveBeenCalled(); + }); +}); diff --git a/src/app/shared/entity-dropdown/entity-dropdown.component.ts b/src/app/shared/entity-dropdown/entity-dropdown.component.ts new file mode 100644 index 0000000000..13d50a8b79 --- /dev/null +++ b/src/app/shared/entity-dropdown/entity-dropdown.component.ts @@ -0,0 +1,207 @@ +import { + ChangeDetectorRef, + Component, + ElementRef, + EventEmitter, + HostListener, + Input, + OnDestroy, + OnInit, + Output +} from '@angular/core'; +import { BehaviorSubject, Observable, Subscription } from 'rxjs'; +import { hasValue } from '../empty.util'; +import { reduce, startWith, switchMap } from 'rxjs/operators'; +import { RemoteData } from '../../core/data/remote-data'; +import { FindListOptions } from '../../core/data/request.models'; +import { PaginatedList } from '../../core/data/paginated-list.model'; +import { EntityTypeService } from '../../core/data/entity-type.service'; +import { ItemType } from '../../core/shared/item-relationships/item-type.model'; +import { getFirstSucceededRemoteWithNotEmptyData } from '../../core/shared/operators'; + +@Component({ + selector: 'ds-entity-dropdown', + templateUrl: './entity-dropdown.component.html', + styleUrls: ['./entity-dropdown.component.scss'] +}) +export class EntityDropdownComponent implements OnInit, OnDestroy { + /** + * The entity list obtained from a search + * @type {Observable} + */ + public searchListEntity$: Observable; + + /** + * A boolean representing if dropdown list is scrollable to the bottom + * @type {boolean} + */ + private scrollableBottom = false; + + /** + * A boolean representing if dropdown list is scrollable to the top + * @type {boolean} + */ + private scrollableTop = false; + + /** + * The list of entity to render + */ + public searchListEntity: ItemType[] = []; + + /** + * TRUE if the parent operation is a 'new submission' operation, FALSE otherwise (eg.: is an 'Import metadata from an external source' operation). + */ + @Input() isSubmission: boolean; + + /** + * The entity to output to the parent component + */ + @Output() selectionChange = new EventEmitter(); + + /** + * A boolean representing if the loader is visible or not + */ + public isLoadingList: BehaviorSubject = new BehaviorSubject(false); + + /** + * A numeric representig current page + */ + public currentPage: number; + + /** + * A boolean representing if exist another page to render + */ + public hasNextPage: boolean; + + /** + * Array to track all subscriptions and unsubscribe them onDestroy + * @type {Array} + */ + public subs: Subscription[] = []; + + /** + * Initialize instance variables + * + * @param {ChangeDetectorRef} changeDetectorRef + * @param {EntityTypeService} entityTypeService + * @param {ElementRef} el + */ + constructor( + private changeDetectorRef: ChangeDetectorRef, + private entityTypeService: EntityTypeService, + private el: ElementRef + ) { } + + /** + * Method called on mousewheel event, it prevent the page scroll + * when arriving at the top/bottom of dropdown menu + * + * @param event + * mousewheel event + */ + @HostListener('mousewheel', ['$event']) onMousewheel(event) { + if (event.wheelDelta > 0 && this.scrollableTop) { + event.preventDefault(); + } + if (event.wheelDelta < 0 && this.scrollableBottom) { + event.preventDefault(); + } + } + + /** + * Initialize entity list + */ + ngOnInit() { + this.resetPagination(); + this.populateEntityList(this.currentPage); + } + + /** + * Check if dropdown scrollbar is at the top or bottom of the dropdown list + * + * @param event + */ + public onScroll(event) { + this.scrollableBottom = (event.target.scrollTop + event.target.clientHeight === event.target.scrollHeight); + this.scrollableTop = (event.target.scrollTop === 0); + } + + /** + * Method used from infitity scroll for retrive more data on scroll down + */ + public onScrollDown() { + if ( this.hasNextPage ) { + this.populateEntityList(++this.currentPage); + } + } + + /** + * Emit a [selectionChange] event when a new entity is selected from list + * + * @param event + * the selected [ItemType] + */ + public onSelect(event: ItemType) { + this.selectionChange.emit(event); + } + + /** + * Method called for populate the entity list + * @param page page number + */ + public populateEntityList(page: number) { + this.isLoadingList.next(true); + // Set the pagination info + const findOptions: FindListOptions = { + elementsPerPage: 10, + currentPage: page + }; + let searchListEntity$; + if (this.isSubmission) { + searchListEntity$ = this.entityTypeService.getAllAuthorizedRelationshipType(findOptions); + } else { + searchListEntity$ = this.entityTypeService.getAllAuthorizedRelationshipTypeImport(findOptions); + } + this.searchListEntity$ = searchListEntity$.pipe( + getFirstSucceededRemoteWithNotEmptyData(), + switchMap((entityType: RemoteData>) => { + if ( (this.searchListEntity.length + findOptions.elementsPerPage) >= entityType.payload.totalElements ) { + this.hasNextPage = false; + } + return entityType.payload.page; + }), + reduce((acc: any, value: any) => [...acc, value], []), + startWith([]) + ); + this.subs.push( + this.searchListEntity$.subscribe( + (next) => { this.searchListEntity.push(...next); }, undefined, + () => { this.hideShowLoader(false); this.changeDetectorRef.detectChanges(); } + ) + ); + } + + /** + * Reset pagination values + */ + public resetPagination() { + this.currentPage = 1; + this.hasNextPage = true; + this.searchListEntity = []; + } + + /** + * Hide/Show the entity list loader + * @param hideShow true for show, false otherwise + */ + public hideShowLoader(hideShow: boolean) { + this.isLoadingList.next(hideShow); + } + + /** + * Unsubscribe from all subscriptions + */ + ngOnDestroy(): void { + this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); + } +} diff --git a/src/app/shared/file-download-link/file-download-link.component.html b/src/app/shared/file-download-link/file-download-link.component.html index 497502d586..0155c40b0a 100644 --- a/src/app/shared/file-download-link/file-download-link.component.html +++ b/src/app/shared/file-download-link/file-download-link.component.html @@ -1,4 +1,5 @@ - + + diff --git a/src/app/shared/file-download-link/file-download-link.component.spec.ts b/src/app/shared/file-download-link/file-download-link.component.spec.ts index 6f7f50e585..61e9ecb4aa 100644 --- a/src/app/shared/file-download-link/file-download-link.component.spec.ts +++ b/src/app/shared/file-download-link/file-download-link.component.spec.ts @@ -1,62 +1,145 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { FileDownloadLinkComponent } from './file-download-link.component'; -import { AuthService } from '../../core/auth/auth.service'; -import { FileService } from '../../core/shared/file.service'; -import { of as observableOf } from 'rxjs'; import { Bitstream } from '../../core/shared/bitstream.model'; import { By } from '@angular/platform-browser'; import { URLCombiner } from '../../core/url-combiner/url-combiner'; import { getBitstreamModuleRoute } from '../../app-routing-paths'; +import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; +import { cold, getTestScheduler } from 'jasmine-marbles'; +import { FeatureID } from '../../core/data/feature-authorization/feature-id'; +import { Item } from '../../core/shared/item.model'; +import { getItemModuleRoute } from '../../item-page/item-page-routing-paths'; +import { RouterLinkDirectiveStub } from '../testing/router-link-directive.stub'; describe('FileDownloadLinkComponent', () => { let component: FileDownloadLinkComponent; let fixture: ComponentFixture; - let authService: AuthService; - let fileService: FileService; + let scheduler; + let authorizationService: AuthorizationDataService; + let bitstream: Bitstream; + let item: Item; function init() { - authService = jasmine.createSpyObj('authService', { - isAuthenticated: observableOf(true) + authorizationService = jasmine.createSpyObj('authorizationService', { + isAuthorized: cold('-a', {a: true}) }); - fileService = jasmine.createSpyObj('fileService', ['downloadFile']); bitstream = Object.assign(new Bitstream(), { uuid: 'bitstreamUuid', + _links: { + self: {href: 'obj-selflink'} + } + }); + item = Object.assign(new Item(), { + uuid: 'itemUuid', + _links: { + self: {href: 'obj-selflink'} + } }); } - beforeEach(waitForAsync(() => { - init(); + function initTestbed() { TestBed.configureTestingModule({ - declarations: [FileDownloadLinkComponent], + declarations: [FileDownloadLinkComponent, RouterLinkDirectiveStub], providers: [ - { provide: AuthService, useValue: authService }, - { provide: FileService, useValue: fileService }, + {provide: AuthorizationDataService, useValue: authorizationService}, ] }) .compileComponents(); - })); - - beforeEach(() => { - fixture = TestBed.createComponent(FileDownloadLinkComponent); - component = fixture.componentInstance; - component.bitstream = bitstream; - fixture.detectChanges(); - }); + } describe('init', () => { - describe('getBitstreamPath', () => { - it('should set the bitstreamPath based on the input bitstream', () => { - expect(component.bitstreamPath).toEqual(new URLCombiner(getBitstreamModuleRoute(), bitstream.uuid, 'download').toString()); + describe('when the user has download rights', () => { + beforeEach(waitForAsync(() => { + scheduler = getTestScheduler(); + init(); + initTestbed(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(FileDownloadLinkComponent); + component = fixture.componentInstance; + component.bitstream = bitstream; + component.item = item; + fixture.detectChanges(); + }); + it('should return the bitstreamPath based on the input bitstream', () => { + expect(component.bitstreamPath$).toBeObservable(cold('-a', {a: { routerLink: new URLCombiner(getBitstreamModuleRoute(), bitstream.uuid, 'download').toString(), queryParams: {} }})); + expect(component.canDownload$).toBeObservable(cold('--a', {a: true})); + + }); + it('should init the component', () => { + scheduler.flush(); + fixture.detectChanges(); + const link = fixture.debugElement.query(By.css('a')); + expect(link.injector.get(RouterLinkDirectiveStub).routerLink).toContain(new URLCombiner(getBitstreamModuleRoute(), bitstream.uuid, 'download').toString()); + const lock = fixture.debugElement.query(By.css('.fa-lock')); + expect(lock).toBeNull(); + }); + }); + describe('when the user has no download rights but has the right to request a copy', () => { + beforeEach(waitForAsync(() => { + scheduler = getTestScheduler(); + init(); + (authorizationService.isAuthorized as jasmine.Spy).and.callFake((featureId, object) => { + if (featureId === FeatureID.CanDownload) { + return cold('-a', {a: false}); + } + return cold('-a', {a: true}); + }); + initTestbed(); + })); + beforeEach(() => { + fixture = TestBed.createComponent(FileDownloadLinkComponent); + component = fixture.componentInstance; + component.item = item; + component.bitstream = bitstream; + fixture.detectChanges(); + }); + it('should return the bitstreamPath based on the input bitstream', () => { + expect(component.bitstreamPath$).toBeObservable(cold('-a', {a: { routerLink: new URLCombiner(getItemModuleRoute(), item.uuid, 'request-a-copy').toString(), queryParams: { bitstream: bitstream.uuid } }})); + expect(component.canDownload$).toBeObservable(cold('--a', {a: false})); + + }); + it('should init the component', () => { + scheduler.flush(); + fixture.detectChanges(); + const link = fixture.debugElement.query(By.css('a')); + expect(link.injector.get(RouterLinkDirectiveStub).routerLink).toContain(new URLCombiner(getItemModuleRoute(), item.uuid, 'request-a-copy').toString()); + const lock = fixture.debugElement.query(By.css('.fa-lock')).nativeElement; + expect(lock).toBeTruthy(); + }); + }); + describe('when the user has no download rights and no request a copy rights', () => { + beforeEach(waitForAsync(() => { + scheduler = getTestScheduler(); + init(); + (authorizationService.isAuthorized as jasmine.Spy).and.returnValue(cold('-a', {a: false})); + initTestbed(); + })); + beforeEach(() => { + fixture = TestBed.createComponent(FileDownloadLinkComponent); + component = fixture.componentInstance; + component.bitstream = bitstream; + component.item = item; + fixture.detectChanges(); + }); + it('should return the bitstreamPath based on the input bitstream', () => { + expect(component.bitstreamPath$).toBeObservable(cold('-a', {a: { routerLink: new URLCombiner(getBitstreamModuleRoute(), bitstream.uuid, 'download').toString(), queryParams: {} }})); + expect(component.canDownload$).toBeObservable(cold('--a', {a: false})); + + }); + it('should init the component', () => { + scheduler.flush(); + fixture.detectChanges(); + const link = fixture.debugElement.query(By.css('a')); + expect(link.injector.get(RouterLinkDirectiveStub).routerLink).toContain(new URLCombiner(getBitstreamModuleRoute(), bitstream.uuid, 'download').toString()); + const lock = fixture.debugElement.query(By.css('.fa-lock')).nativeElement; + expect(lock).toBeTruthy(); + }); }); }); - - it('should init the component', () => { - const link = fixture.debugElement.query(By.css('a')).nativeElement; - expect(link.href).toContain(new URLCombiner(getBitstreamModuleRoute(), bitstream.uuid, 'download').toString()); - }); - }); }); diff --git a/src/app/shared/file-download-link/file-download-link.component.ts b/src/app/shared/file-download-link/file-download-link.component.ts index b415e1e701..a79a71b634 100644 --- a/src/app/shared/file-download-link/file-download-link.component.ts +++ b/src/app/shared/file-download-link/file-download-link.component.ts @@ -1,6 +1,12 @@ import { Component, Input, OnInit } from '@angular/core'; import { Bitstream } from '../../core/shared/bitstream.model'; -import { getBitstreamDownloadRoute } from '../../app-routing-paths'; +import { getBitstreamDownloadRoute, getBitstreamRequestACopyRoute } from '../../app-routing-paths'; +import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; +import { FeatureID } from '../../core/data/feature-authorization/feature-id'; +import { hasValue, isNotEmpty } from '../empty.util'; +import { map } from 'rxjs/operators'; +import { of as observableOf, combineLatest as observableCombineLatest, Observable } from 'rxjs'; +import { Item } from '../../core/shared/item.model'; @Component({ selector: 'ds-file-download-link', @@ -19,6 +25,8 @@ export class FileDownloadLinkComponent implements OnInit { */ @Input() bitstream: Bitstream; + @Input() item: Item; + /** * Additional css classes to apply to link */ @@ -29,13 +37,44 @@ export class FileDownloadLinkComponent implements OnInit { */ @Input() isBlank = false; - bitstreamPath: string; + @Input() enableRequestACopy = true; + + bitstreamPath$: Observable<{ + routerLink: string, + queryParams: any, + }>; + + canDownload$: Observable; + + constructor( + private authorizationService: AuthorizationDataService, + ) { + } ngOnInit() { - this.bitstreamPath = this.getBitstreamPath(); + if (this.enableRequestACopy) { + this.canDownload$ = this.authorizationService.isAuthorized(FeatureID.CanDownload, isNotEmpty(this.bitstream) ? this.bitstream.self : undefined); + const canRequestACopy$ = this.authorizationService.isAuthorized(FeatureID.CanRequestACopy, isNotEmpty(this.bitstream) ? this.bitstream.self : undefined); + this.bitstreamPath$ = observableCombineLatest([this.canDownload$, canRequestACopy$]).pipe( + map(([canDownload, canRequestACopy]) => this.getBitstreamPath(canDownload, canRequestACopy)) + ); + } else { + this.bitstreamPath$ = observableOf(this.getBitstreamDownloadPath()); + this.canDownload$ = observableOf(true); + } } - getBitstreamPath() { - return getBitstreamDownloadRoute(this.bitstream); + getBitstreamPath(canDownload: boolean, canRequestACopy: boolean) { + if (!canDownload && canRequestACopy && hasValue(this.item)) { + return getBitstreamRequestACopyRoute(this.item, this.bitstream); + } + return this.getBitstreamDownloadPath(); + } + + getBitstreamDownloadPath() { + return { + routerLink: getBitstreamDownloadRoute(this.bitstream), + queryParams: {} + }; } } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html index 2fa3ea1723..fc115e043a 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html @@ -1,79 +1,80 @@
- - - -
+ + +
-
- - - -
+
+ +
+ + +
-
- {{ message | translate: model.validators }} -
+
+ {{ message | translate: model.validators }} +
-
-
- -
-
- -
- - - - - - - -
-
- +
+ +
+
+ +
+
+ + + + + + + +
+
+
- - diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.html index a5d6d63418..d5421a254f 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.html @@ -1,50 +1,54 @@ -
- +
+
+ + {{model.placeholder}} * + + - - - + + +
diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.scss b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.scss index 9eab449eeb..97698b2102 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.scss +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.scss @@ -1,3 +1,7 @@ .col-lg-1 { width: auto; } + +legend { + font-size: initial; +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.spec.ts index 87e49956bb..4989dab93a 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.spec.ts @@ -69,6 +69,7 @@ describe('DsDatePickerComponent test suite', () => { [bindId]='bindId' [group]='group' [model]='model' + [legend]='legend' (blur)='onBlur($event)' (change)='onValueChange($event)' (focus)='onFocus($event)'>`; diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.ts index 438f78a6a0..78f9935829 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.ts @@ -20,6 +20,7 @@ export class DsDatePickerComponent extends DynamicFormControlComponent implement @Input() bindId = true; @Input() group: FormGroup; @Input() model: DynamicDsDatePickerModel; + @Input() legend: string; @Output() selected = new EventEmitter(); @Output() remove = new EventEmitter(); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.model.ts index fa71e0b6dd..1c053ffc80 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.model.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.model.ts @@ -1,24 +1,30 @@ import { DynamicDateControlModel, - DynamicDateControlModelConfig, + DynamicDatePickerModelConfig, DynamicFormControlLayout, serializable } from '@ng-dynamic-forms/core'; export const DYNAMIC_FORM_CONTROL_TYPE_DSDATEPICKER = 'DATE'; +export interface DynamicDsDateControlModelConfig extends DynamicDatePickerModelConfig { + legend?: string; +} + /** * Dynamic Date Picker Model class */ export class DynamicDsDatePickerModel extends DynamicDateControlModel { @serializable() readonly type: string = DYNAMIC_FORM_CONTROL_TYPE_DSDATEPICKER; malformedDate: boolean; + legend: string; hasLanguages = false; repeatable = false; - constructor(config: DynamicDateControlModelConfig, layout?: DynamicFormControlLayout) { + constructor(config: DynamicDsDateControlModelConfig, layout?: DynamicFormControlLayout) { super(config, layout); this.malformedDate = false; + this.legend = config.legend; } } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.html index 0e48b85c78..8a4d502287 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.html @@ -1,8 +1,13 @@
-
+
-

{{'form.loading' | translate}}

@@ -40,5 +50,3 @@
- - diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.ts index 55cd43fc3c..fb5a9474d5 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.ts @@ -12,7 +12,9 @@ import { RelationshipOptions } from '../../models/relationship-options.model'; import { SearchResult } from '../../../../search/search-result.model'; import { Item } from '../../../../../core/shared/item.model'; import { - AddRelationshipAction, RemoveRelationshipAction, UpdateRelationshipNameVariantAction, + AddRelationshipAction, + RemoveRelationshipAction, + UpdateRelationshipNameVariantAction, } from './relationship.actions'; import { RelationshipService } from '../../../../../core/data/relationship.service'; import { RelationshipTypeService } from '../../../../../core/data/relationship-type.service'; @@ -25,9 +27,9 @@ import { ExternalSourceService } from '../../../../../core/data/external-source. import { Router } from '@angular/router'; import { RemoteDataBuildService } from '../../../../../core/cache/builders/remote-data-build.service'; import { getAllSucceededRemoteDataPayload } from '../../../../../core/shared/operators'; +import { followLink } from '../../../../utils/follow-link-config.model'; import { RelationshipType } from '../../../../../core/shared/item-relationships/relationship-type.model'; - @Component({ selector: 'ds-dynamic-lookup-relation-modal', styleUrls: ['./dynamic-lookup-relation-modal.component.scss'], @@ -188,7 +190,14 @@ export class DsDynamicLookupRelationModalComponent implements OnInit, OnDestroy if (isNotEmpty(this.relationshipOptions.externalSources)) { this.externalSourcesRD$ = this.rdbService.aggregate( - this.relationshipOptions.externalSources.map((source) => this.externalSourceService.findById(source)) + this.relationshipOptions.externalSources.map((source) => { + return this.externalSourceService.findById( + source, + true, + true, + followLink('entityTypes') + ); + }) ).pipe( getAllSucceededRemoteDataPayload() ); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/dynamic-lookup-relation-external-source-tab.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/dynamic-lookup-relation-external-source-tab.component.spec.ts index 6309b74792..62c33bd967 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/dynamic-lookup-relation-external-source-tab.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/dynamic-lookup-relation-external-source-tab.component.spec.ts @@ -26,6 +26,7 @@ import { ExternalSourceEntryImportModalComponent } from './external-source-entry import { createPaginatedList } from '../../../../../testing/utils.test'; import { PaginationService } from '../../../../../../core/pagination/pagination.service'; import { PaginationServiceStub } from '../../../../../testing/pagination-service.stub'; +import { ItemType } from '../../../../../../core/shared/item-relationships/item-type.model'; describe('DsDynamicLookupRelationExternalSourceTabComponent', () => { let component: DsDynamicLookupRelationExternalSourceTabComponent; @@ -35,10 +36,12 @@ describe('DsDynamicLookupRelationExternalSourceTabComponent', () => { let selectableListService; let modalService; + const itemType = Object.assign(new ItemType(), { label: 'Person' }); const externalSource = { id: 'orcidV2', name: 'orcidV2', - hierarchical: false + hierarchical: false, + entityTypes: createSuccessfulRemoteDataObject$(createPaginatedList([itemType])) } as ExternalSource; const externalEntries = [ Object.assign({ diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/dynamic-lookup-relation-external-source-tab.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/dynamic-lookup-relation-external-source-tab.component.ts index 57abdd1a32..48b9edc82a 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/dynamic-lookup-relation-external-source-tab.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/dynamic-lookup-relation-external-source-tab.component.ts @@ -7,7 +7,7 @@ import { RemoteData } from '../../../../../../core/data/remote-data'; import { PaginatedList } from '../../../../../../core/data/paginated-list.model'; import { ExternalSourceEntry } from '../../../../../../core/shared/external-source-entry.model'; import { ExternalSource } from '../../../../../../core/shared/external-source.model'; -import { startWith, switchMap } from 'rxjs/operators'; +import { map, startWith, switchMap } from 'rxjs/operators'; import { PaginatedSearchOptions } from '../../../../../search/paginated-search-options.model'; import { Context } from '../../../../../../core/shared/context.model'; import { ListableObject } from '../../../../../object-collection/shared/listable-object.model'; @@ -22,6 +22,8 @@ import { Item } from '../../../../../../core/shared/item.model'; import { Collection } from '../../../../../../core/shared/collection.model'; import { PaginationService } from '../../../../../../core/pagination/pagination.service'; import { Observable, Subscription } from 'rxjs'; +import { ItemType } from '../../../../../../core/shared/item-relationships/item-type.model'; +import { getFirstCompletedRemoteData } from '../../../../../../core/shared/operators'; @Component({ selector: 'ds-dynamic-lookup-relation-external-source-tab', @@ -116,6 +118,11 @@ export class DsDynamicLookupRelationExternalSourceTabComponent implements OnInit */ importObjectSub: Subscription; + /** + * The entity types compatible with the given external source + */ + relatedEntityType: ItemType; + constructor(private router: Router, public searchConfigService: SearchConfigurationService, private externalSourceService: ExternalSourceService, @@ -129,6 +136,15 @@ export class DsDynamicLookupRelationExternalSourceTabComponent implements OnInit * Get the entries for the selected external source */ ngOnInit(): void { + this.externalSource.entityTypes.pipe( + getFirstCompletedRemoteData(), + map((entityTypesRD: RemoteData>) => { + return (entityTypesRD.hasSucceeded && entityTypesRD.payload.totalElements > 0) ? entityTypesRD.payload.page[0] : null; + }) + ).subscribe((entityType: ItemType) => { + this.relatedEntityType = entityType; + }); + this.resetRoute(); this.entriesRD$ = this.searchConfigService.paginatedSearchOptions.pipe( switchMap((searchOptions: PaginatedSearchOptions) => @@ -155,6 +171,7 @@ export class DsDynamicLookupRelationExternalSourceTabComponent implements OnInit modalComp.collection = this.collection; modalComp.relationship = this.relationship; modalComp.label = this.label; + modalComp.relatedEntityType = this.relatedEntityType; this.importObjectSub = modalComp.importedObject.subscribe((object) => { this.selectableListService.selectSingle(this.listId, object); this.importedObject.emit(object); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/external-source-entry-import-modal/external-source-entry-import-modal.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/external-source-entry-import-modal/external-source-entry-import-modal.component.html index a4fc356ef9..44f21fb4b4 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/external-source-entry-import-modal/external-source-entry-import-modal.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/external-source-entry-import-modal/external-source-entry-import-modal.component.html @@ -17,13 +17,6 @@
{{ (labelPrefix + 'entities' | translate) }}
-
-
- - -
-
- { component.externalSourceEntry = entry; component.label = label; component.relationship = relationship; - component.collection = submissionCollection; component.item = submissionItem; fixture.detectChanges(); }); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/external-source-entry-import-modal/external-source-entry-import-modal.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/external-source-entry-import-modal/external-source-entry-import-modal.component.ts index ca179a8080..d586520b64 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/external-source-entry-import-modal/external-source-entry-import-modal.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/external-source-entry-import-modal/external-source-entry-import-modal.component.ts @@ -1,5 +1,5 @@ import { Component, EventEmitter, OnInit } from '@angular/core'; -import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { NgbActiveModal, NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; import { ExternalSourceEntry } from '../../../../../../../core/shared/external-source-entry.model'; import { MetadataValue } from '../../../../../../../core/shared/metadata.models'; import { Metadata } from '../../../../../../../core/shared/metadata.utils'; @@ -15,14 +15,16 @@ import { CollectionElementLinkType } from '../../../../../../object-collection/c import { Context } from '../../../../../../../core/shared/context.model'; import { SelectableListService } from '../../../../../../object-list/selectable-list/selectable-list.service'; import { ListableObject } from '../../../../../../object-collection/shared/listable-object.model'; -import { Collection } from '../../../../../../../core/shared/collection.model'; import { ItemDataService } from '../../../../../../../core/data/item-data.service'; import { PaginationComponentOptions } from '../../../../../../pagination/pagination-component-options.model'; -import { getRemoteDataPayload, getFirstSucceededRemoteData } from '../../../../../../../core/shared/operators'; -import { take } from 'rxjs/operators'; +import { getFirstSucceededRemoteData, getRemoteDataPayload } from '../../../../../../../core/shared/operators'; +import { switchMap, take } from 'rxjs/operators'; import { ItemSearchResult } from '../../../../../../object-collection/shared/item-search-result.model'; import { NotificationsService } from '../../../../../../notifications/notifications.service'; import { TranslateService } from '@ngx-translate/core'; +import { ItemType } from '../../../../../../../core/shared/item-relationships/item-type.model'; +import { SubmissionImportExternalCollectionComponent } from '../../../../../../../submission/import-external/import-external-collection/submission-import-external-collection.component'; +import { CollectionListEntry } from '../../../../../../collection-dropdown/collection-dropdown.component'; /** * The possible types of import for the external entry @@ -67,16 +69,6 @@ export class ExternalSourceEntryImportModalComponent implements OnInit { */ item: Item; - /** - * The collection the user is submitting in - */ - collection: Collection; - - /** - * The ID of the collection to import entries to - */ - collectionId: string; - /** * The current relationship-options used for filtering results */ @@ -147,8 +139,19 @@ export class ExternalSourceEntryImportModalComponent implements OnInit { */ authorityEnabled = false; + /** + * The entity types compatible with the given external source + */ + relatedEntityType: ItemType; + + /** + * The modal for the collection selection + */ + modalRef: NgbModalRef; + constructor(public modal: NgbActiveModal, public lookupRelationService: LookupRelationService, + private modalService: NgbModal, private selectService: SelectableListService, private itemService: ItemDataService, private notificationsService: NotificationsService, @@ -160,7 +163,6 @@ export class ExternalSourceEntryImportModalComponent implements OnInit { const pagination = Object.assign(new PaginationComponentOptions(), { id: 'external-entry-import', pageSize: 5 }); this.searchOptions = Object.assign(new PaginatedSearchOptions({ query: this.externalSourceEntry.value, pagination: pagination })); this.localEntitiesRD$ = this.lookupRelationService.getLocalResults(this.relationship, this.searchOptions); - this.collectionId = this.collection.id; } /** @@ -211,16 +213,26 @@ export class ExternalSourceEntryImportModalComponent implements OnInit { * Create and import a new entity from the external entry */ importNewEntity() { - this.itemService.importExternalSourceEntry(this.externalSourceEntry, this.collectionId).pipe( - getFirstSucceededRemoteData(), - getRemoteDataPayload(), - take(1) + this.modalRef = this.modalService.open(SubmissionImportExternalCollectionComponent, { + size: 'lg', + }); + this.modalRef.componentInstance.entityType = this.relatedEntityType.label; + + this.modalRef.componentInstance.selectedEvent.pipe( + switchMap((collectionListEntry: CollectionListEntry) => { + return this.itemService.importExternalSourceEntry(this.externalSourceEntry, collectionListEntry.collection.id).pipe( + getFirstSucceededRemoteData(), + getRemoteDataPayload(), + take(1) + ); + }) ).subscribe((item: Item) => { this.lookupRelationService.removeLocalResultsCache(); const searchResult = Object.assign(new ItemSearchResult(), { indexableObject: item }); this.notificationsService.success(this.translateService.get(this.labelPrefix + this.label + '.added.new-entity')); + this.modalRef.close(); this.importedObject.emit(searchResult); }); } diff --git a/src/app/shared/form/builder/parsers/date-field-parser.ts b/src/app/shared/form/builder/parsers/date-field-parser.ts index 0f69b0fd57..aef0219579 100644 --- a/src/app/shared/form/builder/parsers/date-field-parser.ts +++ b/src/app/shared/form/builder/parsers/date-field-parser.ts @@ -1,6 +1,8 @@ import { FieldParser } from './field-parser'; -import { DynamicDatePickerModelConfig } from '@ng-dynamic-forms/core'; -import { DynamicDsDatePickerModel } from '../ds-dynamic-form-ui/models/date-picker/date-picker.model'; +import { + DynamicDsDateControlModelConfig, + DynamicDsDatePickerModel +} from '../ds-dynamic-form-ui/models/date-picker/date-picker.model'; import { isNotEmpty } from '../../../empty.util'; import { DS_DATE_PICKER_SEPARATOR } from '../ds-dynamic-form-ui/models/date-picker/date-picker.component'; import { FormFieldMetadataValueObject } from '../models/form-field-metadata-value.model'; @@ -9,7 +11,8 @@ export class DateFieldParser extends FieldParser { public modelFactory(fieldValue?: FormFieldMetadataValueObject | any, label?: boolean): any { let malformedDate = false; - const inputDateModelConfig: DynamicDatePickerModelConfig = this.initModel(null, label); + const inputDateModelConfig: DynamicDsDateControlModelConfig = this.initModel(null, false, true); + inputDateModelConfig.legend = this.configData.label; inputDateModelConfig.toggleIcon = 'fas fa-calendar'; this.setValues(inputDateModelConfig as any, fieldValue); diff --git a/src/app/shared/input-suggestions/validation-suggestions/validation-suggestions.component.html b/src/app/shared/input-suggestions/validation-suggestions/validation-suggestions.component.html index f756169f94..1b958d8d9f 100644 --- a/src/app/shared/input-suggestions/validation-suggestions/validation-suggestions.component.html +++ b/src/app/shared/input-suggestions/validation-suggestions/validation-suggestions.component.html @@ -3,7 +3,7 @@ (keydown.arrowdown)="shiftFocusDown($event)" (keydown.arrowup)="shiftFocusUp($event)" (keydown.esc)="close()" (dsClickOutside)="checkIfValidInput(form);close();"> - { expect(comp.onClickSuggestion).toHaveBeenCalledWith(suggestions[clickedIndex].value); }); }); + describe('can edit input', () => { + describe('test input field readonly property when input disable is true', () => { + beforeEach(() => { + comp.disable = true; + fixture.detectChanges(); + }); + it('it should be true', () => { + fixture.detectChanges(); + const input = fixture.debugElement.query(By.css('input')); + const element = input.nativeElement; + expect(element.readOnly).toBe(true); + }); + }); + describe('test input field readonly property when input disable is false', () => { + beforeEach(() => { + comp.disable = false; + fixture.detectChanges(); + }); + it('it should be true', () => { + fixture.detectChanges(); + const input = fixture.debugElement.query(By.css('input')); + const element = input.nativeElement; + expect(element.readOnly).toBe(false); + }); + }); + }); }); diff --git a/src/app/shared/input-suggestions/validation-suggestions/validation-suggestions.component.ts b/src/app/shared/input-suggestions/validation-suggestions/validation-suggestions.component.ts index e7301cd35a..c6f92a41b3 100644 --- a/src/app/shared/input-suggestions/validation-suggestions/validation-suggestions.component.ts +++ b/src/app/shared/input-suggestions/validation-suggestions/validation-suggestions.component.ts @@ -42,7 +42,10 @@ export class ValidationSuggestionsComponent extends InputSuggestionsComponent im * The suggestions that should be shown */ @Input() suggestions: InputSuggestion[] = []; - + /** + * The possibility to edit metadata + */ + @Input() disable; constructor(private metadataFieldValidator: MetadataFieldValidator, private objectUpdatesService: ObjectUpdatesService) { super(); diff --git a/src/app/shared/item/item-versions/item-versions-delete-modal/item-versions-delete-modal.component.html b/src/app/shared/item/item-versions/item-versions-delete-modal/item-versions-delete-modal.component.html new file mode 100644 index 0000000000..0c0b72272f --- /dev/null +++ b/src/app/shared/item/item-versions/item-versions-delete-modal/item-versions-delete-modal.component.html @@ -0,0 +1,22 @@ +
+ + + +
diff --git a/src/app/shared/item/item-versions/item-versions-delete-modal/item-versions-delete-modal.component.scss b/src/app/shared/item/item-versions/item-versions-delete-modal/item-versions-delete-modal.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/shared/item/item-versions/item-versions-delete-modal/item-versions-delete-modal.component.spec.ts b/src/app/shared/item/item-versions/item-versions-delete-modal/item-versions-delete-modal.component.spec.ts new file mode 100644 index 0000000000..8a0d4a58d9 --- /dev/null +++ b/src/app/shared/item/item-versions/item-versions-delete-modal/item-versions-delete-modal.component.spec.ts @@ -0,0 +1,31 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ItemVersionsDeleteModalComponent } from './item-versions-delete-modal.component'; +import { TranslateModule } from '@ngx-translate/core'; +import { RouterTestingModule } from '@angular/router/testing'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; + +describe('ItemVersionsDeleteModalComponent', () => { + let component: ItemVersionsDeleteModalComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ItemVersionsDeleteModalComponent], + imports: [ TranslateModule.forRoot(), RouterTestingModule.withRoutes([]) ], + providers: [ + { provide: NgbActiveModal }, + ] + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ItemVersionsDeleteModalComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/item/item-versions/item-versions-delete-modal/item-versions-delete-modal.component.ts b/src/app/shared/item/item-versions/item-versions-delete-modal/item-versions-delete-modal.component.ts new file mode 100644 index 0000000000..35618390d9 --- /dev/null +++ b/src/app/shared/item/item-versions/item-versions-delete-modal/item-versions-delete-modal.component.ts @@ -0,0 +1,25 @@ +import { Component } from '@angular/core'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; + +@Component({ + selector: 'ds-item-versions-delete-modal', + templateUrl: './item-versions-delete-modal.component.html', + styleUrls: ['./item-versions-delete-modal.component.scss'] +}) +export class ItemVersionsDeleteModalComponent { + + versionNumber: number; + + constructor( + protected activeModal: NgbActiveModal,) { + } + + onModalClose() { + this.activeModal.dismiss(); + } + + onModalSubmit() { + this.activeModal.close(); + } + +} diff --git a/src/app/shared/item/item-versions/item-versions-shared.service.spec.ts b/src/app/shared/item/item-versions/item-versions-shared.service.spec.ts new file mode 100644 index 0000000000..a9f9596548 --- /dev/null +++ b/src/app/shared/item/item-versions/item-versions-shared.service.spec.ts @@ -0,0 +1,56 @@ +import { TestBed } from '@angular/core/testing'; + +import { ItemVersionsSharedService } from './item-versions-shared.service'; +import { ActivatedRoute } from '@angular/router'; +import { VersionDataService } from '../../../core/data/version-data.service'; +import { AuthService } from '../../../core/auth/auth.service'; +import { NotificationsService } from '../../notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; +import { VersionHistoryDataService } from '../../../core/data/version-history-data.service'; +import { WorkspaceitemDataService } from '../../../core/submission/workspaceitem-data.service'; +import { WorkflowItemDataService } from '../../../core/submission/workflowitem-data.service'; +import { createFailedRemoteDataObject, createSuccessfulRemoteDataObject } from '../../remote-data.utils'; +import { Version } from '../../../core/shared/version.model'; + +describe('ItemVersionsSharedService', () => { + let service: ItemVersionsSharedService; + let notificationService: NotificationsService; + + const successfulVersionRD = createSuccessfulRemoteDataObject(new Version()); + const failedVersionRD = createFailedRemoteDataObject(); + + const notificationsServiceSpy = jasmine.createSpyObj('notificationsServiceSpy', ['success', 'error']); + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + { provide: ActivatedRoute, useValue: {} }, + { provide: VersionDataService, useValue: {} }, + { provide: VersionHistoryDataService, useValue: {} }, + { provide: AuthService, useValue: {} }, + { provide: NotificationsService, useValue: notificationsServiceSpy }, + { provide: TranslateService, useValue: { get: () => undefined, } }, + { provide: WorkspaceitemDataService, useValue: {} }, + { provide: WorkflowItemDataService, useValue: {} }, + ], + }); + service = TestBed.inject(ItemVersionsSharedService); + notificationService = TestBed.inject(NotificationsService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('when notifyCreateNewVersion is called', () => { + it('should notify when successful', () => { + service.notifyCreateNewVersion(successfulVersionRD); + expect(notificationService.success).toHaveBeenCalled(); + }); + it('should notify when not successful', () => { + service.notifyCreateNewVersion(failedVersionRD); + expect(notificationService.error).toHaveBeenCalled(); + }); + }); + +}); diff --git a/src/app/shared/item/item-versions/item-versions-shared.service.ts b/src/app/shared/item/item-versions/item-versions-shared.service.ts new file mode 100644 index 0000000000..996623509c --- /dev/null +++ b/src/app/shared/item/item-versions/item-versions-shared.service.ts @@ -0,0 +1,35 @@ +import { Injectable } from '@angular/core'; +import { NotificationsService } from '../../notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; +import { RemoteData } from '../../../core/data/remote-data'; +import { Version } from '../../../core/shared/version.model'; + +@Injectable({ + providedIn: 'root' +}) +export class ItemVersionsSharedService { + + constructor( + private notificationsService: NotificationsService, + private translateService: TranslateService, + ) { + } + + private static msg(key: string): string { + const translationPrefix = 'item.version.create.notification'; + return translationPrefix + '.' + key; + } + + /** + * Notify success/failure after creating a new version. + * + * @param newVersionRD the new version that has been created + */ + public notifyCreateNewVersion(newVersionRD: RemoteData): void { + const newVersionNumber = newVersionRD?.payload?.version; + newVersionRD.hasSucceeded ? + this.notificationsService.success(null, this.translateService.get(ItemVersionsSharedService.msg('success'), {version: newVersionNumber})) : + this.notificationsService.error(null, this.translateService.get(ItemVersionsSharedService.msg(newVersionRD?.statusCode === 422 ? 'inProgress' : 'failure'))); + } + +} diff --git a/src/app/shared/item/item-versions/item-versions-summary-modal/item-versions-summary-modal.component.html b/src/app/shared/item/item-versions/item-versions-summary-modal/item-versions-summary-modal.component.html new file mode 100644 index 0000000000..e49e257339 --- /dev/null +++ b/src/app/shared/item/item-versions/item-versions-summary-modal/item-versions-summary-modal.component.html @@ -0,0 +1,36 @@ +
+ + + +
diff --git a/src/app/shared/item/item-versions/item-versions-summary-modal/item-versions-summary-modal.component.scss b/src/app/shared/item/item-versions/item-versions-summary-modal/item-versions-summary-modal.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/shared/item/item-versions/item-versions-summary-modal/item-versions-summary-modal.component.spec.ts b/src/app/shared/item/item-versions/item-versions-summary-modal/item-versions-summary-modal.component.spec.ts new file mode 100644 index 0000000000..657e8c0e75 --- /dev/null +++ b/src/app/shared/item/item-versions/item-versions-summary-modal/item-versions-summary-modal.component.spec.ts @@ -0,0 +1,31 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ItemVersionsSummaryModalComponent } from './item-versions-summary-modal.component'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; +import { RouterTestingModule } from '@angular/router/testing'; + +describe('ItemVersionsSummaryModalComponent', () => { + let component: ItemVersionsSummaryModalComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ ItemVersionsSummaryModalComponent ], + imports: [ TranslateModule.forRoot(), RouterTestingModule.withRoutes([]) ], + providers: [ + { provide: NgbActiveModal }, + ] + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ItemVersionsSummaryModalComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/item/item-versions/item-versions-summary-modal/item-versions-summary-modal.component.ts b/src/app/shared/item/item-versions/item-versions-summary-modal/item-versions-summary-modal.component.ts new file mode 100644 index 0000000000..31bb3078c0 --- /dev/null +++ b/src/app/shared/item/item-versions/item-versions-summary-modal/item-versions-summary-modal.component.ts @@ -0,0 +1,31 @@ +import { Component, EventEmitter, Output } from '@angular/core'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; + +@Component({ + selector: 'ds-item-versions-summary-modal', + templateUrl: './item-versions-summary-modal.component.html', + styleUrls: ['./item-versions-summary-modal.component.scss'] +}) +export class ItemVersionsSummaryModalComponent { + + versionNumber: number; + newVersionSummary: string; + firstVersion = true; + + @Output() createVersionEvent: EventEmitter = new EventEmitter(); + + constructor( + protected activeModal: NgbActiveModal, + ) { + } + + onModalClose() { + this.activeModal.dismiss(); + } + + onModalSubmit() { + this.createVersionEvent.emit(this.newVersionSummary); + this.activeModal.close(); + } + +} diff --git a/src/app/shared/item/item-versions/item-versions.component.html b/src/app/shared/item/item-versions/item-versions.component.html index 34764e7925..d8850bc544 100644 --- a/src/app/shared/item/item-versions/item-versions.component.html +++ b/src/app/shared/item/item-versions/item-versions.component.html @@ -2,45 +2,146 @@

{{"item.version.history.head" | translate}}

+ + {{ "item.version.history.selected.alert" | translate : {version: itemVersion.version} }} + - +
- - - - - - - + + + + + + - - - - + + - - - + + + +
{{"item.version.history.table.version" | translate}}{{"item.version.history.table.item" | translate}}{{"item.version.history.table.editor" | translate}}{{"item.version.history.table.date" | translate}}{{"item.version.history.table.summary" | translate}}
{{"item.version.history.table.version" | translate}}{{"item.version.history.table.editor" | translate}}{{"item.version.history.table.date" | translate}}{{"item.version.history.table.summary" | translate}}
{{version?.version}} - - {{item?.handle}} - * - - +
+ + + + +
+ + + + {{version.version}} + + + {{version.version}} + + * + + + {{ "item.version.history.table.workspaceItem" | translate }} + + + + {{ "item.version.history.table.workflowItem" | translate }} + + +
+ +
+ +
+ + + + + + + + + + +
+ +
+ +
+
+
{{eperson?.name}} - {{version?.created}}{{version?.summary}}
+ {{version?.created | date : 'yyyy-MM-dd HH:mm:ss'}} + +
+ + {{version?.summary}} + + + +
+ +
+ + + + + + + + + +
+ + +
* {{"item.version.history.selected" | translate}}
- +
diff --git a/src/app/shared/item/item-versions/item-versions.component.scss b/src/app/shared/item/item-versions/item-versions.component.scss new file mode 100644 index 0000000000..5594e0cafe --- /dev/null +++ b/src/app/shared/item/item-versions/item-versions.component.scss @@ -0,0 +1,9 @@ +.left-column { + float: left; + text-align: left; +} + +.right-column { + float: right; + text-align: right; +} diff --git a/src/app/shared/item/item-versions/item-versions.component.spec.ts b/src/app/shared/item/item-versions/item-versions.component.spec.ts index cc28779537..fff0744aba 100644 --- a/src/app/shared/item/item-versions/item-versions.component.spec.ts +++ b/src/app/shared/item/item-versions/item-versions.component.spec.ts @@ -11,70 +11,138 @@ import { VersionHistoryDataService } from '../../../core/data/version-history-da import { By } from '@angular/platform-browser'; import { createSuccessfulRemoteDataObject$ } from '../../remote-data.utils'; import { createPaginatedList } from '../../testing/utils.test'; -import { PaginationComponentOptions } from '../../pagination/pagination-component-options.model'; -import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model'; -import { of as observableOf } from 'rxjs'; +import { EMPTY, of, of as observableOf } from 'rxjs'; import { PaginationService } from '../../../core/pagination/pagination.service'; import { PaginationServiceStub } from '../../testing/pagination-service.stub'; +import { AuthService } from '../../../core/auth/auth.service'; +import { VersionDataService } from '../../../core/data/version-data.service'; +import { ItemDataService } from '../../../core/data/item-data.service'; +import { FormBuilder } from '@angular/forms'; +import { NotificationsService } from '../../notifications/notifications.service'; +import { NotificationsServiceStub } from '../../testing/notifications-service.stub'; +import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; +import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; +import { WorkspaceitemDataService } from '../../../core/submission/workspaceitem-data.service'; +import { WorkflowItemDataService } from '../../../core/submission/workflowitem-data.service'; describe('ItemVersionsComponent', () => { let component: ItemVersionsComponent; let fixture: ComponentFixture; + let authenticationService: AuthService; + let authorizationService: AuthorizationDataService; + let versionHistoryService: VersionHistoryDataService; + let workspaceItemDataService: WorkspaceitemDataService; + let workflowItemDataService: WorkflowItemDataService; + let versionService: VersionDataService; const versionHistory = Object.assign(new VersionHistory(), { - id: '1' + id: '1', + draftVersion: true, }); + const version1 = Object.assign(new Version(), { id: '1', version: 1, created: new Date(2020, 1, 1), summary: 'first version', - versionhistory: createSuccessfulRemoteDataObject$(versionHistory) + versionhistory: createSuccessfulRemoteDataObject$(versionHistory), + _links: { + self: { + href: 'version2-url', + }, + }, }); const version2 = Object.assign(new Version(), { id: '2', version: 2, summary: 'second version', created: new Date(2020, 1, 2), - versionhistory: createSuccessfulRemoteDataObject$(versionHistory) + versionhistory: createSuccessfulRemoteDataObject$(versionHistory), + _links: { + self: { + href: 'version2-url', + }, + }, }); const versions = [version1, version2]; versionHistory.versions = createSuccessfulRemoteDataObject$(createPaginatedList(versions)); - const item1 = Object.assign(new Item(), { + + const item1 = Object.assign(new Item(), { // is a workspace item uuid: 'item-identifier-1', handle: '123456789/1', - version: createSuccessfulRemoteDataObject$(version1) + version: createSuccessfulRemoteDataObject$(version1), + _links: { + self: { + href: '/items/item-identifier-1' + } + } }); const item2 = Object.assign(new Item(), { uuid: 'item-identifier-2', handle: '123456789/2', - version: createSuccessfulRemoteDataObject$(version2) + version: createSuccessfulRemoteDataObject$(version2), + _links: { + self: { + href: '/items/item-identifier-2' + } + } }); const items = [item1, item2]; version1.item = createSuccessfulRemoteDataObject$(item1); version2.item = createSuccessfulRemoteDataObject$(item2); - const versionHistoryService = jasmine.createSpyObj('versionHistoryService', { - getVersions: createSuccessfulRemoteDataObject$(createPaginatedList(versions)) + + const versionHistoryServiceSpy = jasmine.createSpyObj('versionHistoryService', { + getVersions: createSuccessfulRemoteDataObject$(createPaginatedList(versions)), + }); + const authenticationServiceSpy = jasmine.createSpyObj('authenticationService', { + isAuthenticated: observableOf(true), + setRedirectUrl: {} + }); + const authorizationServiceSpy = jasmine.createSpyObj('authorizationService', ['isAuthorized']); + const workspaceItemDataServiceSpy = jasmine.createSpyObj('workspaceItemDataService', { + findByItem: EMPTY, + }); + const workflowItemDataServiceSpy = jasmine.createSpyObj('workflowItemDataService', { + findByItem: EMPTY, + }); + const versionServiceSpy = jasmine.createSpyObj('versionService', { + findById: EMPTY, }); - const paginationService = new PaginationServiceStub(); - beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ declarations: [ItemVersionsComponent, VarDirective], imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], providers: [ - { provide: VersionHistoryDataService, useValue: versionHistoryService }, - { provide: PaginationService, useValue: paginationService } + {provide: PaginationService, useValue: new PaginationServiceStub()}, + {provide: FormBuilder, useValue: new FormBuilder()}, + {provide: NotificationsService, useValue: new NotificationsServiceStub()}, + {provide: AuthService, useValue: authenticationServiceSpy}, + {provide: AuthorizationDataService, useValue: authorizationServiceSpy}, + {provide: VersionHistoryDataService, useValue: versionHistoryServiceSpy}, + {provide: ItemDataService, useValue: {}}, + {provide: VersionDataService, useValue: versionServiceSpy}, + {provide: WorkspaceitemDataService, useValue: workspaceItemDataServiceSpy}, + {provide: WorkflowItemDataService, useValue: workflowItemDataServiceSpy}, ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); + + versionHistoryService = TestBed.inject(VersionHistoryDataService); + authenticationService = TestBed.inject(AuthService); + authorizationService = TestBed.inject(AuthorizationDataService); + workspaceItemDataService = TestBed.inject(WorkspaceitemDataService); + workflowItemDataService = TestBed.inject(WorkflowItemDataService); + versionService = TestBed.inject(VersionDataService); + })); beforeEach(() => { fixture = TestBed.createComponent(ItemVersionsComponent); component = fixture.componentInstance; component.item = item1; + component.displayActions = true; fixture.detectChanges(); }); @@ -88,26 +156,29 @@ describe('ItemVersionsComponent', () => { it(`should display version ${version.version} in the correct column for version ${version.id}`, () => { const id = fixture.debugElement.query(By.css(`#version-row-${version.id} .version-row-element-version`)); - expect(id.nativeElement.textContent).toEqual('' + version.version); + expect(id.nativeElement.textContent).toContain(version.version.toString()); }); - it(`should display item handle ${versionItem.handle} in the correct column for version ${version.id}`, () => { - const item = fixture.debugElement.query(By.css(`#version-row-${version.id} .version-row-element-item`)); - expect(item.nativeElement.textContent).toContain(versionItem.handle); - }); - - // This version's item is equal to the component's item (the selected item) - // Check if the handle contains an asterisk + // Check if the current version contains an asterisk if (item1.uuid === versionItem.uuid) { - it('should add an asterisk to the handle of the selected item', () => { - const item = fixture.debugElement.query(By.css(`#version-row-${version.id} .version-row-element-item`)); + it('should add an asterisk to the version of the selected item', () => { + const item = fixture.debugElement.query(By.css(`#version-row-${version.id} .version-row-element-version`)); expect(item.nativeElement.textContent).toContain('*'); }); } it(`should display date ${version.created} in the correct column for version ${version.id}`, () => { const date = fixture.debugElement.query(By.css(`#version-row-${version.id} .version-row-element-date`)); - expect(date.nativeElement.textContent).toEqual('' + version.created); + switch (versionItem.uuid) { + case item1.uuid: + expect(date.nativeElement.textContent.trim()).toEqual('2020-02-01 00:00:00'); + break; + case item2.uuid: + expect(date.nativeElement.textContent.trim()).toEqual('2020-02-02 00:00:00'); + break; + default: + throw new Error('Unexpected versionItem'); + } }); it(`should display summary ${version.summary} in the correct column for version ${version.id}`, () => { @@ -115,4 +186,85 @@ describe('ItemVersionsComponent', () => { expect(summary.nativeElement.textContent).toEqual(version.summary); }); }); + + describe('when the user can only delete a version', () => { + beforeAll(waitForAsync(() => { + const canDelete = (featureID: FeatureID, url: string ) => of(featureID === FeatureID.CanDeleteVersion); + authorizationServiceSpy.isAuthorized.and.callFake(canDelete); + })); + it('should not disable the delete button', () => { + const deleteButtons = fixture.debugElement.queryAll(By.css(`.version-row-element-delete`)); + deleteButtons.forEach((btn) => { + expect(btn.nativeElement.disabled).toBe(false); + }); + }); + it('should disable other buttons', () => { + const createButtons = fixture.debugElement.queryAll(By.css(`.version-row-element-create`)); + createButtons.forEach((btn) => { + expect(btn.nativeElement.disabled).toBe(true); + }); + const editButtons = fixture.debugElement.queryAll(By.css(`.version-row-element-create`)); + editButtons.forEach((btn) => { + expect(btn.nativeElement.disabled).toBe(true); + }); + }); + }); + + describe('when page is changed', () => { + it('should call getAllVersions', () => { + spyOn(component, 'getAllVersions'); + component.onPageChange(); + expect(component.getAllVersions).toHaveBeenCalled(); + }); + }); + + describe('when onSummarySubmit() is called', () => { + const id = 'version-being-edited-id'; + beforeEach(() => { + component.versionBeingEditedId = id; + }); + it('should call versionService.findById', () => { + component.onSummarySubmit(); + expect(versionService.findById).toHaveBeenCalledWith(id); + }); + }); + + describe('when editing is enabled for an item', () => { + beforeEach(() => { + component.enableVersionEditing(version1); + }); + it('should set all variables', () => { + expect(component.versionBeingEditedSummary).toEqual('first version'); + expect(component.versionBeingEditedNumber).toEqual(1); + expect(component.versionBeingEditedId).toEqual('1'); + }); + it('isAnyBeingEdited should be true', () => { + expect(component.isAnyBeingEdited()).toBeTrue(); + }); + it('isThisBeingEdited should be true for version1', () => { + expect(component.isThisBeingEdited(version1)).toBeTrue(); + }); + it('isThisBeingEdited should be false for version2', () => { + expect(component.isThisBeingEdited(version2)).toBeFalse(); + }); + }); + + describe('when editing is disabled', () => { + beforeEach(() => { + component.disableVersionEditing(); + }); + it('should unset all variables', () => { + expect(component.versionBeingEditedSummary).toBeUndefined(); + expect(component.versionBeingEditedNumber).toBeUndefined(); + expect(component.versionBeingEditedId).toBeUndefined(); + }); + it('isAnyBeingEdited should be false', () => { + expect(component.isAnyBeingEdited()).toBeFalse(); + }); + it('isThisBeingEdited should be false for all versions', () => { + expect(component.isThisBeingEdited(version1)).toBeFalse(); + expect(component.isThisBeingEdited(version2)).toBeFalse(); + }); + }); + }); diff --git a/src/app/shared/item/item-versions/item-versions.component.ts b/src/app/shared/item/item-versions/item-versions.component.ts index 268c6f00db..e7d65919d6 100644 --- a/src/app/shared/item/item-versions/item-versions.component.ts +++ b/src/app/shared/item/item-versions/item-versions.component.ts @@ -2,14 +2,24 @@ import { Component, Input, OnInit } from '@angular/core'; import { Item } from '../../../core/shared/item.model'; import { Version } from '../../../core/shared/version.model'; import { RemoteData } from '../../../core/data/remote-data'; -import { BehaviorSubject, combineLatest as observableCombineLatest, Observable } from 'rxjs'; +import { + BehaviorSubject, + combineLatest, + combineLatest as observableCombineLatest, + Observable, + of, + Subscription, +} from 'rxjs'; import { VersionHistory } from '../../../core/shared/version-history.model'; import { getAllSucceededRemoteData, getAllSucceededRemoteDataPayload, + getFirstCompletedRemoteData, + getFirstSucceededRemoteData, + getFirstSucceededRemoteDataPayload, getRemoteDataPayload } from '../../../core/shared/operators'; -import { map, startWith, switchMap } from 'rxjs/operators'; +import { map, mergeMap, startWith, switchMap, take, tap } from 'rxjs/operators'; import { PaginatedList } from '../../../core/data/paginated-list.model'; import { PaginationComponentOptions } from '../../pagination/pagination-component-options.model'; import { VersionHistoryDataService } from '../../../core/data/version-history-data.service'; @@ -18,16 +28,38 @@ import { AlertType } from '../../alert/aletr-type'; import { followLink } from '../../utils/follow-link-config.model'; import { hasValue, hasValueOperator } from '../../empty.util'; import { PaginationService } from '../../../core/pagination/pagination.service'; -import { getItemPageRoute } from '../../../item-page/item-page-routing-paths'; +import { + getItemEditVersionhistoryRoute, + getItemPageRoute, + getItemVersionRoute +} from '../../../item-page/item-page-routing-paths'; +import { FormBuilder } from '@angular/forms'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { ItemVersionsSummaryModalComponent } from './item-versions-summary-modal/item-versions-summary-modal.component'; +import { NotificationsService } from '../../notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; +import { ItemVersionsDeleteModalComponent } from './item-versions-delete-modal/item-versions-delete-modal.component'; +import { VersionDataService } from '../../../core/data/version-data.service'; +import { ItemDataService } from '../../../core/data/item-data.service'; +import { Router } from '@angular/router'; +import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; +import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; +import { ItemVersionsSharedService } from './item-versions-shared.service'; +import { WorkspaceItem } from '../../../core/submission/models/workspaceitem.model'; +import { WorkspaceitemDataService } from '../../../core/submission/workspaceitem-data.service'; +import { WorkflowItemDataService } from '../../../core/submission/workflowitem-data.service'; @Component({ selector: 'ds-item-versions', - templateUrl: './item-versions.component.html' + templateUrl: './item-versions.component.html', + styleUrls: ['./item-versions.component.scss'] }) + /** * Component listing all available versions of the history the provided item is a part of */ export class ItemVersionsComponent implements OnInit { + /** * The item to display a version history for */ @@ -45,6 +77,16 @@ export class ItemVersionsComponent implements OnInit { */ @Input() displayTitle = true; + /** + * Whether or not to display the action buttons (delete/create/edit version) + */ + @Input() displayActions: boolean; + + /** + * Array of active subscriptions + */ + subs: Subscription[] = []; + /** * The AlertType enumeration * @type {AlertType} @@ -57,14 +99,19 @@ export class ItemVersionsComponent implements OnInit { versionRD$: Observable>; /** - * The item's full version history + * The item's full version history (remote data) */ versionHistoryRD$: Observable>; + /** + * The item's full version history + */ + versionHistory$: Observable; + /** * The version history's list of versions */ - versionsRD$: Observable>>; + versionsRD$: BehaviorSubject>> = new BehaviorSubject>>(null); /** * Verify if the list of versions has at least one e-person to display @@ -72,6 +119,12 @@ export class ItemVersionsComponent implements OnInit { */ hasEpersons$: Observable; + /** + * Verify if there is an inprogress submission in the version history + * Used to disable the "Create version" button + */ + hasDraftVersion$: Observable; + /** * The amount of versions to display per page */ @@ -81,17 +134,12 @@ export class ItemVersionsComponent implements OnInit { * The page options to use for fetching the versions * Start at page 1 and always use the set page size */ - options = Object.assign(new PaginationComponentOptions(),{ + options = Object.assign(new PaginationComponentOptions(), { id: 'ivo', currentPage: 1, pageSize: this.pageSize }); - /** - * The current page being displayed - */ - currentPage$ = new BehaviorSubject(1); - /** * The routes to the versions their item pages * Key: Item ID @@ -101,9 +149,301 @@ export class ItemVersionsComponent implements OnInit { [itemId: string]: string }>; + /** + * The number of the version whose summary is currently being edited + */ + versionBeingEditedNumber: number; + + /** + * The id of the version whose summary is currently being edited + */ + versionBeingEditedId: string; + + /** + * The summary currently being edited + */ + versionBeingEditedSummary: string; + + canCreateVersion$: Observable; + createVersionTitle$: Observable; + constructor(private versionHistoryService: VersionHistoryDataService, - private paginationService: PaginationService - ) { + private versionService: VersionDataService, + private itemService: ItemDataService, + private paginationService: PaginationService, + private formBuilder: FormBuilder, + private modalService: NgbModal, + private notificationsService: NotificationsService, + private translateService: TranslateService, + private router: Router, + private itemVersionShared: ItemVersionsSharedService, + private authorizationService: AuthorizationDataService, + private workspaceItemDataService: WorkspaceitemDataService, + private workflowItemDataService: WorkflowItemDataService, + ) { + } + + /** + * True when a version is being edited + * (used to disable buttons for other versions) + */ + isAnyBeingEdited(): boolean { + return this.versionBeingEditedNumber != null; + } + + /** + * True if the specified version is being edited + * (used to show input field and to change buttons for specified version) + */ + isThisBeingEdited(version: Version): boolean { + return version?.version === this.versionBeingEditedNumber; + } + + /** + * Enables editing for the specified version + */ + enableVersionEditing(version: Version): void { + this.versionBeingEditedSummary = version?.summary; + this.versionBeingEditedNumber = version?.version; + this.versionBeingEditedId = version?.id; + } + + /** + * Disables editing for the specified version and discards all pending changes + */ + disableVersionEditing(): void { + this.versionBeingEditedSummary = undefined; + this.versionBeingEditedNumber = undefined; + this.versionBeingEditedId = undefined; + } + + /** + * Get the route to the specified version + * @param versionId the ID of the version for which the route will be retrieved + */ + getVersionRoute(versionId: string) { + return getItemVersionRoute(versionId); + } + + /** + * Applies changes to version currently being edited + */ + onSummarySubmit() { + + const successMessageKey = 'item.version.edit.notification.success'; + const failureMessageKey = 'item.version.edit.notification.failure'; + + this.versionService.findById(this.versionBeingEditedId).pipe( + getFirstSucceededRemoteData(), + switchMap((findRes: RemoteData) => { + const payload = findRes.payload; + const summary = {summary: this.versionBeingEditedSummary,}; + const updatedVersion = Object.assign({}, payload, summary); + return this.versionService.update(updatedVersion).pipe(getFirstCompletedRemoteData()); + }), + ).subscribe((updatedVersionRD: RemoteData) => { + if (updatedVersionRD.hasSucceeded) { + this.notificationsService.success(null, this.translateService.get(successMessageKey, {'version': this.versionBeingEditedNumber})); + this.getAllVersions(this.versionHistory$); + } else { + this.notificationsService.warning(null, this.translateService.get(failureMessageKey, {'version': this.versionBeingEditedNumber})); + } + this.disableVersionEditing(); + } + ); + } + + /** + * Delete the item and get the result of the operation + * @param item + */ + deleteItemAndGetResult$(item: Item): Observable { + return this.itemService.delete(item.id).pipe( + getFirstCompletedRemoteData(), + map((deleteItemRes) => deleteItemRes.hasSucceeded), + take(1), + ); + } + + /** + * Deletes the specified version, notify the success/failure and redirect to latest version + * @param version the version to be deleted + * @param redirectToLatest force the redirect to the latest version in the history + */ + deleteVersion(version: Version, redirectToLatest: boolean): void { + const successMessageKey = 'item.version.delete.notification.success'; + const failureMessageKey = 'item.version.delete.notification.failure'; + const versionNumber = version.version; + const versionItem$ = version.item; + + // Open modal + const activeModal = this.modalService.open(ItemVersionsDeleteModalComponent); + activeModal.componentInstance.versionNumber = version.version; + activeModal.componentInstance.firstVersion = false; + + // On modal submit/dismiss + activeModal.result.then(() => { + versionItem$.pipe( + getFirstSucceededRemoteDataPayload(), + // Retrieve version history and invalidate cache + mergeMap((item: Item) => combineLatest([ + of(item), + this.versionHistoryService.getVersionHistoryFromVersion$(version).pipe( + tap((versionHistory: VersionHistory) => { + this.versionHistoryService.invalidateVersionHistoryCache(versionHistory.id); + }) + ) + ])), + // Delete item + mergeMap(([item, versionHistory]: [Item, VersionHistory]) => combineLatest([ + this.deleteItemAndGetResult$(item), + of(versionHistory) + ])), + // Retrieve new latest version + mergeMap(([deleteItemResult, versionHistory]: [boolean, VersionHistory]) => combineLatest([ + of(deleteItemResult), + this.versionHistoryService.getLatestVersionItemFromHistory$(versionHistory).pipe( + tap(() => { + this.getAllVersions(of(versionHistory)); + }), + ) + ])), + ).subscribe(([deleteHasSucceeded, newLatestVersionItem]: [boolean, Item]) => { + // Notify operation result and redirect to latest item + if (deleteHasSucceeded) { + this.notificationsService.success(null, this.translateService.get(successMessageKey, {'version': versionNumber})); + } else { + this.notificationsService.error(null, this.translateService.get(failureMessageKey, {'version': versionNumber})); + } + if (redirectToLatest) { + const path = getItemEditVersionhistoryRoute(newLatestVersionItem); + this.router.navigateByUrl(path); + } + }); + }); + } + + /** + * Creates a new version starting from the specified one + * @param version the version from which a new one will be created + */ + createNewVersion(version: Version) { + const versionNumber = version.version; + + // Open modal and set current version number + const activeModal = this.modalService.open(ItemVersionsSummaryModalComponent); + activeModal.componentInstance.versionNumber = versionNumber; + + // On createVersionEvent emitted create new version and notify + activeModal.componentInstance.createVersionEvent.pipe( + mergeMap((summary: string) => combineLatest([ + of(summary), + version.item.pipe(getFirstSucceededRemoteDataPayload()) + ])), + mergeMap(([summary, item]: [string, Item]) => this.versionHistoryService.createVersion(item._links.self.href, summary)), + // show success/failure notification + tap((newVersionRD: RemoteData) => { + this.itemVersionShared.notifyCreateNewVersion(newVersionRD); + if (newVersionRD.hasSucceeded) { + const versionHistory$ = this.versionService.getHistoryFromVersion(version).pipe( + tap((versionHistory: VersionHistory) => { + this.itemService.invalidateItemCache(this.item.uuid); + this.versionHistoryService.invalidateVersionHistoryCache(versionHistory.id); + }), + ); + this.getAllVersions(versionHistory$); + } + }), + // get workspace item + getFirstSucceededRemoteDataPayload(), + switchMap((newVersion: Version) => this.itemService.findByHref(newVersion._links.item.href)), + getFirstSucceededRemoteDataPayload(), + switchMap((newVersionItem: Item) => this.workspaceItemDataService.findByItem(newVersionItem.uuid, true, false)), + getFirstSucceededRemoteDataPayload(), + ).subscribe((wsItem) => { + const wsiId = wsItem.id; + const route = 'workspaceitems/' + wsiId + '/edit'; + this.router.navigateByUrl(route); + }); + } + + /** + * Check is the current user can edit the version summary + * @param version + */ + canEditVersion$(version: Version): Observable { + return this.authorizationService.isAuthorized(FeatureID.CanEditVersion, version.self); + } + + /** + * Check if the current user can delete the version + * @param version + */ + canDeleteVersion$(version: Version): Observable { + return this.authorizationService.isAuthorized(FeatureID.CanDeleteVersion, version.self); + } + + /** + * Get all versions for the given version history and store them in versionRD$ + * @param versionHistory$ + */ + getAllVersions(versionHistory$: Observable): void { + const currentPagination = this.paginationService.getCurrentPagination(this.options.id, this.options); + observableCombineLatest([versionHistory$, currentPagination]).pipe( + switchMap(([versionHistory, options]: [VersionHistory, PaginationComponentOptions]) => { + return this.versionHistoryService.getVersions(versionHistory.id, + new PaginatedSearchOptions({pagination: Object.assign({}, options, {currentPage: options.currentPage})}), + false, true, followLink('item'), followLink('eperson')); + }), + getFirstCompletedRemoteData(), + ).subscribe((res: RemoteData>) => { + this.versionsRD$.next(res); + }); + } + + /** + * Updates the page + */ + onPageChange() { + this.getAllVersions(this.versionHistory$); + } + + /** + * Get the ID of the workspace item, if present, otherwise return undefined + * @param versionItem the item for which retrieve the workspace item id + */ + getWorkspaceId(versionItem): Observable { + return versionItem.pipe( + getFirstSucceededRemoteDataPayload(), + map((item: Item) => item.uuid), + switchMap((itemUuid: string) => this.workspaceItemDataService.findByItem(itemUuid, true)), + getFirstCompletedRemoteData(), + map((res: RemoteData) => res?.payload?.id ), + ); + } + + /** + * Get the ID of the workflow item, if present, otherwise return undefined + * @param versionItem the item for which retrieve the workspace item id + */ + getWorkflowId(versionItem): Observable { + return versionItem.pipe( + getFirstSucceededRemoteDataPayload(), + map((item: Item) => item.uuid), + switchMap((itemUuid: string) => this.workflowItemDataService.findByItem(itemUuid, true)), + getFirstCompletedRemoteData(), + map((res: RemoteData) => res?.payload?.id ), + ); + } + + /** + * redirect to the edit page of the workspace item + * @param id$ the id of the workspace item + */ + editWorkspaceItem(id$: Observable) { + id$.subscribe((id) => { + this.router.navigateByUrl('workspaceitems/' + id + '/edit'); + }); } /** @@ -116,20 +456,27 @@ export class ItemVersionsComponent implements OnInit { getAllSucceededRemoteData(), getRemoteDataPayload(), hasValueOperator(), - switchMap((version: Version) => version.versionhistory) + switchMap((version: Version) => version.versionhistory), ); - const versionHistory$ = this.versionHistoryRD$.pipe( - getAllSucceededRemoteData(), - getRemoteDataPayload(), + this.versionHistory$ = this.versionHistoryRD$.pipe( + getFirstSucceededRemoteDataPayload(), hasValueOperator(), ); - const currentPagination = this.paginationService.getCurrentPagination(this.options.id, this.options); - this.versionsRD$ = observableCombineLatest(versionHistory$, currentPagination).pipe( - switchMap(([versionHistory, options]: [VersionHistory, PaginationComponentOptions]) => - this.versionHistoryService.getVersions(versionHistory.id, - new PaginatedSearchOptions({pagination: Object.assign({}, options, { currentPage: options.currentPage })}), - true, true, followLink('item'), followLink('eperson'))) + + this.canCreateVersion$ = this.authorizationService.isAuthorized(FeatureID.CanCreateVersion, this.item.self); + + // If there is a draft item in the version history the 'Create version' button is disabled and a different tooltip message is shown + this.hasDraftVersion$ = this.versionHistoryRD$.pipe( + getFirstSucceededRemoteDataPayload(), + map((res) => Boolean(res?.draftVersion)), ); + + this.createVersionTitle$ = this.hasDraftVersion$.pipe( + take(1), + switchMap((res) => of(res ? 'item.version.history.table.action.hasDraft' : 'item.version.history.table.action.newVersion')) + ); + + this.getAllVersions(this.versionHistory$); this.hasEpersons$ = this.versionsRD$.pipe( getAllSucceededRemoteData(), getRemoteDataPayload(), @@ -150,8 +497,15 @@ export class ItemVersionsComponent implements OnInit { } ngOnDestroy(): void { + this.cleanupSubscribes(); this.paginationService.clearPagination(this.options.id); } + /** + * Unsub all subscriptions + */ + cleanupSubscribes() { + this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); + } } diff --git a/src/app/shared/item/item-versions/notice/item-versions-notice.component.html b/src/app/shared/item/item-versions/notice/item-versions-notice.component.html index cec0bdcb04..fb6fa34746 100644 --- a/src/app/shared/item/item-versions/notice/item-versions-notice.component.html +++ b/src/app/shared/item/item-versions/notice/item-versions-notice.component.html @@ -1,4 +1,4 @@ - diff --git a/src/app/shared/item/item-versions/notice/item-versions-notice.component.spec.ts b/src/app/shared/item/item-versions/notice/item-versions-notice.component.spec.ts index f2184b136a..2849ba4909 100644 --- a/src/app/shared/item/item-versions/notice/item-versions-notice.component.spec.ts +++ b/src/app/shared/item/item-versions/notice/item-versions-notice.component.spec.ts @@ -10,10 +10,13 @@ import { VersionHistoryDataService } from '../../../../core/data/version-history import { By } from '@angular/platform-browser'; import { createSuccessfulRemoteDataObject$ } from '../../../remote-data.utils'; import { createPaginatedList } from '../../../testing/utils.test'; +import { of } from 'rxjs'; +import { take } from 'rxjs/operators'; describe('ItemVersionsNoticeComponent', () => { let component: ItemVersionsNoticeComponent; let fixture: ComponentFixture; + let versionHistoryService: VersionHistoryDataService; const versionHistory = Object.assign(new VersionHistory(), { id: '1' @@ -48,19 +51,29 @@ describe('ItemVersionsNoticeComponent', () => { }); firstVersion.item = createSuccessfulRemoteDataObject$(firstItem); latestVersion.item = createSuccessfulRemoteDataObject$(latestItem); - const versionHistoryService = jasmine.createSpyObj('versionHistoryService', { - getVersions: createSuccessfulRemoteDataObject$(createPaginatedList(versions)) - }); + + const versionHistoryServiceSpy = jasmine.createSpyObj('versionHistoryService', + ['getVersions', 'getLatestVersionFromHistory$', 'isLatest$', ] + ); beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ declarations: [ItemVersionsNoticeComponent], imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], providers: [ - { provide: VersionHistoryDataService, useValue: versionHistoryService } + { provide: VersionHistoryDataService, useValue: versionHistoryServiceSpy } ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); + + versionHistoryService = TestBed.inject(VersionHistoryDataService); + + const isLatestFcn = (version: Version) => of((version.version === latestVersion.version)); + + versionHistoryServiceSpy.getVersions.and.returnValue(createSuccessfulRemoteDataObject$(createPaginatedList(versions))); + versionHistoryServiceSpy.getLatestVersionFromHistory$.and.returnValue(of(latestVersion)); + versionHistoryServiceSpy.isLatest$.and.callFake(isLatestFcn); })); describe('when the item is the latest version', () => { @@ -85,6 +98,19 @@ describe('ItemVersionsNoticeComponent', () => { }); }); + describe('isLatest', () => { + it('firstVersion should not be the latest', () => { + versionHistoryService.isLatest$(firstVersion).pipe(take(1)).subscribe((res) => { + expect(res).toBeFalse(); + }); + }); + it('latestVersion should be the latest', () => { + versionHistoryService.isLatest$(latestVersion).pipe(take(1)).subscribe((res) => { + expect(res).toBeTrue(); + }); + }); + }); + function initComponentWithItem(item: Item) { fixture = TestBed.createComponent(ItemVersionsNoticeComponent); component = fixture.componentInstance; diff --git a/src/app/shared/item/item-versions/notice/item-versions-notice.component.ts b/src/app/shared/item/item-versions/notice/item-versions-notice.component.ts index 2fd39b661c..a292ea65c6 100644 --- a/src/app/shared/item/item-versions/notice/item-versions-notice.component.ts +++ b/src/app/shared/item/item-versions/notice/item-versions-notice.component.ts @@ -1,15 +1,16 @@ import { Component, Input, OnInit } from '@angular/core'; import { Item } from '../../../../core/shared/item.model'; -import { PaginationComponentOptions } from '../../../pagination/pagination-component-options.model'; -import { PaginatedSearchOptions } from '../../../search/paginated-search-options.model'; -import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; +import { Observable } from 'rxjs'; import { RemoteData } from '../../../../core/data/remote-data'; import { VersionHistory } from '../../../../core/shared/version-history.model'; import { Version } from '../../../../core/shared/version.model'; import { hasValue, hasValueOperator } from '../../../empty.util'; -import { getAllSucceededRemoteData, getRemoteDataPayload } from '../../../../core/shared/operators'; -import { filter, map, startWith, switchMap } from 'rxjs/operators'; -import { followLink } from '../../../utils/follow-link-config.model'; +import { + getAllSucceededRemoteData, + getFirstSucceededRemoteDataPayload, + getRemoteDataPayload +} from '../../../../core/shared/operators'; +import { map, startWith, switchMap } from 'rxjs/operators'; import { VersionHistoryDataService } from '../../../../core/data/version-history-data.service'; import { AlertType } from '../../../alert/aletr-type'; import { getItemPageRoute } from '../../../../item-page/item-page-routing-paths'; @@ -47,16 +48,11 @@ export class ItemVersionsNoticeComponent implements OnInit { * Is the item's version equal to the latest version from the version history? * This will determine whether or not to display a notice linking to the latest version */ - isLatestVersion$: Observable; + showLatestVersionNotice$: Observable; /** * Pagination options to fetch a single version on the first page (this is the latest version in the history) */ - latestVersionOptions = Object.assign(new PaginationComponentOptions(),{ - id: 'item-newest-version-options', - currentPage: 1, - pageSize: 1 - }); /** * The AlertType enumeration @@ -71,7 +67,6 @@ export class ItemVersionsNoticeComponent implements OnInit { * Initialize the component's observables */ ngOnInit(): void { - const latestVersionSearch = new PaginatedSearchOptions({pagination: this.latestVersionOptions}); if (hasValue(this.item.version)) { this.versionRD$ = this.item.version; this.versionHistoryRD$ = this.versionRD$.pipe( @@ -80,25 +75,17 @@ export class ItemVersionsNoticeComponent implements OnInit { hasValueOperator(), switchMap((version: Version) => version.versionhistory) ); - const versionHistory$ = this.versionHistoryRD$.pipe( - getAllSucceededRemoteData(), - getRemoteDataPayload(), - ); - this.latestVersion$ = versionHistory$.pipe( - switchMap((versionHistory: VersionHistory) => - this.versionHistoryService.getVersions(versionHistory.id, latestVersionSearch, true, true, followLink('item'))), - getAllSucceededRemoteData(), - getRemoteDataPayload(), - hasValueOperator(), - filter((versions) => versions.page.length > 0), - map((versions) => versions.page[0]) + + this.latestVersion$ = this.versionHistoryRD$.pipe( + getFirstSucceededRemoteDataPayload(), + switchMap((vh) => this.versionHistoryService.getLatestVersionFromHistory$(vh)) ); - this.isLatestVersion$ = observableCombineLatest( - this.versionRD$.pipe(getAllSucceededRemoteData(), getRemoteDataPayload()), this.latestVersion$ - ).pipe( - map(([itemVersion, latestVersion]: [Version, Version]) => itemVersion.id === latestVersion.id), - startWith(true) + this.showLatestVersionNotice$ = this.versionRD$.pipe( + getFirstSucceededRemoteDataPayload(), + switchMap((version) => this.versionHistoryService.isLatest$(version)), + map((isLatest) => isLatest != null && !isLatest), + startWith(false), ); } } diff --git a/src/app/shared/metadata-representation/metadata-representation.decorator.spec.ts b/src/app/shared/metadata-representation/metadata-representation.decorator.spec.ts index 25c5be0129..d6239401d4 100644 --- a/src/app/shared/metadata-representation/metadata-representation.decorator.spec.ts +++ b/src/app/shared/metadata-representation/metadata-representation.decorator.spec.ts @@ -7,12 +7,17 @@ import { import { MetadataRepresentationType } from '../../core/shared/metadata-representation/metadata-representation.model'; import { Context } from '../../core/shared/context.model'; import * as uuidv4 from 'uuid/v4'; +import { environment } from '../../../environments/environment'; + +let ogEnvironmentThemes; describe('MetadataRepresentation decorator function', () => { const type1 = 'TestType'; const type2 = 'TestType2'; const type3 = 'TestType3'; const type4 = 'RandomType'; + const typeAncestor = 'TestTypeAncestor'; + const typeUnthemed = 'TestTypeUnthemed'; let prefix; /* tslint:disable:max-classes-per-file */ @@ -31,6 +36,12 @@ describe('MetadataRepresentation decorator function', () => { class Test3ItemSubmission { } + class TestAncestorComponent { + } + + class TestUnthemedComponent { + } + /* tslint:enable:max-classes-per-file */ beforeEach(() => { @@ -46,8 +57,18 @@ describe('MetadataRepresentation decorator function', () => { metadataRepresentationComponent(key + type2, MetadataRepresentationType.Item, Context.Workspace)(Test2ItemSubmission); metadataRepresentationComponent(key + type3, MetadataRepresentationType.Item, Context.Workspace)(Test3ItemSubmission); + + // Register a metadata representation in the 'ancestor' theme + metadataRepresentationComponent(key + typeAncestor, MetadataRepresentationType.Item, Context.Any, 'ancestor')(TestAncestorComponent); + metadataRepresentationComponent(key + typeUnthemed, MetadataRepresentationType.Item, Context.Any)(TestUnthemedComponent); + + ogEnvironmentThemes = environment.themes; } + afterEach(() => { + environment.themes = ogEnvironmentThemes; + }); + describe('If there\'s an exact match', () => { it('should return the matching class', () => { const component = getMetadataRepresentationComponent(prefix + type3, MetadataRepresentationType.Item, Context.Workspace); @@ -76,4 +97,55 @@ describe('MetadataRepresentation decorator function', () => { }); }); }); + + describe('With theme extensions', () => { + // We're only interested in the cases that the requested theme doesn't match the requested entityType, + // as the cases where it does are already covered by the tests above + describe('If requested theme has no match', () => { + beforeEach(() => { + environment.themes = [ + { + name: 'requested', // Doesn't match any entityType + extends: 'intermediate', + }, + { + name: 'intermediate', // Doesn't match any entityType + extends: 'ancestor', + }, + { + name: 'ancestor', // Matches typeAncestor, but not typeUnthemed + } + ]; + }); + + it('should return component from the first ancestor theme that matches its entityType', () => { + const component = getMetadataRepresentationComponent(prefix + typeAncestor, MetadataRepresentationType.Item, Context.Any, 'requested'); + expect(component).toEqual(TestAncestorComponent); + }); + + it('should return default component if none of the ancestor themes match its entityType', () => { + const component = getMetadataRepresentationComponent(prefix + typeUnthemed, MetadataRepresentationType.Item, Context.Any, 'requested'); + expect(component).toEqual(TestUnthemedComponent); + }); + }); + + describe('If there is a theme extension cycle', () => { + beforeEach(() => { + environment.themes = [ + { name: 'extension-cycle', extends: 'broken1' }, + { name: 'broken1', extends: 'broken2' }, + { name: 'broken2', extends: 'broken3' }, + { name: 'broken3', extends: 'broken1' }, + ]; + }); + + it('should throw an error', () => { + expect(() => { + getMetadataRepresentationComponent(prefix + typeAncestor, MetadataRepresentationType.Item, Context.Any, 'extension-cycle'); + }).toThrowError( + 'Theme extension cycle detected: extension-cycle -> broken1 -> broken2 -> broken3 -> broken1' + ); + }); + }); + }); }); diff --git a/src/app/shared/metadata-representation/metadata-representation.decorator.ts b/src/app/shared/metadata-representation/metadata-representation.decorator.ts index 0b5bea33d9..ae601e480c 100644 --- a/src/app/shared/metadata-representation/metadata-representation.decorator.ts +++ b/src/app/shared/metadata-representation/metadata-representation.decorator.ts @@ -3,6 +3,10 @@ import { hasNoValue, hasValue } from '../empty.util'; import { Context } from '../../core/shared/context.model'; import { InjectionToken } from '@angular/core'; import { GenericConstructor } from '../../core/shared/generic-constructor'; +import { + resolveTheme, + DEFAULT_THEME, DEFAULT_CONTEXT +} from '../object-collection/shared/listable-object/listable-object.decorator'; export const METADATA_REPRESENTATION_COMPONENT_FACTORY = new InjectionToken<(entityType: string, mdRepresentationType: MetadataRepresentationType, context: Context, theme: string) => GenericConstructor>('getMetadataRepresentationComponent', { providedIn: 'root', @@ -13,8 +17,6 @@ export const map = new Map(); export const DEFAULT_ENTITY_TYPE = 'Publication'; export const DEFAULT_REPRESENTATION_TYPE = MetadataRepresentationType.PlainText; -export const DEFAULT_CONTEXT = Context.Any; -export const DEFAULT_THEME = '*'; /** * Decorator function to store metadata representation mapping @@ -57,8 +59,9 @@ export function getMetadataRepresentationComponent(entityType: string, mdReprese if (hasValue(entityAndMDRepMap)) { const contextMap = entityAndMDRepMap.get(context); if (hasValue(contextMap)) { - if (hasValue(contextMap.get(theme))) { - return contextMap.get(theme); + const match = resolveTheme(contextMap, theme); + if (hasValue(match)) { + return match; } if (hasValue(contextMap.get(DEFAULT_THEME))) { return contextMap.get(DEFAULT_THEME); diff --git a/src/app/shared/mocks/external-source.service.mock.ts b/src/app/shared/mocks/external-source.service.mock.ts index fd6d7cdc46..f1afed19af 100644 --- a/src/app/shared/mocks/external-source.service.mock.ts +++ b/src/app/shared/mocks/external-source.service.mock.ts @@ -11,6 +11,9 @@ export const externalSourceOrcid: ExternalSource = { entries: { href: 'https://dspace7.4science.cloud/server/api/integration/externalsources/orcid/entries' }, + entityTypes: { + href: 'https://dspace7.4science.cloud/server/api/integration/externalsources/my_staff_db/entityTypes' + }, self: { href: 'https://dspace7.4science.cloud/server/api/integration/externalsources/orcid' } @@ -26,6 +29,9 @@ export const externalSourceCiencia: ExternalSource = { entries: { href: 'https://dspace7.4science.cloud/server/api/integration/externalsources/ciencia/entries' }, + entityTypes: { + href: 'https://dspace7.4science.cloud/server/api/integration/externalsources/my_staff_db/entityTypes' + }, self: { href: 'https://dspace7.4science.cloud/server/api/integration/externalsources/ciencia' } @@ -41,6 +47,9 @@ export const externalSourceMyStaffDb: ExternalSource = { entries: { href: 'https://dspace7.4science.cloud/server/api/integration/externalsources/my_staff_db/entries' }, + entityTypes: { + href: 'https://dspace7.4science.cloud/server/api/integration/externalsources/my_staff_db/entityTypes' + }, self: { href: 'https://dspace7.4science.cloud/server/api/integration/externalsources/my_staff_db' } @@ -53,6 +62,7 @@ export const externalSourceMyStaffDb: ExternalSource = { export function getMockExternalSourceService(): ExternalSourceService { return jasmine.createSpyObj('ExternalSourceService', { findAll: jasmine.createSpy('findAll'), + searchBy: jasmine.createSpy('searchBy'), getExternalSourceEntries: jasmine.createSpy('getExternalSourceEntries'), }); } diff --git a/src/app/shared/mocks/theme-service.mock.ts b/src/app/shared/mocks/theme-service.mock.ts index 3594270807..058ba993bc 100644 --- a/src/app/shared/mocks/theme-service.mock.ts +++ b/src/app/shared/mocks/theme-service.mock.ts @@ -1,9 +1,18 @@ import { ThemeService } from '../theme-support/theme.service'; import { of as observableOf } from 'rxjs'; +import { ThemeConfig } from '../../../config/theme.model'; +import { isNotEmpty } from '../empty.util'; -export function getMockThemeService(themeName = 'base'): ThemeService { - return jasmine.createSpyObj('themeService', { +export function getMockThemeService(themeName = 'base', themes?: ThemeConfig[]): ThemeService { + const spy = jasmine.createSpyObj('themeService', { getThemeName: themeName, - getThemeName$: observableOf(themeName) + getThemeName$: observableOf(themeName), + getThemeConfigFor: undefined, }); + + if (isNotEmpty(themes)) { + spy.getThemeConfigFor.and.callFake((name: string) => themes.find(theme => theme.name === name)); + } + + return spy; } diff --git a/src/app/shared/notifications/notification/notification.component.spec.ts b/src/app/shared/notifications/notification/notification.component.spec.ts index 7b7ee57d26..2bded57636 100644 --- a/src/app/shared/notifications/notification/notification.component.spec.ts +++ b/src/app/shared/notifications/notification/notification.component.spec.ts @@ -1,4 +1,4 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; import { BrowserModule, By } from '@angular/platform-browser'; import { ChangeDetectorRef, DebugElement } from '@angular/core'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; @@ -16,6 +16,7 @@ import { Notification } from '../models/notification.model'; import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; import { TranslateLoaderMock } from '../../mocks/translate-loader.mock'; import { storeModuleConfig } from '../../../app.reducer'; +import { BehaviorSubject } from 'rxjs'; describe('NotificationComponent', () => { @@ -83,6 +84,8 @@ describe('NotificationComponent', () => { deContent = fixture.debugElement.query(By.css('.notification-content')); elContent = deContent.nativeElement; elType = fixture.debugElement.query(By.css('.notification-icon')).nativeElement; + + spyOn(comp, 'remove'); }); it('should create component', () => { @@ -124,4 +127,51 @@ describe('NotificationComponent', () => { expect(elContent.innerHTML).toEqual(htmlContent); }); + describe('dismiss countdown', () => { + const TIMEOUT = 5000; + let isPaused$: BehaviorSubject; + + beforeEach(() => { + isPaused$ = new BehaviorSubject(false); + comp.isPaused$ = isPaused$; + comp.notification = { + id: '1', + type: NotificationType.Info, + title: 'Notif. title', + content: 'test', + options: Object.assign( + new NotificationOptions(), + { timeout: TIMEOUT } + ), + html: true + }; + }); + + it('should remove notification after timeout', fakeAsync(() => { + comp.ngOnInit(); + tick(TIMEOUT); + expect(comp.remove).toHaveBeenCalled(); + })); + + describe('isPaused$', () => { + it('should pause countdown on true', fakeAsync(() => { + comp.ngOnInit(); + tick(TIMEOUT / 2); + isPaused$.next(true); + tick(TIMEOUT); + expect(comp.remove).not.toHaveBeenCalled(); + })); + + it('should resume paused countdown on false', fakeAsync(() => { + comp.ngOnInit(); + tick(TIMEOUT / 4); + isPaused$.next(true); + tick(TIMEOUT / 4); + isPaused$.next(false); + tick(TIMEOUT); + expect(comp.remove).toHaveBeenCalled(); + })); + }); + }); + }); diff --git a/src/app/shared/notifications/notification/notification.component.ts b/src/app/shared/notifications/notification/notification.component.ts index 0c64d3e263..5f00084761 100644 --- a/src/app/shared/notifications/notification/notification.component.ts +++ b/src/app/shared/notifications/notification/notification.component.ts @@ -1,4 +1,4 @@ -import {of as observableOf, Observable } from 'rxjs'; +import { Observable, of as observableOf } from 'rxjs'; import { ChangeDetectionStrategy, ChangeDetectorRef, @@ -23,6 +23,7 @@ import { fadeInEnter, fadeInState, fadeOutLeave, fadeOutState } from '../../anim import { NotificationAnimationsStatus } from '../models/notification-animations-type'; import { isNotEmpty } from '../../empty.util'; import { INotification } from '../models/notification.model'; +import { filter, first } from 'rxjs/operators'; @Component({ selector: 'ds-notification', @@ -47,6 +48,11 @@ export class NotificationComponent implements OnInit, OnDestroy { @Input() public notification = null as INotification; + /** + * Whether this notification's countdown should be paused + */ + @Input() public isPaused$: Observable = observableOf(false); + // Progress bar variables public title: Observable; public content: Observable; @@ -99,17 +105,21 @@ export class NotificationComponent implements OnInit, OnDestroy { private instance = () => { this.diff = (new Date().getTime() - this.start) - (this.count * this.speed); - if (this.count++ === this.steps) { - this.remove(); - // this.item.timeoutEnd!.emit(); - } else if (!this.stopTime) { - if (this.showProgressBar) { - this.progressWidth += 100 / this.steps; - } + this.isPaused$.pipe( + filter(paused => !paused), + first(), + ).subscribe(() => { + if (this.count++ === this.steps) { + this.remove(); + } else if (!this.stopTime) { + if (this.showProgressBar) { + this.progressWidth += 100 / this.steps; + } - this.timer = setTimeout(this.instance, (this.speed - this.diff)); - } - this.zone.run(() => this.cdr.detectChanges()); + this.timer = setTimeout(this.instance, (this.speed - this.diff)); + } + this.zone.run(() => this.cdr.detectChanges()); + }); } public remove() { diff --git a/src/app/shared/notifications/notifications-board/notifications-board.component.html b/src/app/shared/notifications/notifications-board/notifications-board.component.html index 15f5044bc4..854842f30d 100644 --- a/src/app/shared/notifications/notifications-board/notifications-board.component.html +++ b/src/app/shared/notifications/notifications-board/notifications-board.component.html @@ -1,7 +1,10 @@ -
+
+ [notification]="a" [isPaused$]="isPaused$">
diff --git a/src/app/shared/notifications/notifications-board/notifications-board.component.spec.ts b/src/app/shared/notifications/notifications-board/notifications-board.component.spec.ts index dad667cf3d..1d3faabdaa 100644 --- a/src/app/shared/notifications/notifications-board/notifications-board.component.spec.ts +++ b/src/app/shared/notifications/notifications-board/notifications-board.component.spec.ts @@ -1,5 +1,5 @@ import { ComponentFixture, inject, TestBed, waitForAsync } from '@angular/core/testing'; -import { BrowserModule } from '@angular/platform-browser'; +import { BrowserModule, By } from '@angular/platform-browser'; import { ChangeDetectorRef } from '@angular/core'; import { NotificationsService } from '../notifications.service'; @@ -14,6 +14,9 @@ import { NotificationType } from '../models/notification-type'; import { uniqueId } from 'lodash'; import { INotificationBoardOptions } from '../../../../config/notifications-config.interfaces'; import { NotificationsServiceStub } from '../../testing/notifications-service.stub'; +import { cold } from 'jasmine-marbles'; + +export const bools = { f: false, t: true }; describe('NotificationsBoardComponent', () => { let comp: NotificationsBoardComponent; @@ -67,6 +70,40 @@ describe('NotificationsBoardComponent', () => { it('should have two notifications', () => { expect(comp.notifications.length).toBe(2); + expect(fixture.debugElement.queryAll(By.css('ds-notification')).length).toBe(2); + }); + + describe('notification countdown', () => { + let wrapper; + + beforeEach(() => { + wrapper = fixture.debugElement.query(By.css('div.notifications-wrapper')); + }); + + it('should not be paused by default', () => { + expect(comp.isPaused$).toBeObservable(cold('f', bools)); + }); + + it('should pause on mouseenter', () => { + wrapper.triggerEventHandler('mouseenter'); + + expect(comp.isPaused$).toBeObservable(cold('t', bools)); + }); + + it('should resume on mouseleave', () => { + wrapper.triggerEventHandler('mouseenter'); + wrapper.triggerEventHandler('mouseleave'); + + expect(comp.isPaused$).toBeObservable(cold('f', bools)); + }); + + it('should be passed to all notifications', () => { + fixture.debugElement.queryAll(By.css('ds-notification')) + .map(node => node.componentInstance) + .forEach(notification => { + expect(notification.isPaused$).toEqual(comp.isPaused$); + }); + }); }); }) diff --git a/src/app/shared/notifications/notifications-board/notifications-board.component.ts b/src/app/shared/notifications/notifications-board/notifications-board.component.ts index 829cfadf0f..f153d1009e 100644 --- a/src/app/shared/notifications/notifications-board/notifications-board.component.ts +++ b/src/app/shared/notifications/notifications-board/notifications-board.component.ts @@ -9,7 +9,7 @@ import { } from '@angular/core'; import { select, Store } from '@ngrx/store'; -import { Subscription } from 'rxjs'; +import { BehaviorSubject, Subscription } from 'rxjs'; import { difference } from 'lodash'; import { NotificationsService } from '../notifications.service'; @@ -44,6 +44,11 @@ export class NotificationsBoardComponent implements OnInit, OnDestroy { public rtl = false; public animate: 'fade' | 'fromTop' | 'fromRight' | 'fromBottom' | 'fromLeft' | 'rotate' | 'scale' = 'fromRight'; + /** + * Whether to pause the dismiss countdown of all notifications on the board + */ + public isPaused$: BehaviorSubject = new BehaviorSubject(false); + constructor(private service: NotificationsService, private store: Store, private cdr: ChangeDetectorRef) { @@ -129,7 +134,6 @@ export class NotificationsBoardComponent implements OnInit, OnDestroy { } }); } - ngOnDestroy(): void { if (this.sub) { this.sub.unsubscribe(); diff --git a/src/app/shared/number-picker/number-picker.component.html b/src/app/shared/number-picker/number-picker.component.html index 1f6c08d92e..58b6def50b 100644 --- a/src/app/shared/number-picker/number-picker.component.html +++ b/src/app/shared/number-picker/number-picker.component.html @@ -1,6 +1,6 @@
-
+
diff --git a/src/app/shared/search-form/scope-selector-modal/scope-selector-modal.component.html b/src/app/shared/search-form/scope-selector-modal/scope-selector-modal.component.html new file mode 100644 index 0000000000..bf5c15e963 --- /dev/null +++ b/src/app/shared/search-form/scope-selector-modal/scope-selector-modal.component.html @@ -0,0 +1,19 @@ +
+ + +
diff --git a/src/app/shared/search-form/scope-selector-modal/scope-selector-modal.component.scss b/src/app/shared/search-form/scope-selector-modal/scope-selector-modal.component.scss new file mode 100644 index 0000000000..0daf4cfa5f --- /dev/null +++ b/src/app/shared/search-form/scope-selector-modal/scope-selector-modal.component.scss @@ -0,0 +1,3 @@ +#create-community-or-separator { + top: 0; +} \ No newline at end of file diff --git a/src/app/shared/search-form/scope-selector-modal/scope-selector-modal.component.spec.ts b/src/app/shared/search-form/scope-selector-modal/scope-selector-modal.component.spec.ts new file mode 100644 index 0000000000..42d00aaa08 --- /dev/null +++ b/src/app/shared/search-form/scope-selector-modal/scope-selector-modal.component.spec.ts @@ -0,0 +1,73 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { ActivatedRoute, Router } from '@angular/router'; +import { ScopeSelectorModalComponent } from './scope-selector-modal.component'; +import { Community } from '../../../core/shared/community.model'; +import { MetadataValue } from '../../../core/shared/metadata.models'; +import { createSuccessfulRemoteDataObject } from '../../remote-data.utils'; +import { RouterStub } from '../../testing/router.stub'; + +describe('ScopeSelectorModalComponent', () => { + let component: ScopeSelectorModalComponent; + let fixture: ComponentFixture; + let debugElement: DebugElement; + + const community = new Community(); + community.uuid = '1234-1234-1234-1234'; + community.metadata = { + 'dc.title': [Object.assign(new MetadataValue(), { + value: 'Community title', + language: undefined + })] + }; + const router = new RouterStub(); + const communityRD = createSuccessfulRemoteDataObject(community); + const modalStub = jasmine.createSpyObj('modalStub', ['close']); + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [ScopeSelectorModalComponent], + providers: [ + { provide: NgbActiveModal, useValue: modalStub }, + { + provide: ActivatedRoute, + useValue: { + root: { + snapshot: { + data: { + dso: communityRD, + }, + }, + } + }, + }, + { + provide: Router, useValue: router + } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ScopeSelectorModalComponent); + component = fixture.componentInstance; + debugElement = fixture.debugElement; + fixture.detectChanges(); + spyOn(component.scopeChange, 'emit'); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should call navigate on the router with the correct edit path when navigate is called', () => { + component.navigate(community); + expect(component.scopeChange.emit).toHaveBeenCalledWith(community); + }); + +}); diff --git a/src/app/shared/search-form/scope-selector-modal/scope-selector-modal.component.ts b/src/app/shared/search-form/scope-selector-modal/scope-selector-modal.component.ts new file mode 100644 index 0000000000..86c3010287 --- /dev/null +++ b/src/app/shared/search-form/scope-selector-modal/scope-selector-modal.component.ts @@ -0,0 +1,44 @@ +import { Component, EventEmitter, OnInit } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { DSpaceObjectType } from '../../../core/shared/dspace-object-type.model'; +import { DSOSelectorModalWrapperComponent, SelectorActionType } from '../../dso-selector/modal-wrappers/dso-selector-modal-wrapper.component'; +import { DSpaceObject } from '../../../core/shared/dspace-object.model'; + +/** + * Component to wrap a button - to select the entire repository - + * and a list of parent communities - for scope selection + * inside a modal + * Used to select a scope + */ +@Component({ + selector: 'ds-scope-selector-modal', + styleUrls: ['./scope-selector-modal.component.scss'], + templateUrl: './scope-selector-modal.component.html', +}) +export class ScopeSelectorModalComponent extends DSOSelectorModalWrapperComponent implements OnInit { + objectType = DSpaceObjectType.COMMUNITY; + /** + * The types of DSO that can be selected from this list + */ + selectorTypes = [DSpaceObjectType.COMMUNITY, DSpaceObjectType.COLLECTION]; + + /** + * The type of action to perform + */ + action = SelectorActionType.SET_SCOPE; + + /** + * Emits the selected scope as a DSpaceObject when a user clicks one + */ + scopeChange = new EventEmitter(); + + constructor(protected activeModal: NgbActiveModal, protected route: ActivatedRoute) { + super(activeModal, route); + } + + navigate(dso: DSpaceObject) { + /* Handle complex search navigation in underlying component */ + this.scopeChange.emit(dso); + } +} diff --git a/src/app/shared/search-form/search-form.component.html b/src/app/shared/search-form/search-form.component.html index 940f3502c3..fe6191cee7 100644 --- a/src/app/shared/search-form/search-form.component.html +++ b/src/app/shared/search-form/search-form.component.html @@ -1,17 +1,14 @@ -
-
- -
-
-
- - + +
+
+
+ +
+ + -
+
diff --git a/src/app/shared/search-form/search-form.component.scss b/src/app/shared/search-form/search-form.component.scss index 4576be4b28..cf3a354364 100644 --- a/src/app/shared/search-form/search-form.component.scss +++ b/src/app/shared/search-form/search-form.component.scss @@ -3,3 +3,7 @@ background-color: var(--bs-input-bg); color: var(--bs-input-color); } + +.scope-button { + max-width: var(--ds-search-form-scope-max-width); +} diff --git a/src/app/shared/search-form/search-form.component.spec.ts b/src/app/shared/search-form/search-form.component.spec.ts index 1469eac566..333e48336d 100644 --- a/src/app/shared/search-form/search-form.component.spec.ts +++ b/src/app/shared/search-form/search-form.component.spec.ts @@ -8,13 +8,11 @@ import { Community } from '../../core/shared/community.model'; import { TranslateModule } from '@ngx-translate/core'; import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { SearchService } from '../../core/shared/search/search.service'; -import { PaginationComponentOptions } from '../pagination/pagination-component-options.model'; -import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; -import { FindListOptions } from '../../core/data/request.models'; -import { of as observableOf } from 'rxjs'; import { PaginationService } from '../../core/pagination/pagination.service'; import { SearchConfigurationService } from '../../core/shared/search/search-configuration.service'; import { PaginationServiceStub } from '../testing/pagination-service.stub'; +import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; +import { createSuccessfulRemoteDataObject$ } from '../remote-data.utils'; describe('SearchFormComponent', () => { let comp: SearchFormComponent; @@ -35,7 +33,8 @@ describe('SearchFormComponent', () => { useValue: {} }, { provide: PaginationService, useValue: paginationService }, - { provide: SearchConfigurationService, useValue: searchConfigService } + { provide: SearchConfigurationService, useValue: searchConfigService }, + { provide: DSpaceObjectDataService, useValue: { findById: () => createSuccessfulRemoteDataObject$(undefined)} } ], declarations: [SearchFormComponent] }).compileComponents(); @@ -48,24 +47,6 @@ describe('SearchFormComponent', () => { el = de.nativeElement; }); - it('should display scopes when available with default and all scopes', () => { - - comp.scopes = objects; - fixture.detectChanges(); - const select: HTMLElement = de.query(By.css('select')).nativeElement; - expect(select).toBeDefined(); - const options: HTMLCollection = select.children; - const defOption: Element = options.item(0); - expect(defOption.getAttribute('value')).toBe(''); - - let index = 1; - objects.forEach((object) => { - expect(options.item(index).textContent).toBe(object.name); - expect(options.item(index).getAttribute('value')).toBe(object.uuid); - index++; - }); - }); - it('should not display scopes when empty', () => { fixture.detectChanges(); const select = de.query(By.css('select')); @@ -84,17 +65,17 @@ describe('SearchFormComponent', () => { })); it('should select correct scope option in scope select', fakeAsync(() => { - comp.scopes = objects; - fixture.detectChanges(); + fixture.detectChanges(); + comp.showScopeSelector = true; const testCommunity = objects[1]; - comp.scope = testCommunity.id; + comp.selectedScope.next(testCommunity); fixture.detectChanges(); tick(); - const scopeSelect = de.query(By.css('select')).nativeElement; + const scopeSelect = de.query(By.css('.scope-button')).nativeElement; - expect(scopeSelect.value).toBe(testCommunity.id); + expect(scopeSelect.textContent).toBe(testCommunity.name); })); // it('should call updateSearch when clicking the submit button with correct parameters', fakeAsync(() => { // comp.query = 'Test String' @@ -118,7 +99,7 @@ describe('SearchFormComponent', () => { // // expect(comp.updateSearch).toHaveBeenCalledWith({ scope: scope, query: query }); // })); -}); + }); export const objects: DSpaceObject[] = [ Object.assign(new Community(), { diff --git a/src/app/shared/search-form/search-form.component.ts b/src/app/shared/search-form/search-form.component.ts index 2791aee378..cb9b43dbd1 100644 --- a/src/app/shared/search-form/search-form.component.ts +++ b/src/app/shared/search-form/search-form.component.ts @@ -1,4 +1,4 @@ -import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { Router } from '@angular/router'; import { isNotEmpty } from '../empty.util'; @@ -6,6 +6,12 @@ import { SearchService } from '../../core/shared/search/search.service'; import { currentPath } from '../utils/route.utils'; import { PaginationService } from '../../core/pagination/pagination.service'; import { SearchConfigurationService } from '../../core/shared/search/search-configuration.service'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { ScopeSelectorModalComponent } from './scope-selector-modal/scope-selector-modal.component'; +import { take } from 'rxjs/operators'; +import { BehaviorSubject } from 'rxjs'; +import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; +import { getFirstSucceededRemoteDataPayload } from '../../core/shared/operators'; /** * This component renders a simple item page. @@ -22,7 +28,7 @@ import { SearchConfigurationService } from '../../core/shared/search/search-conf /** * Component that represents the search form */ -export class SearchFormComponent { +export class SearchFormComponent implements OnInit { /** * The search query */ @@ -39,12 +45,9 @@ export class SearchFormComponent { @Input() scope = ''; - @Input() currentUrl: string; + selectedScope: BehaviorSubject = new BehaviorSubject(undefined); - /** - * The available scopes - */ - @Input() scopes: DSpaceObject[]; + @Input() currentUrl: string; /** * Whether or not the search button should be displayed large @@ -61,15 +64,33 @@ export class SearchFormComponent { */ @Input() searchPlaceholder: string; + /** + * Defines whether or not to show the scope selector + */ + @Input() showScopeSelector = false; + /** * Output the search data on submit */ @Output() submitSearch = new EventEmitter(); - constructor(private router: Router, private searchService: SearchService, + constructor(private router: Router, + private searchService: SearchService, private paginationService: PaginationService, - private searchConfig: SearchConfigurationService - ) { + private searchConfig: SearchConfigurationService, + private modalService: NgbModal, + private dsoService: DSpaceObjectDataService + ) { + } + + /** + * Retrieve the scope object from the URL so we can show its name + */ + ngOnInit(): void { + if (isNotEmpty(this.scope)) { + this.dsoService.findById(this.scope).pipe(getFirstSucceededRemoteDataPayload()) + .subscribe((scope: DSpaceObject) => this.selectedScope.next(scope)); + } } /** @@ -85,8 +106,8 @@ export class SearchFormComponent { * Updates the search when the current scope has been changed * @param {string} scope The new scope */ - onScopeChange(scope: string) { - this.updateSearch({ scope }); + onScopeChange(scope: DSpaceObject) { + this.updateSearch({ scope: scope ? scope.uuid : undefined }); } /** @@ -94,11 +115,11 @@ export class SearchFormComponent { * @param data Updated parameters */ updateSearch(data: any) { - const queryParams = Object.assign({}, data); - const pageParam = this.paginationService.getPageParam(this.searchConfig.paginationID); - queryParams[pageParam] = 1; + const queryParams = Object.assign({}, data); + const pageParam = this.paginationService.getPageParam(this.searchConfig.paginationID); + queryParams[pageParam] = 1; - this.router.navigate(this.getSearchLinkParts(), { + this.router.navigate(this.getSearchLinkParts(), { queryParams: queryParams, queryParamsHandling: 'merge' }); @@ -131,4 +152,15 @@ export class SearchFormComponent { } return this.getSearchLink().split('/'); } + + /** + * Open the scope modal so the user can select DSO as scope + */ + openScopeModal() { + const ref = this.modalService.open(ScopeSelectorModalComponent); + ref.componentInstance.scopeChange.pipe(take(1)).subscribe((scope: DSpaceObject) => { + this.selectedScope.next(scope); + this.onScopeChange(scope); + }); + } } diff --git a/src/app/shared/search/search-results/search-results.component.html b/src/app/shared/search/search-results/search-results.component.html index 6e7d9c76bf..4e6bca094e 100644 --- a/src/app/shared/search/search-results/search-results.component.html +++ b/src/app/shared/search/search-results/search-results.component.html @@ -7,6 +7,7 @@ [hideGear]="true" [selectable]="selectable" [selectionConfig]="selectionConfig" + [linkType]="linkType" [context]="context" [hidePaginationDetail]="hidePaginationDetail" (deselectObject)="deselectObject.emit($event)" diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index 9b993e551f..d4658d81f7 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -53,7 +53,8 @@ import { FormComponent } from './form/form.component'; import { DsDynamicOneboxComponent } from './form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component'; import { DsDynamicScrollableDropdownComponent } from './form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component'; import { - DsDynamicFormControlContainerComponent, dsDynamicFormControlMapFn, + DsDynamicFormControlContainerComponent, + dsDynamicFormControlMapFn, } from './form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component'; import { DsDynamicFormComponent } from './form/builder/ds-dynamic-form-ui/ds-dynamic-form.component'; import { DragClickDirective } from './utils/drag-click.directive'; @@ -202,6 +203,7 @@ import { EpersonSearchBoxComponent } from './resource-policies/form/eperson-grou import { GroupSearchBoxComponent } from './resource-policies/form/eperson-group-list/group-search-box/group-search-box.component'; import { FileDownloadLinkComponent } from './file-download-link/file-download-link.component'; import { CollectionDropdownComponent } from './collection-dropdown/collection-dropdown.component'; +import { EntityDropdownComponent } from './entity-dropdown/entity-dropdown.component'; import { DsSelectComponent } from './ds-select/ds-select.component'; import { VocabularyTreeviewComponent } from './vocabulary-treeview/vocabulary-treeview.component'; import { CurationFormComponent } from '../curation-form/curation-form.component'; @@ -211,6 +213,7 @@ import { CollectionSidebarSearchListElementComponent } from './object-list/sideb import { CommunitySidebarSearchListElementComponent } from './object-list/sidebar-search-list-element/community/community-sidebar-search-list-element.component'; import { AuthorizedCollectionSelectorComponent } from './dso-selector/dso-selector/authorized-collection-selector/authorized-collection-selector.component'; import { DsoPageEditButtonComponent } from './dso-page/dso-page-edit-button/dso-page-edit-button.component'; +import { DsoPageVersionButtonComponent } from './dso-page/dso-page-version-button/dso-page-version-button.component'; import { HoverClassDirective } from './hover-class.directive'; import { ValidationSuggestionsComponent } from './input-suggestions/validation-suggestions/validation-suggestions.component'; import { ItemAlertsComponent } from './item/item-alerts/item-alerts.component'; @@ -233,6 +236,10 @@ import { OnClickMenuItemComponent } from './menu/menu-item/onclick-menu-item.com import { TextMenuItemComponent } from './menu/menu-item/text-menu-item.component'; import { ThemedConfigurationSearchPageComponent } from '../search-page/themed-configuration-search-page.component'; import { SearchNavbarComponent } from '../search-navbar/search-navbar.component'; +import { ItemVersionsSummaryModalComponent } from './item/item-versions/item-versions-summary-modal/item-versions-summary-modal.component'; +import { ItemVersionsDeleteModalComponent } from './item/item-versions/item-versions-delete-modal/item-versions-delete-modal.component'; +import { ScopeSelectorModalComponent } from './search-form/scope-selector-modal/scope-selector-modal.component'; +import { BitstreamRequestACopyPageComponent } from './bitstream-request-a-copy-page/bitstream-request-a-copy-page.component'; /** * Declaration needed to make sure all decorator functions are called in time @@ -431,7 +438,9 @@ const COMPONENTS = [ GroupSearchBoxComponent, FileDownloadLinkComponent, BitstreamDownloadPageComponent, + BitstreamRequestACopyPageComponent, CollectionDropdownComponent, + EntityDropdownComponent, ExportMetadataSelectorComponent, ConfirmationModalComponent, VocabularyTreeviewComponent, @@ -458,7 +467,8 @@ const COMPONENTS = [ PublicationSidebarSearchListElementComponent, CollectionSidebarSearchListElementComponent, CommunitySidebarSearchListElementComponent, - SearchNavbarComponent + SearchNavbarComponent, + ScopeSelectorModalComponent, ]; const ENTRY_COMPONENTS = [ @@ -512,6 +522,7 @@ const ENTRY_COMPONENTS = [ CollectionDropdownComponent, FileDownloadLinkComponent, BitstreamDownloadPageComponent, + BitstreamRequestACopyPageComponent, CurationFormComponent, ExportMetadataSelectorComponent, ConfirmationModalComponent, @@ -522,7 +533,8 @@ const ENTRY_COMPONENTS = [ CommunitySidebarSearchListElementComponent, LinkMenuItemComponent, OnClickMenuItemComponent, - TextMenuItemComponent + TextMenuItemComponent, + ScopeSelectorModalComponent, ]; const SHARED_SEARCH_PAGE_COMPONENTS = [ @@ -534,6 +546,7 @@ const SHARED_ITEM_PAGE_COMPONENTS = [ MetadataFieldWrapperComponent, MetadataValuesComponent, DsoPageEditButtonComponent, + DsoPageVersionButtonComponent, ItemAlertsComponent, GenericItemPageFieldComponent, MetadataRepresentationListComponent, @@ -584,7 +597,9 @@ const DIRECTIVES = [ ...COMPONENTS, ...DIRECTIVES, ...SHARED_ITEM_PAGE_COMPONENTS, - ...SHARED_SEARCH_PAGE_COMPONENTS + ...SHARED_SEARCH_PAGE_COMPONENTS, + ItemVersionsSummaryModalComponent, + ItemVersionsDeleteModalComponent, ], providers: [ ...PROVIDERS diff --git a/src/app/shared/testing/dynamic-form-mock-services.ts b/src/app/shared/testing/dynamic-form-mock-services.ts index 2cf705ff90..1cbd470e23 100644 --- a/src/app/shared/testing/dynamic-form-mock-services.ts +++ b/src/app/shared/testing/dynamic-form-mock-services.ts @@ -1,5 +1,6 @@ export const mockDynamicFormLayoutService = jasmine.createSpyObj('DynamicFormLayoutService', { - getElementId: jasmine.createSpy('getElementId') + getElementId: jasmine.createSpy('getElementId'), + getClass: 'class', }); export const mockDynamicFormValidationService = jasmine.createSpyObj('DynamicFormValidationService', { diff --git a/src/app/shared/testing/eperson.mock.ts b/src/app/shared/testing/eperson.mock.ts index 0ab3fa9ca1..370c2ff1b9 100644 --- a/src/app/shared/testing/eperson.mock.ts +++ b/src/app/shared/testing/eperson.mock.ts @@ -11,9 +11,9 @@ export const EPersonMock: EPerson = Object.assign(new EPerson(), { selfRegistered: false, _links: { self: { - href: 'https://dspace.4science.it/dspace-spring-rest/api/eperson/epersons/testid', + href: 'https://rest.api/dspace-spring-rest/api/eperson/epersons/testid', }, - groups: { href: 'https://dspace.4science.it/dspace-spring-rest/api/eperson/epersons/testid/groups' } + groups: { href: 'https://rest.api/dspace-spring-rest/api/eperson/epersons/testid/groups' } }, id: 'testid', uuid: 'testid', @@ -57,9 +57,9 @@ export const EPersonMock2: EPerson = Object.assign(new EPerson(), { selfRegistered: true, _links: { self: { - href: 'https://dspace.4science.it/dspace-spring-rest/api/eperson/epersons/testid2', + href: 'https://rest.api/dspace-spring-rest/api/eperson/epersons/testid2', }, - groups: { href: 'https://dspace.4science.it/dspace-spring-rest/api/eperson/epersons/testid2/groups' } + groups: { href: 'https://rest.api/dspace-spring-rest/api/eperson/epersons/testid2/groups' } }, id: 'testid2', uuid: 'testid2', diff --git a/src/app/shared/testing/group-mock.ts b/src/app/shared/testing/group-mock.ts index 24a78a58e5..a6db4c922e 100644 --- a/src/app/shared/testing/group-mock.ts +++ b/src/app/shared/testing/group-mock.ts @@ -1,5 +1,7 @@ import { Group } from '../../core/eperson/models/group.model'; import { EPersonMock } from './eperson.mock'; +import { of } from 'rxjs'; +import { createSuccessfulRemoteDataObject$ } from '../remote-data.utils'; export const GroupMock2: Group = Object.assign(new Group(), { handle: null, @@ -9,16 +11,17 @@ export const GroupMock2: Group = Object.assign(new Group(), { selfRegistered: false, _links: { self: { - href: 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid2', + href: 'https://rest.api/server/api/eperson/groups/testgroupid2', }, - subgroups: { href: 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid2/subgroups' }, - object: { href: 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid2/object' }, - epersons: { href: 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid2/epersons' } + subgroups: { href: 'https://rest.api/server/api/eperson/groups/testgroupid2/subgroups' }, + object: { href: 'https://rest.api/server/api/eperson/groups/testgroupid2/object' }, + epersons: { href: 'https://rest.api/server/api/eperson/groups/testgroupid2/epersons' } }, _name: 'testgroupname2', id: 'testgroupid2', uuid: 'testgroupid2', type: 'group', + object: createSuccessfulRemoteDataObject$({ name: 'testgroupid2objectName'}) }); export const GroupMock: Group = Object.assign(new Group(), { @@ -29,11 +32,11 @@ export const GroupMock: Group = Object.assign(new Group(), { permanent: false, _links: { self: { - href: 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid', + href: 'https://rest.api/server/api/eperson/groups/testgroupid', }, - subgroups: { href: 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid/subgroups' }, - object: { href: 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid2/object' }, - epersons: { href: 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid/epersons' } + subgroups: { href: 'https://rest.api/server/api/eperson/groups/testgroupid/subgroups' }, + object: { href: 'https://rest.api/server/api/eperson/groups/testgroupid2/object' }, + epersons: { href: 'https://rest.api/server/api/eperson/groups/testgroupid/epersons' } }, _name: 'testgroupname', id: 'testgroupid', diff --git a/src/app/shared/theme-support/theme.effects.spec.ts b/src/app/shared/theme-support/theme.effects.spec.ts index 7a0e9c8f19..43727df8d6 100644 --- a/src/app/shared/theme-support/theme.effects.spec.ts +++ b/src/app/shared/theme-support/theme.effects.spec.ts @@ -1,75 +1,17 @@ import { ThemeEffects } from './theme.effects'; -import { of as observableOf } from 'rxjs'; import { TestBed } from '@angular/core/testing'; import { provideMockActions } from '@ngrx/effects/testing'; -import { LinkService } from '../../core/cache/builders/link.service'; import { cold, hot } from 'jasmine-marbles'; import { ROOT_EFFECTS_INIT } from '@ngrx/effects'; import { SetThemeAction } from './theme.actions'; -import { Theme } from '../../../config/theme.model'; import { provideMockStore } from '@ngrx/store/testing'; -import { ROUTER_NAVIGATED } from '@ngrx/router-store'; -import { ResolverActionTypes } from '../../core/resolving/resolver.actions'; -import { Community } from '../../core/shared/community.model'; -import { COMMUNITY } from '../../core/shared/community.resource-type'; -import { NoOpAction } from '../ngrx/no-op.action'; -import { ITEM } from '../../core/shared/item.resource-type'; -import { DSpaceObject } from '../../core/shared/dspace-object.model'; -import { Item } from '../../core/shared/item.model'; -import { Collection } from '../../core/shared/collection.model'; -import { COLLECTION } from '../../core/shared/collection.resource-type'; -import { - createNoContentRemoteDataObject$, - createSuccessfulRemoteDataObject$ -} from '../remote-data.utils'; import { BASE_THEME_NAME } from './theme.constants'; -/** - * LinkService able to mock recursively resolving DSO parent links - * Every time resolveLinkWithoutAttaching is called, it returns the next object in the array of ancestorDSOs until - * none are left, after which it returns a no-content remote-date - */ -class MockLinkService { - index = -1; - - constructor(private ancestorDSOs: DSpaceObject[]) { - } - - resolveLinkWithoutAttaching() { - if (this.index >= this.ancestorDSOs.length - 1) { - return createNoContentRemoteDataObject$(); - } else { - this.index++; - return createSuccessfulRemoteDataObject$(this.ancestorDSOs[this.index]); - } - } -} - describe('ThemeEffects', () => { let themeEffects: ThemeEffects; - let linkService: LinkService; let initialState; - let ancestorDSOs: DSpaceObject[]; - function init() { - ancestorDSOs = [ - Object.assign(new Collection(), { - type: COLLECTION.value, - uuid: 'collection-uuid', - _links: { owningCommunity: { href: 'owning-community-link' } } - }), - Object.assign(new Community(), { - type: COMMUNITY.value, - uuid: 'sub-community-uuid', - _links: { parentCommunity: { href: 'parent-community-link' } } - }), - Object.assign(new Community(), { - type: COMMUNITY.value, - uuid: 'top-community-uuid', - }), - ]; - linkService = new MockLinkService(ancestorDSOs) as any; initialState = { theme: { currentTheme: 'custom', @@ -82,7 +24,6 @@ describe('ThemeEffects', () => { TestBed.configureTestingModule({ providers: [ ThemeEffects, - { provide: LinkService, useValue: linkService }, provideMockStore({ initialState }), provideMockActions(() => mockActions) ] @@ -110,205 +51,4 @@ describe('ThemeEffects', () => { expect(themeEffects.initTheme$).toBeObservable(expected); }); }); - - describe('updateThemeOnRouteChange$', () => { - const url = '/test/route'; - const dso = Object.assign(new Community(), { - type: COMMUNITY.value, - uuid: '0958c910-2037-42a9-81c7-dca80e3892b4', - }); - - function spyOnPrivateMethods() { - spyOn((themeEffects as any), 'getAncestorDSOs').and.returnValue(() => observableOf([dso])); - spyOn((themeEffects as any), 'matchThemeToDSOs').and.returnValue(new Theme({ name: 'custom' })); - spyOn((themeEffects as any), 'getActionForMatch').and.returnValue(new SetThemeAction('custom')); - } - - describe('when a resolved action is present', () => { - beforeEach(() => { - setupEffectsWithActions( - hot('--ab-', { - a: { - type: ROUTER_NAVIGATED, - payload: { routerState: { url } }, - }, - b: { - type: ResolverActionTypes.RESOLVED, - payload: { url, dso }, - } - }) - ); - spyOnPrivateMethods(); - }); - - it('should set the theme it receives from the DSO', () => { - const expected = cold('--b-', { - b: new SetThemeAction('custom') - }); - - expect(themeEffects.updateThemeOnRouteChange$).toBeObservable(expected); - }); - }); - - describe('when no resolved action is present', () => { - beforeEach(() => { - setupEffectsWithActions( - hot('--a-', { - a: { - type: ROUTER_NAVIGATED, - payload: { routerState: { url } }, - }, - }) - ); - spyOnPrivateMethods(); - }); - - it('should set the theme it receives from the route url', () => { - const expected = cold('--b-', { - b: new SetThemeAction('custom') - }); - - expect(themeEffects.updateThemeOnRouteChange$).toBeObservable(expected); - }); - }); - - describe('when no themes are present', () => { - beforeEach(() => { - setupEffectsWithActions( - hot('--a-', { - a: { - type: ROUTER_NAVIGATED, - payload: { routerState: { url } }, - }, - }) - ); - (themeEffects as any).themes = []; - }); - - it('should return an empty action', () => { - const expected = cold('--b-', { - b: new NoOpAction() - }); - - expect(themeEffects.updateThemeOnRouteChange$).toBeObservable(expected); - }); - }); - }); - - describe('private functions', () => { - beforeEach(() => { - setupEffectsWithActions(hot('-', {})); - }); - - describe('getActionForMatch', () => { - it('should return a SET action if the new theme differs from the current theme', () => { - const theme = new Theme({ name: 'new-theme' }); - expect((themeEffects as any).getActionForMatch(theme, 'old-theme')).toEqual(new SetThemeAction('new-theme')); - }); - - it('should return an empty action if the new theme equals the current theme', () => { - const theme = new Theme({ name: 'old-theme' }); - expect((themeEffects as any).getActionForMatch(theme, 'old-theme')).toEqual(new NoOpAction()); - }); - }); - - describe('matchThemeToDSOs', () => { - let themes: Theme[]; - let nonMatchingTheme: Theme; - let itemMatchingTheme: Theme; - let communityMatchingTheme: Theme; - let dsos: DSpaceObject[]; - - beforeEach(() => { - nonMatchingTheme = Object.assign(new Theme({ name: 'non-matching-theme' }), { - matches: () => false - }); - itemMatchingTheme = Object.assign(new Theme({ name: 'item-matching-theme' }), { - matches: (url, dso) => (dso as any).type === ITEM.value - }); - communityMatchingTheme = Object.assign(new Theme({ name: 'community-matching-theme' }), { - matches: (url, dso) => (dso as any).type === COMMUNITY.value - }); - dsos = [ - Object.assign(new Item(), { - type: ITEM.value, - uuid: 'item-uuid', - }), - Object.assign(new Collection(), { - type: COLLECTION.value, - uuid: 'collection-uuid', - }), - Object.assign(new Community(), { - type: COMMUNITY.value, - uuid: 'community-uuid', - }), - ]; - }); - - describe('when no themes match any of the DSOs', () => { - beforeEach(() => { - themes = [ nonMatchingTheme ]; - themeEffects.themes = themes; - }); - - it('should return undefined', () => { - expect((themeEffects as any).matchThemeToDSOs(dsos, '')).toBeUndefined(); - }); - }); - - describe('when one of the themes match a DSOs', () => { - beforeEach(() => { - themes = [ nonMatchingTheme, itemMatchingTheme ]; - themeEffects.themes = themes; - }); - - it('should return the matching theme', () => { - expect((themeEffects as any).matchThemeToDSOs(dsos, '')).toEqual(itemMatchingTheme); - }); - }); - - describe('when multiple themes match some of the DSOs', () => { - it('should return the first matching theme', () => { - themes = [ nonMatchingTheme, itemMatchingTheme, communityMatchingTheme ]; - themeEffects.themes = themes; - expect((themeEffects as any).matchThemeToDSOs(dsos, '')).toEqual(itemMatchingTheme); - - themes = [ nonMatchingTheme, communityMatchingTheme, itemMatchingTheme ]; - themeEffects.themes = themes; - expect((themeEffects as any).matchThemeToDSOs(dsos, '')).toEqual(communityMatchingTheme); - }); - }); - }); - - describe('getAncestorDSOs', () => { - it('should return an array of the provided DSO and its ancestors', (done) => { - const dso = Object.assign(new Item(), { - type: ITEM.value, - uuid: 'item-uuid', - _links: { owningCollection: { href: 'owning-collection-link' } }, - }); - - observableOf(dso).pipe( - (themeEffects as any).getAncestorDSOs() - ).subscribe((result) => { - expect(result).toEqual([dso, ...ancestorDSOs]); - done(); - }); - }); - - it('should return an array of just the provided DSO if it doesn\'t have any parents', (done) => { - const dso = { - type: ITEM.value, - uuid: 'item-uuid', - }; - - observableOf(dso).pipe( - (themeEffects as any).getAncestorDSOs() - ).subscribe((result) => { - expect(result).toEqual([dso]); - done(); - }); - }); - }); - }); }); diff --git a/src/app/shared/theme-support/theme.effects.ts b/src/app/shared/theme-support/theme.effects.ts index 894cfeca75..e120257728 100644 --- a/src/app/shared/theme-support/theme.effects.ts +++ b/src/app/shared/theme-support/theme.effects.ts @@ -1,22 +1,9 @@ import { Injectable } from '@angular/core'; import { createEffect, Actions, ofType, ROOT_EFFECTS_INIT } from '@ngrx/effects'; -import { ROUTER_NAVIGATED, RouterNavigatedAction } from '@ngrx/router-store'; -import { map, withLatestFrom, expand, switchMap, toArray, startWith, filter } from 'rxjs/operators'; +import { map } from 'rxjs/operators'; import { SetThemeAction } from './theme.actions'; import { environment } from '../../../environments/environment'; -import { ThemeConfig, themeFactory, Theme, } from '../../../config/theme.model'; -import { hasValue, isNotEmpty, hasNoValue } from '../empty.util'; -import { NoOpAction } from '../ngrx/no-op.action'; -import { Store, select } from '@ngrx/store'; -import { ThemeState } from './theme.reducer'; -import { currentThemeSelector } from './theme.service'; -import { of as observableOf, EMPTY, Observable } from 'rxjs'; -import { ResolverActionTypes, ResolvedAction } from '../../core/resolving/resolver.actions'; -import { followLink } from '../utils/follow-link-config.model'; -import { RemoteData } from '../../core/data/remote-data'; -import { DSpaceObject } from '../../core/shared/dspace-object.model'; -import { getFirstCompletedRemoteData } from '../../core/shared/operators'; -import { LinkService } from '../../core/cache/builders/link.service'; +import { hasValue, hasNoValue } from '../empty.util'; import { BASE_THEME_NAME } from './theme.constants'; export const DEFAULT_THEME_CONFIG = environment.themes.find((themeConfig: any) => @@ -27,16 +14,6 @@ export const DEFAULT_THEME_CONFIG = environment.themes.find((themeConfig: any) = @Injectable() export class ThemeEffects { - /** - * The list of configured themes - */ - themes: Theme[]; - - /** - * True if at least one theme depends on the route - */ - hasDynamicTheme: boolean; - /** * Initialize with a theme that doesn't depend on the route. */ @@ -53,133 +30,8 @@ export class ThemeEffects { ) ); - /** - * An effect that fires when a route change completes, - * and determines whether or not the theme should change - */ - updateThemeOnRouteChange$ = createEffect(() => this.actions$.pipe( - // Listen for when a route change ends - ofType(ROUTER_NAVIGATED), - withLatestFrom( - // Pull in the latest resolved action, or undefined if none was dispatched yet - this.actions$.pipe(ofType(ResolverActionTypes.RESOLVED), startWith(undefined)), - // and the current theme from the store - this.store.pipe(select(currentThemeSelector)) - ), - switchMap(([navigatedAction, resolvedAction, currentTheme]: [RouterNavigatedAction, ResolvedAction, string]) => { - if (this.hasDynamicTheme === true && isNotEmpty(this.themes)) { - const currentRouteUrl = navigatedAction.payload.routerState.url; - // If resolvedAction exists, and deals with the current url - if (hasValue(resolvedAction) && resolvedAction.payload.url === currentRouteUrl) { - // Start with the resolved dso and go recursively through its parents until you reach the top-level community - return observableOf(resolvedAction.payload.dso).pipe( - this.getAncestorDSOs(), - map((dsos: DSpaceObject[]) => { - const dsoMatch = this.matchThemeToDSOs(dsos, currentRouteUrl); - return this.getActionForMatch(dsoMatch, currentTheme); - }) - ); - } - - // check whether the route itself matches - const routeMatch = this.themes.find((theme: Theme) => theme.matches(currentRouteUrl, undefined)); - - return [this.getActionForMatch(routeMatch, currentTheme)]; - } - - // If there are no themes configured, do nothing - return [new NoOpAction()]; - }) - ) - ); - - /** - * return the action to dispatch based on the given matching theme - * - * @param newTheme The theme to create an action for - * @param currentThemeName The name of the currently active theme - * @private - */ - private getActionForMatch(newTheme: Theme, currentThemeName: string): SetThemeAction | NoOpAction { - if (hasValue(newTheme) && newTheme.config.name !== currentThemeName) { - // If we have a match, and it isn't already the active theme, set it as the new theme - return new SetThemeAction(newTheme.config.name); - } else { - // Otherwise, do nothing - return new NoOpAction(); - } - } - - /** - * Check the given DSpaceObjects in order to see if they match the configured themes in order. - * If a match is found, the matching theme is returned - * - * @param dsos The DSpaceObjects to check - * @param currentRouteUrl The url for the current route - * @private - */ - private matchThemeToDSOs(dsos: DSpaceObject[], currentRouteUrl: string): Theme { - // iterate over the themes in order, and return the first one that matches - return this.themes.find((theme: Theme) => { - // iterate over the dsos's in order (most specific one first, so Item, Collection, - // Community), and return the first one that matches the current theme - const match = dsos.find((dso: DSpaceObject) => theme.matches(currentRouteUrl, dso)); - return hasValue(match); - }); - - } - - /** - * An rxjs operator that will return an array of all the ancestors of the DSpaceObject used as - * input. The initial DSpaceObject will be the first element of the output array, followed by - * its parent, its grandparent etc - * - * @private - */ - private getAncestorDSOs() { - return (source: Observable): Observable => - source.pipe( - expand((dso: DSpaceObject) => { - // Check if the dso exists and has a parent link - if (hasValue(dso) && typeof (dso as any).getParentLinkKey === 'function') { - const linkName = (dso as any).getParentLinkKey(); - // If it does, retrieve it. - return this.linkService.resolveLinkWithoutAttaching(dso, followLink(linkName)).pipe( - getFirstCompletedRemoteData(), - map((rd: RemoteData) => { - if (hasValue(rd.payload)) { - // If there's a parent, use it for the next iteration - return rd.payload; - } else { - // If there's no parent, or an error, return null, which will stop recursion - // in the next iteration - return null; - } - }), - ); - } - - // The current dso has no value, or no parent. Return EMPTY to stop recursion - return EMPTY; - }), - // only allow through DSOs that have a value - filter((dso: DSpaceObject) => hasValue(dso)), - // Wait for recursion to complete, and emit all results at once, in an array - toArray() - ); - } - constructor( private actions$: Actions, - private store: Store, - private linkService: LinkService, ) { - // Create objects from the theme configs in the environment file - this.themes = environment.themes.map((themeConfig: ThemeConfig) => themeFactory(themeConfig)); - this.hasDynamicTheme = environment.themes.some((themeConfig: any) => - hasValue(themeConfig.regex) || - hasValue(themeConfig.handle) || - hasValue(themeConfig.uuid) - ); } } diff --git a/src/app/shared/theme-support/theme.service.spec.ts b/src/app/shared/theme-support/theme.service.spec.ts new file mode 100644 index 0000000000..84043369c0 --- /dev/null +++ b/src/app/shared/theme-support/theme.service.spec.ts @@ -0,0 +1,370 @@ +import { of as observableOf } from 'rxjs'; +import { TestBed } from '@angular/core/testing'; +import { provideMockActions } from '@ngrx/effects/testing'; +import { LinkService } from '../../core/cache/builders/link.service'; +import { cold, hot } from 'jasmine-marbles'; +import { SetThemeAction } from './theme.actions'; +import { Theme } from '../../../config/theme.model'; +import { provideMockStore } from '@ngrx/store/testing'; +import { Community } from '../../core/shared/community.model'; +import { COMMUNITY } from '../../core/shared/community.resource-type'; +import { NoOpAction } from '../ngrx/no-op.action'; +import { ITEM } from '../../core/shared/item.resource-type'; +import { DSpaceObject } from '../../core/shared/dspace-object.model'; +import { Item } from '../../core/shared/item.model'; +import { Collection } from '../../core/shared/collection.model'; +import { COLLECTION } from '../../core/shared/collection.resource-type'; +import { + createNoContentRemoteDataObject$, createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$ +} from '../remote-data.utils'; +import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; +import { ThemeService } from './theme.service'; +import { ROUTER_NAVIGATED } from '@ngrx/router-store'; +import { ActivatedRouteSnapshot } from '@angular/router'; + +/** + * LinkService able to mock recursively resolving DSO parent links + * Every time resolveLinkWithoutAttaching is called, it returns the next object in the array of ancestorDSOs until + * none are left, after which it returns a no-content remote-date + */ +class MockLinkService { + index = -1; + + constructor(private ancestorDSOs: DSpaceObject[]) { + } + + resolveLinkWithoutAttaching() { + if (this.index >= this.ancestorDSOs.length - 1) { + return createNoContentRemoteDataObject$(); + } else { + this.index++; + return createSuccessfulRemoteDataObject$(this.ancestorDSOs[this.index]); + } + } +} + +describe('ThemeService', () => { + let themeService: ThemeService; + let linkService: LinkService; + let initialState; + + let ancestorDSOs: DSpaceObject[]; + + const mockCommunity = Object.assign(new Community(), { + type: COMMUNITY.value, + uuid: 'top-community-uuid', + }); + + function init() { + ancestorDSOs = [ + Object.assign(new Collection(), { + type: COLLECTION.value, + uuid: 'collection-uuid', + _links: { owningCommunity: { href: 'owning-community-link' } } + }), + Object.assign(new Community(), { + type: COMMUNITY.value, + uuid: 'sub-community-uuid', + _links: { parentCommunity: { href: 'parent-community-link' } } + }), + mockCommunity, + ]; + linkService = new MockLinkService(ancestorDSOs) as any; + initialState = { + theme: { + currentTheme: 'custom', + }, + }; + } + + function setupServiceWithActions(mockActions) { + init(); + const mockDsoService = { + findById: () => createSuccessfulRemoteDataObject$(mockCommunity) + }; + TestBed.configureTestingModule({ + providers: [ + ThemeService, + { provide: LinkService, useValue: linkService }, + provideMockStore({ initialState }), + provideMockActions(() => mockActions), + { provide: DSpaceObjectDataService, useValue: mockDsoService } + ] + }); + + themeService = TestBed.inject(ThemeService); + spyOn((themeService as any).store, 'dispatch').and.stub(); + } + + describe('updateThemeOnRouteChange$', () => { + const url = '/test/route'; + const dso = Object.assign(new Community(), { + type: COMMUNITY.value, + uuid: '0958c910-2037-42a9-81c7-dca80e3892b4', + }); + + function spyOnPrivateMethods() { + spyOn((themeService as any), 'getAncestorDSOs').and.returnValue(() => observableOf([dso])); + spyOn((themeService as any), 'matchThemeToDSOs').and.returnValue(new Theme({ name: 'custom' })); + spyOn((themeService as any), 'getActionForMatch').and.returnValue(new SetThemeAction('custom')); + } + + describe('when no resolved action is present', () => { + beforeEach(() => { + setupServiceWithActions( + hot('--a-', { + a: { + type: ROUTER_NAVIGATED, + payload: { routerState: { url } }, + }, + }) + ); + spyOnPrivateMethods(); + }); + + it('should set the theme it receives from the route url', (done) => { + themeService.updateThemeOnRouteChange$(url, {} as ActivatedRouteSnapshot).subscribe(() => { + expect((themeService as any).store.dispatch).toHaveBeenCalledWith(new SetThemeAction('custom') as any); + done(); + }); + }); + + it('should return true', (done) => { + themeService.updateThemeOnRouteChange$(url, {} as ActivatedRouteSnapshot).subscribe((result) => { + expect(result).toEqual(true); + done(); + }); + }); + }); + + describe('when no themes are present', () => { + beforeEach(() => { + setupServiceWithActions( + hot('--a-', { + a: { + type: ROUTER_NAVIGATED, + payload: { routerState: { url } }, + }, + }) + ); + (themeService as any).themes = []; + }); + + it('should not dispatch any action', (done) => { + themeService.updateThemeOnRouteChange$(url, {} as ActivatedRouteSnapshot).subscribe(() => { + expect((themeService as any).store.dispatch).not.toHaveBeenCalled(); + done(); + }); + }); + + it('should return false', (done) => { + themeService.updateThemeOnRouteChange$(url, {} as ActivatedRouteSnapshot).subscribe((result) => { + expect(result).toEqual(false); + done(); + }); + }); + }); + + describe('when a dso is present in the snapshot\'s data', () => { + let snapshot; + + beforeEach(() => { + setupServiceWithActions( + hot('--a-', { + a: { + type: ROUTER_NAVIGATED, + payload: { routerState: { url } }, + }, + }) + ); + spyOnPrivateMethods(); + snapshot = Object.assign({ + data: { + dso: createSuccessfulRemoteDataObject(dso) + } + }); + }); + + it('should match the theme to the dso', (done) => { + themeService.updateThemeOnRouteChange$(url, snapshot).subscribe(() => { + expect((themeService as any).matchThemeToDSOs).toHaveBeenCalled(); + done(); + }); + }); + + it('should set the theme it receives from the data dso', (done) => { + themeService.updateThemeOnRouteChange$(url, snapshot).subscribe(() => { + expect((themeService as any).store.dispatch).toHaveBeenCalledWith(new SetThemeAction('custom') as any); + done(); + }); + }); + + it('should return true', (done) => { + themeService.updateThemeOnRouteChange$(url, snapshot).subscribe((result) => { + expect(result).toEqual(true); + done(); + }); + }); + }); + + describe('when a scope is present in the snapshot\'s parameters', () => { + let snapshot; + + beforeEach(() => { + setupServiceWithActions( + hot('--a-', { + a: { + type: ROUTER_NAVIGATED, + payload: { routerState: { url } }, + }, + }) + ); + spyOnPrivateMethods(); + snapshot = Object.assign({ + queryParams: { + scope: mockCommunity.uuid + } + }); + }); + + it('should match the theme to the dso found through the scope', (done) => { + themeService.updateThemeOnRouteChange$(url, snapshot).subscribe(() => { + expect((themeService as any).matchThemeToDSOs).toHaveBeenCalled(); + done(); + }); + }); + + it('should set the theme it receives from the dso found through the scope', (done) => { + themeService.updateThemeOnRouteChange$(url, snapshot).subscribe(() => { + expect((themeService as any).store.dispatch).toHaveBeenCalledWith(new SetThemeAction('custom') as any); + done(); + }); + }); + + it('should return true', (done) => { + themeService.updateThemeOnRouteChange$(url, snapshot).subscribe((result) => { + expect(result).toEqual(true); + done(); + }); + }); + }); + }); + + describe('private functions', () => { + beforeEach(() => { + setupServiceWithActions(hot('-', {})); + }); + + describe('getActionForMatch', () => { + it('should return a SET action if the new theme differs from the current theme', () => { + const theme = new Theme({ name: 'new-theme' }); + expect((themeService as any).getActionForMatch(theme, 'old-theme')).toEqual(new SetThemeAction('new-theme')); + }); + + it('should return an empty action if the new theme equals the current theme', () => { + const theme = new Theme({ name: 'old-theme' }); + expect((themeService as any).getActionForMatch(theme, 'old-theme')).toEqual(new NoOpAction()); + }); + }); + + describe('matchThemeToDSOs', () => { + let themes: Theme[]; + let nonMatchingTheme: Theme; + let itemMatchingTheme: Theme; + let communityMatchingTheme: Theme; + let dsos: DSpaceObject[]; + + beforeEach(() => { + nonMatchingTheme = Object.assign(new Theme({ name: 'non-matching-theme' }), { + matches: () => false + }); + itemMatchingTheme = Object.assign(new Theme({ name: 'item-matching-theme' }), { + matches: (url, dso) => (dso as any).type === ITEM.value + }); + communityMatchingTheme = Object.assign(new Theme({ name: 'community-matching-theme' }), { + matches: (url, dso) => (dso as any).type === COMMUNITY.value + }); + dsos = [ + Object.assign(new Item(), { + type: ITEM.value, + uuid: 'item-uuid', + }), + Object.assign(new Collection(), { + type: COLLECTION.value, + uuid: 'collection-uuid', + }), + Object.assign(new Community(), { + type: COMMUNITY.value, + uuid: 'community-uuid', + }), + ]; + }); + + describe('when no themes match any of the DSOs', () => { + beforeEach(() => { + themes = [ nonMatchingTheme ]; + themeService.themes = themes; + }); + + it('should return undefined', () => { + expect((themeService as any).matchThemeToDSOs(dsos, '')).toBeUndefined(); + }); + }); + + describe('when one of the themes match a DSOs', () => { + beforeEach(() => { + themes = [ nonMatchingTheme, itemMatchingTheme ]; + themeService.themes = themes; + }); + + it('should return the matching theme', () => { + expect((themeService as any).matchThemeToDSOs(dsos, '')).toEqual(itemMatchingTheme); + }); + }); + + describe('when multiple themes match some of the DSOs', () => { + it('should return the first matching theme', () => { + themes = [ nonMatchingTheme, itemMatchingTheme, communityMatchingTheme ]; + themeService.themes = themes; + expect((themeService as any).matchThemeToDSOs(dsos, '')).toEqual(itemMatchingTheme); + + themes = [ nonMatchingTheme, communityMatchingTheme, itemMatchingTheme ]; + themeService.themes = themes; + expect((themeService as any).matchThemeToDSOs(dsos, '')).toEqual(communityMatchingTheme); + }); + }); + }); + + describe('getAncestorDSOs', () => { + it('should return an array of the provided DSO and its ancestors', (done) => { + const dso = Object.assign(new Item(), { + type: ITEM.value, + uuid: 'item-uuid', + _links: { owningCollection: { href: 'owning-collection-link' } }, + }); + + observableOf(dso).pipe( + (themeService as any).getAncestorDSOs() + ).subscribe((result) => { + expect(result).toEqual([dso, ...ancestorDSOs]); + done(); + }); + }); + + it('should return an array of just the provided DSO if it doesn\'t have any parents', (done) => { + const dso = { + type: ITEM.value, + uuid: 'item-uuid', + }; + + observableOf(dso).pipe( + (themeService as any).getAncestorDSOs() + ).subscribe((result) => { + expect(result).toEqual([dso]); + done(); + }); + }); + }); + }); +}); diff --git a/src/app/shared/theme-support/theme.service.ts b/src/app/shared/theme-support/theme.service.ts index 7b0af93e04..d72c827ab3 100644 --- a/src/app/shared/theme-support/theme.service.ts +++ b/src/app/shared/theme-support/theme.service.ts @@ -1,10 +1,26 @@ -import { Injectable } from '@angular/core'; +import { Injectable, Inject } from '@angular/core'; import { Store, createFeatureSelector, createSelector, select } from '@ngrx/store'; import { Observable } from 'rxjs/internal/Observable'; import { ThemeState } from './theme.reducer'; -import { SetThemeAction } from './theme.actions'; -import { take } from 'rxjs/operators'; -import { hasValue } from '../empty.util'; +import { SetThemeAction, ThemeActionTypes } from './theme.actions'; +import { expand, filter, map, switchMap, take, toArray } from 'rxjs/operators'; +import { hasValue, isNotEmpty } from '../empty.util'; +import { RemoteData } from '../../core/data/remote-data'; +import { DSpaceObject } from '../../core/shared/dspace-object.model'; +import { + getFirstCompletedRemoteData, + getFirstSucceededRemoteData, + getRemoteDataPayload +} from '../../core/shared/operators'; +import { EMPTY, of as observableOf } from 'rxjs'; +import { Theme, ThemeConfig, themeFactory } from '../../../config/theme.model'; +import { NO_OP_ACTION_TYPE, NoOpAction } from '../ngrx/no-op.action'; +import { followLink } from '../utils/follow-link-config.model'; +import { LinkService } from '../../core/cache/builders/link.service'; +import { environment } from '../../../environments/environment'; +import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; +import { ActivatedRouteSnapshot } from '@angular/router'; +import { GET_THEME_CONFIG_FOR_FACTORY } from '../object-collection/shared/listable-object/listable-object.decorator'; export const themeStateSelector = createFeatureSelector('theme'); @@ -17,9 +33,29 @@ export const currentThemeSelector = createSelector( providedIn: 'root' }) export class ThemeService { + /** + * The list of configured themes + */ + themes: Theme[]; + + /** + * True if at least one theme depends on the route + */ + hasDynamicTheme: boolean; + constructor( private store: Store, + private linkService: LinkService, + private dSpaceObjectDataService: DSpaceObjectDataService, + @Inject(GET_THEME_CONFIG_FOR_FACTORY) private gtcf: (str) => ThemeConfig ) { + // Create objects from the theme configs in the environment file + this.themes = environment.themes.map((themeConfig: ThemeConfig) => themeFactory(themeConfig)); + this.hasDynamicTheme = environment.themes.some((themeConfig: any) => + hasValue(themeConfig.regex) || + hasValue(themeConfig.handle) || + hasValue(themeConfig.uuid) + ); } setTheme(newName: string) { @@ -43,4 +79,174 @@ export class ThemeService { ); } + /** + * Determine whether or not the theme needs to change depending on the current route's URL and snapshot data + * If the snapshot contains a dso, this will be used to match a theme + * If the snapshot contains a scope parameters, this will be used to match a theme + * Otherwise the URL is matched against + * If none of the above find a match, the theme doesn't change + * @param currentRouteUrl + * @param activatedRouteSnapshot + * @return Observable boolean emitting whether or not the theme has been changed + */ + updateThemeOnRouteChange$(currentRouteUrl: string, activatedRouteSnapshot: ActivatedRouteSnapshot): Observable { + // and the current theme from the store + const currentTheme$: Observable = this.store.pipe(select(currentThemeSelector)); + + const action$ = currentTheme$.pipe( + switchMap((currentTheme: string) => { + const snapshotWithData = this.findRouteData(activatedRouteSnapshot); + if (this.hasDynamicTheme === true && isNotEmpty(this.themes)) { + if (hasValue(snapshotWithData) && hasValue(snapshotWithData.data) && hasValue(snapshotWithData.data.dso)) { + const dsoRD: RemoteData = snapshotWithData.data.dso; + if (dsoRD.hasSucceeded) { + // Start with the resolved dso and go recursively through its parents until you reach the top-level community + return observableOf(dsoRD.payload).pipe( + this.getAncestorDSOs(), + map((dsos: DSpaceObject[]) => { + const dsoMatch = this.matchThemeToDSOs(dsos, currentRouteUrl); + return this.getActionForMatch(dsoMatch, currentTheme); + }) + ); + } + } + if (hasValue(activatedRouteSnapshot.queryParams) && hasValue(activatedRouteSnapshot.queryParams.scope)) { + const dsoFromScope$: Observable> = this.dSpaceObjectDataService.findById(activatedRouteSnapshot.queryParams.scope); + // Start with the resolved dso and go recursively through its parents until you reach the top-level community + return dsoFromScope$.pipe( + getFirstSucceededRemoteData(), + getRemoteDataPayload(), + this.getAncestorDSOs(), + map((dsos: DSpaceObject[]) => { + const dsoMatch = this.matchThemeToDSOs(dsos, currentRouteUrl); + return this.getActionForMatch(dsoMatch, currentTheme); + }) + ); + } + + // check whether the route itself matches + const routeMatch = this.themes.find((theme: Theme) => theme.matches(currentRouteUrl, undefined)); + + return [this.getActionForMatch(routeMatch, currentTheme)]; + } + + // If there are no themes configured, do nothing + return [new NoOpAction()]; + }), + take(1), + ); + + action$.pipe( + filter((action) => action.type !== NO_OP_ACTION_TYPE), + ).subscribe((action) => { + this.store.dispatch(action); + }); + + return action$.pipe( + map((action) => action.type === ThemeActionTypes.SET), + ); + } + + /** + * Find a DSpaceObject in one of the provided route snapshots their data + * Recursively looks for the dso in the routes their child routes until it reaches a dead end or finds one + * @param routes + */ + findRouteData(...routes: ActivatedRouteSnapshot[]) { + const result = routes.find((route) => hasValue(route.data) && hasValue(route.data.dso)); + if (hasValue(result)) { + return result; + } else { + const nextLevelRoutes = routes + .map((route: ActivatedRouteSnapshot) => route.children) + .reduce((combined: ActivatedRouteSnapshot[], current: ActivatedRouteSnapshot[]) => [...combined, ...current]); + if (isNotEmpty(nextLevelRoutes)) { + return this.findRouteData(...nextLevelRoutes); + } else { + return undefined; + } + } + } + + /** + * An rxjs operator that will return an array of all the ancestors of the DSpaceObject used as + * input. The initial DSpaceObject will be the first element of the output array, followed by + * its parent, its grandparent etc + * + * @private + */ + private getAncestorDSOs() { + return (source: Observable): Observable => + source.pipe( + expand((dso: DSpaceObject) => { + // Check if the dso exists and has a parent link + if (hasValue(dso) && typeof (dso as any).getParentLinkKey === 'function') { + const linkName = (dso as any).getParentLinkKey(); + // If it does, retrieve it. + return this.linkService.resolveLinkWithoutAttaching(dso, followLink(linkName)).pipe( + getFirstCompletedRemoteData(), + map((rd: RemoteData) => { + if (hasValue(rd.payload)) { + // If there's a parent, use it for the next iteration + return rd.payload; + } else { + // If there's no parent, or an error, return null, which will stop recursion + // in the next iteration + return null; + } + }), + ); + } + + // The current dso has no value, or no parent. Return EMPTY to stop recursion + return EMPTY; + }), + // only allow through DSOs that have a value + filter((dso: DSpaceObject) => hasValue(dso)), + // Wait for recursion to complete, and emit all results at once, in an array + toArray() + ); + } + + /** + * return the action to dispatch based on the given matching theme + * + * @param newTheme The theme to create an action for + * @param currentThemeName The name of the currently active theme + * @private + */ + private getActionForMatch(newTheme: Theme, currentThemeName: string): SetThemeAction | NoOpAction { + if (hasValue(newTheme) && newTheme.config.name !== currentThemeName) { + // If we have a match, and it isn't already the active theme, set it as the new theme + return new SetThemeAction(newTheme.config.name); + } else { + // Otherwise, do nothing + return new NoOpAction(); + } + } + + /** + * Check the given DSpaceObjects in order to see if they match the configured themes in order. + * If a match is found, the matching theme is returned + * + * @param dsos The DSpaceObjects to check + * @param currentRouteUrl The url for the current route + * @private + */ + private matchThemeToDSOs(dsos: DSpaceObject[], currentRouteUrl: string): Theme { + // iterate over the themes in order, and return the first one that matches + return this.themes.find((theme: Theme) => { + // iterate over the dsos's in order (most specific one first, so Item, Collection, + // Community), and return the first one that matches the current theme + const match = dsos.find((dso: DSpaceObject) => theme.matches(currentRouteUrl, dso)); + return hasValue(match); + }); + } + + /** + * Searches for a ThemeConfig by its name; + */ + getThemeConfigFor(themeName: string): ThemeConfig { + return this.gtcf(themeName); + } } diff --git a/src/app/shared/theme-support/themed.component.spec.ts b/src/app/shared/theme-support/themed.component.spec.ts index abaee28a29..1db6de072d 100644 --- a/src/app/shared/theme-support/themed.component.spec.ts +++ b/src/app/shared/theme-support/themed.component.spec.ts @@ -5,6 +5,7 @@ import { VarDirective } from '../utils/var.directive'; import { ThemeService } from './theme.service'; import { getMockThemeService } from '../mocks/theme-service.mock'; import { TestComponent } from './test/test.component.spec'; +import { ThemeConfig } from '../../../config/theme.model'; /* tslint:disable:max-classes-per-file */ @Component({ @@ -32,8 +33,8 @@ describe('ThemedComponent', () => { let fixture: ComponentFixture; let themeService: ThemeService; - function setupTestingModuleForTheme(theme: string) { - themeService = getMockThemeService(theme); + function setupTestingModuleForTheme(theme: string, themes?: ThemeConfig[]) { + themeService = getMockThemeService(theme, themes); TestBed.configureTestingModule({ imports: [], declarations: [TestThemedComponent, VarDirective], @@ -44,17 +45,20 @@ describe('ThemedComponent', () => { }).compileComponents(); } + function initComponent() { + fixture = TestBed.createComponent(TestThemedComponent); + component = fixture.componentInstance; + spyOn(component as any, 'importThemedComponent').and.callThrough(); + component.testInput = 'changed'; + fixture.detectChanges(); + } + describe('when the current theme matches a themed component', () => { beforeEach(waitForAsync(() => { setupTestingModuleForTheme('custom'); })); - beforeEach(() => { - fixture = TestBed.createComponent(TestThemedComponent); - component = fixture.componentInstance; - component.testInput = 'changed'; - fixture.detectChanges(); - }); + beforeEach(initComponent); it('should set compRef to the themed component', waitForAsync(() => { fixture.whenStable().then(() => { @@ -70,28 +74,127 @@ describe('ThemedComponent', () => { }); describe('when the current theme doesn\'t match a themed component', () => { - beforeEach(waitForAsync(() => { - setupTestingModuleForTheme('non-existing-theme'); - })); + describe('and it doesn\'t extend another theme', () => { + beforeEach(waitForAsync(() => { + setupTestingModuleForTheme('non-existing-theme'); + })); - beforeEach(() => { - fixture = TestBed.createComponent(TestThemedComponent); - component = fixture.componentInstance; - component.testInput = 'changed'; - fixture.detectChanges(); + beforeEach(initComponent); + + it('should set compRef to the default component', waitForAsync(() => { + fixture.whenStable().then(() => { + expect((component as any).compRef.instance.type).toEqual('default'); + }); + })); + + it('should sync up this component\'s input with the default component', waitForAsync(() => { + fixture.whenStable().then(() => { + expect((component as any).compRef.instance.testInput).toEqual('changed'); + }); + })); }); - it('should set compRef to the default component', waitForAsync(() => { - fixture.whenStable().then(() => { - expect((component as any).compRef.instance.type).toEqual('default'); - }); - })); + describe('and it extends another theme', () => { + describe('that doesn\'t match it either', () => { + beforeEach(waitForAsync(() => { + setupTestingModuleForTheme('current-theme', [ + { name: 'current-theme', extends: 'non-existing-theme' }, + ]); + })); - it('should sync up this component\'s input with the default component', waitForAsync(() => { - fixture.whenStable().then(() => { - expect((component as any).compRef.instance.testInput).toEqual('changed'); + beforeEach(initComponent); + + it('should set compRef to the default component', waitForAsync(() => { + fixture.whenStable().then(() => { + expect((component as any).importThemedComponent).toHaveBeenCalledWith('current-theme'); + expect((component as any).importThemedComponent).toHaveBeenCalledWith('non-existing-theme'); + expect((component as any).compRef.instance.type).toEqual('default'); + }); + })); + + it('should sync up this component\'s input with the default component', waitForAsync(() => { + fixture.whenStable().then(() => { + expect((component as any).compRef.instance.testInput).toEqual('changed'); + }); + })); }); - })); + + describe('that does match it', () => { + beforeEach(waitForAsync(() => { + setupTestingModuleForTheme('current-theme', [ + { name: 'current-theme', extends: 'custom' }, + ]); + })); + + beforeEach(initComponent); + + it('should set compRef to the themed component', waitForAsync(() => { + fixture.whenStable().then(() => { + expect((component as any).importThemedComponent).toHaveBeenCalledWith('current-theme'); + expect((component as any).importThemedComponent).toHaveBeenCalledWith('custom'); + expect((component as any).compRef.instance.type).toEqual('themed'); + }); + })); + + it('should sync up this component\'s input with the themed component', waitForAsync(() => { + fixture.whenStable().then(() => { + expect((component as any).compRef.instance.testInput).toEqual('changed'); + }); + })); + }); + + describe('that extends another theme that doesn\'t match it either', () => { + beforeEach(waitForAsync(() => { + setupTestingModuleForTheme('current-theme', [ + { name: 'current-theme', extends: 'parent-theme' }, + { name: 'parent-theme', extends: 'non-existing-theme' }, + ]); + })); + + beforeEach(initComponent); + + it('should set compRef to the default component', waitForAsync(() => { + fixture.whenStable().then(() => { + expect((component as any).importThemedComponent).toHaveBeenCalledWith('current-theme'); + expect((component as any).importThemedComponent).toHaveBeenCalledWith('parent-theme'); + expect((component as any).importThemedComponent).toHaveBeenCalledWith('non-existing-theme'); + expect((component as any).compRef.instance.type).toEqual('default'); + }); + })); + + it('should sync up this component\'s input with the default component', waitForAsync(() => { + fixture.whenStable().then(() => { + expect((component as any).compRef.instance.testInput).toEqual('changed'); + }); + })); + }); + + describe('that extends another theme that does match it', () => { + beforeEach(waitForAsync(() => { + setupTestingModuleForTheme('current-theme', [ + { name: 'current-theme', extends: 'parent-theme' }, + { name: 'parent-theme', extends: 'custom' }, + ]); + })); + + beforeEach(initComponent); + + it('should set compRef to the themed component', waitForAsync(() => { + fixture.whenStable().then(() => { + expect((component as any).importThemedComponent).toHaveBeenCalledWith('current-theme'); + expect((component as any).importThemedComponent).toHaveBeenCalledWith('parent-theme'); + expect((component as any).importThemedComponent).toHaveBeenCalledWith('custom'); + expect((component as any).compRef.instance.type).toEqual('themed'); + }); + })); + + it('should sync up this component\'s input with the themed component', waitForAsync(() => { + fixture.whenStable().then(() => { + expect((component as any).compRef.instance.testInput).toEqual('changed'); + }); + })); + }); + }); }); }); /* tslint:enable:max-classes-per-file */ diff --git a/src/app/shared/theme-support/themed.component.ts b/src/app/shared/theme-support/themed.component.ts index 1a41327209..6646c0aa30 100644 --- a/src/app/shared/theme-support/themed.component.ts +++ b/src/app/shared/theme-support/themed.component.ts @@ -11,7 +11,7 @@ import { OnChanges } from '@angular/core'; import { hasValue, isNotEmpty } from '../empty.util'; -import { Subscription } from 'rxjs'; +import { Observable, of as observableOf, Subscription } from 'rxjs'; import { ThemeService } from './theme.service'; import { fromPromise } from 'rxjs/internal-compatibility'; import { catchError, switchMap, map } from 'rxjs/operators'; @@ -69,31 +69,27 @@ export abstract class ThemedComponent implements OnInit, OnDestroy, OnChanges this.lazyLoadSub.unsubscribe(); } - this.lazyLoadSub = - fromPromise(this.importThemedComponent(this.themeService.getThemeName())).pipe( - // if there is no themed version of the component an exception is thrown, - // catch it and return null instead - catchError(() => [null]), - switchMap((themedFile: any) => { - if (hasValue(themedFile) && hasValue(themedFile[this.getComponentName()])) { - // if the file is not null, and exports a component with the specified name, - // return that component - return [themedFile[this.getComponentName()]]; - } else { - // otherwise import and return the default component - return fromPromise(this.importUnthemedComponent()).pipe( - map((unthemedFile: any) => { - return unthemedFile[this.getComponentName()]; - }) - ); - } - }), - ).subscribe((constructor: GenericConstructor) => { - const factory = this.resolver.resolveComponentFactory(constructor); - this.compRef = this.vcr.createComponent(factory); - this.connectInputsAndOutputs(); - this.cdr.markForCheck(); - }); + this.lazyLoadSub = this.resolveThemedComponent(this.themeService.getThemeName()).pipe( + switchMap((themedFile: any) => { + if (hasValue(themedFile) && hasValue(themedFile[this.getComponentName()])) { + // if the file is not null, and exports a component with the specified name, + // return that component + return [themedFile[this.getComponentName()]]; + } else { + // otherwise import and return the default component + return fromPromise(this.importUnthemedComponent()).pipe( + map((unthemedFile: any) => { + return unthemedFile[this.getComponentName()]; + }) + ); + } + }), + ).subscribe((constructor: GenericConstructor) => { + const factory = this.resolver.resolveComponentFactory(constructor); + this.compRef = this.vcr.createComponent(factory); + this.connectInputsAndOutputs(); + this.cdr.markForCheck(); + }); } protected destroyComponentInstance(): void { @@ -113,4 +109,32 @@ export abstract class ThemedComponent implements OnInit, OnDestroy, OnChanges }); } } + + /** + * Attempt to import this component from the current theme or a theme it {@link NamedThemeConfig.extends}. + * Recurse until we succeed or when until we run out of themes to fall back to. + * + * @param themeName The name of the theme to check + * @param checkedThemeNames The list of theme names that are already checked + * @private + */ + private resolveThemedComponent(themeName?: string, checkedThemeNames: string[] = []): Observable { + if (isNotEmpty(themeName)) { + return fromPromise(this.importThemedComponent(themeName)).pipe( + catchError(() => { + // Try the next ancestor theme instead + const nextTheme = this.themeService.getThemeConfigFor(themeName)?.extends; + const nextCheckedThemeNames = [...checkedThemeNames, themeName]; + if (checkedThemeNames.includes(nextTheme)) { + throw new Error('Theme extension cycle detected: ' + [...nextCheckedThemeNames, nextTheme].join(' -> ')); + } else { + return this.resolveThemedComponent(nextTheme, nextCheckedThemeNames); + } + }), + ); + } else { + // If we got here, we've failed to import this component from any ancestor theme → fall back to unthemed + return observableOf(null); + } + } } diff --git a/src/app/shared/uploader/uploader.component.html b/src/app/shared/uploader/uploader.component.html index caa94d1709..109473bc97 100644 --- a/src/app/shared/uploader/uploader.component.html +++ b/src/app/shared/uploader/uploader.component.html @@ -19,11 +19,14 @@ (fileOver)="fileOverBase($event)" class="well ds-base-drop-zone mt-1 mb-3 text-muted">
- {{dropMsg | translate}} {{'uploader.or' | translate}} - + + + {{dropMsg | translate}}{{'uploader.or' | translate}} + + +
diff --git a/src/app/submission/form/submission-form.component.scss b/src/app/submission/form/submission-form.component.scss index 56d6288764..c5e142b89c 100644 --- a/src/app/submission/form/submission-form.component.scss +++ b/src/app/submission/form/submission-form.component.scss @@ -17,3 +17,23 @@ z-index: var(--ds-submission-footer-z-index); } +.btn-link-focus { + // behave as btn-link but does not override box-shadow of btn-link:focus + font-weight: $font-weight-normal; + color: $link-color; + text-decoration: $link-decoration; + @include hover { + color: $link-hover-color; + text-decoration: $link-hover-decoration; + } + &:disabled, + &.disabled { + color: $btn-link-disabled-color; + pointer-events: none; + } + + &:focus, + &.focus { + text-decoration: $link-hover-decoration; + } +} diff --git a/src/app/submission/import-external/import-external-collection/submission-import-external-collection.component.html b/src/app/submission/import-external/import-external-collection/submission-import-external-collection.component.html index 73b41378c8..6fb6ab3382 100644 --- a/src/app/submission/import-external/import-external-collection/submission-import-external-collection.component.html +++ b/src/app/submission/import-external/import-external-collection/submission-import-external-collection.component.html @@ -1,11 +1,16 @@
- diff --git a/src/app/submission/import-external/import-external-collection/submission-import-external-collection.component.spec.ts b/src/app/submission/import-external/import-external-collection/submission-import-external-collection.component.spec.ts index 1247eda0dc..cd7fa86b0a 100644 --- a/src/app/submission/import-external/import-external-collection/submission-import-external-collection.component.spec.ts +++ b/src/app/submission/import-external/import-external-collection/submission-import-external-collection.component.spec.ts @@ -1,10 +1,11 @@ -import { Component, NO_ERRORS_SCHEMA, EventEmitter } from '@angular/core'; -import { waitForAsync, TestBed, ComponentFixture, inject } from '@angular/core/testing'; +import { Component, EventEmitter, NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, fakeAsync, inject, TestBed, waitForAsync } from '@angular/core/testing'; import { TranslateModule } from '@ngx-translate/core'; import { createTestComponent } from '../../../shared/testing/utils.test'; import { SubmissionImportExternalCollectionComponent } from './submission-import-external-collection.component'; import { CollectionListEntry } from '../../../shared/collection-dropdown/collection-dropdown.component'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { By } from '@angular/platform-browser'; describe('SubmissionImportExternalCollectionComponent test suite', () => { let comp: SubmissionImportExternalCollectionComponent; @@ -76,6 +77,46 @@ describe('SubmissionImportExternalCollectionComponent test suite', () => { expect(compAsAny.activeModal.dismiss).toHaveBeenCalled(); }); + + it('should be in loading state when search is not completed', () => { + comp.loading = null; + expect(comp.isLoading()).toBeFalse(); + + comp.loading = true; + expect(comp.isLoading()).toBeTrue(); + + comp.loading = false; + expect(comp.isLoading()).toBeFalse(); + }); + + it('should set loading variable to false on searchComplete event', () => { + comp.loading = null; + + comp.searchComplete(); + expect(comp.loading).toBe(false); + + }); + + it('should emit theOnlySelectable', () => { + spyOn(comp.selectedEvent, 'emit').and.callThrough(); + + const selected: any = {}; + comp.theOnlySelectable(selected); + + expect(comp.selectedEvent.emit).toHaveBeenCalledWith(selected); + }); + + it('dropdown should be invisible when the component is loading', fakeAsync(() => { + + spyOn(comp, 'isLoading').and.returnValue(true); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + const dropdownMenu = fixture.debugElement.query(By.css('ds-collection-dropdown')).nativeElement; + expect(dropdownMenu.classList).toContain('d-none'); + }); + })); + }); }); diff --git a/src/app/submission/import-external/import-external-collection/submission-import-external-collection.component.ts b/src/app/submission/import-external/import-external-collection/submission-import-external-collection.component.ts index cbac0cb710..e35bde03cf 100644 --- a/src/app/submission/import-external/import-external-collection/submission-import-external-collection.component.ts +++ b/src/app/submission/import-external/import-external-collection/submission-import-external-collection.component.ts @@ -1,4 +1,4 @@ -import { Component, Output, EventEmitter } from '@angular/core'; +import { Component, EventEmitter, Output } from '@angular/core'; import { CollectionListEntry } from '../../../shared/collection-dropdown/collection-dropdown.component'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; @@ -16,6 +16,16 @@ export class SubmissionImportExternalCollectionComponent { */ @Output() public selectedEvent = new EventEmitter(); + /** + * If present this value is used to filter collection list by entity type + */ + public entityType: string; + + /** + * If collection searching is pending or not + */ + public loading = true; + /** * Initialize the component variables. * @param {NgbActiveModal} activeModal @@ -37,4 +47,28 @@ export class SubmissionImportExternalCollectionComponent { public closeCollectionModal(): void { this.activeModal.dismiss(false); } + + /** + * Propagate the onlySelectable collection + * @param theOnlySelectable + */ + public theOnlySelectable(theOnlySelectable: CollectionListEntry) { + this.selectedEvent.emit(theOnlySelectable); + } + + /** + * Set the hasChoice state + * @param hasChoice + */ + public searchComplete() { + this.loading = false; + } + + /** + * If the component is in loading state. + */ + public isLoading(): boolean { + return !!this.loading; + } + } diff --git a/src/app/submission/import-external/import-external-preview/submission-import-external-preview.component.html b/src/app/submission/import-external/import-external-preview/submission-import-external-preview.component.html index 83c1ed82b6..bbb0dbcc94 100644 --- a/src/app/submission/import-external/import-external-preview/submission-import-external-preview.component.html +++ b/src/app/submission/import-external/import-external-preview/submission-import-external-preview.component.html @@ -1,5 +1,5 @@