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 5db4e2c2c3..db884b54a8 100644 --- a/package.json +++ b/package.json @@ -159,7 +159,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/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 b1d0c5bed3..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'; @@ -33,10 +33,11 @@ import { RequestService } from '../../../core/data/request.service'; import { NoContent } from '../../../core/shared/NoContent.model'; import { PaginationService } from '../../../core/pagination/pagination.service'; import { followLink } from '../../../shared/utils/follow-link-config.model'; +import { ValidateEmailNotTaken } from './validators/email-taken.validator'; @Component({ selector: 'ds-eperson-form', - templateUrl: './eperson-form.component.html' + templateUrl: './eperson-form.component.html', }) /** * A form used for creating and editing EPeople @@ -161,7 +162,13 @@ export class EPersonFormComponent implements OnInit, OnDestroy { */ isImpersonated = false; - constructor(public epersonService: EPersonDataService, + /** + * Subscription to email field value change + */ + emailValueChangeSubscribe: Subscription; + + constructor(protected changeDetectorRef: ChangeDetectorRef, + public epersonService: EPersonDataService, public groupsDataService: GroupDataService, private formBuilderService: FormBuilderService, private translateService: TranslateService, @@ -187,6 +194,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy { * This method will initialise the page */ initialisePage() { + observableCombineLatest( this.translateService.get(`${this.messagePrefix}.firstName`), this.translateService.get(`${this.messagePrefix}.lastName`), @@ -219,9 +227,13 @@ export class EPersonFormComponent implements OnInit, OnDestroy { name: 'email', validators: { required: null, - pattern: '^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,4}$' + pattern: '^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,4}$', }, required: true, + errorMessages: { + emailTaken: 'error.validation.emailTaken', + pattern: 'error.validation.NotValidEmail' + }, hint: emailHint }); this.canLogIn = new DynamicCheckboxModel( @@ -260,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, @@ -280,7 +299,13 @@ export class EPersonFormComponent implements OnInit, OnDestroy { ); this.canImpersonate$ = activeEPerson$.pipe( - switchMap((eperson) => this.authorizationService.isAuthorized(FeatureID.LoginOnBehalfOf, hasValue(eperson) ? eperson.self : undefined)) + switchMap((eperson) => { + if (hasValue(eperson)) { + return this.authorizationService.isAuthorized(FeatureID.LoginOnBehalfOf, eperson.self); + } else { + return observableOf(false); + } + }) ); this.canDelete$ = activeEPerson$.pipe( switchMap((eperson) => this.authorizationService.isAuthorized(FeatureID.CanDelete, hasValue(eperson) ? eperson.self : undefined)) @@ -343,10 +368,10 @@ export class EPersonFormComponent implements OnInit, OnDestroy { getFirstCompletedRemoteData() ).subscribe((rd: RemoteData) => { if (rd.hasSucceeded) { - this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.created.success', {name: ePersonToCreate.name})); + this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.created.success', { name: ePersonToCreate.name })); this.submitForm.emit(ePersonToCreate); } else { - this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.created.failure', {name: ePersonToCreate.name})); + this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.created.failure', { name: ePersonToCreate.name })); this.cancelForm.emit(); } }); @@ -382,10 +407,10 @@ export class EPersonFormComponent implements OnInit, OnDestroy { const response = this.epersonService.updateEPerson(editedEperson); response.pipe(getFirstCompletedRemoteData()).subscribe((rd: RemoteData) => { if (rd.hasSucceeded) { - this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.edited.success', {name: editedEperson.name})); + this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.edited.success', { name: editedEperson.name })); this.submitForm.emit(editedEperson); } else { - this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.edited.failure', {name: editedEperson.name})); + this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.edited.failure', { name: editedEperson.name })); this.cancelForm.emit(); } }); @@ -395,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 @@ -417,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 */ @@ -436,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/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.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/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/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/edit-item-page/item-metadata/item-metadata.component.html b/src/app/item-page/edit-item-page/item-metadata/item-metadata.component.html index e154487402..70cd2aaa39 100644 --- a/src/app/item-page/edit-item-page/item-metadata/item-metadata.component.html +++ b/src/app/item-page/edit-item-page/item-metadata/item-metadata.component.html @@ -1,66 +1,69 @@
- + {{"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 eb6d2c7b87..2e3e248692 100644 --- a/src/app/item-page/item-page.module.ts +++ b/src/app/item-page/item-page.module.ts @@ -31,6 +31,8 @@ import { MediaViewerVideoComponent } from './media-viewer/media-viewer-video/med import { MediaViewerImageComponent } from './media-viewer/media-viewer-image/media-viewer-image.component'; import { NgxGalleryModule } from '@kolkov/ngx-gallery'; import { MiradorViewerComponent } from './mirador-viewer/mirador-viewer.component'; +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'; @@ -63,7 +65,8 @@ const DECLARATIONS = [ MediaViewerComponent, MediaViewerVideoComponent, MediaViewerImageComponent, - MiradorViewerComponent + MiradorViewerComponent, + VersionPageComponent, ]; @NgModule({ @@ -78,7 +81,8 @@ const DECLARATIONS = [ 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/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/untyped-item/untyped-item.component.html b/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.html index 84724ac1fd..04794717f1 100644 --- a/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.html +++ b/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.html @@ -12,6 +12,9 @@
+
diff --git a/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.spec.ts b/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.spec.ts index 462548c71e..1a946736cd 100644 --- a/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.spec.ts +++ b/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.spec.ts @@ -34,6 +34,12 @@ import { UntypedItemComponent } from './untyped-item.component'; import { RouteService } from '../../../../core/services/route.service'; import { of } from 'rxjs'; import { createPaginatedList } from '../../../../shared/testing/utils.test'; +import { VersionHistoryDataService } from '../../../../core/data/version-history-data.service'; +import { VersionDataService } from '../../../../core/data/version-data.service'; +import { RouterTestingModule } from '@angular/router/testing'; +import { WorkspaceitemDataService } from '../../../../core/submission/workspaceitem-data.service'; +import { SearchService } from '../../../../core/shared/search/search.service'; +import { ItemVersionsSharedService } from '../../../../shared/item/item-versions/item-versions-shared.service'; const iiifEnabledMap: MetadataMap = { @@ -66,31 +72,38 @@ describe('UntypedItemComponent', () => { } }; TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot({ - loader: { - provide: TranslateLoader, - useClass: TranslateLoaderMock - } - })], - declarations: [UntypedItemComponent, GenericItemPageFieldComponent, TruncatePipe], - providers: [ - {provide: ItemDataService, useValue: {}}, - {provide: TruncatableService, useValue: {}}, - {provide: RelationshipService, useValue: {}}, - {provide: ObjectCacheService, useValue: {}}, - {provide: UUIDService, useValue: {}}, - {provide: Store, useValue: {}}, - {provide: RemoteDataBuildService, useValue: {}}, - {provide: CommunityDataService, useValue: {}}, - {provide: HALEndpointService, useValue: {}}, - {provide: NotificationsService, useValue: {}}, - {provide: HttpClient, useValue: {}}, - {provide: DSOChangeAnalyzer, useValue: {}}, - {provide: DefaultChangeAnalyzer, useValue: {}}, - {provide: BitstreamDataService, useValue: mockBitstreamDataService}, - {provide: RouteService, useValue: mockRouteService} + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }), + RouterTestingModule, + ], + declarations: [UntypedItemComponent, GenericItemPageFieldComponent, TruncatePipe ], + providers: [ + { provide: ItemDataService, useValue: {} }, + { provide: TruncatableService, useValue: {} }, + { provide: RelationshipService, useValue: {} }, + { provide: ObjectCacheService, useValue: {} }, + { provide: UUIDService, useValue: {} }, + { provide: Store, useValue: {} }, + { provide: RemoteDataBuildService, useValue: {} }, + { provide: CommunityDataService, useValue: {} }, + { provide: HALEndpointService, useValue: {} }, + { provide: NotificationsService, useValue: {} }, + { 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-submission.component.ts b/src/app/my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.ts index c1e67561b2..469f04ffd5 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 @@ -119,7 +119,7 @@ export class MyDSpaceNewSubmissionComponent implements OnDestroy, OnInit { } /** - * Method called on clicking the button "New Submition", It opens a dialog for + * Method called on clicking the button "New Submission", It opens a dialog for * select a collection. */ openDialog() { 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/request-copy/deny-request-copy/deny-request-copy.component.html b/src/app/request-copy/deny-request-copy/deny-request-copy.component.html new file mode 100644 index 0000000000..b00bc079dd --- /dev/null +++ b/src/app/request-copy/deny-request-copy/deny-request-copy.component.html @@ -0,0 +1,9 @@ +
+

{{'deny-request-copy.header' | translate}}

+
+

{{'deny-request-copy.intro' | translate}}

+ + +
+ +
diff --git a/src/app/request-copy/deny-request-copy/deny-request-copy.component.scss b/src/app/request-copy/deny-request-copy/deny-request-copy.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/request-copy/deny-request-copy/deny-request-copy.component.spec.ts b/src/app/request-copy/deny-request-copy/deny-request-copy.component.spec.ts new file mode 100644 index 0000000000..c88bfd3b5e --- /dev/null +++ b/src/app/request-copy/deny-request-copy/deny-request-copy.component.spec.ts @@ -0,0 +1,177 @@ +import { DenyRequestCopyComponent } from './deny-request-copy.component'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { VarDirective } from '../../shared/utils/var.directive'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { RouterTestingModule } from '@angular/router/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { AuthService } from '../../core/auth/auth.service'; +import { ItemDataService } from '../../core/data/item-data.service'; +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { ItemRequestDataService } from '../../core/data/item-request-data.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { of as observableOf } from 'rxjs'; +import { + createFailedRemoteDataObject$, + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$ +} from '../../shared/remote-data.utils'; +import { ItemRequest } from '../../core/shared/item-request.model'; +import { EPerson } from '../../core/eperson/models/eperson.model'; +import { Item } from '../../core/shared/item.model'; +import { RequestCopyEmail } from '../email-request-copy/request-copy-email.model'; + +describe('DenyRequestCopyComponent', () => { + let component: DenyRequestCopyComponent; + let fixture: ComponentFixture; + + let router: Router; + let route: ActivatedRoute; + let authService: AuthService; + let translateService: TranslateService; + let itemDataService: ItemDataService; + let nameService: DSONameService; + let itemRequestService: ItemRequestDataService; + let notificationsService: NotificationsService; + + let itemRequest: ItemRequest; + let user: EPerson; + let item: Item; + let itemName: string; + let itemUrl: string; + + beforeEach(waitForAsync(() => { + itemRequest = Object.assign(new ItemRequest(), { + token: 'item-request-token', + requestName: 'requester name' + }); + user = Object.assign(new EPerson(), { + metadata: { + 'eperson.firstname': [ + { + value: 'first' + } + ], + 'eperson.lastname': [ + { + value: 'last' + } + ] + }, + email: 'user-email', + }); + itemName = 'item-name'; + itemUrl = 'item-url'; + item = Object.assign(new Item(), { + id: 'item-id', + metadata: { + 'dc.identifier.uri': [ + { + value: itemUrl + } + ], + 'dc.title': [ + { + value: itemName + } + ] + } + }); + + router = jasmine.createSpyObj('router', { + navigateByUrl: jasmine.createSpy('navigateByUrl'), + }); + route = jasmine.createSpyObj('route', {}, { + data: observableOf({ + request: createSuccessfulRemoteDataObject(itemRequest), + }), + }); + authService = jasmine.createSpyObj('authService', { + isAuthenticated: observableOf(true), + getAuthenticatedUserFromStore: observableOf(user), + }); + itemDataService = jasmine.createSpyObj('itemDataService', { + findById: createSuccessfulRemoteDataObject$(item), + }); + nameService = jasmine.createSpyObj('nameService', { + getName: itemName, + }); + itemRequestService = jasmine.createSpyObj('itemRequestService', { + deny: createSuccessfulRemoteDataObject$(itemRequest), + }); + notificationsService = jasmine.createSpyObj('notificationsService', ['success', 'error']); + + TestBed.configureTestingModule({ + declarations: [DenyRequestCopyComponent, VarDirective], + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], + providers: [ + { provide: Router, useValue: router }, + { provide: ActivatedRoute, useValue: route }, + { provide: AuthService, useValue: authService }, + { provide: ItemDataService, useValue: itemDataService }, + { provide: DSONameService, useValue: nameService }, + { provide: ItemRequestDataService, useValue: itemRequestService }, + { provide: NotificationsService, useValue: notificationsService }, + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(DenyRequestCopyComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + translateService = (component as any).translateService; + spyOn(translateService, 'get').and.returnValue(observableOf('translated-message')); + }); + + it('message$ should be parameterized correctly', (done) => { + component.message$.subscribe(() => { + expect(translateService.get).toHaveBeenCalledWith(jasmine.anything(), Object.assign({ + recipientName: itemRequest.requestName, + itemUrl: itemUrl, + itemName: itemName, + authorName: user.name, + authorEmail: user.email, + })); + done(); + }); + }); + + describe('deny', () => { + let email: RequestCopyEmail; + + describe('when the request is successful', () => { + beforeEach(() => { + email = new RequestCopyEmail('subject', 'message'); + (itemRequestService.deny as jasmine.Spy).and.returnValue(createSuccessfulRemoteDataObject$(itemRequest)); + component.deny(email); + }); + + it('should display a success notification', () => { + expect(notificationsService.success).toHaveBeenCalled(); + }); + + it('should navigate to the homepage', () => { + expect(router.navigateByUrl).toHaveBeenCalledWith('/'); + }); + }); + + describe('when the request is unsuccessful', () => { + beforeEach(() => { + email = new RequestCopyEmail('subject', 'message'); + (itemRequestService.deny as jasmine.Spy).and.returnValue(createFailedRemoteDataObject$()); + component.deny(email); + }); + + it('should display a success notification', () => { + expect(notificationsService.error).toHaveBeenCalled(); + }); + + it('should not navigate', () => { + expect(router.navigateByUrl).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/src/app/request-copy/deny-request-copy/deny-request-copy.component.ts b/src/app/request-copy/deny-request-copy/deny-request-copy.component.ts new file mode 100644 index 0000000000..0795cf5919 --- /dev/null +++ b/src/app/request-copy/deny-request-copy/deny-request-copy.component.ts @@ -0,0 +1,112 @@ +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { map, switchMap } from 'rxjs/operators'; +import { ItemRequest } from '../../core/shared/item-request.model'; +import { Observable } from 'rxjs/internal/Observable'; +import { + getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload, + redirectOn4xx +} from '../../core/shared/operators'; +import { RemoteData } from '../../core/data/remote-data'; +import { AuthService } from '../../core/auth/auth.service'; +import { TranslateService } from '@ngx-translate/core'; +import { combineLatest as observableCombineLatest } from 'rxjs'; +import { ItemDataService } from '../../core/data/item-data.service'; +import { EPerson } from '../../core/eperson/models/eperson.model'; +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { Item } from '../../core/shared/item.model'; +import { isNotEmpty } from '../../shared/empty.util'; +import { ItemRequestDataService } from '../../core/data/item-request-data.service'; +import { RequestCopyEmail } from '../email-request-copy/request-copy-email.model'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; + +@Component({ + selector: 'ds-deny-request-copy', + styleUrls: ['./deny-request-copy.component.scss'], + templateUrl: './deny-request-copy.component.html' +}) +/** + * Component for denying an item request + */ +export class DenyRequestCopyComponent implements OnInit { + /** + * The item request to deny + */ + itemRequestRD$: Observable>; + + /** + * The default subject of the message to send to the user requesting the item + */ + subject$: Observable; + /** + * The default contents of the message to send to the user requesting the item + */ + message$: Observable; + + constructor( + private router: Router, + private route: ActivatedRoute, + private authService: AuthService, + private translateService: TranslateService, + private itemDataService: ItemDataService, + private nameService: DSONameService, + private itemRequestService: ItemRequestDataService, + private notificationsService: NotificationsService, + ) { + + } + + ngOnInit(): void { + this.itemRequestRD$ = this.route.data.pipe( + map((data) => data.request as RemoteData), + getFirstCompletedRemoteData(), + redirectOn4xx(this.router, this.authService), + ); + + const msgParams$ = observableCombineLatest( + this.itemRequestRD$.pipe(getFirstSucceededRemoteDataPayload()), + this.authService.getAuthenticatedUserFromStore(), + ).pipe( + switchMap(([itemRequest, user]: [ItemRequest, EPerson]) => { + return this.itemDataService.findById(itemRequest.itemId).pipe( + getFirstSucceededRemoteDataPayload(), + map((item: Item) => { + const uri = item.firstMetadataValue('dc.identifier.uri'); + return Object.assign({ + recipientName: itemRequest.requestName, + itemUrl: isNotEmpty(uri) ? uri : item.handle, + itemName: this.nameService.getName(item), + authorName: user.name, + authorEmail: user.email, + }); + }), + ); + }), + ); + + this.subject$ = this.translateService.get('deny-request-copy.email.subject'); + this.message$ = msgParams$.pipe( + switchMap((params) => this.translateService.get('deny-request-copy.email.message', params)), + ); + } + + /** + * Deny the item request + * @param email Subject and contents of the message to send back to the user requesting the item + */ + deny(email: RequestCopyEmail) { + this.itemRequestRD$.pipe( + getFirstSucceededRemoteDataPayload(), + switchMap((itemRequest: ItemRequest) => this.itemRequestService.deny(itemRequest.token, email)), + getFirstCompletedRemoteData() + ).subscribe((rd) => { + if (rd.hasSucceeded) { + this.notificationsService.success(this.translateService.get('deny-request-copy.success')); + this.router.navigateByUrl('/'); + } else { + this.notificationsService.error(this.translateService.get('deny-request-copy.error'), rd.errorMessage); + } + }); + } + +} diff --git a/src/app/request-copy/email-request-copy/email-request-copy.component.html b/src/app/request-copy/email-request-copy/email-request-copy.component.html new file mode 100644 index 0000000000..d7633b0334 --- /dev/null +++ b/src/app/request-copy/email-request-copy/email-request-copy.component.html @@ -0,0 +1,30 @@ +
+
+ + +
+ {{ 'grant-deny-request-copy.email.subject.empty' | translate }} +
+
+
+ + +
+ {{ 'grant-deny-request-copy.email.message.empty' | translate }} +
+
+ +
+ + +
+
diff --git a/src/app/request-copy/email-request-copy/email-request-copy.component.scss b/src/app/request-copy/email-request-copy/email-request-copy.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/request-copy/email-request-copy/email-request-copy.component.spec.ts b/src/app/request-copy/email-request-copy/email-request-copy.component.spec.ts new file mode 100644 index 0000000000..3857c0d91b --- /dev/null +++ b/src/app/request-copy/email-request-copy/email-request-copy.component.spec.ts @@ -0,0 +1,47 @@ +import { EmailRequestCopyComponent } from './email-request-copy.component'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { VarDirective } from '../../shared/utils/var.directive'; +import { TranslateModule } from '@ngx-translate/core'; +import { RouterTestingModule } from '@angular/router/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { Location } from '@angular/common'; +import { RequestCopyEmail } from './request-copy-email.model'; + +describe('EmailRequestCopyComponent', () => { + let component: EmailRequestCopyComponent; + let fixture: ComponentFixture; + + let location: Location; + + beforeEach(waitForAsync(() => { + location = jasmine.createSpyObj('location', ['back']); + + TestBed.configureTestingModule({ + declarations: [EmailRequestCopyComponent, VarDirective], + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], + providers: [ + { provide: Location, useValue: location }, + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(EmailRequestCopyComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('return should navigate to the previous page', () => { + component.return(); + expect(location.back).toHaveBeenCalled(); + }); + + it('submit should emit an email object', () => { + spyOn(component.send, 'emit').and.stub(); + component.subject = 'test-subject'; + component.message = 'test-message'; + component.submit(); + expect(component.send.emit).toHaveBeenCalledWith(new RequestCopyEmail('test-subject', 'test-message')); + }); +}); diff --git a/src/app/request-copy/email-request-copy/email-request-copy.component.ts b/src/app/request-copy/email-request-copy/email-request-copy.component.ts new file mode 100644 index 0000000000..ab2c8b4526 --- /dev/null +++ b/src/app/request-copy/email-request-copy/email-request-copy.component.ts @@ -0,0 +1,45 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { RequestCopyEmail } from './request-copy-email.model'; +import { Location } from '@angular/common'; + +@Component({ + selector: 'ds-email-request-copy', + styleUrls: ['./email-request-copy.component.scss'], + templateUrl: './email-request-copy.component.html' +}) +/** + * A form component for an email to send back to the user requesting an item + */ +export class EmailRequestCopyComponent { + /** + * Event emitter for sending the email + */ + @Output() send: EventEmitter = new EventEmitter(); + + /** + * The subject of the email + */ + @Input() subject: string; + + /** + * The contents of the email + */ + @Input() message: string; + + constructor(protected location: Location) { + } + + /** + * Submit the email + */ + submit() { + this.send.emit(new RequestCopyEmail(this.subject, this.message)); + } + + /** + * Return to the previous page + */ + return() { + this.location.back(); + } +} diff --git a/src/app/request-copy/email-request-copy/request-copy-email.model.ts b/src/app/request-copy/email-request-copy/request-copy-email.model.ts new file mode 100644 index 0000000000..3ae83e8815 --- /dev/null +++ b/src/app/request-copy/email-request-copy/request-copy-email.model.ts @@ -0,0 +1,8 @@ +/** + * A class representing an email to send back to the user requesting an item + */ +export class RequestCopyEmail { + constructor(public subject: string, + public message: string) { + } +} diff --git a/src/app/request-copy/grant-deny-request-copy/grant-deny-request-copy.component.html b/src/app/request-copy/grant-deny-request-copy/grant-deny-request-copy.component.html new file mode 100644 index 0000000000..37b275d8f8 --- /dev/null +++ b/src/app/request-copy/grant-deny-request-copy/grant-deny-request-copy.component.html @@ -0,0 +1,30 @@ +
+

{{'grant-deny-request-copy.header' | translate}}

+
+ +
+

{{'grant-deny-request-copy.processed' | translate}}

+

+ {{'grant-deny-request-copy.home-page' | translate}} +

+
+
+ +
diff --git a/src/app/request-copy/grant-deny-request-copy/grant-deny-request-copy.component.scss b/src/app/request-copy/grant-deny-request-copy/grant-deny-request-copy.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/request-copy/grant-deny-request-copy/grant-deny-request-copy.component.spec.ts b/src/app/request-copy/grant-deny-request-copy/grant-deny-request-copy.component.spec.ts new file mode 100644 index 0000000000..5c37a86f24 --- /dev/null +++ b/src/app/request-copy/grant-deny-request-copy/grant-deny-request-copy.component.spec.ts @@ -0,0 +1,141 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { VarDirective } from '../../shared/utils/var.directive'; +import { TranslateModule } from '@ngx-translate/core'; +import { RouterTestingModule } from '@angular/router/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { AuthService } from '../../core/auth/auth.service'; +import { ItemDataService } from '../../core/data/item-data.service'; +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { of as observableOf } from 'rxjs'; +import { + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$ +} from '../../shared/remote-data.utils'; +import { ItemRequest } from '../../core/shared/item-request.model'; +import { Item } from '../../core/shared/item.model'; +import { GrantDenyRequestCopyComponent } from './grant-deny-request-copy.component'; +import { getItemPageRoute } from '../../item-page/item-page-routing-paths'; +import { getRequestCopyDenyRoute, getRequestCopyGrantRoute } from '../request-copy-routing-paths'; +import { By } from '@angular/platform-browser'; + +describe('GrantDenyRequestCopyComponent', () => { + let component: GrantDenyRequestCopyComponent; + let fixture: ComponentFixture; + + let router: Router; + let route: ActivatedRoute; + let authService: AuthService; + let itemDataService: ItemDataService; + let nameService: DSONameService; + + let itemRequest: ItemRequest; + let item: Item; + let itemName: string; + let itemUrl: string; + + beforeEach(waitForAsync(() => { + itemRequest = Object.assign(new ItemRequest(), { + token: 'item-request-token', + requestName: 'requester name' + }); + itemName = 'item-name'; + item = Object.assign(new Item(), { + id: 'item-id', + metadata: { + 'dc.identifier.uri': [ + { + value: itemUrl + } + ], + 'dc.title': [ + { + value: itemName + } + ] + } + }); + itemUrl = getItemPageRoute(item); + + route = jasmine.createSpyObj('route', {}, { + data: observableOf({ + request: createSuccessfulRemoteDataObject(itemRequest), + }), + }); + authService = jasmine.createSpyObj('authService', { + isAuthenticated: observableOf(true), + }); + itemDataService = jasmine.createSpyObj('itemDataService', { + findById: createSuccessfulRemoteDataObject$(item), + }); + nameService = jasmine.createSpyObj('nameService', { + getName: itemName, + }); + + TestBed.configureTestingModule({ + declarations: [GrantDenyRequestCopyComponent, VarDirective], + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], + providers: [ + { provide: ActivatedRoute, useValue: route }, + { provide: AuthService, useValue: authService }, + { provide: ItemDataService, useValue: itemDataService }, + { provide: DSONameService, useValue: nameService }, + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(GrantDenyRequestCopyComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + router = (component as any).router; + spyOn(router, 'navigateByUrl').and.stub(); + }); + + it('should initialise itemName$', (done) => { + component.itemName$.subscribe((result) => { + expect(result).toEqual(itemName); + done(); + }); + }); + + it('should initialise itemUrl$', (done) => { + component.itemUrl$.subscribe((result) => { + expect(result).toEqual(itemUrl); + done(); + }); + }); + + it('should initialise denyRoute$', (done) => { + component.denyRoute$.subscribe((result) => { + expect(result).toEqual(getRequestCopyDenyRoute(itemRequest.token)); + done(); + }); + }); + + it('should initialise grantRoute$', (done) => { + component.grantRoute$.subscribe((result) => { + expect(result).toEqual(getRequestCopyGrantRoute(itemRequest.token)); + done(); + }); + }); + + describe('processed message', () => { + it('should not be displayed when decisionDate is undefined', () => { + const message = fixture.debugElement.query(By.css('.processed-message')); + expect(message).toBeNull(); + }); + + it('should be displayed when decisionDate is defined', () => { + component.itemRequestRD$ = createSuccessfulRemoteDataObject$(Object.assign(new ItemRequest(), itemRequest, { + decisionDate: 'defined-date' + })); + fixture.detectChanges(); + + const message = fixture.debugElement.query(By.css('.processed-message')); + expect(message).not.toBeNull(); + }); + }); +}); diff --git a/src/app/request-copy/grant-deny-request-copy/grant-deny-request-copy.component.ts b/src/app/request-copy/grant-deny-request-copy/grant-deny-request-copy.component.ts new file mode 100644 index 0000000000..5ba81e991b --- /dev/null +++ b/src/app/request-copy/grant-deny-request-copy/grant-deny-request-copy.component.ts @@ -0,0 +1,97 @@ +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { map, switchMap } from 'rxjs/operators'; +import { ItemRequest } from '../../core/shared/item-request.model'; +import { Observable } from 'rxjs/internal/Observable'; +import { + getFirstCompletedRemoteData, + getFirstSucceededRemoteDataPayload, + redirectOn4xx +} from '../../core/shared/operators'; +import { RemoteData } from '../../core/data/remote-data'; +import { AuthService } from '../../core/auth/auth.service'; +import { getRequestCopyDenyRoute, getRequestCopyGrantRoute } from '../request-copy-routing-paths'; +import { Item } from '../../core/shared/item.model'; +import { ItemDataService } from '../../core/data/item-data.service'; +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { getItemPageRoute } from '../../item-page/item-page-routing-paths'; + +@Component({ + selector: 'ds-grant-deny-request-copy', + styleUrls: ['./grant-deny-request-copy.component.scss'], + templateUrl: './grant-deny-request-copy.component.html' +}) +/** + * Component for an author to decide to grant or deny an item request + */ +export class GrantDenyRequestCopyComponent implements OnInit { + /** + * The item request to grant or deny + */ + itemRequestRD$: Observable>; + + /** + * The item the request is requesting access to + */ + itemRD$: Observable>; + + /** + * The name of the item + */ + itemName$: Observable; + + /** + * The url of the item + */ + itemUrl$: Observable; + + /** + * The route to the page for denying access to the item + */ + denyRoute$: Observable; + + /** + * The route to the page for granting access to the item + */ + grantRoute$: Observable; + + constructor( + private router: Router, + private route: ActivatedRoute, + private authService: AuthService, + private itemDataService: ItemDataService, + private nameService: DSONameService, + ) { + + } + + ngOnInit(): void { + this.itemRequestRD$ = this.route.data.pipe( + map((data) => data.request as RemoteData), + getFirstCompletedRemoteData(), + redirectOn4xx(this.router, this.authService), + ); + this.itemRD$ = this.itemRequestRD$.pipe( + getFirstSucceededRemoteDataPayload(), + switchMap((itemRequest: ItemRequest) => this.itemDataService.findById(itemRequest.itemId)), + ); + this.itemName$ = this.itemRD$.pipe( + getFirstSucceededRemoteDataPayload(), + map((item) => this.nameService.getName(item)), + ); + this.itemUrl$ = this.itemRD$.pipe( + getFirstSucceededRemoteDataPayload(), + map((item) => getItemPageRoute(item)), + ); + + this.denyRoute$ = this.itemRequestRD$.pipe( + getFirstSucceededRemoteDataPayload(), + map((itemRequest: ItemRequest) => getRequestCopyDenyRoute(itemRequest.token)) + ); + this.grantRoute$ = this.itemRequestRD$.pipe( + getFirstSucceededRemoteDataPayload(), + map((itemRequest: ItemRequest) => getRequestCopyGrantRoute(itemRequest.token)) + ); + } + +} diff --git a/src/app/request-copy/grant-request-copy/grant-request-copy.component.html b/src/app/request-copy/grant-request-copy/grant-request-copy.component.html new file mode 100644 index 0000000000..d2c2cfc3c8 --- /dev/null +++ b/src/app/request-copy/grant-request-copy/grant-request-copy.component.html @@ -0,0 +1,17 @@ +
+

{{'grant-request-copy.header' | translate}}

+
+

{{'grant-request-copy.intro' | translate}}

+ + +

{{ 'grant-deny-request-copy.email.permissions.info' | translate }}

+
+
+ + +
+
+
+
+ +
diff --git a/src/app/request-copy/grant-request-copy/grant-request-copy.component.scss b/src/app/request-copy/grant-request-copy/grant-request-copy.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/request-copy/grant-request-copy/grant-request-copy.component.spec.ts b/src/app/request-copy/grant-request-copy/grant-request-copy.component.spec.ts new file mode 100644 index 0000000000..b6ccb8557e --- /dev/null +++ b/src/app/request-copy/grant-request-copy/grant-request-copy.component.spec.ts @@ -0,0 +1,177 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { VarDirective } from '../../shared/utils/var.directive'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { RouterTestingModule } from '@angular/router/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { AuthService } from '../../core/auth/auth.service'; +import { ItemDataService } from '../../core/data/item-data.service'; +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { ItemRequestDataService } from '../../core/data/item-request-data.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { of as observableOf } from 'rxjs'; +import { + createFailedRemoteDataObject$, + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$ +} from '../../shared/remote-data.utils'; +import { ItemRequest } from '../../core/shared/item-request.model'; +import { EPerson } from '../../core/eperson/models/eperson.model'; +import { Item } from '../../core/shared/item.model'; +import { RequestCopyEmail } from '../email-request-copy/request-copy-email.model'; +import { GrantRequestCopyComponent } from './grant-request-copy.component'; + +describe('GrantRequestCopyComponent', () => { + let component: GrantRequestCopyComponent; + let fixture: ComponentFixture; + + let router: Router; + let route: ActivatedRoute; + let authService: AuthService; + let translateService: TranslateService; + let itemDataService: ItemDataService; + let nameService: DSONameService; + let itemRequestService: ItemRequestDataService; + let notificationsService: NotificationsService; + + let itemRequest: ItemRequest; + let user: EPerson; + let item: Item; + let itemName: string; + let itemUrl: string; + + beforeEach(waitForAsync(() => { + itemRequest = Object.assign(new ItemRequest(), { + token: 'item-request-token', + requestName: 'requester name' + }); + user = Object.assign(new EPerson(), { + metadata: { + 'eperson.firstname': [ + { + value: 'first' + } + ], + 'eperson.lastname': [ + { + value: 'last' + } + ] + }, + email: 'user-email', + }); + itemName = 'item-name'; + itemUrl = 'item-url'; + item = Object.assign(new Item(), { + id: 'item-id', + metadata: { + 'dc.identifier.uri': [ + { + value: itemUrl + } + ], + 'dc.title': [ + { + value: itemName + } + ] + } + }); + + router = jasmine.createSpyObj('router', { + navigateByUrl: jasmine.createSpy('navigateByUrl'), + }); + route = jasmine.createSpyObj('route', {}, { + data: observableOf({ + request: createSuccessfulRemoteDataObject(itemRequest), + }), + }); + authService = jasmine.createSpyObj('authService', { + isAuthenticated: observableOf(true), + getAuthenticatedUserFromStore: observableOf(user), + }); + itemDataService = jasmine.createSpyObj('itemDataService', { + findById: createSuccessfulRemoteDataObject$(item), + }); + nameService = jasmine.createSpyObj('nameService', { + getName: itemName, + }); + itemRequestService = jasmine.createSpyObj('itemRequestService', { + grant: createSuccessfulRemoteDataObject$(itemRequest), + }); + notificationsService = jasmine.createSpyObj('notificationsService', ['success', 'error']); + + TestBed.configureTestingModule({ + declarations: [GrantRequestCopyComponent, VarDirective], + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], + providers: [ + { provide: Router, useValue: router }, + { provide: ActivatedRoute, useValue: route }, + { provide: AuthService, useValue: authService }, + { provide: ItemDataService, useValue: itemDataService }, + { provide: DSONameService, useValue: nameService }, + { provide: ItemRequestDataService, useValue: itemRequestService }, + { provide: NotificationsService, useValue: notificationsService }, + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(GrantRequestCopyComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + translateService = (component as any).translateService; + spyOn(translateService, 'get').and.returnValue(observableOf('translated-message')); + }); + + it('message$ should be parameterized correctly', (done) => { + component.message$.subscribe(() => { + expect(translateService.get).toHaveBeenCalledWith(jasmine.anything(), Object.assign({ + recipientName: itemRequest.requestName, + itemUrl: itemUrl, + itemName: itemName, + authorName: user.name, + authorEmail: user.email, + })); + done(); + }); + }); + + describe('grant', () => { + let email: RequestCopyEmail; + + describe('when the request is successful', () => { + beforeEach(() => { + email = new RequestCopyEmail('subject', 'message'); + (itemRequestService.grant as jasmine.Spy).and.returnValue(createSuccessfulRemoteDataObject$(itemRequest)); + component.grant(email); + }); + + it('should display a success notification', () => { + expect(notificationsService.success).toHaveBeenCalled(); + }); + + it('should navigate to the homepage', () => { + expect(router.navigateByUrl).toHaveBeenCalledWith('/'); + }); + }); + + describe('when the request is unsuccessful', () => { + beforeEach(() => { + email = new RequestCopyEmail('subject', 'message'); + (itemRequestService.grant as jasmine.Spy).and.returnValue(createFailedRemoteDataObject$()); + component.grant(email); + }); + + it('should display a success notification', () => { + expect(notificationsService.error).toHaveBeenCalled(); + }); + + it('should not navigate', () => { + expect(router.navigateByUrl).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/src/app/request-copy/grant-request-copy/grant-request-copy.component.ts b/src/app/request-copy/grant-request-copy/grant-request-copy.component.ts new file mode 100644 index 0000000000..e3a4614f5d --- /dev/null +++ b/src/app/request-copy/grant-request-copy/grant-request-copy.component.ts @@ -0,0 +1,118 @@ +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { map, switchMap } from 'rxjs/operators'; +import { ItemRequest } from '../../core/shared/item-request.model'; +import { Observable } from 'rxjs/internal/Observable'; +import { + getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload, + redirectOn4xx +} from '../../core/shared/operators'; +import { RemoteData } from '../../core/data/remote-data'; +import { AuthService } from '../../core/auth/auth.service'; +import { TranslateService } from '@ngx-translate/core'; +import { combineLatest as observableCombineLatest } from 'rxjs'; +import { ItemDataService } from '../../core/data/item-data.service'; +import { EPerson } from '../../core/eperson/models/eperson.model'; +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { Item } from '../../core/shared/item.model'; +import { isNotEmpty } from '../../shared/empty.util'; +import { ItemRequestDataService } from '../../core/data/item-request-data.service'; +import { RequestCopyEmail } from '../email-request-copy/request-copy-email.model'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; + +@Component({ + selector: 'ds-grant-request-copy', + styleUrls: ['./grant-request-copy.component.scss'], + templateUrl: './grant-request-copy.component.html' +}) +/** + * Component for granting an item request + */ +export class GrantRequestCopyComponent implements OnInit { + /** + * The item request to accept + */ + itemRequestRD$: Observable>; + + /** + * The default subject of the message to send to the user requesting the item + */ + subject$: Observable; + /** + * The default contents of the message to send to the user requesting the item + */ + message$: Observable; + + /** + * Whether or not the item should be open access, to avoid future requests + * Defaults to false + */ + suggestOpenAccess = false; + + constructor( + private router: Router, + private route: ActivatedRoute, + private authService: AuthService, + private translateService: TranslateService, + private itemDataService: ItemDataService, + private nameService: DSONameService, + private itemRequestService: ItemRequestDataService, + private notificationsService: NotificationsService, + ) { + + } + + ngOnInit(): void { + this.itemRequestRD$ = this.route.data.pipe( + map((data) => data.request as RemoteData), + getFirstCompletedRemoteData(), + redirectOn4xx(this.router, this.authService), + ); + + const msgParams$ = observableCombineLatest( + this.itemRequestRD$.pipe(getFirstSucceededRemoteDataPayload()), + this.authService.getAuthenticatedUserFromStore(), + ).pipe( + switchMap(([itemRequest, user]: [ItemRequest, EPerson]) => { + return this.itemDataService.findById(itemRequest.itemId).pipe( + getFirstSucceededRemoteDataPayload(), + map((item: Item) => { + const uri = item.firstMetadataValue('dc.identifier.uri'); + return Object.assign({ + recipientName: itemRequest.requestName, + itemUrl: isNotEmpty(uri) ? uri : item.handle, + itemName: this.nameService.getName(item), + authorName: user.name, + authorEmail: user.email, + }); + }), + ); + }), + ); + + this.subject$ = this.translateService.get('grant-request-copy.email.subject'); + this.message$ = msgParams$.pipe( + switchMap((params) => this.translateService.get('grant-request-copy.email.message', params)), + ); + } + + /** + * Grant the item request + * @param email Subject and contents of the message to send back to the user requesting the item + */ + grant(email: RequestCopyEmail) { + this.itemRequestRD$.pipe( + getFirstSucceededRemoteDataPayload(), + switchMap((itemRequest: ItemRequest) => this.itemRequestService.grant(itemRequest.token, email, this.suggestOpenAccess)), + getFirstCompletedRemoteData() + ).subscribe((rd) => { + if (rd.hasSucceeded) { + this.notificationsService.success(this.translateService.get('grant-request-copy.success')); + this.router.navigateByUrl('/'); + } else { + this.notificationsService.error(this.translateService.get('grant-request-copy.error'), rd.errorMessage); + } + }); + } + +} diff --git a/src/app/request-copy/request-copy-routing-paths.ts b/src/app/request-copy/request-copy-routing-paths.ts new file mode 100644 index 0000000000..1d0204a1b8 --- /dev/null +++ b/src/app/request-copy/request-copy-routing-paths.ts @@ -0,0 +1,18 @@ +import { URLCombiner } from '../core/url-combiner/url-combiner'; +import { getRequestCopyModulePath } from '../app-routing-paths'; + +export function getRequestCopyRoute(token: string) { + return new URLCombiner(getRequestCopyModulePath(), token).toString(); +} + +export const REQUEST_COPY_DENY_PATH = 'deny'; + +export function getRequestCopyDenyRoute(token: string) { + return new URLCombiner(getRequestCopyRoute(token), REQUEST_COPY_DENY_PATH).toString(); +} + +export const REQUEST_COPY_GRANT_PATH = 'grant'; + +export function getRequestCopyGrantRoute(token: string) { + return new URLCombiner(getRequestCopyRoute(token), REQUEST_COPY_GRANT_PATH).toString(); +} diff --git a/src/app/request-copy/request-copy-routing.module.ts b/src/app/request-copy/request-copy-routing.module.ts new file mode 100644 index 0000000000..e7a205d0aa --- /dev/null +++ b/src/app/request-copy/request-copy-routing.module.ts @@ -0,0 +1,40 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { RequestCopyResolver } from './request-copy.resolver'; +import { GrantDenyRequestCopyComponent } from './grant-deny-request-copy/grant-deny-request-copy.component'; +import { REQUEST_COPY_DENY_PATH, REQUEST_COPY_GRANT_PATH } from './request-copy-routing-paths'; +import { DenyRequestCopyComponent } from './deny-request-copy/deny-request-copy.component'; +import { GrantRequestCopyComponent } from './grant-request-copy/grant-request-copy.component'; + +@NgModule({ + imports: [ + RouterModule.forChild([ + { + path: ':token', + resolve: { + request: RequestCopyResolver + }, + children: [ + { + path: '', + component: GrantDenyRequestCopyComponent, + }, + { + path: REQUEST_COPY_DENY_PATH, + component: DenyRequestCopyComponent, + }, + { + path: REQUEST_COPY_GRANT_PATH, + component: GrantRequestCopyComponent, + }, + ] + } + ]) + ], + providers: [ + RequestCopyResolver, + GrantDenyRequestCopyComponent + ] +}) +export class RequestCopyRoutingModule { +} diff --git a/src/app/request-copy/request-copy.module.ts b/src/app/request-copy/request-copy.module.ts new file mode 100644 index 0000000000..d55d5ad83f --- /dev/null +++ b/src/app/request-copy/request-copy.module.ts @@ -0,0 +1,30 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '../shared/shared.module'; +import { GrantDenyRequestCopyComponent } from './grant-deny-request-copy/grant-deny-request-copy.component'; +import { RequestCopyRoutingModule } from './request-copy-routing.module'; +import { DenyRequestCopyComponent } from './deny-request-copy/deny-request-copy.component'; +import { EmailRequestCopyComponent } from './email-request-copy/email-request-copy.component'; +import { GrantRequestCopyComponent } from './grant-request-copy/grant-request-copy.component'; + +@NgModule({ + imports: [ + CommonModule, + SharedModule, + RequestCopyRoutingModule + ], + declarations: [ + GrantDenyRequestCopyComponent, + DenyRequestCopyComponent, + EmailRequestCopyComponent, + GrantRequestCopyComponent, + ], + providers: [] +}) + +/** + * Module related to components used to grant or deny an item request + */ +export class RequestCopyModule { + +} diff --git a/src/app/request-copy/request-copy.resolver.ts b/src/app/request-copy/request-copy.resolver.ts new file mode 100644 index 0000000000..eb5c5cf0f0 --- /dev/null +++ b/src/app/request-copy/request-copy.resolver.ts @@ -0,0 +1,26 @@ +import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; +import { RemoteData } from '../core/data/remote-data'; +import { ItemRequest } from '../core/shared/item-request.model'; +import { Observable } from 'rxjs/internal/Observable'; +import { ItemRequestDataService } from '../core/data/item-request-data.service'; +import { Injectable } from '@angular/core'; +import { getFirstCompletedRemoteData } from '../core/shared/operators'; + +/** + * Resolves an {@link ItemRequest} from the token found in the route's parameters + */ +@Injectable() +export class RequestCopyResolver implements Resolve> { + + constructor( + private itemRequestDataService: ItemRequestDataService, + ) { + } + + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable> | Promise> | RemoteData { + return this.itemRequestDataService.findById(route.params.token).pipe( + getFirstCompletedRemoteData(), + ); + } + +} diff --git a/src/app/search-page/search.component.html b/src/app/search-page/search.component.html index d8aa25e4a3..3489cccdfb 100644 --- a/src/app/search-page/search.component.html +++ b/src/app/search-page/search.component.html @@ -47,12 +47,12 @@ [query]="(searchOptions$ | async)?.query" [scope]="(searchOptions$ | async)?.scope" [currentUrl]="searchLink" - [scopes]="(scopeListRD$ | async)" + [showScopeSelector]="true" [inPlaceSearch]="inPlaceSearch" [searchPlaceholder]="'search.search-form.placeholder' | translate">
-
+
diff --git a/src/app/search-page/search.component.ts b/src/app/search-page/search.component.ts index d4d65b87fe..8be21af552 100644 --- a/src/app/search-page/search.component.ts +++ b/src/app/search-page/search.component.ts @@ -55,11 +55,6 @@ export class SearchComponent implements OnInit { */ sortOptions$: Observable; - /** - * The current relevant scopes - */ - scopeListRD$: Observable; - /** * Emits true if were on a small screen */ @@ -137,9 +132,7 @@ export class SearchComponent implements OnInit { ).subscribe((results) => { this.resultsRD$.next(results); }); - this.scopeListRD$ = this.searchConfigService.getCurrentScope('').pipe( - switchMap((scopeId) => this.service.getScopes(scopeId)) - ); + if (isEmpty(this.configuration$)) { this.configuration$ = this.searchConfigService.getCurrentConfiguration('default'); } diff --git a/src/app/shared/bitstream-request-a-copy-page/bitstream-request-a-copy-page.component.html b/src/app/shared/bitstream-request-a-copy-page/bitstream-request-a-copy-page.component.html new file mode 100644 index 0000000000..1fae737fdb --- /dev/null +++ b/src/app/shared/bitstream-request-a-copy-page/bitstream-request-a-copy-page.component.html @@ -0,0 +1,87 @@ +
+

{{'bitstream-request-a-copy.header' | translate}}

+
+ {{'bitstream-request-a-copy.alert.canDownload1' | translate}} + {{'bitstream-request-a-copy.alert.canDownload2'| translate}} +
+
+

{{'bitstream-request-a-copy.intro' | translate}} {{itemName}}

+

{{'bitstream-request-a-copy.intro.bitstream.one' | translate}} {{bitstreamName}}

+

{{'bitstream-request-a-copy.intro.bitstream.all' | translate}}

+
+
+ +
+
+
+ + +
+ + {{ 'bitstream-request-a-copy.name.error' | translate }} + +
+
+
+
+
+ + +
+ + {{ 'bitstream-request-a-copy.email.error' | translate }} + +
+ {{'bitstream-request-a-copy.email.hint' |translate}} +
+
+
+
+
{{'bitstream-request-a-copy.allfiles.label' |translate}}
+
+ + +
+
+ + +
+
+
+
+
+ + +
+
+
+
+ +
+
+
+ + + {{'bitstream-request-a-copy.return' | translate}} + + + +
+
+
diff --git a/src/app/shared/bitstream-request-a-copy-page/bitstream-request-a-copy-page.component.spec.ts b/src/app/shared/bitstream-request-a-copy-page/bitstream-request-a-copy-page.component.spec.ts new file mode 100644 index 0000000000..cc44ef8587 --- /dev/null +++ b/src/app/shared/bitstream-request-a-copy-page/bitstream-request-a-copy-page.component.spec.ts @@ -0,0 +1,289 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { AuthService } from '../../core/auth/auth.service'; +import { of as observableOf } from 'rxjs'; +import { Bitstream } from '../../core/shared/bitstream.model'; +import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; +import { + createFailedRemoteDataObject$, + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$ +} from '../remote-data.utils'; +import { ActivatedRoute, Router } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; +import { CommonModule } from '@angular/common'; +import { BitstreamRequestACopyPageComponent } from './bitstream-request-a-copy-page.component'; +import { By } from '@angular/platform-browser'; +import { RouterStub } from '../testing/router.stub'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { NotificationsServiceStub } from '../testing/notifications-service.stub'; +import { ItemRequestDataService } from '../../core/data/item-request-data.service'; +import { NotificationsService } from '../notifications/notifications.service'; +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { DSONameServiceMock } from '../mocks/dso-name.service.mock'; +import { Item } from '../../core/shared/item.model'; +import { EPerson } from '../../core/eperson/models/eperson.model'; +import { ItemRequest } from '../../core/shared/item-request.model'; +import { Location } from '@angular/common'; +import { BitstreamDataService } from '../../core/data/bitstream-data.service'; + + +describe('BitstreamRequestACopyPageComponent', () => { + let component: BitstreamRequestACopyPageComponent; + let fixture: ComponentFixture; + + let authService: AuthService; + let authorizationService: AuthorizationDataService; + let activatedRoute; + let router; + let itemRequestDataService; + let notificationsService; + let location; + let bitstreamDataService; + + let item: Item; + let bitstream: Bitstream; + let eperson; + + function init() { + eperson = Object.assign(new EPerson(), { + email: 'test@mail.org', + metadata: { + 'eperson.firstname': [{value: 'Test'}], + 'eperson.lastname': [{value: 'User'}], + } + }); + authService = jasmine.createSpyObj('authService', { + isAuthenticated: observableOf(false), + getAuthenticatedUserFromStore: observableOf(eperson) + }); + authorizationService = jasmine.createSpyObj('authorizationSerivice', { + isAuthorized: observableOf(true) + }); + + itemRequestDataService = jasmine.createSpyObj('itemRequestDataService', { + requestACopy: createSuccessfulRemoteDataObject$({}) + }); + + location = jasmine.createSpyObj('location', { + back: {} + }); + + notificationsService = new NotificationsServiceStub(); + + item = Object.assign(new Item(), {uuid: 'item-uuid'}); + + bitstream = Object.assign(new Bitstream(), { + uuid: 'bitstreamUuid', + _links: { + content: {href: 'bitstream-content-link'}, + self: {href: 'bitstream-self-link'}, + } + }); + + activatedRoute = { + data: observableOf({ + dso: createSuccessfulRemoteDataObject( + item + ) + }), + queryParams: observableOf({ + bitstream : bitstream.uuid + }) + }; + + bitstreamDataService = jasmine.createSpyObj('bitstreamDataService', { + findById: createSuccessfulRemoteDataObject$(bitstream) + }); + + router = new RouterStub(); + } + + function initTestbed() { + TestBed.configureTestingModule({ + imports: [CommonModule, TranslateModule.forRoot(), FormsModule, ReactiveFormsModule], + declarations: [BitstreamRequestACopyPageComponent], + providers: [ + {provide: Location, useValue: location}, + {provide: ActivatedRoute, useValue: activatedRoute}, + {provide: Router, useValue: router}, + {provide: AuthorizationDataService, useValue: authorizationService}, + {provide: AuthService, useValue: authService}, + {provide: ItemRequestDataService, useValue: itemRequestDataService}, + {provide: NotificationsService, useValue: notificationsService}, + {provide: DSONameService, useValue: new DSONameServiceMock()}, + {provide: BitstreamDataService, useValue: bitstreamDataService}, + ] + }) + .compileComponents(); + } + + describe('init', () => { + beforeEach(waitForAsync(() => { + init(); + initTestbed(); + })); + beforeEach(() => { + fixture = TestBed.createComponent(BitstreamRequestACopyPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + it('should init the comp', () => { + expect(component).toBeTruthy(); + }); + }); + + describe('should show a form to request a copy', () => { + describe('when the user is not logged in', () => { + beforeEach(waitForAsync(() => { + init(); + initTestbed(); + })); + beforeEach(() => { + fixture = TestBed.createComponent(BitstreamRequestACopyPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + it('show the form with no values filled in based on the user', () => { + expect(component.name.value).toEqual(''); + expect(component.email.value).toEqual(''); + expect(component.allfiles.value).toEqual('false'); + expect(component.message.value).toEqual(''); + }); + }); + + describe('when the user is logged in', () => { + beforeEach(waitForAsync(() => { + init(); + (authService.isAuthenticated as jasmine.Spy).and.returnValue(observableOf(true)); + initTestbed(); + })); + beforeEach(() => { + fixture = TestBed.createComponent(BitstreamRequestACopyPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + it('show the form with values filled in based on the user', () => { + fixture.detectChanges(); + expect(component.name.value).toEqual(eperson.name); + expect(component.email.value).toEqual(eperson.email); + expect(component.allfiles.value).toEqual('false'); + expect(component.message.value).toEqual(''); + }); + }); + describe('when no bitstream was provided', () => { + beforeEach(waitForAsync(() => { + init(); + activatedRoute = { + data: observableOf({ + dso: createSuccessfulRemoteDataObject( + item + ) + }), + queryParams: observableOf({ + }) + }; + initTestbed(); + })); + beforeEach(() => { + fixture = TestBed.createComponent(BitstreamRequestACopyPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + it('should set the all files value to true and disable the false value', () => { + expect(component.name.value).toEqual(''); + expect(component.email.value).toEqual(''); + expect(component.allfiles.value).toEqual('true'); + expect(component.message.value).toEqual(''); + + const allFilesFalse = fixture.debugElement.query(By.css('#allfiles-false')).nativeElement; + expect(allFilesFalse.getAttribute('disabled')).toBeTruthy(); + + }); + }); + describe('when the user has authorization to download the file', () => { + beforeEach(waitForAsync(() => { + init(); + (authService.isAuthenticated as jasmine.Spy).and.returnValue(observableOf(true)); + initTestbed(); + })); + beforeEach(() => { + fixture = TestBed.createComponent(BitstreamRequestACopyPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + it('should show an alert indicating the user can download the file', () => { + const alert = fixture.debugElement.query(By.css('.alert')).nativeElement; + expect(alert.innerHTML).toContain('bitstream-request-a-copy.alert.canDownload'); + }); + }); + }); + + describe('onSubmit', () => { + describe('onSuccess', () => { + beforeEach(waitForAsync(() => { + init(); + initTestbed(); + })); + beforeEach(() => { + fixture = TestBed.createComponent(BitstreamRequestACopyPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + it('should take the current form information and submit it', () => { + component.name.patchValue('User Name'); + component.email.patchValue('user@name.org'); + component.allfiles.patchValue('false'); + component.message.patchValue('I would like to request a copy'); + + component.onSubmit(); + const itemRequest = Object.assign(new ItemRequest(), + { + itemId: item.uuid, + bitstreamId: bitstream.uuid, + allfiles: 'false', + requestEmail: 'user@name.org', + requestName: 'User Name', + requestMessage: 'I would like to request a copy' + }); + + expect(itemRequestDataService.requestACopy).toHaveBeenCalledWith(itemRequest); + expect(notificationsService.success).toHaveBeenCalled(); + expect(location.back).toHaveBeenCalled(); + }); + }); + + describe('onFail', () => { + beforeEach(waitForAsync(() => { + init(); + (itemRequestDataService.requestACopy as jasmine.Spy).and.returnValue(createFailedRemoteDataObject$()); + initTestbed(); + })); + beforeEach(() => { + fixture = TestBed.createComponent(BitstreamRequestACopyPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + it('should take the current form information and submit it', () => { + component.name.patchValue('User Name'); + component.email.patchValue('user@name.org'); + component.allfiles.patchValue('false'); + component.message.patchValue('I would like to request a copy'); + + component.onSubmit(); + const itemRequest = Object.assign(new ItemRequest(), + { + itemId: item.uuid, + bitstreamId: bitstream.uuid, + allfiles: 'false', + requestEmail: 'user@name.org', + requestName: 'User Name', + requestMessage: 'I would like to request a copy' + }); + + expect(itemRequestDataService.requestACopy).toHaveBeenCalledWith(itemRequest); + expect(notificationsService.error).toHaveBeenCalled(); + expect(location.back).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/src/app/shared/bitstream-request-a-copy-page/bitstream-request-a-copy-page.component.ts b/src/app/shared/bitstream-request-a-copy-page/bitstream-request-a-copy-page.component.ts new file mode 100644 index 0000000000..3b9bc38aab --- /dev/null +++ b/src/app/shared/bitstream-request-a-copy-page/bitstream-request-a-copy-page.component.ts @@ -0,0 +1,213 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { filter, map, switchMap, take } from 'rxjs/operators'; +import { ActivatedRoute, Router } from '@angular/router'; +import { hasValue, isNotEmpty } from '../empty.util'; +import { getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload } from '../../core/shared/operators'; +import { Bitstream } from '../../core/shared/bitstream.model'; +import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; +import { FeatureID } from '../../core/data/feature-authorization/feature-id'; +import { AuthService } from '../../core/auth/auth.service'; +import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs'; +import { getBitstreamDownloadRoute, getForbiddenRoute } from '../../app-routing-paths'; +import { TranslateService } from '@ngx-translate/core'; +import { Subscription } from 'rxjs/internal/Subscription'; +import { EPerson } from '../../core/eperson/models/eperson.model'; +import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms'; +import { ItemRequestDataService } from '../../core/data/item-request-data.service'; +import { ItemRequest } from '../../core/shared/item-request.model'; +import { Item } from '../../core/shared/item.model'; +import { NotificationsService } from '../notifications/notifications.service'; +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { Location } from '@angular/common'; +import { BitstreamDataService } from '../../core/data/bitstream-data.service'; +import { getItemPageRoute } from '../../item-page/item-page-routing-paths'; + +@Component({ + selector: 'ds-bitstream-request-a-copy-page', + templateUrl: './bitstream-request-a-copy-page.component.html' +}) +/** + * Page component for requesting a copy for a bitstream + */ +export class BitstreamRequestACopyPageComponent implements OnInit, OnDestroy { + + item$: Observable; + + canDownload$: Observable; + private subs: Subscription[] = []; + requestCopyForm: FormGroup; + + item: Item; + itemName: string; + + bitstream$: Observable; + bitstream: Bitstream; + bitstreamName: string; + + constructor(private location: Location, + private translateService: TranslateService, + private route: ActivatedRoute, + protected router: Router, + private authorizationService: AuthorizationDataService, + private auth: AuthService, + private formBuilder: FormBuilder, + private itemRequestDataService: ItemRequestDataService, + private notificationsService: NotificationsService, + private dsoNameService: DSONameService, + private bitstreamService: BitstreamDataService, + ) { + } + + ngOnInit(): void { + this.requestCopyForm = this.formBuilder.group({ + name: new FormControl('', { + validators: [Validators.required], + }), + email: new FormControl('', { + validators: [Validators.required, + Validators.pattern('^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,4}$')] + }), + allfiles: new FormControl(''), + message: new FormControl(''), + }); + + + this.item$ = this.route.data.pipe( + map((data) => data.dso), + getFirstSucceededRemoteDataPayload() + ); + + this.subs.push(this.item$.subscribe((item) => { + this.item = item; + this.itemName = this.dsoNameService.getName(item); + })); + + this.bitstream$ = this.route.queryParams.pipe( + filter((params) => hasValue(params) && hasValue(params.bitstream)), + switchMap((params) => this.bitstreamService.findById(params.bitstream)), + getFirstSucceededRemoteDataPayload() + ); + + this.subs.push(this.bitstream$.subscribe((bitstream) => { + this.bitstream = bitstream; + this.bitstreamName = this.dsoNameService.getName(bitstream); + })); + + this.canDownload$ = this.bitstream$.pipe( + switchMap((bitstream) => this.authorizationService.isAuthorized(FeatureID.CanDownload, isNotEmpty(bitstream) ? bitstream.self : undefined)) + ); + const canRequestCopy$ = this.bitstream$.pipe( + switchMap((bitstream) => this.authorizationService.isAuthorized(FeatureID.CanRequestACopy, isNotEmpty(bitstream) ? bitstream.self : undefined)), + ); + + this.subs.push(observableCombineLatest([this.canDownload$, canRequestCopy$]).subscribe(([canDownload, canRequestCopy]) => { + if (!canDownload && !canRequestCopy) { + this.router.navigateByUrl(getForbiddenRoute(), {skipLocationChange: true}); + } + })); + this.initValues(); + } + + get name() { + return this.requestCopyForm.get('name'); + } + + get email() { + return this.requestCopyForm.get('email'); + } + + get message() { + return this.requestCopyForm.get('message'); + } + + get allfiles() { + return this.requestCopyForm.get('allfiles'); + } + + /** + * Initialise the form values based on the current user. + */ + private initValues() { + this.getCurrentUser().pipe(take(1)).subscribe((user) => { + this.requestCopyForm.patchValue({allfiles: 'true'}); + if (hasValue(user)) { + this.requestCopyForm.patchValue({name: user.name, email: user.email}); + } + }); + this.bitstream$.pipe(take(1)).subscribe((bitstream) => { + this.requestCopyForm.patchValue({allfiles: 'false'}); + }); + } + + /** + * Retrieve the current user + */ + private getCurrentUser(): Observable { + return this.auth.isAuthenticated().pipe( + switchMap((authenticated) => { + if (authenticated) { + return this.auth.getAuthenticatedUserFromStore(); + } else { + return observableOf(undefined); + } + }) + ); + + } + + /** + * Submit the the form values as an item request to the server. + * When the submission is successful, the user will be redirected to the item page and a success notification will be shown. + * When the submission fails, the user will stay on the page and an error notification will be shown + */ + onSubmit() { + const itemRequest = new ItemRequest(); + if (hasValue(this.bitstream)) { + itemRequest.bitstreamId = this.bitstream.uuid; + } + itemRequest.itemId = this.item.uuid; + itemRequest.allfiles = this.allfiles.value; + itemRequest.requestEmail = this.email.value; + itemRequest.requestName = this.name.value; + itemRequest.requestMessage = this.message.value; + + this.itemRequestDataService.requestACopy(itemRequest).pipe( + getFirstCompletedRemoteData() + ).subscribe((rd) => { + if (rd.hasSucceeded) { + this.notificationsService.success(this.translateService.get('bitstream-request-a-copy.submit.success')); + this.navigateBack(); + } else { + this.notificationsService.error(this.translateService.get('bitstream-request-a-copy.submit.error')); + } + }); + } + + ngOnDestroy(): void { + if (hasValue(this.subs)) { + this.subs.forEach((sub) => { + if (hasValue(sub)) { + sub.unsubscribe(); + } + }); + } + } + + /** + * Navigates back to the user's previous location + */ + navigateBack() { + this.location.back(); + } + + getItemPath() { + return [getItemPageRoute(this.item)]; + } + + /** + * Retrieves the link to the bistream download page + */ + getBitstreamLink() { + return [getBitstreamDownloadRoute(this.bitstream)]; + } +} diff --git a/src/app/shared/dso-page/dso-page-version-button/dso-page-version-button.component.html b/src/app/shared/dso-page/dso-page-version-button/dso-page-version-button.component.html new file mode 100644 index 0000000000..0e2e35dcb7 --- /dev/null +++ b/src/app/shared/dso-page/dso-page-version-button/dso-page-version-button.component.html @@ -0,0 +1,8 @@ + diff --git a/src/app/shared/dso-page/dso-page-version-button/dso-page-version-button.component.scss b/src/app/shared/dso-page/dso-page-version-button/dso-page-version-button.component.scss new file mode 100644 index 0000000000..e8b7d689a3 --- /dev/null +++ b/src/app/shared/dso-page/dso-page-version-button/dso-page-version-button.component.scss @@ -0,0 +1,3 @@ +.btn-dark { + background-color: var(--ds-admin-sidebar-bg); +} diff --git a/src/app/shared/dso-page/dso-page-version-button/dso-page-version-button.component.spec.ts b/src/app/shared/dso-page/dso-page-version-button/dso-page-version-button.component.spec.ts new file mode 100644 index 0000000000..9839507d57 --- /dev/null +++ b/src/app/shared/dso-page/dso-page-version-button/dso-page-version-button.component.spec.ts @@ -0,0 +1,96 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { DsoPageVersionButtonComponent } from './dso-page-version-button.component'; +import { Item } from '../../../core/shared/item.model'; +import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; +import { Observable, of, of as observableOf } from 'rxjs'; +import { TranslateModule } from '@ngx-translate/core'; +import { RouterTestingModule } from '@angular/router/testing'; +import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; +import { By } from '@angular/platform-browser'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { VersionHistoryDataService } from '../../../core/data/version-history-data.service'; + +describe('DsoPageVersionButtonComponent', () => { + let component: DsoPageVersionButtonComponent; + let fixture: ComponentFixture; + + let authorizationService: AuthorizationDataService; + let versionHistoryService: VersionHistoryDataService; + + let dso: Item; + let tooltipMsg: Observable; + + const authorizationServiceSpy = jasmine.createSpyObj('authorizationService', ['isAuthorized']); + + const versionHistoryServiceSpy = jasmine.createSpyObj('versionHistoryService', + ['getVersions', 'getLatestVersionFromHistory$', 'isLatest$', 'hasDraftVersion$'] + ); + + beforeEach(waitForAsync(() => { + dso = Object.assign(new Item(), { + id: 'test-item', + _links: { + self: { href: 'test-item-selflink' }, + version: { href: 'test-item-version-selflink' }, + }, + }); + tooltipMsg = of('tooltip-msg'); + + TestBed.configureTestingModule({ + declarations: [DsoPageVersionButtonComponent], + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NgbModule], + providers: [ + { provide: AuthorizationDataService, useValue: authorizationServiceSpy }, + { provide: VersionHistoryDataService, useValue: versionHistoryServiceSpy }, + ] + }).compileComponents(); + + authorizationService = TestBed.inject(AuthorizationDataService); + versionHistoryService = TestBed.inject(VersionHistoryDataService); + + versionHistoryServiceSpy.hasDraftVersion$.and.returnValue(observableOf(true)); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(DsoPageVersionButtonComponent); + component = fixture.componentInstance; + component.dso = dso; + component.tooltipMsg$ = tooltipMsg; + fixture.detectChanges(); + }); + + it('should check the authorization of the current user', () => { + expect(authorizationService.isAuthorized).toHaveBeenCalledWith(FeatureID.CanCreateVersion, dso.self); + }); + + it('should check if the item has a draft version', () => { + expect(versionHistoryServiceSpy.hasDraftVersion$).toHaveBeenCalledWith(dso._links.version.href); + }); + + describe('when the user is authorized', () => { + beforeEach(() => { + authorizationServiceSpy.isAuthorized.and.returnValue(observableOf(true)); + component.ngOnInit(); + fixture.detectChanges(); + }); + + it('should render a button', () => { + const button = fixture.debugElement.query(By.css('button')); + expect(button).not.toBeNull(); + }); + }); + + describe('when the user is not authorized', () => { + beforeEach(() => { + authorizationServiceSpy.isAuthorized.and.returnValue(observableOf(false)); + component.ngOnInit(); + fixture.detectChanges(); + }); + + it('should render a button', () => { + const button = fixture.debugElement.query(By.css('button')); + expect(button).toBeNull(); + }); + }); + +}); diff --git a/src/app/shared/dso-page/dso-page-version-button/dso-page-version-button.component.ts b/src/app/shared/dso-page/dso-page-version-button/dso-page-version-button.component.ts new file mode 100644 index 0000000000..31844fba00 --- /dev/null +++ b/src/app/shared/dso-page/dso-page-version-button/dso-page-version-button.component.ts @@ -0,0 +1,78 @@ +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; +import { Observable } from 'rxjs/internal/Observable'; +import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; +import { VersionHistoryDataService } from '../../../core/data/version-history-data.service'; +import { Item } from '../../../core/shared/item.model'; +import { map, startWith, switchMap } from 'rxjs/operators'; +import { of } from 'rxjs'; + +@Component({ + selector: 'ds-dso-page-version-button', + templateUrl: './dso-page-version-button.component.html', + styleUrls: ['./dso-page-version-button.component.scss'] +}) +/** + * Display a button linking to the edit page of a DSpaceObject + */ +export class DsoPageVersionButtonComponent implements OnInit { + /** + * The item for which display a button to create a new version + */ + @Input() dso: Item; + + /** + * A message for the tooltip on the button + * Supports i18n keys + */ + @Input() tooltipMsgCreate: string; + + /** + * A message for the tooltip on the button (when is disabled) + * Supports i18n keys + */ + @Input() tooltipMsgHasDraft: string; + + /** + * Emits an event that triggers the creation of the new version + */ + @Output() newVersionEvent = new EventEmitter(); + + /** + * Whether or not the current user is authorized to create a new version of the DSpaceObject + */ + isAuthorized$: Observable; + + disableNewVersionButton$: Observable; + + tooltipMsg$: Observable; + + constructor( + protected authorizationService: AuthorizationDataService, + protected versionHistoryService: VersionHistoryDataService, + ) { + } + + /** + * Creates a new version for the current item + */ + createNewVersion() { + this.newVersionEvent.emit(); + } + + ngOnInit() { + this.isAuthorized$ = this.authorizationService.isAuthorized(FeatureID.CanCreateVersion, this.dso.self); + + this.disableNewVersionButton$ = this.versionHistoryService.hasDraftVersion$(this.dso._links.version.href).pipe( + // button is disabled if hasDraftVersion = true, and enabled if hasDraftVersion = false or null + // (hasDraftVersion is null when a version history does not exist) + map((res) => Boolean(res)), + startWith(true), + ); + + this.tooltipMsg$ = this.disableNewVersionButton$.pipe( + switchMap((hasDraftVersion) => of(hasDraftVersion ? this.tooltipMsgHasDraft : this.tooltipMsgCreate)), + ); + } + +} 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/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/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/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/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/shared.module.ts b/src/app/shared/shared.module.ts index f529e0600a..03bbbcdc72 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -212,6 +212,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'; @@ -234,6 +235,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 @@ -433,6 +438,7 @@ const COMPONENTS = [ GroupSearchBoxComponent, FileDownloadLinkComponent, BitstreamDownloadPageComponent, + BitstreamRequestACopyPageComponent, CollectionDropdownComponent, ExportMetadataSelectorComponent, ConfirmationModalComponent, @@ -460,7 +466,8 @@ const COMPONENTS = [ PublicationSidebarSearchListElementComponent, CollectionSidebarSearchListElementComponent, CommunitySidebarSearchListElementComponent, - SearchNavbarComponent + SearchNavbarComponent, + ScopeSelectorModalComponent, ]; const ENTRY_COMPONENTS = [ @@ -514,6 +521,7 @@ const ENTRY_COMPONENTS = [ CollectionDropdownComponent, FileDownloadLinkComponent, BitstreamDownloadPageComponent, + BitstreamRequestACopyPageComponent, CurationFormComponent, ExportMetadataSelectorComponent, ConfirmationModalComponent, @@ -524,7 +532,8 @@ const ENTRY_COMPONENTS = [ CommunitySidebarSearchListElementComponent, LinkMenuItemComponent, OnClickMenuItemComponent, - TextMenuItemComponent + TextMenuItemComponent, + ScopeSelectorModalComponent, ]; const SHARED_SEARCH_PAGE_COMPONENTS = [ @@ -536,6 +545,7 @@ const SHARED_ITEM_PAGE_COMPONENTS = [ MetadataFieldWrapperComponent, MetadataValuesComponent, DsoPageEditButtonComponent, + DsoPageVersionButtonComponent, ItemAlertsComponent, GenericItemPageFieldComponent, MetadataRepresentationListComponent, @@ -586,7 +596,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/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/sections/container/section-container.component.html b/src/app/submission/sections/container/section-container.component.html index 28ed377c4a..c510c7ddf1 100644 --- a/src/app/submission/sections/container/section-container.component.html +++ b/src/app/submission/sections/container/section-container.component.html @@ -15,15 +15,15 @@ {{ 'submission.sections.'+sectionData.header | translate }}
+ title="{{'submission.sections.status.warnings.title' | translate}}" role="img" [attr.aria-label]="'submission.sections.status.warnings.aria' | translate"> + title="{{'submission.sections.status.errors.title' | translate}}" role="img" [attr.aria-label]="'submission.sections.status.errors.aria' | translate"> + title="{{'submission.sections.status.valid.title' | translate}}" role="img" [attr.aria-label]="'submission.sections.status.valid.aria' | translate"> diff --git a/src/app/submission/sections/upload/file/edit/section-upload-file-edit.model.ts b/src/app/submission/sections/upload/file/edit/section-upload-file-edit.model.ts index 300a4b461f..14886128a5 100644 --- a/src/app/submission/sections/upload/file/edit/section-upload-file-edit.model.ts +++ b/src/app/submission/sections/upload/file/edit/section-upload-file-edit.model.ts @@ -74,7 +74,7 @@ export const BITSTREAM_FORM_ACCESS_CONDITION_START_DATE_CONFIG: DynamicDatePicke required: null }, errorMessages: { - required: 'submission.sections.upload.form.date-required' + required: 'submission.sections.upload.form.date-required-from' } }; export const BITSTREAM_FORM_ACCESS_CONDITION_START_DATE_LAYOUT: DynamicFormControlLayout = { @@ -104,7 +104,7 @@ export const BITSTREAM_FORM_ACCESS_CONDITION_END_DATE_CONFIG: DynamicDatePickerM required: null }, errorMessages: { - required: 'submission.sections.upload.form.date-required' + required: 'submission.sections.upload.form.date-required-until' } }; export const BITSTREAM_FORM_ACCESS_CONDITION_END_DATE_LAYOUT: DynamicFormControlLayout = { diff --git a/src/app/submission/sections/upload/file/section-upload-file.component.html b/src/app/submission/sections/upload/file/section-upload-file.component.html index 09c062d191..259418c22c 100644 --- a/src/app/submission/sections/upload/file/section-upload-file.component.html +++ b/src/app/submission/sections/upload/file/section-upload-file.component.html @@ -10,16 +10,16 @@
- + - - - -