diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e2680420a21..52f20470a3c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -33,6 +33,8 @@ jobs: #CHROME_VERSION: "90.0.4430.212-1" # Bump Node heap size (OOM in CI after upgrading to Angular 15) NODE_OPTIONS: '--max-old-space-size=4096' + # Project name to use when running docker-compose prior to e2e tests + COMPOSE_PROJECT_NAME: 'ci' strategy: # Create a matrix of Node versions to test against (in parallel) matrix: diff --git a/cypress.config.ts b/cypress.config.ts index 91eeb9838b3..458b035a484 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -9,8 +9,9 @@ export default defineConfig({ openMode: 0, }, env: { - // Global constants used in DSpace e2e tests (see also ./cypress/support/e2e.ts) - // May be overridden in our cypress.json config file using specified environment variables. + // Global DSpace environment variables used in all our Cypress e2e tests + // May be modified in this config, or overridden in a variety of ways. + // See Cypress environment variable docs: https://docs.cypress.io/guides/guides/environment-variables // Default values listed here are all valid for the Demo Entities Data set available at // https://github.com/DSpace-Labs/AIP-Files/releases/tag/demo-entities-data // (This is the data set used in our CI environment) @@ -21,12 +22,14 @@ export default defineConfig({ // Community/collection/publication used for view/edit tests DSPACE_TEST_COMMUNITY: '0958c910-2037-42a9-81c7-dca80e3892b4', DSPACE_TEST_COLLECTION: '282164f5-d325-4740-8dd1-fa4d6d3e7200', - DSPACE_TEST_ENTITY_PUBLICATION: 'e98b0f27-5c19-49a0-960d-eb6ad5287067', + DSPACE_TEST_ENTITY_PUBLICATION: '6160810f-1e53-40db-81ef-f6621a727398', // Search term (should return results) used in search tests DSPACE_TEST_SEARCH_TERM: 'test', - // Collection used for submission tests + // Main Collection used for submission tests. Should be able to accept normal Item objects DSPACE_TEST_SUBMIT_COLLECTION_NAME: 'Sample Collection', DSPACE_TEST_SUBMIT_COLLECTION_UUID: '9d8334e9-25d3-4a67-9cea-3dffdef80144', + // Collection used for Person entity submission tests. MUST be configured with EntityType=Person. + DSPACE_TEST_SUBMIT_PERSON_COLLECTION_NAME: 'People', // Account used to test basic submission process DSPACE_TEST_SUBMIT_USER: 'dspacedemo+submit@gmail.com', DSPACE_TEST_SUBMIT_USER_PASSWORD: 'dspace', diff --git a/cypress/e2e/admin-sidebar.cy.ts b/cypress/e2e/admin-sidebar.cy.ts new file mode 100644 index 00000000000..7612eb53132 --- /dev/null +++ b/cypress/e2e/admin-sidebar.cy.ts @@ -0,0 +1,28 @@ +import { Options } from 'cypress-axe'; +import { testA11y } from 'cypress/support/utils'; + +describe('Admin Sidebar', () => { + beforeEach(() => { + // Must login as an Admin for sidebar to appear + cy.visit('/login'); + cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); + }); + + it('should be pinnable and pass accessibility tests', () => { + // Pin the sidebar open + cy.get('#sidebar-collapse-toggle').click(); + + // Click on every expandable section to open all menus + cy.get('ds-expandable-admin-sidebar-section').click({multiple: true}); + + // Analyze for accessibility + testA11y('ds-admin-sidebar', + { + rules: { + // Currently all expandable sections have nested interactive elements + // See https://github.com/DSpace/dspace-angular/issues/2178 + 'nested-interactive': { enabled: false }, + } + } as Options); + }); +}); diff --git a/cypress/e2e/breadcrumbs.cy.ts b/cypress/e2e/breadcrumbs.cy.ts index ea6acdafcde..0cddbc723c6 100644 --- a/cypress/e2e/breadcrumbs.cy.ts +++ b/cypress/e2e/breadcrumbs.cy.ts @@ -1,10 +1,9 @@ -import { TEST_ENTITY_PUBLICATION } from 'cypress/support/e2e'; 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/'.concat(TEST_ENTITY_PUBLICATION)); + cy.visit('/entities/publication/'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION'))); // Wait for breadcrumbs to be visible cy.get('ds-breadcrumbs').should('be.visible'); diff --git a/cypress/e2e/collection-edit.cy.ts b/cypress/e2e/collection-edit.cy.ts new file mode 100644 index 00000000000..3e7ecf61410 --- /dev/null +++ b/cypress/e2e/collection-edit.cy.ts @@ -0,0 +1,128 @@ +import { testA11y } from 'cypress/support/utils'; + +const COLLECTION_EDIT_PAGE = '/collections/'.concat(Cypress.env('DSPACE_TEST_COLLECTION')).concat('/edit'); + +beforeEach(() => { + // All tests start with visiting the Edit Collection Page + cy.visit(COLLECTION_EDIT_PAGE); + + // This page is restricted, so we will be shown the login form. Fill it out & submit. + cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); +}); + +describe('Edit Collection > Edit Metadata tab', () => { + it('should pass accessibility tests', () => { + // tag must be loaded + cy.get('ds-edit-collection').should('be.visible'); + + // Analyze for accessibility issues + testA11y('ds-edit-collection'); + }); +}); + +describe('Edit Collection > Assign Roles tab', () => { + + it('should pass accessibility tests', () => { + cy.get('a[data-test="roles"]').click(); + + // tag must be loaded + cy.get('ds-collection-roles').should('be.visible'); + + // Analyze for accessibility issues + testA11y('ds-collection-roles'); + }); +}); + +describe('Edit Collection > Content Source tab', () => { + + it('should pass accessibility tests', () => { + cy.get('a[data-test="source"]').click(); + + // tag must be loaded + cy.get('ds-collection-source').should('be.visible'); + + // Check the external source checkbox (to display all fields on the page) + cy.get('#externalSourceCheck').check(); + + // Wait for the source controls to appear + cy.get('ds-collection-source-controls').should('be.visible'); + + // Analyze entire page for accessibility issues + testA11y('ds-collection-source'); + }); +}); + +describe('Edit Collection > Curate tab', () => { + + it('should pass accessibility tests', () => { + cy.get('a[data-test="curate"]').click(); + + // tag must be loaded + cy.get('ds-collection-curate').should('be.visible'); + + // Analyze for accessibility issues + testA11y('ds-collection-curate'); + }); +}); + +describe('Edit Collection > Access Control tab', () => { + + it('should pass accessibility tests', () => { + cy.get('a[data-test="access-control"]').click(); + + // tag must be loaded + cy.get('ds-collection-access-control').should('be.visible'); + + // Analyze for accessibility issues + testA11y('ds-collection-access-control'); + }); +}); + +describe('Edit Collection > Authorizations tab', () => { + + it('should pass accessibility tests', () => { + cy.get('a[data-test="authorizations"]').click(); + + // tag must be loaded + cy.get('ds-collection-authorizations').should('be.visible'); + + // Analyze for accessibility issues + testA11y('ds-collection-authorizations'); + }); +}); + +describe('Edit Collection > Item Mapper tab', () => { + + it('should pass accessibility tests', () => { + cy.get('a[data-test="mapper"]').click(); + + // tag must be loaded + cy.get('ds-collection-item-mapper').should('be.visible'); + + // Analyze entire page for accessibility issues + testA11y('ds-collection-item-mapper'); + + // Click on the "Map new Items" tab + cy.get('li[data-test="mapTab"] a').click(); + + // Make sure search form is now visible + cy.get('ds-search-form').should('be.visible'); + + // Analyze entire page (again) for accessibility issues + testA11y('ds-collection-item-mapper'); + }); +}); + + +describe('Edit Collection > Delete page', () => { + + it('should pass accessibility tests', () => { + cy.get('a[data-test="delete-button"]').click(); + + // tag must be loaded + cy.get('ds-delete-collection').should('be.visible'); + + // Analyze for accessibility issues + testA11y('ds-delete-collection'); + }); +}); diff --git a/cypress/e2e/collection-page.cy.ts b/cypress/e2e/collection-page.cy.ts index a034b4361d6..55c10cc6e22 100644 --- a/cypress/e2e/collection-page.cy.ts +++ b/cypress/e2e/collection-page.cy.ts @@ -1,10 +1,9 @@ -import { TEST_COLLECTION } from 'cypress/support/e2e'; import { testA11y } from 'cypress/support/utils'; describe('Collection Page', () => { it('should pass accessibility tests', () => { - cy.visit('/collections/'.concat(TEST_COLLECTION)); + cy.visit('/collections/'.concat(Cypress.env('DSPACE_TEST_COLLECTION'))); // tag must be loaded cy.get('ds-collection-page').should('be.visible'); diff --git a/cypress/e2e/collection-statistics.cy.ts b/cypress/e2e/collection-statistics.cy.ts index 6df4e9a4542..43bf67ce51f 100644 --- a/cypress/e2e/collection-statistics.cy.ts +++ b/cypress/e2e/collection-statistics.cy.ts @@ -1,11 +1,11 @@ -import { REGEX_MATCH_NON_EMPTY_TEXT, TEST_COLLECTION } from 'cypress/support/e2e'; +import { REGEX_MATCH_NON_EMPTY_TEXT } from 'cypress/support/e2e'; import { testA11y } from 'cypress/support/utils'; describe('Collection Statistics Page', () => { - const COLLECTIONSTATISTICSPAGE = '/statistics/collections/'.concat(TEST_COLLECTION); + const COLLECTIONSTATISTICSPAGE = '/statistics/collections/'.concat(Cypress.env('DSPACE_TEST_COLLECTION')); it('should load if you click on "Statistics" from a Collection page', () => { - cy.visit('/collections/'.concat(TEST_COLLECTION)); + cy.visit('/collections/'.concat(Cypress.env('DSPACE_TEST_COLLECTION'))); cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click(); cy.location('pathname').should('eq', COLLECTIONSTATISTICSPAGE); }); @@ -18,7 +18,7 @@ describe('Collection Statistics Page', () => { it('should contain a "Total visits per month" section', () => { cy.visit(COLLECTIONSTATISTICSPAGE); // Check just for existence because this table is empty in CI environment as it's historical data - cy.get('.'.concat(TEST_COLLECTION).concat('_TotalVisitsPerMonth')).should('exist'); + cy.get('.'.concat(Cypress.env('DSPACE_TEST_COLLECTION')).concat('_TotalVisitsPerMonth')).should('exist'); }); it('should pass accessibility tests', () => { diff --git a/cypress/e2e/community-edit.cy.ts b/cypress/e2e/community-edit.cy.ts new file mode 100644 index 00000000000..8fc1a7733e7 --- /dev/null +++ b/cypress/e2e/community-edit.cy.ts @@ -0,0 +1,86 @@ +import { testA11y } from 'cypress/support/utils'; + +const COMMUNITY_EDIT_PAGE = '/communities/'.concat(Cypress.env('DSPACE_TEST_COMMUNITY')).concat('/edit'); + +beforeEach(() => { + // All tests start with visiting the Edit Community Page + cy.visit(COMMUNITY_EDIT_PAGE); + + // This page is restricted, so we will be shown the login form. Fill it out & submit. + cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); +}); + +describe('Edit Community > Edit Metadata tab', () => { + it('should pass accessibility tests', () => { + // tag must be loaded + cy.get('ds-edit-community').should('be.visible'); + + // Analyze for accessibility issues + testA11y('ds-edit-community'); + }); +}); + +describe('Edit Community > Assign Roles tab', () => { + + it('should pass accessibility tests', () => { + cy.get('a[data-test="roles"]').click(); + + // tag must be loaded + cy.get('ds-community-roles').should('be.visible'); + + // Analyze for accessibility issues + testA11y('ds-community-roles'); + }); +}); + +describe('Edit Community > Curate tab', () => { + + it('should pass accessibility tests', () => { + cy.get('a[data-test="curate"]').click(); + + // tag must be loaded + cy.get('ds-community-curate').should('be.visible'); + + // Analyze for accessibility issues + testA11y('ds-community-curate'); + }); +}); + +describe('Edit Community > Access Control tab', () => { + + it('should pass accessibility tests', () => { + cy.get('a[data-test="access-control"]').click(); + + // tag must be loaded + cy.get('ds-community-access-control').should('be.visible'); + + // Analyze for accessibility issues + testA11y('ds-community-access-control'); + }); +}); + +describe('Edit Community > Authorizations tab', () => { + + it('should pass accessibility tests', () => { + cy.get('a[data-test="authorizations"]').click(); + + // tag must be loaded + cy.get('ds-community-authorizations').should('be.visible'); + + // Analyze for accessibility issues + testA11y('ds-community-authorizations'); + }); +}); + +describe('Edit Community > Delete page', () => { + + it('should pass accessibility tests', () => { + cy.get('a[data-test="delete-button"]').click(); + + // tag must be loaded + cy.get('ds-delete-community').should('be.visible'); + + // Analyze for accessibility issues + testA11y('ds-delete-community'); + }); +}); diff --git a/cypress/e2e/community-page.cy.ts b/cypress/e2e/community-page.cy.ts index 6c628e21ce1..386bb592a0a 100644 --- a/cypress/e2e/community-page.cy.ts +++ b/cypress/e2e/community-page.cy.ts @@ -1,15 +1,14 @@ -import { TEST_COMMUNITY } from 'cypress/support/e2e'; import { testA11y } from 'cypress/support/utils'; describe('Community Page', () => { it('should pass accessibility tests', () => { - cy.visit('/communities/'.concat(TEST_COMMUNITY)); + cy.visit('/communities/'.concat(Cypress.env('DSPACE_TEST_COMMUNITY'))); // tag must be loaded cy.get('ds-community-page').should('be.visible'); // Analyze for accessibility issues - testA11y('ds-community-page',); + testA11y('ds-community-page'); }); }); diff --git a/cypress/e2e/community-statistics.cy.ts b/cypress/e2e/community-statistics.cy.ts index 710450e7972..ca306eff5c2 100644 --- a/cypress/e2e/community-statistics.cy.ts +++ b/cypress/e2e/community-statistics.cy.ts @@ -1,11 +1,11 @@ -import { REGEX_MATCH_NON_EMPTY_TEXT, TEST_COMMUNITY } from 'cypress/support/e2e'; +import { REGEX_MATCH_NON_EMPTY_TEXT } from 'cypress/support/e2e'; import { testA11y } from 'cypress/support/utils'; describe('Community Statistics Page', () => { - const COMMUNITYSTATISTICSPAGE = '/statistics/communities/'.concat(TEST_COMMUNITY); + const COMMUNITYSTATISTICSPAGE = '/statistics/communities/'.concat(Cypress.env('DSPACE_TEST_COMMUNITY')); it('should load if you click on "Statistics" from a Community page', () => { - cy.visit('/communities/'.concat(TEST_COMMUNITY)); + cy.visit('/communities/'.concat(Cypress.env('DSPACE_TEST_COMMUNITY'))); cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click(); cy.location('pathname').should('eq', COMMUNITYSTATISTICSPAGE); }); @@ -18,7 +18,7 @@ describe('Community Statistics Page', () => { it('should contain a "Total visits per month" section', () => { cy.visit(COMMUNITYSTATISTICSPAGE); // Check just for existence because this table is empty in CI environment as it's historical data - cy.get('.'.concat(TEST_COMMUNITY).concat('_TotalVisitsPerMonth')).should('exist'); + cy.get('.'.concat(Cypress.env('DSPACE_TEST_COMMUNITY')).concat('_TotalVisitsPerMonth')).should('exist'); }); it('should pass accessibility tests', () => { diff --git a/cypress/e2e/homepage-statistics.cy.ts b/cypress/e2e/homepage-statistics.cy.ts index 2a1ab9785ab..ff7dbeb852d 100644 --- a/cypress/e2e/homepage-statistics.cy.ts +++ b/cypress/e2e/homepage-statistics.cy.ts @@ -1,4 +1,4 @@ -import { REGEX_MATCH_NON_EMPTY_TEXT, TEST_ENTITY_PUBLICATION } from 'cypress/support/e2e'; +import { REGEX_MATCH_NON_EMPTY_TEXT } from 'cypress/support/e2e'; import { testA11y } from 'cypress/support/utils'; import '../support/commands'; @@ -11,8 +11,8 @@ describe('Site Statistics Page', () => { it('should pass accessibility tests', () => { // generate 2 view events on an Item's page - cy.generateViewEvent(TEST_ENTITY_PUBLICATION, 'item'); - cy.generateViewEvent(TEST_ENTITY_PUBLICATION, 'item'); + cy.generateViewEvent(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION'), 'item'); + cy.generateViewEvent(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION'), 'item'); cy.visit('/statistics'); diff --git a/cypress/e2e/item-edit.cy.ts b/cypress/e2e/item-edit.cy.ts new file mode 100644 index 00000000000..b4c01a1a946 --- /dev/null +++ b/cypress/e2e/item-edit.cy.ts @@ -0,0 +1,135 @@ +import { Options } from 'cypress-axe'; +import { testA11y } from 'cypress/support/utils'; + +const ITEM_EDIT_PAGE = '/items/'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION')).concat('/edit'); + +beforeEach(() => { + // All tests start with visiting the Edit Item Page + cy.visit(ITEM_EDIT_PAGE); + + // This page is restricted, so we will be shown the login form. Fill it out & submit. + cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); +}); + +describe('Edit Item > Edit Metadata tab', () => { + it('should pass accessibility tests', () => { + cy.get('a[data-test="metadata"]').click(); + + // tag must be loaded + cy.get('ds-edit-item-page').should('be.visible'); + + // Analyze for accessibility issues + testA11y('ds-edit-item-page'); + }); +}); + +describe('Edit Item > Status tab', () => { + + it('should pass accessibility tests', () => { + cy.get('a[data-test="status"]').click(); + + // tag must be loaded + cy.get('ds-item-status').should('be.visible'); + + // Analyze for accessibility issues + testA11y('ds-item-status'); + }); +}); + +describe('Edit Item > Bitstreams tab', () => { + + it('should pass accessibility tests', () => { + cy.get('a[data-test="bitstreams"]').click(); + + // tag must be loaded + cy.get('ds-item-bitstreams').should('be.visible'); + + // Table of item bitstreams must also be loaded + cy.get('div.item-bitstreams').should('be.visible'); + + // Analyze for accessibility issues + testA11y('ds-item-bitstreams', + { + rules: { + // Currently Bitstreams page loads a pagination component per Bundle + // and they all use the same 'id="p-dad"'. + 'duplicate-id': { enabled: false }, + } + } as Options + ); + }); +}); + +describe('Edit Item > Curate tab', () => { + + it('should pass accessibility tests', () => { + cy.get('a[data-test="curate"]').click(); + + // tag must be loaded + cy.get('ds-item-curate').should('be.visible'); + + // Analyze for accessibility issues + testA11y('ds-item-curate'); + }); +}); + +describe('Edit Item > Relationships tab', () => { + + it('should pass accessibility tests', () => { + cy.get('a[data-test="relationships"]').click(); + + // tag must be loaded + cy.get('ds-item-relationships').should('be.visible'); + + // Analyze for accessibility issues + testA11y('ds-item-relationships'); + }); +}); + +describe('Edit Item > Version History tab', () => { + + it('should pass accessibility tests', () => { + cy.get('a[data-test="versionhistory"]').click(); + + // tag must be loaded + cy.get('ds-item-version-history').should('be.visible'); + + // Analyze for accessibility issues + testA11y('ds-item-version-history'); + }); +}); + +describe('Edit Item > Access Control tab', () => { + + it('should pass accessibility tests', () => { + cy.get('a[data-test="access-control"]').click(); + + // tag must be loaded + cy.get('ds-item-access-control').should('be.visible'); + + // Analyze for accessibility issues + testA11y('ds-item-access-control'); + }); +}); + +describe('Edit Item > Collection Mapper tab', () => { + + it('should pass accessibility tests', () => { + cy.get('a[data-test="mapper"]').click(); + + // tag must be loaded + cy.get('ds-item-collection-mapper').should('be.visible'); + + // Analyze entire page for accessibility issues + testA11y('ds-item-collection-mapper'); + + // Click on the "Map new collections" tab + cy.get('li[data-test="mapTab"] a').click(); + + // Make sure search form is now visible + cy.get('ds-search-form').should('be.visible'); + + // Analyze entire page (again) for accessibility issues + testA11y('ds-item-collection-mapper'); + }); +}); diff --git a/cypress/e2e/item-page.cy.ts b/cypress/e2e/item-page.cy.ts index 9dba6eb8cea..a6a208e9f45 100644 --- a/cypress/e2e/item-page.cy.ts +++ b/cypress/e2e/item-page.cy.ts @@ -1,9 +1,8 @@ -import { TEST_ENTITY_PUBLICATION } from 'cypress/support/e2e'; import { testA11y } from 'cypress/support/utils'; describe('Item Page', () => { - const ITEMPAGE = '/items/'.concat(TEST_ENTITY_PUBLICATION); - const ENTITYPAGE = '/entities/publication/'.concat(TEST_ENTITY_PUBLICATION); + const ITEMPAGE = '/items/'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION')); + const ENTITYPAGE = '/entities/publication/'.concat(Cypress.env('DSPACE_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', () => { diff --git a/cypress/e2e/item-statistics.cy.ts b/cypress/e2e/item-statistics.cy.ts index 9b90cb24afc..b856744cba7 100644 --- a/cypress/e2e/item-statistics.cy.ts +++ b/cypress/e2e/item-statistics.cy.ts @@ -1,11 +1,11 @@ -import { REGEX_MATCH_NON_EMPTY_TEXT, TEST_ENTITY_PUBLICATION } from 'cypress/support/e2e'; +import { REGEX_MATCH_NON_EMPTY_TEXT } from 'cypress/support/e2e'; import { testA11y } from 'cypress/support/utils'; describe('Item Statistics Page', () => { - const ITEMSTATISTICSPAGE = '/statistics/items/'.concat(TEST_ENTITY_PUBLICATION); + const ITEMSTATISTICSPAGE = '/statistics/items/'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION')); it('should load if you click on "Statistics" from an Item/Entity page', () => { - cy.visit('/entities/publication/'.concat(TEST_ENTITY_PUBLICATION)); + cy.visit('/entities/publication/'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION'))); cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click(); cy.location('pathname').should('eq', ITEMSTATISTICSPAGE); }); @@ -24,7 +24,7 @@ describe('Item Statistics Page', () => { it('should contain a "Total visits per month" section', () => { cy.visit(ITEMSTATISTICSPAGE); // Check just for existence because this table is empty in CI environment as it's historical data - cy.get('.'.concat(TEST_ENTITY_PUBLICATION).concat('_TotalVisitsPerMonth')).should('exist'); + cy.get('.'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION')).concat('_TotalVisitsPerMonth')).should('exist'); }); it('should pass accessibility tests', () => { diff --git a/cypress/e2e/login-modal.cy.ts b/cypress/e2e/login-modal.cy.ts index d29c13c2f96..c56b98fd269 100644 --- a/cypress/e2e/login-modal.cy.ts +++ b/cypress/e2e/login-modal.cy.ts @@ -1,4 +1,3 @@ -import { TEST_ADMIN_PASSWORD, TEST_ADMIN_USER, TEST_ENTITY_PUBLICATION } from 'cypress/support/e2e'; import { testA11y } from 'cypress/support/utils'; const page = { @@ -37,7 +36,7 @@ const page = { describe('Login Modal', () => { it('should login when clicking button & stay on same page', () => { - const ENTITYPAGE = '/entities/publication/'.concat(TEST_ENTITY_PUBLICATION); + const ENTITYPAGE = '/entities/publication/'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION')); cy.visit(ENTITYPAGE); // Login menu should exist @@ -47,7 +46,7 @@ describe('Login Modal', () => { page.openLoginMenu(); cy.get('.form-login').should('be.visible'); - page.submitLoginAndPasswordByPressingButton(TEST_ADMIN_USER, TEST_ADMIN_PASSWORD); + page.submitLoginAndPasswordByPressingButton(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); cy.get('ds-log-in').should('not.exist'); // Verify we are still on the same page @@ -67,7 +66,7 @@ describe('Login Modal', () => { cy.get('.form-login').should('be.visible'); // Login, and the tag should no longer exist - page.submitLoginAndPasswordByPressingEnter(TEST_ADMIN_USER, TEST_ADMIN_PASSWORD); + page.submitLoginAndPasswordByPressingEnter(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); cy.get('.form-login').should('not.exist'); // Verify we are still on homepage @@ -81,7 +80,7 @@ describe('Login Modal', () => { it('should support logout', () => { // First authenticate & access homepage - cy.login(TEST_ADMIN_USER, TEST_ADMIN_PASSWORD); + cy.login(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); cy.visit('/'); // Verify ds-log-in tag doesn't exist, but ds-log-out tag does exist @@ -109,6 +108,9 @@ describe('Login Modal', () => { cy.get('ds-themed-navbar [data-test="register"]').click(); cy.location('pathname').should('eq', '/register'); cy.get('ds-register-email').should('exist'); + + // Test accessibility of this page + testA11y('ds-register-email'); }); it('should allow forgot password', () => { @@ -123,16 +125,26 @@ describe('Login Modal', () => { cy.get('ds-themed-navbar [data-test="forgot"]').click(); cy.location('pathname').should('eq', '/forgot'); cy.get('ds-forgot-email').should('exist'); + + // Test accessibility of this page + testA11y('ds-forgot-email'); }); - it('should pass accessibility tests', () => { + it('should pass accessibility tests in menus', () => { cy.visit('/'); + // Open login menu & verify accessibility page.openLoginMenu(); - cy.get('ds-log-in').should('exist'); - - // Analyze for accessibility issues testA11y('ds-log-in'); + + // Now login + page.submitLoginAndPasswordByPressingButton(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); + cy.get('ds-log-in').should('not.exist'); + + // Open user menu, verify user menu accesibility + page.openUserMenu(); + cy.get('ds-user-menu').should('be.visible'); + testA11y('ds-user-menu'); }); }); diff --git a/cypress/e2e/my-dspace.cy.ts b/cypress/e2e/my-dspace.cy.ts index 13f4a1b5471..c48656ffcc0 100644 --- a/cypress/e2e/my-dspace.cy.ts +++ b/cypress/e2e/my-dspace.cy.ts @@ -1,5 +1,3 @@ -import { Options } from 'cypress-axe'; -import { TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD, TEST_SUBMIT_COLLECTION_NAME } from 'cypress/support/e2e'; import { testA11y } from 'cypress/support/utils'; describe('My DSpace page', () => { @@ -7,7 +5,7 @@ describe('My DSpace page', () => { cy.visit('/mydspace'); // This page is restricted, so we will be shown the login form. Fill it out & submit. - cy.loginViaForm(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD); + cy.loginViaForm(Cypress.env('DSPACE_TEST_SUBMIT_USER'), Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD')); cy.get('ds-my-dspace-page').should('be.visible'); @@ -26,7 +24,7 @@ describe('My DSpace page', () => { cy.visit('/mydspace'); // This page is restricted, so we will be shown the login form. Fill it out & submit. - cy.loginViaForm(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD); + cy.loginViaForm(Cypress.env('DSPACE_TEST_SUBMIT_USER'), Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD')); cy.get('ds-my-dspace-page').should('be.visible'); @@ -35,16 +33,8 @@ describe('My DSpace page', () => { cy.get('ds-object-detail').should('be.visible'); - // Analyze for accessibility issues - testA11y('ds-my-dspace-page', - { - rules: { - // Search filters fail these two "moderate" impact rules - 'heading-order': { enabled: false }, - 'landmark-unique': { enabled: false } - } - } as Options - ); + // Analyze for accessibility issues + testA11y('ds-my-dspace-page'); }); // NOTE: Deleting existing submissions is exercised by submission.spec.ts @@ -52,7 +42,7 @@ describe('My DSpace page', () => { cy.visit('/mydspace'); // This page is restricted, so we will be shown the login form. Fill it out & submit. - cy.loginViaForm(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD); + cy.loginViaForm(Cypress.env('DSPACE_TEST_SUBMIT_USER'), Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD')); // Open the New Submission dropdown cy.get('button[data-test="submission-dropdown"]').click(); @@ -63,10 +53,10 @@ describe('My DSpace page', () => { cy.get('ds-create-item-parent-selector').should('be.visible'); // Type in a known Collection name in the search box - cy.get('ds-authorized-collection-selector input[type="search"]').type(TEST_SUBMIT_COLLECTION_NAME); + cy.get('ds-authorized-collection-selector input[type="search"]').type(Cypress.env('DSPACE_TEST_SUBMIT_COLLECTION_NAME')); // Click on the button matching that known Collection name - cy.get('ds-authorized-collection-selector button[title="'.concat(TEST_SUBMIT_COLLECTION_NAME).concat('"]')).click(); + cy.get('ds-authorized-collection-selector button[title="'.concat(Cypress.env('DSPACE_TEST_SUBMIT_COLLECTION_NAME')).concat('"]')).click(); // New URL should include /workspaceitems, as we've started a new submission cy.url().should('include', '/workspaceitems'); @@ -75,7 +65,7 @@ describe('My DSpace page', () => { cy.get('ds-submission-edit').should('be.visible'); // A Collection menu button should exist & its value should be the selected collection - cy.get('#collectionControlsMenuButton span').should('have.text', TEST_SUBMIT_COLLECTION_NAME); + cy.get('#collectionControlsMenuButton span').should('have.text', Cypress.env('DSPACE_TEST_SUBMIT_COLLECTION_NAME')); // Now that we've created a submission, we'll test that we can go back and Edit it. // Get our Submission URL, to parse out the ID of this new submission @@ -124,7 +114,7 @@ describe('My DSpace page', () => { cy.visit('/mydspace'); // This page is restricted, so we will be shown the login form. Fill it out & submit. - cy.loginViaForm(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD); + cy.loginViaForm(Cypress.env('DSPACE_TEST_SUBMIT_USER'), Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD')); // Open the New Import dropdown cy.get('button[data-test="import-dropdown"]').click(); @@ -136,6 +126,9 @@ describe('My DSpace page', () => { // The external import searchbox should be visible cy.get('ds-submission-import-external-searchbar').should('be.visible'); + + // Test for accessibility issues + testA11y('ds-submission-import-external'); }); }); diff --git a/cypress/e2e/search-navbar.cy.ts b/cypress/e2e/search-navbar.cy.ts index 648db17fe65..9dd93c7a2dd 100644 --- a/cypress/e2e/search-navbar.cy.ts +++ b/cypress/e2e/search-navbar.cy.ts @@ -1,5 +1,3 @@ -import { TEST_SEARCH_TERM } from 'cypress/support/e2e'; - const page = { fillOutQueryInNavBar(query) { // Click the magnifying glass @@ -17,7 +15,7 @@ const page = { describe('Search from Navigation Bar', () => { // NOTE: these tests currently assume this query will return results! - const query = TEST_SEARCH_TERM; + const query = Cypress.env('DSPACE_TEST_SEARCH_TERM'); it('should go to search page with correct query if submitted (from home)', () => { cy.visit('/'); diff --git a/cypress/e2e/search-page.cy.ts b/cypress/e2e/search-page.cy.ts index 755f8eaac6c..429f4e6da46 100644 --- a/cypress/e2e/search-page.cy.ts +++ b/cypress/e2e/search-page.cy.ts @@ -1,8 +1,10 @@ import { Options } from 'cypress-axe'; -import { TEST_SEARCH_TERM } from 'cypress/support/e2e'; import { testA11y } from 'cypress/support/utils'; describe('Search Page', () => { + // NOTE: these tests currently assume this query will return results! + const query = Cypress.env('DSPACE_TEST_SEARCH_TERM'); + 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'); @@ -13,8 +15,8 @@ describe('Search Page', () => { }); it('should load results and pass accessibility tests', () => { - cy.visit('/search?query='.concat(TEST_SEARCH_TERM)); - cy.get('[data-test="search-box"]').should('have.value', TEST_SEARCH_TERM); + cy.visit('/search?query='.concat(query)); + cy.get('[data-test="search-box"]').should('have.value', query); // tag must be loaded cy.get('ds-search-page').should('be.visible'); @@ -31,7 +33,7 @@ describe('Search Page', () => { }); it('should have a working grid view that passes accessibility tests', () => { - cy.visit('/search?query='.concat(TEST_SEARCH_TERM)); + cy.visit('/search?query='.concat(query)); // Click button in sidebar to display grid view cy.get('ds-search-sidebar [data-test="grid-view"]').click(); @@ -46,9 +48,8 @@ describe('Search Page', () => { testA11y('ds-search-page', { rules: { - // Search filters fail these two "moderate" impact rules - 'heading-order': { enabled: false }, - 'landmark-unique': { enabled: false } + // Card titles fail this test currently + 'heading-order': { enabled: false } } } as Options ); diff --git a/cypress/e2e/submission.cy.ts b/cypress/e2e/submission.cy.ts index ed10b2d13aa..4402410f234 100644 --- a/cypress/e2e/submission.cy.ts +++ b/cypress/e2e/submission.cy.ts @@ -1,14 +1,16 @@ -import { TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD, TEST_SUBMIT_COLLECTION_NAME, TEST_SUBMIT_COLLECTION_UUID } from 'cypress/support/e2e'; +import { testA11y } from 'cypress/support/utils'; +//import { TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD, TEST_SUBMIT_COLLECTION_NAME, TEST_SUBMIT_COLLECTION_UUID, TEST_ADMIN_USER, TEST_ADMIN_PASSWORD } from 'cypress/support/e2e'; +import { Options } from 'cypress-axe'; describe('New Submission page', () => { - // NOTE: We already test that new submissions can be started from MyDSpace in my-dspace.spec.ts + // NOTE: We already test that new Item submissions can be started from MyDSpace in my-dspace.spec.ts it('should create a new submission when using /submit path & pass accessibility', () => { // Test that calling /submit with collection & entityType will create a new submission - cy.visit('/submit?collection='.concat(TEST_SUBMIT_COLLECTION_UUID).concat('&entityType=none')); + cy.visit('/submit?collection='.concat(Cypress.env('DSPACE_TEST_SUBMIT_COLLECTION_UUID')).concat('&entityType=none')); // This page is restricted, so we will be shown the login form. Fill it out & submit. - cy.loginViaForm(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD); + cy.loginViaForm(Cypress.env('DSPACE_TEST_SUBMIT_USER'), Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD')); // Should redirect to /workspaceitems, as we've started a new submission cy.url().should('include', '/workspaceitems'); @@ -17,7 +19,7 @@ describe('New Submission page', () => { cy.get('ds-submission-edit').should('be.visible'); // A Collection menu button should exist & it's value should be the selected collection - cy.get('#collectionControlsMenuButton span').should('have.text', TEST_SUBMIT_COLLECTION_NAME); + cy.get('#collectionControlsMenuButton span').should('have.text', Cypress.env('DSPACE_TEST_SUBMIT_COLLECTION_NAME')); // 4 sections should be visible by default cy.get('div#section_traditionalpageone').should('be.visible'); @@ -25,6 +27,25 @@ describe('New Submission page', () => { cy.get('div#section_upload').should('be.visible'); cy.get('div#section_license').should('be.visible'); + // Test entire page for accessibility + testA11y('ds-submission-edit', + { + rules: { + // Author & Subject fields have invalid "aria-multiline" attrs. + // See https://github.com/DSpace/dspace-angular/issues/1272 + 'aria-allowed-attr': { enabled: false }, + // All panels are accordians & fail "aria-required-children" and "nested-interactive". + // Seem to require updating ng-bootstrap and https://github.com/DSpace/dspace-angular/issues/2216 + 'aria-required-children': { enabled: false }, + 'nested-interactive': { enabled: false }, + // All select boxes fail to have a name / aria-label. + // This is a bug in ng-dynamic-forms and may require https://github.com/DSpace/dspace-angular/issues/2216 + 'select-name': { enabled: false }, + } + + } as Options + ); + // Discard button should work // Clicking it will display a confirmation, which we will confirm with another click cy.get('button#discard').click(); @@ -33,10 +54,10 @@ describe('New Submission page', () => { it('should block submission & show errors if required fields are missing', () => { // Create a new submission - cy.visit('/submit?collection='.concat(TEST_SUBMIT_COLLECTION_UUID).concat('&entityType=none')); + cy.visit('/submit?collection='.concat(Cypress.env('DSPACE_TEST_SUBMIT_COLLECTION_UUID')).concat('&entityType=none')); // This page is restricted, so we will be shown the login form. Fill it out & submit. - cy.loginViaForm(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD); + cy.loginViaForm(Cypress.env('DSPACE_TEST_SUBMIT_USER'), Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD')); // Attempt an immediate deposit without filling out any fields cy.get('button#deposit').click(); @@ -93,10 +114,10 @@ describe('New Submission page', () => { it('should allow for deposit if all required fields completed & file uploaded', () => { // Create a new submission - cy.visit('/submit?collection='.concat(TEST_SUBMIT_COLLECTION_UUID).concat('&entityType=none')); + cy.visit('/submit?collection='.concat(Cypress.env('DSPACE_TEST_SUBMIT_COLLECTION_UUID')).concat('&entityType=none')); // This page is restricted, so we will be shown the login form. Fill it out & submit. - cy.loginViaForm(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD); + cy.loginViaForm(Cypress.env('DSPACE_TEST_SUBMIT_USER'), Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD')); // Fill out all required fields (Title, Date) cy.get('input#dc_title').type('DSpace logo uploaded via e2e tests'); @@ -131,4 +152,76 @@ describe('New Submission page', () => { cy.get('ds-notification div.alert-success').should('be.visible'); }); + it('is possible to submit a new "Person" and that form passes accessibility', () => { + // To submit a different entity type, we'll start from MyDSpace + cy.visit('/mydspace'); + + // This page is restricted, so we will be shown the login form. Fill it out & submit. + // NOTE: At this time, we MUST login as admin to submit Person objects + cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); + + // Open the New Submission dropdown + cy.get('button[data-test="submission-dropdown"]').click(); + // Click on the "Person" type in that dropdown + cy.get('#entityControlsDropdownMenu button[title="Person"]').click(); + + // This should display the (popup window) + cy.get('ds-create-item-parent-selector').should('be.visible'); + + // Type in a known Collection name in the search box + cy.get('ds-authorized-collection-selector input[type="search"]').type(Cypress.env('DSPACE_TEST_SUBMIT_PERSON_COLLECTION_NAME')); + + // Click on the button matching that known Collection name + cy.get('ds-authorized-collection-selector button[title="'.concat(Cypress.env('DSPACE_TEST_SUBMIT_PERSON_COLLECTION_NAME')).concat('"]')).click(); + + // New URL should include /workspaceitems, as we've started a new submission + cy.url().should('include', '/workspaceitems'); + + // The Submission edit form tag should be visible + cy.get('ds-submission-edit').should('be.visible'); + + // A Collection menu button should exist & its value should be the selected collection + cy.get('#collectionControlsMenuButton span').should('have.text', Cypress.env('DSPACE_TEST_SUBMIT_PERSON_COLLECTION_NAME')); + + // 3 sections should be visible by default + cy.get('div#section_personStep').should('be.visible'); + cy.get('div#section_upload').should('be.visible'); + cy.get('div#section_license').should('be.visible'); + + // Test entire page for accessibility + testA11y('ds-submission-edit', + { + rules: { + // All panels are accordians & fail "aria-required-children" and "nested-interactive". + // Seem to require updating ng-bootstrap and https://github.com/DSpace/dspace-angular/issues/2216 + 'aria-required-children': { enabled: false }, + 'nested-interactive': { enabled: false }, + } + + } as Options + ); + + // Click the lookup button next to "Publication" field + cy.get('button[data-test="lookup-button"]').click(); + + // A popup modal window should be visible + cy.get('ds-dynamic-lookup-relation-modal').should('be.visible'); + + // Popup modal should also pass accessibility tests + //testA11y('ds-dynamic-lookup-relation-modal'); + testA11y({ + include: ['ds-dynamic-lookup-relation-modal'], + exclude: [ + ['ul.nav-tabs'] // Tabs at top of model have several issues which seem to be caused by ng-bootstrap + ], + }); + + // Close popup window + cy.get('ds-dynamic-lookup-relation-modal button.close').click(); + + // Back on the form, click the discard button to remove new submission + // Clicking it will display a confirmation, which we will confirm with another click + cy.get('button#discard').click(); + cy.get('button#discard_submit').click(); + }); }); diff --git a/cypress/plugins/index.ts b/cypress/plugins/index.ts index ead38afb921..cc3dccba38e 100644 --- a/cypress/plugins/index.ts +++ b/cypress/plugins/index.ts @@ -1,5 +1,11 @@ const fs = require('fs'); +// These two global variables are used to store information about the REST API used +// by these e2e tests. They are filled out prior to running any tests in the before() +// method of e2e.ts. They can then be accessed by any tests via the getters below. +let REST_BASE_URL: string; +let REST_DOMAIN: string; + // Plugins enable you to tap into, modify, or extend the internal behavior of Cypress // For more info, visit https://on.cypress.io/plugins-api module.exports = (on, config) => { @@ -30,6 +36,24 @@ module.exports = (on, config) => { } return null; + }, + // Save value of REST Base URL, looked up before all tests. + // This allows other tests to use it easily via getRestBaseURL() below. + saveRestBaseURL(url: string) { + return (REST_BASE_URL = url); + }, + // Retrieve currently saved value of REST Base URL + getRestBaseURL() { + return REST_BASE_URL ; + }, + // Save value of REST Domain, looked up before all tests. + // This allows other tests to use it easily via getRestBaseDomain() below. + saveRestBaseDomain(domain: string) { + return (REST_DOMAIN = domain); + }, + // Retrieve currently saved value of REST Domain + getRestBaseDomain() { + return REST_DOMAIN ; } }); }; diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 92f0b1aeeb6..7da454e2d0c 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -5,11 +5,7 @@ import { AuthTokenInfo, TOKENITEM } from 'src/app/core/auth/models/auth-token-info.model'; import { DSPACE_XSRF_COOKIE, XSRF_REQUEST_HEADER } from 'src/app/core/xsrf/xsrf.constants'; - -// NOTE: FALLBACK_TEST_REST_BASE_URL is only used if Cypress cannot read the REST API BaseURL -// from the Angular UI's config.json. See 'login()'. -export const FALLBACK_TEST_REST_BASE_URL = 'http://localhost:8080/server'; -export const FALLBACK_TEST_REST_DOMAIN = 'localhost'; +import { v4 as uuidv4 } from 'uuid'; // Declare Cypress namespace to help with Intellisense & code completion in IDEs // ALL custom commands MUST be listed here for code completion to work @@ -41,6 +37,13 @@ declare global { * @param dsoType type of DSpace Object (e.g. "item", "collection", "community") */ generateViewEvent(uuid: string, dsoType: string): typeof generateViewEvent; + + /** + * Create a new CSRF token and add to required Cookie. CSRF Token is returned + * in chainable in order to allow it to be sent also in required CSRF header. + * @returns Chainable reference to allow CSRF token to also be sent in header. + */ + createCSRFCookie(): Chainable; } } } @@ -54,59 +57,32 @@ declare global { * @param password password to login as */ function login(email: string, password: string): void { - // Cypress doesn't have access to the running application in Node.js. - // So, it's not possible to inject or load the AppConfig or environment of the Angular UI. - // Instead, we'll read our running application's config.json, which contains the configs & - // is regenerated at runtime each time the Angular UI application starts up. - cy.task('readUIConfig').then((str: string) => { - // Parse config into a JSON object - const config = JSON.parse(str); - - // Find the URL of our REST API. Have a fallback ready, just in case 'rest.baseUrl' cannot be found. - let baseRestUrl = FALLBACK_TEST_REST_BASE_URL; - if (!config.rest.baseUrl) { - console.warn("Could not load 'rest.baseUrl' from config.json. Falling back to " + FALLBACK_TEST_REST_BASE_URL); - } else { - //console.log("Found 'rest.baseUrl' in config.json. Using this REST API for login: ".concat(config.rest.baseUrl)); - baseRestUrl = config.rest.baseUrl; - } - - // Now find domain of our REST API, again with a fallback. - let baseDomain = FALLBACK_TEST_REST_DOMAIN; - if (!config.rest.host) { - console.warn("Could not load 'rest.host' from config.json. Falling back to " + FALLBACK_TEST_REST_DOMAIN); - } else { - baseDomain = config.rest.host; - } - - // Create a fake CSRF Token. Set it in the required server-side cookie - const csrfToken = 'fakeLoginCSRFToken'; - cy.setCookie(DSPACE_XSRF_COOKIE, csrfToken, { 'domain': baseDomain }); - - // Now, send login POST request including that CSRF token - cy.request({ - method: 'POST', - url: baseRestUrl + '/api/authn/login', - headers: { [XSRF_REQUEST_HEADER]: csrfToken}, - form: true, // indicates the body should be form urlencoded - body: { user: email, password: password } - }).then((resp) => { - // We expect a successful login - expect(resp.status).to.eq(200); - // We expect to have a valid authorization header returned (with our auth token) - expect(resp.headers).to.have.property('authorization'); - - // Initialize our AuthTokenInfo object from the authorization header. - const authheader = resp.headers.authorization as string; - const authinfo: AuthTokenInfo = new AuthTokenInfo(authheader); - - // Save our AuthTokenInfo object to our dsAuthInfo UI cookie - // This ensures the UI will recognize we are logged in on next "visit()" - cy.setCookie(TOKENITEM, JSON.stringify(authinfo)); + // Create a fake CSRF cookie/token to use in POST + cy.createCSRFCookie().then((csrfToken: string) => { + // get our REST API's base URL, also needed for POST + cy.task('getRestBaseURL').then((baseRestUrl: string) => { + // Now, send login POST request including that CSRF token + cy.request({ + method: 'POST', + url: baseRestUrl + '/api/authn/login', + headers: { [XSRF_REQUEST_HEADER]: csrfToken}, + form: true, // indicates the body should be form urlencoded + body: { user: email, password: password } + }).then((resp) => { + // We expect a successful login + expect(resp.status).to.eq(200); + // We expect to have a valid authorization header returned (with our auth token) + expect(resp.headers).to.have.property('authorization'); + + // Initialize our AuthTokenInfo object from the authorization header. + const authheader = resp.headers.authorization as string; + const authinfo: AuthTokenInfo = new AuthTokenInfo(authheader); + + // Save our AuthTokenInfo object to our dsAuthInfo UI cookie + // This ensures the UI will recognize we are logged in on next "visit()" + cy.setCookie(TOKENITEM, JSON.stringify(authinfo)); + }); }); - - // Remove cookie with fake CSRF token, as it's no longer needed - cy.clearCookie(DSPACE_XSRF_COOKIE); }); } // Add as a Cypress command (i.e. assign to 'cy.login') @@ -141,56 +117,53 @@ Cypress.Commands.add('loginViaForm', loginViaForm); * @param dsoType type of DSpace Object (e.g. "item", "collection", "community") */ function generateViewEvent(uuid: string, dsoType: string): void { - // Cypress doesn't have access to the running application in Node.js. - // So, it's not possible to inject or load the AppConfig or environment of the Angular UI. - // Instead, we'll read our running application's config.json, which contains the configs & - // is regenerated at runtime each time the Angular UI application starts up. - cy.task('readUIConfig').then((str: string) => { - // Parse config into a JSON object - const config = JSON.parse(str); + // Create a fake CSRF cookie/token to use in POST + cy.createCSRFCookie().then((csrfToken: string) => { + // get our REST API's base URL, also needed for POST + cy.task('getRestBaseURL').then((baseRestUrl: string) => { + // Now, send 'statistics/viewevents' POST request including that fake CSRF token in required header + cy.request({ + method: 'POST', + url: baseRestUrl + '/api/statistics/viewevents', + headers: { + [XSRF_REQUEST_HEADER] : csrfToken, + // use a known public IP address to avoid being seen as a "bot" + 'X-Forwarded-For': '1.1.1.1', + // Use a user-agent of a Firefox browser on Windows. This again avoids being seen as a "bot" + 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/119.0', + }, + //form: true, // indicates the body should be form urlencoded + body: { targetId: uuid, targetType: dsoType }, + }).then((resp) => { + // We expect a 201 (which means statistics event was created) + expect(resp.status).to.eq(201); + }); + }); + }); +} +// Add as a Cypress command (i.e. assign to 'cy.generateViewEvent') +Cypress.Commands.add('generateViewEvent', generateViewEvent); - // Find the URL of our REST API. Have a fallback ready, just in case 'rest.baseUrl' cannot be found. - let baseRestUrl = FALLBACK_TEST_REST_BASE_URL; - if (!config.rest.baseUrl) { - console.warn("Could not load 'rest.baseUrl' from config.json. Falling back to " + FALLBACK_TEST_REST_BASE_URL); - } else { - baseRestUrl = config.rest.baseUrl; - } - // Now find domain of our REST API, again with a fallback. - let baseDomain = FALLBACK_TEST_REST_DOMAIN; - if (!config.rest.host) { - console.warn("Could not load 'rest.host' from config.json. Falling back to " + FALLBACK_TEST_REST_DOMAIN); - } else { - baseDomain = config.rest.host; - } +/** + * Can be used by tests to generate a random XSRF/CSRF token and save it to + * the required XSRF/CSRF cookie for usage when sending POST requests or similar. + * The generated CSRF token is returned in a Chainable to allow it to be also sent + * in the CSRF HTTP Header. + * @returns a Cypress Chainable which can be used to get the generated CSRF Token + */ +function createCSRFCookie(): Cypress.Chainable { + // Generate a new token which is a random UUID + const csrfToken: string = uuidv4(); + // Save it to our required cookie + cy.task('getRestBaseDomain').then((baseDomain: string) => { // Create a fake CSRF Token. Set it in the required server-side cookie - const csrfToken = 'fakeGenerateViewEventCSRFToken'; cy.setCookie(DSPACE_XSRF_COOKIE, csrfToken, { 'domain': baseDomain }); - - // Now, send 'statistics/viewevents' POST request including that fake CSRF token in required header - cy.request({ - method: 'POST', - url: baseRestUrl + '/api/statistics/viewevents', - headers: { - [XSRF_REQUEST_HEADER] : csrfToken, - // use a known public IP address to avoid being seen as a "bot" - 'X-Forwarded-For': '1.1.1.1', - // Use a user-agent of a Firefox browser on Windows. This again avoids being seen as a "bot" - 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/119.0', - }, - //form: true, // indicates the body should be form urlencoded - body: { targetId: uuid, targetType: dsoType }, - }).then((resp) => { - // We expect a 201 (which means statistics event was created) - expect(resp.status).to.eq(201); - }); - - // Remove cookie with fake CSRF token, as it's no longer needed - cy.clearCookie(DSPACE_XSRF_COOKIE); }); -} -// Add as a Cypress command (i.e. assign to 'cy.generateViewEvent') -Cypress.Commands.add('generateViewEvent', generateViewEvent); + // return the generated token wrapped in a chainable + return cy.wrap(csrfToken); +} +// Add as a Cypress command (i.e. assign to 'cy.createCSRFCookie') +Cypress.Commands.add('createCSRFCookie', createCSRFCookie); diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts index dd7ee1824c4..f6c68650528 100644 --- a/cypress/support/e2e.ts +++ b/cypress/support/e2e.ts @@ -19,45 +19,54 @@ import './commands'; // Import Cypress Axe tools for all tests // https://github.com/component-driven/cypress-axe import 'cypress-axe'; +import { DSPACE_XSRF_COOKIE } from 'src/app/core/xsrf/xsrf.constants'; + + +// Runs once before all tests +before(() => { + // Cypress doesn't have access to the running application in Node.js. + // So, it's not possible to inject or load the AppConfig or environment of the Angular UI. + // Instead, we'll read our running application's config.json, which contains the configs & + // is regenerated at runtime each time the Angular UI application starts up. + cy.task('readUIConfig').then((str: string) => { + // Parse config into a JSON object + const config = JSON.parse(str); + + // Find URL of our REST API & save to global variable via task + let baseRestUrl = FALLBACK_TEST_REST_BASE_URL; + if (!config.rest.baseUrl) { + console.warn("Could not load 'rest.baseUrl' from config.json. Falling back to " + FALLBACK_TEST_REST_BASE_URL); + } else { + baseRestUrl = config.rest.baseUrl; + } + cy.task('saveRestBaseURL', baseRestUrl); + + // Find domain of our REST API & save to global variable via task. + let baseDomain = FALLBACK_TEST_REST_DOMAIN; + if (!config.rest.host) { + console.warn("Could not load 'rest.host' from config.json. Falling back to " + FALLBACK_TEST_REST_DOMAIN); + } else { + baseDomain = config.rest.host; + } + cy.task('saveRestBaseDomain', baseDomain); + + }); +}); // Runs once before the first test in each "block" beforeEach(() => { // Pre-agree to all Klaro cookies by setting the klaro-anonymous cookie // This just ensures it doesn't get in the way of matching other objects in the page. cy.setCookie('klaro-anonymous', '{%22authentication%22:true%2C%22preferences%22:true%2C%22acknowledgement%22:true%2C%22google-analytics%22:true%2C%22google-recaptcha%22:true}'); -}); - -// For better stability between tests, we visit "about:blank" (i.e. blank page) after each test. -// This ensures any remaining/outstanding XHR requests are killed, so they don't affect the next test. -// Borrowed from: https://glebbahmutov.com/blog/visit-blank-page-between-tests/ -/*afterEach(() => { - cy.window().then((win) => { - win.location.href = 'about:blank'; - }); -});*/ - -// Global constants used in tests -// May be overridden in our cypress.json config file using specified environment variables. -// Default values listed here are all valid for the Demo Entities Data set available at -// https://github.com/DSpace-Labs/AIP-Files/releases/tag/demo-entities-data -// (This is the data set used in our CI environment) - -// Admin account used for administrative tests -export const TEST_ADMIN_USER = Cypress.env('DSPACE_TEST_ADMIN_USER') || 'dspacedemo+admin@gmail.com'; -export const TEST_ADMIN_PASSWORD = Cypress.env('DSPACE_TEST_ADMIN_PASSWORD') || 'dspace'; -// Community/collection/publication used for view/edit tests -export const TEST_COLLECTION = Cypress.env('DSPACE_TEST_COLLECTION') || '282164f5-d325-4740-8dd1-fa4d6d3e7200'; -export const TEST_COMMUNITY = Cypress.env('DSPACE_TEST_COMMUNITY') || '0958c910-2037-42a9-81c7-dca80e3892b4'; -export const TEST_ENTITY_PUBLICATION = Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION') || 'e98b0f27-5c19-49a0-960d-eb6ad5287067'; -// Search term (should return results) used in search tests -export const TEST_SEARCH_TERM = Cypress.env('DSPACE_TEST_SEARCH_TERM') || 'test'; -// Collection used for submission tests -export const TEST_SUBMIT_COLLECTION_NAME = Cypress.env('DSPACE_TEST_SUBMIT_COLLECTION_NAME') || 'Sample Collection'; -export const TEST_SUBMIT_COLLECTION_UUID = Cypress.env('DSPACE_TEST_SUBMIT_COLLECTION_UUID') || '9d8334e9-25d3-4a67-9cea-3dffdef80144'; -export const TEST_SUBMIT_USER = Cypress.env('DSPACE_TEST_SUBMIT_USER') || 'dspacedemo+submit@gmail.com'; -export const TEST_SUBMIT_USER_PASSWORD = Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD') || 'dspace'; + // Remove any CSRF cookies saved from prior tests + cy.clearCookie(DSPACE_XSRF_COOKIE); +}); +// NOTE: FALLBACK_TEST_REST_BASE_URL is only used if Cypress cannot read the REST API BaseURL +// from the Angular UI's config.json. See 'before()' above. +const FALLBACK_TEST_REST_BASE_URL = 'http://localhost:8080/server'; +const FALLBACK_TEST_REST_DOMAIN = 'localhost'; // USEFUL REGEX for testing diff --git a/docker/cli.assetstore.yml b/docker/cli.assetstore.yml index 40e4974c7c7..31bc53f64d8 100644 --- a/docker/cli.assetstore.yml +++ b/docker/cli.assetstore.yml @@ -14,13 +14,8 @@ # Therefore, it should be kept in sync with that file version: "3.7" -networks: - dspacenet: - services: dspace-cli: - networks: - dspacenet: {} environment: # This assetstore zip is available from https://github.com/DSpace-Labs/AIP-Files/releases/tag/demo-entities-data - LOADASSETS=https://github.com/DSpace-Labs/AIP-Files/releases/download/demo-entities-data/assetstore.tar.gz diff --git a/docker/cli.yml b/docker/cli.yml index 223ec356b9c..cc266b186f9 100644 --- a/docker/cli.yml +++ b/docker/cli.yml @@ -13,7 +13,13 @@ # # Therefore, it should be kept in sync with that file version: "3.7" - +networks: + # Default to using network named 'dspacenet' from docker-compose-rest.yml. + # Its full name will be prepended with the project name (e.g. "-p d7" means it will be named "d7_dspacenet") + # If COMPOSITE_PROJECT_NAME is missing, default value will be "docker" (name of folder this file is in) + default: + name: ${COMPOSE_PROJECT_NAME:-docker}_dspacenet + external: true services: dspace-cli: image: "${DOCKER_OWNER:-dspace}/dspace-cli:${DSPACE_VER:-latest}" @@ -30,16 +36,12 @@ services: # solr.server: Ensure we are using the 'dspacesolr' image for Solr solr__P__server: http://dspacesolr:8983/solr volumes: - - "assetstore:/dspace/assetstore" + # Keep DSpace assetstore directory between reboots + - assetstore:/dspace/assetstore entrypoint: /dspace/bin/dspace command: help - networks: - - dspacenet tty: true stdin_open: true volumes: assetstore: - -networks: - dspacenet: diff --git a/docker/docker-compose-ci.yml b/docker/docker-compose-ci.yml index edbb5b07598..07993e20c62 100644 --- a/docker/docker-compose-ci.yml +++ b/docker/docker-compose-ci.yml @@ -33,11 +33,11 @@ services: # Tell Statistics to commit all views immediately instead of waiting on Solr's autocommit. # This allows us to generate statistics in e2e tests so that statistics pages can be tested thoroughly. solr__D__statistics__P__autoCommit: 'false' + image: "${DOCKER_OWNER:-dspace}/dspace:${DSPACE_VER:-latest-test}" depends_on: - dspacedb - image: dspace/dspace:latest-test networks: - dspacenet: + - dspacenet ports: - published: 8080 target: 8080 @@ -45,8 +45,6 @@ services: tty: true volumes: - assetstore:/dspace/assetstore - # Mount DSpace's solr configs to a volume, so that we can share to 'dspacesolr' container (see below) - - solr_configs:/dspace/solr # Ensure that the database is ready BEFORE starting tomcat # 1. While a TCP connection to dspacedb port 5432 is not available, continue to sleep # 2. Then, run database migration to init database tables (including any out-of-order ignored migrations, if any) @@ -70,21 +68,18 @@ services: PGDATA: /pgdata image: dspace/dspace-postgres-pgcrypto:loadsql networks: - dspacenet: + - dspacenet stdin_open: true tty: true volumes: + # Keep Postgres data directory between reboots - pgdata:/pgdata # DSpace Solr container dspacesolr: container_name: dspacesolr - # Uses official Solr image at https://hub.docker.com/_/solr/ - image: solr:8.11-slim - # Needs main 'dspace' container to start first to guarantee access to solr_configs - depends_on: - - dspace + image: "${DOCKER_OWNER:-dspace}/dspace-solr:${DSPACE_VER:-latest}" networks: - dspacenet: + - dspacenet ports: - published: 8983 target: 8983 @@ -92,9 +87,6 @@ services: tty: true working_dir: /var/solr/data volumes: - # Mount our "solr_configs" volume available under the Solr's configsets folder (in a 'dspace' subfolder) - # This copies the Solr configs from main 'dspace' container into 'dspacesolr' via that volume - - solr_configs:/opt/solr/server/solr/configsets/dspace # Keep Solr data directory between reboots - solr_data:/var/solr/data # Initialize all DSpace Solr cores using the mounted configsets (see above), then start Solr @@ -103,14 +95,18 @@ services: - '-c' - | init-var-solr - precreate-core authority /opt/solr/server/solr/configsets/dspace/authority - precreate-core oai /opt/solr/server/solr/configsets/dspace/oai - precreate-core search /opt/solr/server/solr/configsets/dspace/search - precreate-core statistics /opt/solr/server/solr/configsets/dspace/statistics + precreate-core authority /opt/solr/server/solr/configsets/authority + cp -r /opt/solr/server/solr/configsets/authority/* authority + precreate-core oai /opt/solr/server/solr/configsets/oai + cp -r /opt/solr/server/solr/configsets/oai/* oai + precreate-core search /opt/solr/server/solr/configsets/search + cp -r /opt/solr/server/solr/configsets/search/* search + precreate-core statistics /opt/solr/server/solr/configsets/statistics + cp -r /opt/solr/server/solr/configsets/statistics/* statistics + precreate-core qaevent /opt/solr/server/solr/configsets/qaevent + cp -r /opt/solr/server/solr/configsets/qaevent/* qaevent exec solr -f volumes: assetstore: pgdata: solr_data: - # Special volume used to share Solr configs from 'dspace' to 'dspacesolr' container (see above) - solr_configs: \ No newline at end of file diff --git a/docker/docker-compose-rest.yml b/docker/docker-compose-rest.yml index ea766600efa..e1577ec8375 100644 --- a/docker/docker-compose-rest.yml +++ b/docker/docker-compose-rest.yml @@ -43,7 +43,7 @@ services: depends_on: - dspacedb networks: - dspacenet: + - dspacenet ports: - published: 8080 target: 8080 @@ -51,8 +51,6 @@ services: tty: true volumes: - assetstore:/dspace/assetstore - # Mount DSpace's solr configs to a volume, so that we can share to 'dspacesolr' container (see below) - - solr_configs:/dspace/solr # Ensure that the database is ready BEFORE starting tomcat # 1. While a TCP connection to dspacedb port 5432 is not available, continue to sleep # 2. Then, run database migration to init database tables @@ -69,25 +67,23 @@ services: container_name: dspacedb environment: PGDATA: /pgdata - image: dspace/dspace-postgres-pgcrypto + image: "${DOCKER_OWNER:-dspace}/dspace-postgres-pgcrypto:${DSPACE_VER:-latest}" networks: - dspacenet: + - dspacenet ports: - published: 5432 target: 5432 stdin_open: true tty: true volumes: + # Keep Postgres data directory between reboots - pgdata:/pgdata # DSpace Solr container dspacesolr: container_name: dspacesolr image: "${DOCKER_OWNER:-dspace}/dspace-solr:${DSPACE_VER:-latest}" - # Needs main 'dspace' container to start first to guarantee access to solr_configs - depends_on: - - dspace networks: - dspacenet: + - dspacenet ports: - published: 8983 target: 8983 @@ -115,10 +111,10 @@ services: cp -r /opt/solr/server/solr/configsets/search/* search precreate-core statistics /opt/solr/server/solr/configsets/statistics cp -r /opt/solr/server/solr/configsets/statistics/* statistics + precreate-core qaevent /opt/solr/server/solr/configsets/qaevent + cp -r /opt/solr/server/solr/configsets/qaevent/* qaevent exec solr -f volumes: assetstore: pgdata: solr_data: - # Special volume used to share Solr configs from 'dspace' to 'dspacesolr' container (see above) - solr_configs: diff --git a/src/app/access-control/bulk-access/bulk-access.component.html b/src/app/access-control/bulk-access/bulk-access.component.html index 382caf85f46..c164cc5c319 100644 --- a/src/app/access-control/bulk-access/bulk-access.component.html +++ b/src/app/access-control/bulk-access/bulk-access.component.html @@ -1,4 +1,5 @@
+

{{ 'admin.access-control.bulk-access.title' | translate }}

diff --git a/src/app/access-control/epeople-registry/epeople-registry.component.ts b/src/app/access-control/epeople-registry/epeople-registry.component.ts index 4596eec98e3..41a66484796 100644 --- a/src/app/access-control/epeople-registry/epeople-registry.component.ts +++ b/src/app/access-control/epeople-registry/epeople-registry.component.ts @@ -201,7 +201,7 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy { deleteEPerson(ePerson: EPerson) { if (hasValue(ePerson.id)) { const modalRef = this.modalService.open(ConfirmationModalComponent); - modalRef.componentInstance.dso = ePerson; + modalRef.componentInstance.name = this.dsoNameService.getName(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'; 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 d7d5a0b49c7..94bca29d319 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 @@ -478,7 +478,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy { take(1), switchMap((eperson: EPerson) => { const modalRef = this.modalService.open(ConfirmationModalComponent); - modalRef.componentInstance.dso = eperson; + modalRef.componentInstance.name = this.dsoNameService.getName(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'; diff --git a/src/app/access-control/group-registry/group-form/group-form.component.ts b/src/app/access-control/group-registry/group-form/group-form.component.ts index 925a8bb8593..0cad1b1dd8c 100644 --- a/src/app/access-control/group-registry/group-form/group-form.component.ts +++ b/src/app/access-control/group-registry/group-form/group-form.component.ts @@ -420,7 +420,7 @@ export class GroupFormComponent implements OnInit, OnDestroy { delete() { this.groupDataService.getActiveGroup().pipe(take(1)).subscribe((group: Group) => { const modalRef = this.modalService.open(ConfirmationModalComponent); - modalRef.componentInstance.dso = group; + modalRef.componentInstance.name = this.dsoNameService.getName(group); modalRef.componentInstance.headerLabel = this.messagePrefix + '.delete-group.modal.header'; modalRef.componentInstance.infoLabel = this.messagePrefix + '.delete-group.modal.info'; modalRef.componentInstance.cancelLabel = this.messagePrefix + '.delete-group.modal.cancel'; diff --git a/src/app/access-control/group-registry/group-form/members-list/members-list.component.html b/src/app/access-control/group-registry/group-form/members-list/members-list.component.html index 7f01e1b720e..fd7e776472b 100644 --- a/src/app/access-control/group-registry/group-form/members-list/members-list.component.html +++ b/src/app/access-control/group-registry/group-form/members-list/members-list.component.html @@ -24,8 +24,7 @@

{{messagePrefix + '.headMembers' | translate}}

{{eperson.id}} - + {{ dsoNameService.getName(eperson) }} @@ -106,8 +105,7 @@
+ + + + + + + + + + + + + + + + + + diff --git a/src/app/notifications/qa/events/quality-assurance-events.component.scss b/src/app/notifications/qa/events/quality-assurance-events.component.scss new file mode 100644 index 00000000000..29c16328c35 --- /dev/null +++ b/src/app/notifications/qa/events/quality-assurance-events.component.scss @@ -0,0 +1,28 @@ +.button-col, .trust-col { + width: 15%; +} + +.title-col { + width: 30%; +} +.content-col { + width: 40%; +} + +.button-width { + width: 100%; +} + +.abstract-container { + height: 76px; + overflow: hidden; +} + +.text-ellipsis { + text-overflow: ellipsis; +} + +.show { + overflow: visible; + height: auto; +} diff --git a/src/app/notifications/qa/events/quality-assurance-events.component.spec.ts b/src/app/notifications/qa/events/quality-assurance-events.component.spec.ts new file mode 100644 index 00000000000..3349dd3154d --- /dev/null +++ b/src/app/notifications/qa/events/quality-assurance-events.component.spec.ts @@ -0,0 +1,340 @@ +import { Component, NO_ERRORS_SCHEMA } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { CommonModule } from '@angular/common'; +import { ComponentFixture, inject, TestBed, waitForAsync } from '@angular/core/testing'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { of as observableOf } from 'rxjs'; +import { + QualityAssuranceEventDataService +} from '../../../core/notifications/qa/events/quality-assurance-event-data.service'; +import { QualityAssuranceEventsComponent } from './quality-assurance-events.component'; +import { + getMockQualityAssuranceEventRestService, + ItemMockPid10, + ItemMockPid8, + ItemMockPid9, + NotificationsMockDspaceObject, + qualityAssuranceEventObjectMissingProjectFound, + qualityAssuranceEventObjectMissingProjectNotFound +} from '../../../shared/mocks/notifications.mock'; +import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { getMockTranslateService } from '../../../shared/mocks/translate.service.mock'; +import { createTestComponent } from '../../../shared/testing/utils.test'; +import { ActivatedRouteStub } from '../../../shared/testing/active-router.stub'; +import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; +import { + QualityAssuranceEventObject +} from '../../../core/notifications/qa/models/quality-assurance-event.model'; +import { QualityAssuranceEventData } from '../project-entry-import-modal/project-entry-import-modal.component'; +import { TestScheduler } from 'rxjs/testing'; +import { cold, getTestScheduler } from 'jasmine-marbles'; +import { followLink } from '../../../shared/utils/follow-link-config.model'; +import { PageInfo } from '../../../core/shared/page-info.model'; +import { buildPaginatedList } from '../../../core/data/paginated-list.model'; +import { + createNoContentRemoteDataObject$, + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$ +} from '../../../shared/remote-data.utils'; +import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model'; +import { PaginationService } from '../../../core/pagination/pagination.service'; +import { PaginationServiceStub } from '../../../shared/testing/pagination-service.stub'; +import { FindListOptions } from '../../../core/data/find-list-options.model'; + +describe('QualityAssuranceEventsComponent test suite', () => { + let fixture: ComponentFixture; + let comp: QualityAssuranceEventsComponent; + let compAsAny: any; + let scheduler: TestScheduler; + + const modalStub = { + open: () => ( {result: new Promise((res, rej) => 'do')} ), + close: () => null, + dismiss: () => null + }; + const qualityAssuranceEventRestServiceStub: any = getMockQualityAssuranceEventRestService(); + const activatedRouteParams = { + qualityAssuranceEventsParams: { + currentPage: 0, + pageSize: 10 + } + }; + const activatedRouteParamsMap = { + id: 'ENRICH!MISSING!PROJECT' + }; + + const events: QualityAssuranceEventObject[] = [ + qualityAssuranceEventObjectMissingProjectFound, + qualityAssuranceEventObjectMissingProjectNotFound + ]; + const paginationService = new PaginationServiceStub(); + + function getQualityAssuranceEventData1(): QualityAssuranceEventData { + return { + event: qualityAssuranceEventObjectMissingProjectFound, + id: qualityAssuranceEventObjectMissingProjectFound.id, + title: qualityAssuranceEventObjectMissingProjectFound.title, + hasProject: true, + projectTitle: qualityAssuranceEventObjectMissingProjectFound.message.title, + projectId: ItemMockPid10.id, + handle: ItemMockPid10.handle, + reason: null, + isRunning: false, + target: ItemMockPid8 + }; + } + + function getQualityAssuranceEventData2(): QualityAssuranceEventData { + return { + event: qualityAssuranceEventObjectMissingProjectNotFound, + id: qualityAssuranceEventObjectMissingProjectNotFound.id, + title: qualityAssuranceEventObjectMissingProjectNotFound.title, + hasProject: false, + projectTitle: null, + projectId: null, + handle: null, + reason: null, + isRunning: false, + target: ItemMockPid9 + }; + } + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + CommonModule, + TranslateModule.forRoot(), + ], + declarations: [ + QualityAssuranceEventsComponent, + TestComponent, + ], + providers: [ + { provide: ActivatedRoute, useValue: new ActivatedRouteStub(activatedRouteParamsMap, activatedRouteParams) }, + { provide: QualityAssuranceEventDataService, useValue: qualityAssuranceEventRestServiceStub }, + { provide: NgbModal, useValue: modalStub }, + { provide: NotificationsService, useValue: new NotificationsServiceStub() }, + { provide: TranslateService, useValue: getMockTranslateService() }, + { provide: PaginationService, useValue: paginationService }, + QualityAssuranceEventsComponent + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents().then(); + scheduler = getTestScheduler(); + })); + + // First test to check the correct component creation + describe('', () => { + let testComp: TestComponent; + let testFixture: ComponentFixture; + + // synchronous beforeEach + beforeEach(() => { + const html = ` + `; + testFixture = createTestComponent(html, TestComponent) as ComponentFixture; + testComp = testFixture.componentInstance; + }); + + afterEach(() => { + testFixture.destroy(); + }); + + it('should create QualityAssuranceEventsComponent', inject([QualityAssuranceEventsComponent], (app: QualityAssuranceEventsComponent) => { + expect(app).toBeDefined(); + })); + }); + + describe('Main tests', () => { + beforeEach(() => { + fixture = TestBed.createComponent(QualityAssuranceEventsComponent); + comp = fixture.componentInstance; + compAsAny = comp; + }); + + afterEach(() => { + fixture.destroy(); + comp = null; + compAsAny = null; + }); + + describe('fetchEvents', () => { + it('should fetch events', () => { + const result = compAsAny.fetchEvents(events); + const expected = cold('(a|)', { + a: [ + getQualityAssuranceEventData1(), + getQualityAssuranceEventData2() + ] + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('modalChoice', () => { + beforeEach(() => { + spyOn(comp, 'executeAction'); + spyOn(comp, 'openModal'); + }); + + it('should call executeAction if a project is present', () => { + const action = 'ACCEPTED'; + comp.modalChoice(action, getQualityAssuranceEventData1(), modalStub); + expect(comp.executeAction).toHaveBeenCalledWith(action, getQualityAssuranceEventData1()); + }); + + it('should call openModal if a project is not present', () => { + const action = 'ACCEPTED'; + comp.modalChoice(action, getQualityAssuranceEventData2(), modalStub); + expect(comp.openModal).toHaveBeenCalledWith(action, getQualityAssuranceEventData2(), modalStub); + }); + }); + + describe('openModal', () => { + it('should call modalService.open', () => { + const action = 'ACCEPTED'; + comp.selectedReason = null; + spyOn(compAsAny.modalService, 'open').and.returnValue({ result: new Promise((res, rej) => 'do' ) }); + spyOn(comp, 'executeAction'); + + comp.openModal(action, getQualityAssuranceEventData1(), modalStub); + expect(compAsAny.modalService.open).toHaveBeenCalled(); + }); + }); + + describe('openModalLookup', () => { + it('should call modalService.open', () => { + spyOn(comp, 'boundProject'); + spyOn(compAsAny.modalService, 'open').and.returnValue( + { + componentInstance: { + externalSourceEntry: null, + label: null, + importedObject: observableOf({ + indexableObject: NotificationsMockDspaceObject + }) + } + } + ); + scheduler.schedule(() => { + comp.openModalLookup(getQualityAssuranceEventData1()); + }); + scheduler.flush(); + + expect(compAsAny.modalService.open).toHaveBeenCalled(); + expect(compAsAny.boundProject).toHaveBeenCalled(); + }); + }); + + describe('executeAction', () => { + it('should call getQualityAssuranceEvents on 200 response from REST', () => { + const action = 'ACCEPTED'; + spyOn(compAsAny, 'getQualityAssuranceEvents').and.returnValue(observableOf([ + getQualityAssuranceEventData1(), + getQualityAssuranceEventData2() + ])); + qualityAssuranceEventRestServiceStub.patchEvent.and.returnValue(createSuccessfulRemoteDataObject$({})); + + scheduler.schedule(() => { + comp.executeAction(action, getQualityAssuranceEventData1()); + }); + scheduler.flush(); + + expect(compAsAny.getQualityAssuranceEvents).toHaveBeenCalled(); + }); + }); + + describe('boundProject', () => { + it('should populate the project data inside "eventData"', () => { + const eventData = getQualityAssuranceEventData2(); + const projectId = 'UUID-23943-34u43-38344'; + const projectName = 'Test Project'; + const projectHandle = '1000/1000'; + qualityAssuranceEventRestServiceStub.boundProject.and.returnValue(createSuccessfulRemoteDataObject$({})); + + scheduler.schedule(() => { + comp.boundProject(eventData, projectId, projectName, projectHandle); + }); + scheduler.flush(); + + expect(eventData.hasProject).toEqual(true); + expect(eventData.projectId).toEqual(projectId); + expect(eventData.projectTitle).toEqual(projectName); + expect(eventData.handle).toEqual(projectHandle); + }); + }); + + describe('removeProject', () => { + it('should remove the project data inside "eventData"', () => { + const eventData = getQualityAssuranceEventData1(); + qualityAssuranceEventRestServiceStub.removeProject.and.returnValue(createNoContentRemoteDataObject$()); + + scheduler.schedule(() => { + comp.removeProject(eventData); + }); + scheduler.flush(); + + expect(eventData.hasProject).toEqual(false); + expect(eventData.projectId).toBeNull(); + expect(eventData.projectTitle).toBeNull(); + expect(eventData.handle).toBeNull(); + }); + }); + + describe('getQualityAssuranceEvents', () => { + it('should call the "qualityAssuranceEventRestService.getEventsByTopic" to take data and "fetchEvents" to populate eventData', () => { + comp.paginationConfig = new PaginationComponentOptions(); + comp.paginationConfig.currentPage = 1; + comp.paginationConfig.pageSize = 20; + comp.paginationSortConfig = new SortOptions('trust', SortDirection.DESC); + comp.topic = activatedRouteParamsMap.id; + const options: FindListOptions = Object.assign(new FindListOptions(), { + currentPage: comp.paginationConfig.currentPage, + elementsPerPage: comp.paginationConfig.pageSize + }); + + const pageInfo = new PageInfo({ + elementsPerPage: comp.paginationConfig.pageSize, + totalElements: 2, + totalPages: 1, + currentPage: comp.paginationConfig.currentPage + }); + const array = [ + qualityAssuranceEventObjectMissingProjectFound, + qualityAssuranceEventObjectMissingProjectNotFound, + ]; + const paginatedList = buildPaginatedList(pageInfo, array); + const paginatedListRD = createSuccessfulRemoteDataObject(paginatedList); + qualityAssuranceEventRestServiceStub.getEventsByTopic.and.returnValue(observableOf(paginatedListRD)); + spyOn(compAsAny, 'fetchEvents').and.returnValue(observableOf([ + getQualityAssuranceEventData1(), + getQualityAssuranceEventData2() + ])); + + scheduler.schedule(() => { + compAsAny.getQualityAssuranceEvents().subscribe(); + }); + scheduler.flush(); + + expect(compAsAny.qualityAssuranceEventRestService.getEventsByTopic).toHaveBeenCalledWith( + activatedRouteParamsMap.id, + options, + followLink('target'),followLink('related') + ); + expect(compAsAny.fetchEvents).toHaveBeenCalled(); + }); + }); + + }); +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { + +} diff --git a/src/app/notifications/qa/events/quality-assurance-events.component.ts b/src/app/notifications/qa/events/quality-assurance-events.component.ts new file mode 100644 index 00000000000..c22c28f41e9 --- /dev/null +++ b/src/app/notifications/qa/events/quality-assurance-events.component.ts @@ -0,0 +1,434 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; + +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateService } from '@ngx-translate/core'; +import { BehaviorSubject, combineLatest, from, Observable, of, Subscription } from 'rxjs'; +import { distinctUntilChanged, last, map, mergeMap, scan, switchMap, take, tap } from 'rxjs/operators'; + +import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model'; +import { PaginatedList } from '../../../core/data/paginated-list.model'; +import { RemoteData } from '../../../core/data/remote-data'; +import { + SourceQualityAssuranceEventMessageObject, + QualityAssuranceEventObject +} from '../../../core/notifications/qa/models/quality-assurance-event.model'; +import { + QualityAssuranceEventDataService +} from '../../../core/notifications/qa/events/quality-assurance-event-data.service'; +import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; +import { Metadata } from '../../../core/shared/metadata.utils'; +import { followLink } from '../../../shared/utils/follow-link-config.model'; +import { hasValue } from '../../../shared/empty.util'; +import { ItemSearchResult } from '../../../shared/object-collection/shared/item-search-result.model'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { + ProjectEntryImportModalComponent, + QualityAssuranceEventData +} from '../project-entry-import-modal/project-entry-import-modal.component'; +import { getFirstCompletedRemoteData } from '../../../core/shared/operators'; +import { PaginationService } from '../../../core/pagination/pagination.service'; +import { Item } from '../../../core/shared/item.model'; +import { FindListOptions } from '../../../core/data/find-list-options.model'; +import {environment} from '../../../../environments/environment'; + +/** + * Component to display the Quality Assurance event list. + */ +@Component({ + selector: 'ds-quality-assurance-events', + templateUrl: './quality-assurance-events.component.html', + styleUrls: ['./quality-assurance-events.component.scss'], +}) +export class QualityAssuranceEventsComponent implements OnInit, OnDestroy { + /** + * The pagination system configuration for HTML listing. + * @type {PaginationComponentOptions} + */ + public paginationConfig: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { + id: 'bep', + currentPage: 1, + pageSize: 10, + pageSizeOptions: [5, 10, 20, 40, 60] + }); + /** + * The Quality Assurance event list sort options. + * @type {SortOptions} + */ + public paginationSortConfig: SortOptions = new SortOptions('trust', SortDirection.DESC); + /** + * Array to save the presence of a project inside an Quality Assurance event. + * @type {QualityAssuranceEventData[]>} + */ + public eventsUpdated$: BehaviorSubject = new BehaviorSubject([]); + /** + * The total number of Quality Assurance events. + * @type {Observable} + */ + public totalElements$: BehaviorSubject = new BehaviorSubject(null); + /** + * The topic of the Quality Assurance events; suitable for displaying. + * @type {string} + */ + public showTopic: string; + /** + * The topic of the Quality Assurance events; suitable for HTTP calls. + * @type {string} + */ + public topic: string; + /** + * The rejected/ignore reason. + * @type {string} + */ + public selectedReason: string; + /** + * Contains the information about the loading status of the page. + * @type {Observable} + */ + public isEventPageLoading: BehaviorSubject = new BehaviorSubject(false); + /** + * The modal reference. + * @type {any} + */ + public modalRef: any; + /** + * Used to store the status of the 'Show more' button of the abstracts. + * @type {boolean} + */ + public showMore = false; + /** + * The quality assurance source base url for project search + */ + public sourceUrlForProjectSearch: string; + /** + * The FindListOptions object + */ + protected defaultConfig: FindListOptions = Object.assign(new FindListOptions(), { sort: this.paginationSortConfig }); + /** + * Array to track all the component subscriptions. Useful to unsubscribe them with 'onDestroy'. + * @type {Array} + */ + protected subs: Subscription[] = []; + + /** + * Initialize the component variables. + * @param {ActivatedRoute} activatedRoute + * @param {NgbModal} modalService + * @param {NotificationsService} notificationsService + * @param {QualityAssuranceEventDataService} qualityAssuranceEventRestService + * @param {PaginationService} paginationService + * @param {TranslateService} translateService + */ + constructor( + private activatedRoute: ActivatedRoute, + private modalService: NgbModal, + private notificationsService: NotificationsService, + private qualityAssuranceEventRestService: QualityAssuranceEventDataService, + private paginationService: PaginationService, + private translateService: TranslateService + ) { + } + + /** + * Component initialization. + */ + ngOnInit(): void { + this.isEventPageLoading.next(true); + + this.activatedRoute.paramMap.pipe( + tap((params) => { + this.sourceUrlForProjectSearch = environment.qualityAssuranceConfig.sourceUrlMapForProjectSearch[params.get('sourceId')]; + }), + map((params) => params.get('topicId')), + take(1), + switchMap((id: string) => { + const regEx = /!/g; + this.showTopic = id.replace(regEx, '/'); + this.topic = id; + return this.getQualityAssuranceEvents(); + }) + ).subscribe((events: QualityAssuranceEventData[]) => { + this.eventsUpdated$.next(events); + this.isEventPageLoading.next(false); + }); + } + + /** + * Check if table have a detail column + */ + public hasDetailColumn(): boolean { + return (this.showTopic.indexOf('/PROJECT') !== -1 || + this.showTopic.indexOf('/PID') !== -1 || + this.showTopic.indexOf('/SUBJECT') !== -1 || + this.showTopic.indexOf('/ABSTRACT') !== -1 + ); + } + + /** + * Open a modal or run the executeAction directly based on the presence of the project. + * + * @param {string} action + * the action (can be: ACCEPTED, REJECTED, DISCARDED, PENDING) + * @param {QualityAssuranceEventData} eventData + * the Quality Assurance event data + * @param {any} content + * Reference to the modal + */ + public modalChoice(action: string, eventData: QualityAssuranceEventData, content: any): void { + if (eventData.hasProject) { + this.executeAction(action, eventData); + } else { + this.openModal(action, eventData, content); + } + } + + /** + * Open the selected modal and performs the action if needed. + * + * @param {string} action + * the action (can be: ACCEPTED, REJECTED, DISCARDED, PENDING) + * @param {QualityAssuranceEventData} eventData + * the Quality Assurance event data + * @param {any} content + * Reference to the modal + */ + public openModal(action: string, eventData: QualityAssuranceEventData, content: any): void { + this.modalService.open(content, { ariaLabelledBy: 'modal-basic-title' }).result.then( + (result) => { + if (result === 'do') { + eventData.reason = this.selectedReason; + this.executeAction(action, eventData); + } + this.selectedReason = null; + }, + (_reason) => { + this.selectedReason = null; + } + ); + } + + /** + * Open a modal where the user can select the project. + * + * @param {QualityAssuranceEventData} eventData + * the Quality Assurance event item data + */ + public openModalLookup(eventData: QualityAssuranceEventData): void { + this.modalRef = this.modalService.open(ProjectEntryImportModalComponent, { + size: 'lg' + }); + const modalComp = this.modalRef.componentInstance; + modalComp.externalSourceEntry = eventData; + modalComp.label = 'project'; + this.subs.push( + modalComp.importedObject.pipe(take(1)) + .subscribe((object: ItemSearchResult) => { + const projectTitle = Metadata.first(object.indexableObject.metadata, 'dc.title'); + this.boundProject( + eventData, + object.indexableObject.id, + projectTitle.value, + object.indexableObject.handle + ); + }) + ); + } + + /** + * Performs the choosen action calling the REST service. + * + * @param {string} action + * the action (can be: ACCEPTED, REJECTED, DISCARDED, PENDING) + * @param {QualityAssuranceEventData} eventData + * the Quality Assurance event data + */ + public executeAction(action: string, eventData: QualityAssuranceEventData): void { + eventData.isRunning = true; + this.subs.push( + this.qualityAssuranceEventRestService.patchEvent(action, eventData.event, eventData.reason).pipe( + getFirstCompletedRemoteData(), + switchMap((rd: RemoteData) => { + if (rd.hasSucceeded) { + this.notificationsService.success( + this.translateService.instant('quality-assurance.event.action.saved') + ); + return this.getQualityAssuranceEvents(); + } else { + this.notificationsService.error( + this.translateService.instant('quality-assurance.event.action.error') + ); + return of(this.eventsUpdated$.value); + } + }) + ).subscribe((events: QualityAssuranceEventData[]) => { + this.eventsUpdated$.next(events); + eventData.isRunning = false; + }) + ); + } + + /** + * Bound a project to the publication described in the Quality Assurance event calling the REST service. + * + * @param {QualityAssuranceEventData} eventData + * the Quality Assurance event item data + * @param {string} projectId + * the project Id to bound + * @param {string} projectTitle + * the project title + * @param {string} projectHandle + * the project handle + */ + public boundProject(eventData: QualityAssuranceEventData, projectId: string, projectTitle: string, projectHandle: string): void { + eventData.isRunning = true; + this.subs.push( + this.qualityAssuranceEventRestService.boundProject(eventData.id, projectId).pipe(getFirstCompletedRemoteData()) + .subscribe((rd: RemoteData) => { + if (rd.hasSucceeded) { + this.notificationsService.success( + this.translateService.instant('quality-assurance.event.project.bounded') + ); + eventData.hasProject = true; + eventData.projectTitle = projectTitle; + eventData.handle = projectHandle; + eventData.projectId = projectId; + } else { + this.notificationsService.error( + this.translateService.instant('quality-assurance.event.project.error') + ); + } + eventData.isRunning = false; + }) + ); + } + + /** + * Remove the bounded project from the publication described in the Quality Assurance event calling the REST service. + * + * @param {QualityAssuranceEventData} eventData + * the Quality Assurance event data + */ + public removeProject(eventData: QualityAssuranceEventData): void { + eventData.isRunning = true; + this.subs.push( + this.qualityAssuranceEventRestService.removeProject(eventData.id).pipe(getFirstCompletedRemoteData()) + .subscribe((rd: RemoteData) => { + if (rd.hasSucceeded) { + this.notificationsService.success( + this.translateService.instant('quality-assurance.event.project.removed') + ); + eventData.hasProject = false; + eventData.projectTitle = null; + eventData.handle = null; + eventData.projectId = null; + } else { + this.notificationsService.error( + this.translateService.instant('quality-assurance.event.project.error') + ); + } + eventData.isRunning = false; + }) + ); + } + + /** + * Check if the event has a valid href. + * @param event + */ + public hasPIDHref(event: SourceQualityAssuranceEventMessageObject): boolean { + return this.getPIDHref(event) !== null; + } + + /** + * Get the event pid href. + * @param event + */ + public getPIDHref(event: SourceQualityAssuranceEventMessageObject): string { + return event.pidHref; + } + + /** + * Dispatch the Quality Assurance events retrival. + */ + public getQualityAssuranceEvents(): Observable { + return this.paginationService.getFindListOptions(this.paginationConfig.id, this.defaultConfig).pipe( + distinctUntilChanged(), + switchMap((options: FindListOptions) => this.qualityAssuranceEventRestService.getEventsByTopic( + this.topic, + options, + followLink('target'), followLink('related') + )), + getFirstCompletedRemoteData(), + switchMap((rd: RemoteData>) => { + if (rd.hasSucceeded) { + this.totalElements$.next(rd.payload.totalElements); + if (rd.payload.totalElements > 0) { + return this.fetchEvents(rd.payload.page); + } else { + return of([]); + } + } else { + throw new Error('Can\'t retrieve Quality Assurance events from the Broker events REST service'); + } + }), + take(1), + tap(() => { + this.qualityAssuranceEventRestService.clearFindByTopicRequests(); + }) + ); + } + + /** + * Unsubscribe from all subscriptions. + */ + ngOnDestroy(): void { + this.subs + .filter((sub) => hasValue(sub)) + .forEach((sub) => sub.unsubscribe()); + } + + /** + * Fetch Quality Assurance events in order to build proper QualityAssuranceEventData object. + * + * @param {QualityAssuranceEventObject[]} events + * the Quality Assurance event item + * @return array of QualityAssuranceEventData + */ + protected fetchEvents(events: QualityAssuranceEventObject[]): Observable { + return from(events).pipe( + mergeMap((event: QualityAssuranceEventObject) => { + const related$ = event.related.pipe( + getFirstCompletedRemoteData(), + ); + const target$ = event.target.pipe( + getFirstCompletedRemoteData() + ); + return combineLatest([related$, target$]).pipe( + map(([relatedItemRD, targetItemRD]: [RemoteData, RemoteData]) => { + const data: QualityAssuranceEventData = { + event: event, + id: event.id, + title: event.title, + hasProject: false, + projectTitle: null, + projectId: null, + handle: null, + reason: null, + isRunning: false, + target: (targetItemRD?.hasSucceeded) ? targetItemRD.payload : null, + }; + if (relatedItemRD?.hasSucceeded && relatedItemRD?.payload?.id) { + data.hasProject = true; + data.projectTitle = event.message.title; + data.projectId = relatedItemRD?.payload?.id; + data.handle = relatedItemRD?.payload?.handle; + } + return data; + }) + ); + }), + scan((acc: any, value: any) => [...acc, value], []), + last() + ); + } +} diff --git a/src/app/notifications/qa/project-entry-import-modal/project-entry-import-modal.component.html b/src/app/notifications/qa/project-entry-import-modal/project-entry-import-modal.component.html new file mode 100644 index 00000000000..0622e08ab09 --- /dev/null +++ b/src/app/notifications/qa/project-entry-import-modal/project-entry-import-modal.component.html @@ -0,0 +1,71 @@ + + + diff --git a/src/app/notifications/qa/project-entry-import-modal/project-entry-import-modal.component.scss b/src/app/notifications/qa/project-entry-import-modal/project-entry-import-modal.component.scss new file mode 100644 index 00000000000..7db9839e384 --- /dev/null +++ b/src/app/notifications/qa/project-entry-import-modal/project-entry-import-modal.component.scss @@ -0,0 +1,3 @@ +.modal-footer { + justify-content: space-between; +} diff --git a/src/app/notifications/qa/project-entry-import-modal/project-entry-import-modal.component.spec.ts b/src/app/notifications/qa/project-entry-import-modal/project-entry-import-modal.component.spec.ts new file mode 100644 index 00000000000..42a57c2ac5e --- /dev/null +++ b/src/app/notifications/qa/project-entry-import-modal/project-entry-import-modal.component.spec.ts @@ -0,0 +1,210 @@ +import { CommonModule } from '@angular/common'; +import { Component, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; +import { of as observableOf } from 'rxjs'; +import { SearchService } from '../../../core/shared/search/search.service'; +import { Item } from '../../../core/shared/item.model'; +import { createTestComponent } from '../../../shared/testing/utils.test'; +import { ImportType, ProjectEntryImportModalComponent } from './project-entry-import-modal.component'; +import { SelectableListService } from '../../../shared/object-list/selectable-list/selectable-list.service'; +import { getMockSearchService } from '../../../shared/mocks/search-service.mock'; +import { PaginatedSearchOptions } from '../../../shared/search/models/paginated-search-options.model'; +import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.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 { + ItemMockPid10, + qualityAssuranceEventObjectMissingProjectFound, + NotificationsMockDspaceObject +} from '../../../shared/mocks/notifications.mock'; + +const eventData = { + event: qualityAssuranceEventObjectMissingProjectFound, + id: qualityAssuranceEventObjectMissingProjectFound.id, + title: qualityAssuranceEventObjectMissingProjectFound.title, + hasProject: true, + projectTitle: qualityAssuranceEventObjectMissingProjectFound.message.title, + projectId: ItemMockPid10.id, + handle: ItemMockPid10.handle, + reason: null, + isRunning: false +}; + +const searchString = 'Test project to search'; +const pagination = Object.assign( + new PaginationComponentOptions(), { + id: 'notifications-project-bound', + pageSize: 3 + } +); +const searchOptions = Object.assign(new PaginatedSearchOptions( + { + configuration: 'funding', + query: searchString, + pagination: pagination + } +)); +const pageInfo = new PageInfo({ + elementsPerPage: 3, + totalElements: 1, + totalPages: 1, + currentPage: 1 +}); +const array = [ + NotificationsMockDspaceObject, +]; +const paginatedList = buildPaginatedList(pageInfo, array); +const paginatedListRD = createSuccessfulRemoteDataObject(paginatedList); + +describe('ProjectEntryImportModalComponent test suite', () => { + let fixture: ComponentFixture; + let comp: ProjectEntryImportModalComponent; + let compAsAny: any; + + const modalStub = jasmine.createSpyObj('modal', ['close', 'dismiss']); + const uuid = '123e4567-e89b-12d3-a456-426614174003'; + const searchServiceStub: any = getMockSearchService(); + + + beforeEach(async (() => { + TestBed.configureTestingModule({ + imports: [ + CommonModule, + TranslateModule.forRoot(), + ], + declarations: [ + ProjectEntryImportModalComponent, + TestComponent, + ], + providers: [ + { provide: NgbActiveModal, useValue: modalStub }, + { provide: SearchService, useValue: searchServiceStub }, + { provide: SelectableListService, useValue: jasmine.createSpyObj('selectableListService', ['deselect', 'select', 'deselectAll']) }, + ProjectEntryImportModalComponent + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents().then(); + })); + + // First test to check the correct component creation + describe('', () => { + let testComp: TestComponent; + let testFixture: ComponentFixture; + + // synchronous beforeEach + beforeEach(() => { + searchServiceStub.search.and.returnValue(observableOf(paginatedListRD)); + const html = ` + `; + testFixture = createTestComponent(html, TestComponent) as ComponentFixture; + testComp = testFixture.componentInstance; + }); + + afterEach(() => { + testFixture.destroy(); + }); + + it('should create ProjectEntryImportModalComponent', inject([ProjectEntryImportModalComponent], (app: ProjectEntryImportModalComponent) => { + expect(app).toBeDefined(); + })); + }); + + describe('Main tests', () => { + beforeEach(() => { + fixture = TestBed.createComponent(ProjectEntryImportModalComponent); + comp = fixture.componentInstance; + compAsAny = comp; + + }); + + describe('close', () => { + it('should close the modal', () => { + comp.close(); + expect(modalStub.close).toHaveBeenCalled(); + }); + }); + + describe('search', () => { + it('should call SearchService.search', () => { + + (searchServiceStub as any).search.and.returnValue(observableOf(paginatedListRD)); + comp.pagination = pagination; + + comp.search(searchString); + expect(comp.searchService.search).toHaveBeenCalledWith(searchOptions); + }); + }); + + describe('bound', () => { + it('should call close, deselectAllLists and importedObject.emit', () => { + spyOn(comp, 'deselectAllLists'); + spyOn(comp, 'close'); + spyOn(comp.importedObject, 'emit'); + comp.selectedEntity = NotificationsMockDspaceObject; + comp.bound(); + + expect(comp.importedObject.emit).toHaveBeenCalled(); + expect(comp.deselectAllLists).toHaveBeenCalled(); + expect(comp.close).toHaveBeenCalled(); + }); + }); + + describe('selectEntity', () => { + const entity = Object.assign(new Item(), { uuid: uuid }); + beforeEach(() => { + comp.selectEntity(entity); + }); + + it('should set selected entity', () => { + expect(comp.selectedEntity).toBe(entity); + }); + + it('should set the import type to local entity', () => { + expect(comp.selectedImportType).toEqual(ImportType.LocalEntity); + }); + }); + + describe('deselectEntity', () => { + const entity = Object.assign(new Item(), { uuid: uuid }); + beforeEach(() => { + comp.selectedImportType = ImportType.LocalEntity; + comp.selectedEntity = entity; + comp.deselectEntity(); + }); + + it('should remove the selected entity', () => { + expect(comp.selectedEntity).toBeUndefined(); + }); + + it('should set the import type to none', () => { + expect(comp.selectedImportType).toEqual(ImportType.None); + }); + }); + + describe('deselectAllLists', () => { + it('should call SelectableListService.deselectAll', () => { + comp.deselectAllLists(); + expect(compAsAny.selectService.deselectAll).toHaveBeenCalledWith(comp.entityListId); + expect(compAsAny.selectService.deselectAll).toHaveBeenCalledWith(comp.authorityListId); + }); + }); + + afterEach(() => { + fixture.destroy(); + comp = null; + compAsAny = null; + }); + }); +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { + eventData = eventData; +} diff --git a/src/app/notifications/qa/project-entry-import-modal/project-entry-import-modal.component.ts b/src/app/notifications/qa/project-entry-import-modal/project-entry-import-modal.component.ts new file mode 100644 index 00000000000..ad9c1035a51 --- /dev/null +++ b/src/app/notifications/qa/project-entry-import-modal/project-entry-import-modal.component.ts @@ -0,0 +1,278 @@ +import { Component, EventEmitter, Input, OnInit } from '@angular/core'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { Observable, of as observableOf, Subscription } from 'rxjs'; +import { RemoteData } from '../../../core/data/remote-data'; +import { PaginatedList } from '../../../core/data/paginated-list.model'; +import { SearchResult } from '../../../shared/search/models/search-result.model'; +import { PaginatedSearchOptions } from '../../../shared/search/models/paginated-search-options.model'; +import { CollectionElementLinkType } from '../../../shared/object-collection/collection-element-link.type'; +import { Context } from '../../../core/shared/context.model'; +import { SelectableListService } from '../../../shared/object-list/selectable-list/selectable-list.service'; +import { ListableObject } from '../../../shared/object-collection/shared/listable-object.model'; +import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; +import { SearchService } from '../../../core/shared/search/search.service'; +import { DSpaceObject } from '../../../core/shared/dspace-object.model'; +import { + SourceQualityAssuranceEventMessageObject, + QualityAssuranceEventObject, +} from '../../../core/notifications/qa/models/quality-assurance-event.model'; +import { hasValue, isNotEmpty } from '../../../shared/empty.util'; +import { Item } from '../../../core/shared/item.model'; + +/** + * The possible types of import for the external entry + */ +export enum ImportType { + None = 'None', + LocalEntity = 'LocalEntity', + LocalAuthority = 'LocalAuthority', + NewEntity = 'NewEntity', + NewAuthority = 'NewAuthority' +} + +/** + * The data type passed from the parent page + */ +export interface QualityAssuranceEventData { + /** + * The Quality Assurance event + */ + event: QualityAssuranceEventObject; + /** + * The Quality Assurance event Id (uuid) + */ + id: string; + /** + * The publication title + */ + title: string; + /** + * Contains the boolean that indicates if a project is present + */ + hasProject: boolean; + /** + * The project title, if present + */ + projectTitle: string; + /** + * The project id (uuid), if present + */ + projectId: string; + /** + * The project handle, if present + */ + handle: string; + /** + * The reject/discard reason + */ + reason: string; + /** + * Contains the boolean that indicates if there is a running operation (REST call) + */ + isRunning: boolean; + /** + * The related publication DSpace item + */ + target?: Item; +} + +@Component({ + selector: 'ds-project-entry-import-modal', + styleUrls: ['./project-entry-import-modal.component.scss'], + templateUrl: './project-entry-import-modal.component.html' +}) +/** + * Component to display a modal window for linking a project to an Quality Assurance event + * Shows information about the selected project and a selectable list. + */ +export class ProjectEntryImportModalComponent implements OnInit { + /** + * The external source entry + */ + @Input() externalSourceEntry: QualityAssuranceEventData; + /** + * The number of results per page + */ + pageSize = 3; + /** + * The prefix for every i18n key within this modal + */ + labelPrefix = 'quality-assurance.event.modal.'; + /** + * The search configuration to retrieve project + */ + configuration = 'funding'; + /** + * The label to use for all messages (added to the end of relevant i18n keys) + */ + label: string; + /** + * The project title from the parent object + */ + projectTitle: string; + /** + * The search results + */ + localEntitiesRD$: Observable>>>; + /** + * Information about the data loading status + */ + isLoading$ = observableOf(true); + /** + * Search options to use for fetching projects + */ + searchOptions: PaginatedSearchOptions; + /** + * The context we're currently in (submission) + */ + context = Context.EntitySearchModalWithNameVariants; + /** + * List ID for selecting local entities + */ + entityListId = 'notifications-project-bound'; + /** + * List ID for selecting local authorities + */ + authorityListId = 'notifications-project-bound-authority'; + /** + * ImportType enum + */ + importType = ImportType; + /** + * The type of link to render in listable elements + */ + linkTypes = CollectionElementLinkType; + /** + * The type of import the user currently has selected + */ + selectedImportType = ImportType.None; + /** + * The selected local entity + */ + selectedEntity: ListableObject; + /** + * An project has been selected, send it to the parent component + */ + importedObject: EventEmitter = new EventEmitter(); + /** + * Pagination options + */ + pagination: PaginationComponentOptions; + /** + * Array to track all the component subscriptions. Useful to unsubscribe them with 'onDestroy'. + * @type {Array} + */ + protected subs: Subscription[] = []; + + /** + * Initialize the component variables. + * @param {NgbActiveModal} modal + * @param {SearchService} searchService + * @param {SelectableListService} selectService + */ + constructor(public modal: NgbActiveModal, + public searchService: SearchService, + private selectService: SelectableListService) { } + + /** + * Component intitialization. + */ + public ngOnInit(): void { + this.pagination = Object.assign(new PaginationComponentOptions(), { id: 'notifications-project-bound', pageSize: this.pageSize }); + this.projectTitle = (this.externalSourceEntry.projectTitle !== null) ? this.externalSourceEntry.projectTitle + : (this.externalSourceEntry.event.message as SourceQualityAssuranceEventMessageObject).title; + this.searchOptions = Object.assign(new PaginatedSearchOptions( + { + configuration: this.configuration, + query: this.projectTitle, + pagination: this.pagination + } + )); + this.localEntitiesRD$ = this.searchService.search(this.searchOptions); + this.subs.push( + this.localEntitiesRD$.subscribe( + () => this.isLoading$ = observableOf(false) + ) + ); + } + + /** + * Close the modal. + */ + public close(): void { + this.deselectAllLists(); + this.modal.close(); + } + + /** + * Perform a project search by title. + */ + public search(searchTitle): void { + if (isNotEmpty(searchTitle)) { + const filterRegEx = /[:]/g; + this.isLoading$ = observableOf(true); + this.searchOptions = Object.assign(new PaginatedSearchOptions( + { + configuration: this.configuration, + query: (searchTitle) ? searchTitle.replace(filterRegEx, '') : searchTitle, + pagination: this.pagination + } + )); + this.localEntitiesRD$ = this.searchService.search(this.searchOptions); + this.subs.push( + this.localEntitiesRD$.subscribe( + () => this.isLoading$ = observableOf(false) + ) + ); + } + } + + /** + * Perform the bound of the project. + */ + public bound(): void { + if (this.selectedEntity !== undefined) { + this.importedObject.emit(this.selectedEntity); + } + this.selectedImportType = ImportType.None; + this.deselectAllLists(); + this.close(); + } + + /** + * Deselected a local entity + */ + public deselectEntity(): void { + this.selectedEntity = undefined; + if (this.selectedImportType === ImportType.LocalEntity) { + this.selectedImportType = ImportType.None; + } + } + + /** + * Selected a local entity + * @param entity + */ + public selectEntity(entity): void { + this.selectedEntity = entity; + this.selectedImportType = ImportType.LocalEntity; + } + + /** + * Deselect every element from both entity and authority lists + */ + public deselectAllLists(): void { + this.selectService.deselectAll(this.entityListId); + this.selectService.deselectAll(this.authorityListId); + } + + /** + * Unsubscribe from all subscriptions. + */ + ngOnDestroy(): void { + this.deselectAllLists(); + this.subs + .filter((sub) => hasValue(sub)) + .forEach((sub) => sub.unsubscribe()); + } +} diff --git a/src/app/notifications/qa/source/quality-assurance-source.actions.ts b/src/app/notifications/qa/source/quality-assurance-source.actions.ts new file mode 100644 index 00000000000..f6d9c19eaaf --- /dev/null +++ b/src/app/notifications/qa/source/quality-assurance-source.actions.ts @@ -0,0 +1,98 @@ +/* eslint-disable max-classes-per-file */ +import { Action } from '@ngrx/store'; +import { type } from '../../../shared/ngrx/type'; +import { QualityAssuranceSourceObject } from '../../../core/notifications/qa/models/quality-assurance-source.model'; + +/** + * For each action type in an action group, make a simple + * enum object for all of this group's action types. + * + * The 'type' utility function coerces strings into string + * literal types and runs a simple check to guarantee all + * action types in the application are unique. + */ +export const QualityAssuranceSourceActionTypes = { + ADD_SOURCE: type('dspace/integration/notifications/qa/ADD_SOURCE'), + RETRIEVE_ALL_SOURCE: type('dspace/integration/notifications/qa/RETRIEVE_ALL_SOURCE'), + RETRIEVE_ALL_SOURCE_ERROR: type('dspace/integration/notifications/qa/RETRIEVE_ALL_SOURCE_ERROR'), +}; + +/** + * An ngrx action to retrieve all the Quality Assurance source. + */ +export class RetrieveAllSourceAction implements Action { + type = QualityAssuranceSourceActionTypes.RETRIEVE_ALL_SOURCE; + payload: { + elementsPerPage: number; + currentPage: number; + }; + + /** + * Create a new RetrieveAllSourceAction. + * + * @param elementsPerPage + * the number of source per page + * @param currentPage + * The page number to retrieve + */ + constructor(elementsPerPage: number, currentPage: number) { + this.payload = { + elementsPerPage, + currentPage + }; + } +} + +/** + * An ngrx action for retrieving 'all Quality Assurance source' error. + */ +export class RetrieveAllSourceErrorAction implements Action { + type = QualityAssuranceSourceActionTypes.RETRIEVE_ALL_SOURCE_ERROR; +} + +/** + * An ngrx action to load the Quality Assurance source objects. + * Called by the ??? effect. + */ +export class AddSourceAction implements Action { + type = QualityAssuranceSourceActionTypes.ADD_SOURCE; + payload: { + source: QualityAssuranceSourceObject[]; + totalPages: number; + currentPage: number; + totalElements: number; + }; + + /** + * Create a new AddSourceAction. + * + * @param source + * the list of source + * @param totalPages + * the total available pages of source + * @param currentPage + * the current page + * @param totalElements + * the total available Quality Assurance source + */ + constructor(source: QualityAssuranceSourceObject[], totalPages: number, currentPage: number, totalElements: number) { + this.payload = { + source, + totalPages, + currentPage, + totalElements + }; + } + +} + +/* tslint:enable:max-classes-per-file */ + +/** + * Export a type alias of all actions in this action group + * so that reducers can easily compose action types. + */ +export type QualityAssuranceSourceActions + = RetrieveAllSourceAction + |RetrieveAllSourceErrorAction + |AddSourceAction; diff --git a/src/app/notifications/qa/source/quality-assurance-source.component.html b/src/app/notifications/qa/source/quality-assurance-source.component.html new file mode 100644 index 00000000000..0f6cf184024 --- /dev/null +++ b/src/app/notifications/qa/source/quality-assurance-source.component.html @@ -0,0 +1,58 @@ +
+
+
+

{{'quality-assurance.title'| translate}}

+ +
+
+
+
+

{{'quality-assurance.source'| translate}}

+ + + + + + + +
+ + + + + + + + + + + + + + + +
{{'quality-assurance.table.source' | translate}}{{'quality-assurance.table.last-event' | translate}}{{'quality-assurance.table.actions' | translate}}
{{sourceElement.id}}{{sourceElement.lastEvent}} +
+ +
+
+
+
+
+
+
+
+ diff --git a/src/themes/custom/app/browse-by/browse-by-switcher/browse-by-switcher.component.html b/src/app/notifications/qa/source/quality-assurance-source.component.scss similarity index 100% rename from src/themes/custom/app/browse-by/browse-by-switcher/browse-by-switcher.component.html rename to src/app/notifications/qa/source/quality-assurance-source.component.scss diff --git a/src/app/notifications/qa/source/quality-assurance-source.component.spec.ts b/src/app/notifications/qa/source/quality-assurance-source.component.spec.ts new file mode 100644 index 00000000000..ba3a903cc5e --- /dev/null +++ b/src/app/notifications/qa/source/quality-assurance-source.component.spec.ts @@ -0,0 +1,152 @@ +import { CommonModule } from '@angular/common'; +import { Component, NO_ERRORS_SCHEMA } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; +import { of as observableOf } from 'rxjs'; +import { ComponentFixture, inject, TestBed, waitForAsync } from '@angular/core/testing'; +import { createTestComponent } from '../../../shared/testing/utils.test'; +import { + getMockNotificationsStateService, + qualityAssuranceSourceObjectMoreAbstract, + qualityAssuranceSourceObjectMorePid +} from '../../../shared/mocks/notifications.mock'; +import { QualityAssuranceSourceComponent } from './quality-assurance-source.component'; +import { NotificationsStateService } from '../../notifications-state.service'; +import { cold } from 'jasmine-marbles'; +import { PaginationServiceStub } from '../../../shared/testing/pagination-service.stub'; +import { PaginationService } from '../../../core/pagination/pagination.service'; + +describe('QualityAssuranceSourceComponent test suite', () => { + let fixture: ComponentFixture; + let comp: QualityAssuranceSourceComponent; + let compAsAny: any; + const mockNotificationsStateService = getMockNotificationsStateService(); + const activatedRouteParams = { + qualityAssuranceSourceParams: { + currentPage: 0, + pageSize: 5 + } + }; + const paginationService = new PaginationServiceStub(); + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + CommonModule, + TranslateModule.forRoot(), + ], + declarations: [ + QualityAssuranceSourceComponent, + TestComponent, + ], + providers: [ + { provide: NotificationsStateService, useValue: mockNotificationsStateService }, + { provide: ActivatedRoute, useValue: { data: observableOf(activatedRouteParams), params: observableOf({}) } }, + { provide: PaginationService, useValue: paginationService }, + QualityAssuranceSourceComponent + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents().then(() => { + mockNotificationsStateService.getQualityAssuranceSource.and.returnValue(observableOf([ + qualityAssuranceSourceObjectMorePid, + qualityAssuranceSourceObjectMoreAbstract + ])); + mockNotificationsStateService.getQualityAssuranceSourceTotalPages.and.returnValue(observableOf(1)); + mockNotificationsStateService.getQualityAssuranceSourceCurrentPage.and.returnValue(observableOf(0)); + mockNotificationsStateService.getQualityAssuranceSourceTotals.and.returnValue(observableOf(2)); + mockNotificationsStateService.isQualityAssuranceSourceLoaded.and.returnValue(observableOf(true)); + mockNotificationsStateService.isQualityAssuranceSourceLoading.and.returnValue(observableOf(false)); + mockNotificationsStateService.isQualityAssuranceSourceProcessing.and.returnValue(observableOf(false)); + }); + })); + + // First test to check the correct component creation + describe('', () => { + let testComp: TestComponent; + let testFixture: ComponentFixture; + + // synchronous beforeEach + beforeEach(() => { + const html = ` + `; + testFixture = createTestComponent(html, TestComponent) as ComponentFixture; + testComp = testFixture.componentInstance; + }); + + afterEach(() => { + testFixture.destroy(); + }); + + it('should create QualityAssuranceSourceComponent', inject([QualityAssuranceSourceComponent], (app: QualityAssuranceSourceComponent) => { + expect(app).toBeDefined(); + })); + }); + + describe('Main tests running with two Source', () => { + beforeEach(() => { + fixture = TestBed.createComponent(QualityAssuranceSourceComponent); + comp = fixture.componentInstance; + compAsAny = comp; + + }); + + afterEach(() => { + fixture.destroy(); + comp = null; + compAsAny = null; + }); + + it(('Should init component properly'), () => { + comp.ngOnInit(); + fixture.detectChanges(); + + expect(comp.sources$).toBeObservable(cold('(a|)', { + a: [ + qualityAssuranceSourceObjectMorePid, + qualityAssuranceSourceObjectMoreAbstract + ] + })); + expect(comp.totalElements$).toBeObservable(cold('(a|)', { + a: 2 + })); + }); + + it(('Should set data properly after the view init'), () => { + spyOn(compAsAny, 'getQualityAssuranceSource'); + + comp.ngAfterViewInit(); + fixture.detectChanges(); + + expect(compAsAny.getQualityAssuranceSource).toHaveBeenCalled(); + }); + + it(('isSourceLoading should return FALSE'), () => { + expect(comp.isSourceLoading()).toBeObservable(cold('(a|)', { + a: false + })); + }); + + it(('isSourceProcessing should return FALSE'), () => { + expect(comp.isSourceProcessing()).toBeObservable(cold('(a|)', { + a: false + })); + }); + + it(('getQualityAssuranceSource should call the service to dispatch a STATE change'), () => { + comp.ngOnInit(); + fixture.detectChanges(); + + compAsAny.notificationsStateService.dispatchRetrieveQualityAssuranceSource(comp.paginationConfig.pageSize, comp.paginationConfig.currentPage).and.callThrough(); + expect(compAsAny.notificationsStateService.dispatchRetrieveQualityAssuranceSource).toHaveBeenCalledWith(comp.paginationConfig.pageSize, comp.paginationConfig.currentPage); + }); + }); +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { + +} diff --git a/src/app/notifications/qa/source/quality-assurance-source.component.ts b/src/app/notifications/qa/source/quality-assurance-source.component.ts new file mode 100644 index 00000000000..aef56d09c71 --- /dev/null +++ b/src/app/notifications/qa/source/quality-assurance-source.component.ts @@ -0,0 +1,142 @@ +import { Component, OnInit } from '@angular/core'; +import { PaginationService } from '../../../core/pagination/pagination.service'; +import { Observable, Subscription } from 'rxjs'; +import { distinctUntilChanged, take } from 'rxjs/operators'; +import { SortOptions } from '../../../core/cache/models/sort-options.model'; +import { QualityAssuranceSourceObject } from '../../../core/notifications/qa/models/quality-assurance-source.model'; +import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; +import { NotificationsStateService } from '../../notifications-state.service'; +import { AdminQualityAssuranceSourcePageParams } from '../../../admin/admin-notifications/admin-quality-assurance-source-page-component/admin-quality-assurance-source-page-resolver.service'; +import { hasValue } from '../../../shared/empty.util'; + +/** + * Component to display the Quality Assurance source list. + */ +@Component({ + selector: 'ds-quality-assurance-source', + templateUrl: './quality-assurance-source.component.html', + styleUrls: ['./quality-assurance-source.component.scss'] +}) +export class QualityAssuranceSourceComponent implements OnInit { + + /** + * The pagination system configuration for HTML listing. + * @type {PaginationComponentOptions} + */ + public paginationConfig: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { + id: 'btp', + pageSize: 10, + pageSizeOptions: [5, 10, 20, 40, 60] + }); + /** + * The Quality Assurance source list sort options. + * @type {SortOptions} + */ + public paginationSortConfig: SortOptions; + /** + * The Quality Assurance source list. + */ + public sources$: Observable; + /** + * The total number of Quality Assurance sources. + */ + public totalElements$: Observable; + /** + * Array to track all the component subscriptions. Useful to unsubscribe them with 'onDestroy'. + * @type {Array} + */ + protected subs: Subscription[] = []; + + /** + * Initialize the component variables. + * @param {PaginationService} paginationService + * @param {NotificationsStateService} notificationsStateService + */ + constructor( + private paginationService: PaginationService, + private notificationsStateService: NotificationsStateService, + ) { } + + /** + * Component initialization. + */ + ngOnInit(): void { + this.sources$ = this.notificationsStateService.getQualityAssuranceSource(); + this.totalElements$ = this.notificationsStateService.getQualityAssuranceSourceTotals(); + } + + /** + * First Quality Assurance source loading after view initialization. + */ + ngAfterViewInit(): void { + this.subs.push( + this.notificationsStateService.isQualityAssuranceSourceLoaded().pipe( + take(1) + ).subscribe(() => { + this.getQualityAssuranceSource(); + }) + ); + } + + /** + * Returns the information about the loading status of the Quality Assurance source (if it's running or not). + * + * @return Observable + * 'true' if the source are loading, 'false' otherwise. + */ + public isSourceLoading(): Observable { + return this.notificationsStateService.isQualityAssuranceSourceLoading(); + } + + /** + * Returns the information about the processing status of the Quality Assurance source (if it's running or not). + * + * @return Observable + * 'true' if there are operations running on the source (ex.: a REST call), 'false' otherwise. + */ + public isSourceProcessing(): Observable { + return this.notificationsStateService.isQualityAssuranceSourceProcessing(); + } + + /** + * Dispatch the Quality Assurance source retrival. + */ + public getQualityAssuranceSource(): void { + this.paginationService.getCurrentPagination(this.paginationConfig.id, this.paginationConfig).pipe( + distinctUntilChanged(), + ).subscribe((options: PaginationComponentOptions) => { + this.notificationsStateService.dispatchRetrieveQualityAssuranceSource( + options.pageSize, + options.currentPage + ); + }); + } + + /** + * Update pagination Config from route params + * + * @param eventsRouteParams + */ + protected updatePaginationFromRouteParams(eventsRouteParams: AdminQualityAssuranceSourcePageParams) { + if (eventsRouteParams.currentPage) { + this.paginationConfig.currentPage = eventsRouteParams.currentPage; + } + if (eventsRouteParams.pageSize) { + if (this.paginationConfig.pageSizeOptions.includes(eventsRouteParams.pageSize)) { + this.paginationConfig.pageSize = eventsRouteParams.pageSize; + } else { + this.paginationConfig.pageSize = this.paginationConfig.pageSizeOptions[0]; + } + } + } + + /** + * Unsubscribe from all subscriptions. + */ + ngOnDestroy(): void { + this.subs + .filter((sub) => hasValue(sub)) + .forEach((sub) => sub.unsubscribe()); + } + +} diff --git a/src/app/notifications/qa/source/quality-assurance-source.effects.ts b/src/app/notifications/qa/source/quality-assurance-source.effects.ts new file mode 100644 index 00000000000..bd85fb18a07 --- /dev/null +++ b/src/app/notifications/qa/source/quality-assurance-source.effects.ts @@ -0,0 +1,93 @@ +import { Injectable } from '@angular/core'; + +import { Store } from '@ngrx/store'; +import { Actions, createEffect, ofType } from '@ngrx/effects'; +import { TranslateService } from '@ngx-translate/core'; +import { catchError, map, switchMap, tap, withLatestFrom } from 'rxjs/operators'; +import { of as observableOf } from 'rxjs'; + +import { + AddSourceAction, + QualityAssuranceSourceActionTypes, + RetrieveAllSourceAction, + RetrieveAllSourceErrorAction, +} from './quality-assurance-source.actions'; +import { + QualityAssuranceSourceObject +} from '../../../core/notifications/qa/models/quality-assurance-source.model'; +import { PaginatedList } from '../../../core/data/paginated-list.model'; +import { QualityAssuranceSourceService } from './quality-assurance-source.service'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { + QualityAssuranceSourceDataService +} from '../../../core/notifications/qa/source/quality-assurance-source-data.service'; + +/** + * Provides effect methods for the Quality Assurance source actions. + */ +@Injectable() +export class QualityAssuranceSourceEffects { + + /** + * Retrieve all Quality Assurance source managing pagination and errors. + */ + retrieveAllSource$ = createEffect(() => this.actions$.pipe( + ofType(QualityAssuranceSourceActionTypes.RETRIEVE_ALL_SOURCE), + withLatestFrom(this.store$), + switchMap(([action, currentState]: [RetrieveAllSourceAction, any]) => { + return this.qualityAssuranceSourceService.getSources( + action.payload.elementsPerPage, + action.payload.currentPage + ).pipe( + map((sources: PaginatedList) => + new AddSourceAction(sources.page, sources.totalPages, sources.currentPage, sources.totalElements) + ), + catchError((error: Error) => { + if (error) { + console.error(error.message); + } + return observableOf(new RetrieveAllSourceErrorAction()); + }) + ); + }) + )); + + /** + * Show a notification on error. + */ + retrieveAllSourceErrorAction$ = createEffect(() => this.actions$.pipe( + ofType(QualityAssuranceSourceActionTypes.RETRIEVE_ALL_SOURCE_ERROR), + tap(() => { + this.notificationsService.error(null, this.translate.get('quality-assurance.source.error.service.retrieve')); + }) + ), { dispatch: false }); + + /** + * Clear find all source requests from cache. + */ + addSourceAction$ = createEffect(() => this.actions$.pipe( + ofType(QualityAssuranceSourceActionTypes.ADD_SOURCE), + tap(() => { + this.qualityAssuranceSourceDataService.clearFindAllSourceRequests(); + }) + ), { dispatch: false }); + + /** + * Initialize the effect class variables. + * @param {Actions} actions$ + * @param {Store} store$ + * @param {TranslateService} translate + * @param {NotificationsService} notificationsService + * @param {QualityAssuranceSourceService} qualityAssuranceSourceService + * @param {QualityAssuranceSourceDataService} qualityAssuranceSourceDataService + */ + constructor( + private actions$: Actions, + private store$: Store, + private translate: TranslateService, + private notificationsService: NotificationsService, + private qualityAssuranceSourceService: QualityAssuranceSourceService, + private qualityAssuranceSourceDataService: QualityAssuranceSourceDataService + ) { + } +} diff --git a/src/app/notifications/qa/source/quality-assurance-source.reducer.spec.ts b/src/app/notifications/qa/source/quality-assurance-source.reducer.spec.ts new file mode 100644 index 00000000000..fcb717067d5 --- /dev/null +++ b/src/app/notifications/qa/source/quality-assurance-source.reducer.spec.ts @@ -0,0 +1,68 @@ +import { + AddSourceAction, + RetrieveAllSourceAction, + RetrieveAllSourceErrorAction + } from './quality-assurance-source.actions'; + import { qualityAssuranceSourceReducer, QualityAssuranceSourceState } from './quality-assurance-source.reducer'; + import { + qualityAssuranceSourceObjectMoreAbstract, + qualityAssuranceSourceObjectMorePid + } from '../../../shared/mocks/notifications.mock'; + + describe('qualityAssuranceSourceReducer test suite', () => { + let qualityAssuranceSourceInitialState: QualityAssuranceSourceState; + const elementPerPage = 3; + const currentPage = 0; + + beforeEach(() => { + qualityAssuranceSourceInitialState = { + source: [], + processing: false, + loaded: false, + totalPages: 0, + currentPage: 0, + totalElements: 0 + }; + }); + + it('Action RETRIEVE_ALL_SOURCE should set the State property "processing" to TRUE', () => { + const expectedState = qualityAssuranceSourceInitialState; + expectedState.processing = true; + + const action = new RetrieveAllSourceAction(elementPerPage, currentPage); + const newState = qualityAssuranceSourceReducer(qualityAssuranceSourceInitialState, action); + + expect(newState).toEqual(expectedState); + }); + + it('Action RETRIEVE_ALL_SOURCE_ERROR should change the State to initial State but processing, loaded, and currentPage', () => { + const expectedState = qualityAssuranceSourceInitialState; + expectedState.processing = false; + expectedState.loaded = true; + expectedState.currentPage = 0; + + const action = new RetrieveAllSourceErrorAction(); + const newState = qualityAssuranceSourceReducer(qualityAssuranceSourceInitialState, action); + + expect(newState).toEqual(expectedState); + }); + + it('Action ADD_SOURCE should populate the State with Quality Assurance source', () => { + const expectedState = { + source: [ qualityAssuranceSourceObjectMorePid, qualityAssuranceSourceObjectMoreAbstract ], + processing: false, + loaded: true, + totalPages: 1, + currentPage: 0, + totalElements: 2 + }; + + const action = new AddSourceAction( + [ qualityAssuranceSourceObjectMorePid, qualityAssuranceSourceObjectMoreAbstract ], + 1, 0, 2 + ); + const newState = qualityAssuranceSourceReducer(qualityAssuranceSourceInitialState, action); + + expect(newState).toEqual(expectedState); + }); + }); diff --git a/src/app/notifications/qa/source/quality-assurance-source.reducer.ts b/src/app/notifications/qa/source/quality-assurance-source.reducer.ts new file mode 100644 index 00000000000..08e26a177ac --- /dev/null +++ b/src/app/notifications/qa/source/quality-assurance-source.reducer.ts @@ -0,0 +1,72 @@ +import { QualityAssuranceSourceObject } from '../../../core/notifications/qa/models/quality-assurance-source.model'; +import { QualityAssuranceSourceActionTypes, QualityAssuranceSourceActions } from './quality-assurance-source.actions'; + +/** + * The interface representing the Quality Assurance source state. + */ +export interface QualityAssuranceSourceState { + source: QualityAssuranceSourceObject[]; + processing: boolean; + loaded: boolean; + totalPages: number; + currentPage: number; + totalElements: number; +} + +/** + * Used for the Quality Assurance source state initialization. + */ +const qualityAssuranceSourceInitialState: QualityAssuranceSourceState = { + source: [], + processing: false, + loaded: false, + totalPages: 0, + currentPage: 0, + totalElements: 0 +}; + +/** + * The Quality Assurance Source Reducer + * + * @param state + * the current state initialized with qualityAssuranceSourceInitialState + * @param action + * the action to perform on the state + * @return QualityAssuranceSourceState + * the new state + */ +export function qualityAssuranceSourceReducer(state = qualityAssuranceSourceInitialState, action: QualityAssuranceSourceActions): QualityAssuranceSourceState { + switch (action.type) { + case QualityAssuranceSourceActionTypes.RETRIEVE_ALL_SOURCE: { + return Object.assign({}, state, { + source: [], + processing: true + }); + } + + case QualityAssuranceSourceActionTypes.ADD_SOURCE: { + return Object.assign({}, state, { + source: action.payload.source, + processing: false, + loaded: true, + totalPages: action.payload.totalPages, + currentPage: state.currentPage, + totalElements: action.payload.totalElements + }); + } + + case QualityAssuranceSourceActionTypes.RETRIEVE_ALL_SOURCE_ERROR: { + return Object.assign({}, state, { + processing: false, + loaded: true, + totalPages: 0, + currentPage: 0, + totalElements: 0 + }); + } + + default: { + return state; + } + } +} diff --git a/src/app/notifications/qa/source/quality-assurance-source.service.spec.ts b/src/app/notifications/qa/source/quality-assurance-source.service.spec.ts new file mode 100644 index 00000000000..5ce2ed8ee02 --- /dev/null +++ b/src/app/notifications/qa/source/quality-assurance-source.service.spec.ts @@ -0,0 +1,69 @@ +import { TestBed } from '@angular/core/testing'; +import { of as observableOf } from 'rxjs'; +import { QualityAssuranceSourceService } from './quality-assurance-source.service'; +import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model'; +import { PageInfo } from '../../../core/shared/page-info.model'; +import { + getMockQualityAssuranceSourceRestService, + qualityAssuranceSourceObjectMoreAbstract, + qualityAssuranceSourceObjectMorePid +} from '../../../shared/mocks/notifications.mock'; +import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; +import { cold } from 'jasmine-marbles'; +import { buildPaginatedList } from '../../../core/data/paginated-list.model'; +import { + QualityAssuranceSourceDataService +} from '../../../core/notifications/qa/source/quality-assurance-source-data.service'; +import { FindListOptions } from '../../../core/data/find-list-options.model'; + +describe('QualityAssuranceSourceService', () => { + let service: QualityAssuranceSourceService; + let restService: QualityAssuranceSourceDataService; + let serviceAsAny: any; + let restServiceAsAny: any; + + const pageInfo = new PageInfo(); + const array = [ qualityAssuranceSourceObjectMorePid, qualityAssuranceSourceObjectMoreAbstract ]; + const paginatedList = buildPaginatedList(pageInfo, array); + const paginatedListRD = createSuccessfulRemoteDataObject(paginatedList); + const elementsPerPage = 3; + const currentPage = 0; + + beforeEach(async () => { + TestBed.configureTestingModule({ + providers: [ + { provide: QualityAssuranceSourceDataService, useClass: getMockQualityAssuranceSourceRestService }, + { provide: QualityAssuranceSourceService, useValue: service } + ] + }).compileComponents(); + }); + + beforeEach(() => { + restService = TestBed.inject(QualityAssuranceSourceDataService); + restServiceAsAny = restService; + restServiceAsAny.getSources.and.returnValue(observableOf(paginatedListRD)); + service = new QualityAssuranceSourceService(restService); + serviceAsAny = service; + }); + + describe('getSources', () => { + it('Should proxy the call to qualityAssuranceSourceRestService.getSources', () => { + const sortOptions = new SortOptions('name', SortDirection.ASC); + const findListOptions: FindListOptions = { + elementsPerPage: elementsPerPage, + currentPage: currentPage, + sort: sortOptions + }; + const result = service.getSources(elementsPerPage, currentPage); + expect((service as any).qualityAssuranceSourceRestService.getSources).toHaveBeenCalledWith(findListOptions); + }); + + it('Should return a paginated list of Quality Assurance Source', () => { + const expected = cold('(a|)', { + a: paginatedList + }); + const result = service.getSources(elementsPerPage, currentPage); + expect(result).toBeObservable(expected); + }); + }); +}); diff --git a/src/app/notifications/qa/source/quality-assurance-source.service.ts b/src/app/notifications/qa/source/quality-assurance-source.service.ts new file mode 100644 index 00000000000..ea0cb2e5c51 --- /dev/null +++ b/src/app/notifications/qa/source/quality-assurance-source.service.ts @@ -0,0 +1,63 @@ +import { Injectable } from '@angular/core'; + +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { + QualityAssuranceSourceDataService +} from '../../../core/notifications/qa/source/quality-assurance-source-data.service'; +import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model'; +import { RemoteData } from '../../../core/data/remote-data'; +import { PaginatedList } from '../../../core/data/paginated-list.model'; +import { + QualityAssuranceSourceObject +} from '../../../core/notifications/qa/models/quality-assurance-source.model'; +import { FindListOptions } from '../../../core/data/find-list-options.model'; +import { getFirstCompletedRemoteData } from '../../../core/shared/operators'; + +/** + * The service handling all Quality Assurance source requests to the REST service. + */ +@Injectable() +export class QualityAssuranceSourceService { + + /** + * Initialize the service variables. + * @param {QualityAssuranceSourceDataService} qualityAssuranceSourceRestService + */ + constructor( + private qualityAssuranceSourceRestService: QualityAssuranceSourceDataService + ) { + } + + /** + * Return the list of Quality Assurance source managing pagination and errors. + * + * @param elementsPerPage + * The number of the source per page + * @param currentPage + * The page number to retrieve + * @return Observable> + * The list of Quality Assurance source. + */ + public getSources(elementsPerPage, currentPage): Observable> { + const sortOptions = new SortOptions('name', SortDirection.ASC); + + const findListOptions: FindListOptions = { + elementsPerPage: elementsPerPage, + currentPage: currentPage, + sort: sortOptions + }; + + return this.qualityAssuranceSourceRestService.getSources(findListOptions).pipe( + getFirstCompletedRemoteData(), + map((rd: RemoteData>) => { + if (rd.hasSucceeded) { + return rd.payload; + } else { + throw new Error('Can\'t retrieve Quality Assurance source from the Broker source REST service'); + } + }) + ); + } +} diff --git a/src/app/notifications/qa/topics/quality-assurance-topics.actions.ts b/src/app/notifications/qa/topics/quality-assurance-topics.actions.ts new file mode 100644 index 00000000000..6b83b1d349c --- /dev/null +++ b/src/app/notifications/qa/topics/quality-assurance-topics.actions.ts @@ -0,0 +1,98 @@ +/* eslint-disable max-classes-per-file */ +import { Action } from '@ngrx/store'; +import { type } from '../../../shared/ngrx/type'; +import { QualityAssuranceTopicObject } from '../../../core/notifications/qa/models/quality-assurance-topic.model'; + +/** + * For each action type in an action group, make a simple + * enum object for all of this group's action types. + * + * The 'type' utility function coerces strings into string + * literal types and runs a simple check to guarantee all + * action types in the application are unique. + */ +export const QualityAssuranceTopicActionTypes = { + ADD_TOPICS: type('dspace/integration/notifications/qa/topic/ADD_TOPICS'), + RETRIEVE_ALL_TOPICS: type('dspace/integration/notifications/qa/topic/RETRIEVE_ALL_TOPICS'), + RETRIEVE_ALL_TOPICS_ERROR: type('dspace/integration/notifications/qa/topic/RETRIEVE_ALL_TOPICS_ERROR'), +}; + +/** + * An ngrx action to retrieve all the Quality Assurance topics. + */ +export class RetrieveAllTopicsAction implements Action { + type = QualityAssuranceTopicActionTypes.RETRIEVE_ALL_TOPICS; + payload: { + elementsPerPage: number; + currentPage: number; + }; + + /** + * Create a new RetrieveAllTopicsAction. + * + * @param elementsPerPage + * the number of topics per page + * @param currentPage + * The page number to retrieve + */ + constructor(elementsPerPage: number, currentPage: number) { + this.payload = { + elementsPerPage, + currentPage + }; + } +} + +/** + * An ngrx action for retrieving 'all Quality Assurance topics' error. + */ +export class RetrieveAllTopicsErrorAction implements Action { + type = QualityAssuranceTopicActionTypes.RETRIEVE_ALL_TOPICS_ERROR; +} + +/** + * An ngrx action to load the Quality Assurance topic objects. + * Called by the ??? effect. + */ +export class AddTopicsAction implements Action { + type = QualityAssuranceTopicActionTypes.ADD_TOPICS; + payload: { + topics: QualityAssuranceTopicObject[]; + totalPages: number; + currentPage: number; + totalElements: number; + }; + + /** + * Create a new AddTopicsAction. + * + * @param topics + * the list of topics + * @param totalPages + * the total available pages of topics + * @param currentPage + * the current page + * @param totalElements + * the total available Quality Assurance topics + */ + constructor(topics: QualityAssuranceTopicObject[], totalPages: number, currentPage: number, totalElements: number) { + this.payload = { + topics, + totalPages, + currentPage, + totalElements + }; + } + +} + +/* tslint:enable:max-classes-per-file */ + +/** + * Export a type alias of all actions in this action group + * so that reducers can easily compose action types. + */ +export type QualityAssuranceTopicsActions + = AddTopicsAction + |RetrieveAllTopicsAction + |RetrieveAllTopicsErrorAction; diff --git a/src/app/notifications/qa/topics/quality-assurance-topics.component.html b/src/app/notifications/qa/topics/quality-assurance-topics.component.html new file mode 100644 index 00000000000..db8586f264d --- /dev/null +++ b/src/app/notifications/qa/topics/quality-assurance-topics.component.html @@ -0,0 +1,57 @@ +
+
+
+

{{'quality-assurance.title'| translate}}

+ {{'quality-assurance.topics.description'| translate:{source: sourceId} }} +
+
+
+
+

{{'quality-assurance.topics'| translate}}

+ + + + + + + +
+ + + + + + + + + + + + + + + +
{{'quality-assurance.table.topic' | translate}}{{'quality-assurance.table.last-event' | translate}}{{'quality-assurance.table.actions' | translate}}
{{topicElement.name}}{{topicElement.lastEvent}} +
+ +
+
+
+
+
+
+
+
diff --git a/src/themes/custom/app/browse-by/browse-by-switcher/browse-by-switcher.component.scss b/src/app/notifications/qa/topics/quality-assurance-topics.component.scss similarity index 100% rename from src/themes/custom/app/browse-by/browse-by-switcher/browse-by-switcher.component.scss rename to src/app/notifications/qa/topics/quality-assurance-topics.component.scss diff --git a/src/app/notifications/qa/topics/quality-assurance-topics.component.spec.ts b/src/app/notifications/qa/topics/quality-assurance-topics.component.spec.ts new file mode 100644 index 00000000000..fd64b82ce7d --- /dev/null +++ b/src/app/notifications/qa/topics/quality-assurance-topics.component.spec.ts @@ -0,0 +1,160 @@ +/* eslint-disable no-empty, @typescript-eslint/no-empty-function */ +import { CommonModule } from '@angular/common'; +import { Component, NO_ERRORS_SCHEMA } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; +import { of as observableOf } from 'rxjs'; +import { ComponentFixture, inject, TestBed, waitForAsync } from '@angular/core/testing'; +import { createTestComponent } from '../../../shared/testing/utils.test'; +import { + getMockNotificationsStateService, + qualityAssuranceTopicObjectMoreAbstract, + qualityAssuranceTopicObjectMorePid +} from '../../../shared/mocks/notifications.mock'; +import { QualityAssuranceTopicsComponent } from './quality-assurance-topics.component'; +import { NotificationsStateService } from '../../notifications-state.service'; +import { cold } from 'jasmine-marbles'; +import { PaginationServiceStub } from '../../../shared/testing/pagination-service.stub'; +import { PaginationService } from '../../../core/pagination/pagination.service'; +import { QualityAssuranceTopicsService } from './quality-assurance-topics.service'; + +describe('QualityAssuranceTopicsComponent test suite', () => { + let fixture: ComponentFixture; + let comp: QualityAssuranceTopicsComponent; + let compAsAny: any; + const mockNotificationsStateService = getMockNotificationsStateService(); + const activatedRouteParams = { + qualityAssuranceTopicsParams: { + currentPage: 0, + pageSize: 5 + } + }; + const paginationService = new PaginationServiceStub(); + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + CommonModule, + TranslateModule.forRoot(), + ], + declarations: [ + QualityAssuranceTopicsComponent, + TestComponent, + ], + providers: [ + { provide: NotificationsStateService, useValue: mockNotificationsStateService }, + { provide: ActivatedRoute, useValue: { data: observableOf(activatedRouteParams), snapshot: { + paramMap: { + get: () => 'openaire', + }, + }}}, + { provide: PaginationService, useValue: paginationService }, + QualityAssuranceTopicsComponent, + // tslint:disable-next-line: no-empty + { provide: QualityAssuranceTopicsService, useValue: { setSourceId: (sourceId: string) => { } }} + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents().then(() => { + mockNotificationsStateService.getQualityAssuranceTopics.and.returnValue(observableOf([ + qualityAssuranceTopicObjectMorePid, + qualityAssuranceTopicObjectMoreAbstract + ])); + mockNotificationsStateService.getQualityAssuranceTopicsTotalPages.and.returnValue(observableOf(1)); + mockNotificationsStateService.getQualityAssuranceTopicsCurrentPage.and.returnValue(observableOf(0)); + mockNotificationsStateService.getQualityAssuranceTopicsTotals.and.returnValue(observableOf(2)); + mockNotificationsStateService.isQualityAssuranceTopicsLoaded.and.returnValue(observableOf(true)); + mockNotificationsStateService.isQualityAssuranceTopicsLoading.and.returnValue(observableOf(false)); + mockNotificationsStateService.isQualityAssuranceTopicsProcessing.and.returnValue(observableOf(false)); + }); + })); + + // First test to check the correct component creation + describe('', () => { + let testComp: TestComponent; + let testFixture: ComponentFixture; + + // synchronous beforeEach + beforeEach(() => { + const html = ` + `; + testFixture = createTestComponent(html, TestComponent) as ComponentFixture; + testComp = testFixture.componentInstance; + }); + + afterEach(() => { + testFixture.destroy(); + }); + + it('should create QualityAssuranceTopicsComponent', inject([QualityAssuranceTopicsComponent], (app: QualityAssuranceTopicsComponent) => { + expect(app).toBeDefined(); + })); + }); + + describe('Main tests running with two topics', () => { + beforeEach(() => { + fixture = TestBed.createComponent(QualityAssuranceTopicsComponent); + comp = fixture.componentInstance; + compAsAny = comp; + + }); + + afterEach(() => { + fixture.destroy(); + comp = null; + compAsAny = null; + }); + + it(('Should init component properly'), () => { + comp.ngOnInit(); + fixture.detectChanges(); + + expect(comp.topics$).toBeObservable(cold('(a|)', { + a: [ + qualityAssuranceTopicObjectMorePid, + qualityAssuranceTopicObjectMoreAbstract + ] + })); + expect(comp.totalElements$).toBeObservable(cold('(a|)', { + a: 2 + })); + }); + + it(('Should set data properly after the view init'), () => { + spyOn(compAsAny, 'getQualityAssuranceTopics'); + + comp.ngAfterViewInit(); + fixture.detectChanges(); + + expect(compAsAny.getQualityAssuranceTopics).toHaveBeenCalled(); + }); + + it(('isTopicsLoading should return FALSE'), () => { + expect(comp.isTopicsLoading()).toBeObservable(cold('(a|)', { + a: false + })); + }); + + it(('isTopicsProcessing should return FALSE'), () => { + expect(comp.isTopicsProcessing()).toBeObservable(cold('(a|)', { + a: false + })); + }); + + it(('getQualityAssuranceTopics should call the service to dispatch a STATE change'), () => { + comp.ngOnInit(); + fixture.detectChanges(); + + compAsAny.notificationsStateService.dispatchRetrieveQualityAssuranceTopics(comp.paginationConfig.pageSize, comp.paginationConfig.currentPage).and.callThrough(); + expect(compAsAny.notificationsStateService.dispatchRetrieveQualityAssuranceTopics).toHaveBeenCalledWith(comp.paginationConfig.pageSize, comp.paginationConfig.currentPage); + }); + }); +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { + +} diff --git a/src/app/notifications/qa/topics/quality-assurance-topics.component.ts b/src/app/notifications/qa/topics/quality-assurance-topics.component.ts new file mode 100644 index 00000000000..542d36a9ed1 --- /dev/null +++ b/src/app/notifications/qa/topics/quality-assurance-topics.component.ts @@ -0,0 +1,161 @@ +import { Component, OnInit } from '@angular/core'; + +import { Observable, Subscription } from 'rxjs'; +import { distinctUntilChanged, take } from 'rxjs/operators'; + +import { SortOptions } from '../../../core/cache/models/sort-options.model'; +import { + QualityAssuranceTopicObject +} from '../../../core/notifications/qa/models/quality-assurance-topic.model'; +import { hasValue } from '../../../shared/empty.util'; +import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; +import { NotificationsStateService } from '../../notifications-state.service'; +import { + AdminQualityAssuranceTopicsPageParams +} from '../../../admin/admin-notifications/admin-quality-assurance-topics-page/admin-quality-assurance-topics-page-resolver.service'; +import { PaginationService } from '../../../core/pagination/pagination.service'; +import { ActivatedRoute } from '@angular/router'; +import { QualityAssuranceTopicsService } from './quality-assurance-topics.service'; + +/** + * Component to display the Quality Assurance topic list. + */ +@Component({ + selector: 'ds-quality-assurance-topic', + templateUrl: './quality-assurance-topics.component.html', + styleUrls: ['./quality-assurance-topics.component.scss'], +}) +export class QualityAssuranceTopicsComponent implements OnInit { + /** + * The pagination system configuration for HTML listing. + * @type {PaginationComponentOptions} + */ + public paginationConfig: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { + id: 'btp', + pageSize: 10, + pageSizeOptions: [5, 10, 20, 40, 60] + }); + /** + * The Quality Assurance topic list sort options. + * @type {SortOptions} + */ + public paginationSortConfig: SortOptions; + /** + * The Quality Assurance topic list. + */ + public topics$: Observable; + /** + * The total number of Quality Assurance topics. + */ + public totalElements$: Observable; + /** + * Array to track all the component subscriptions. Useful to unsubscribe them with 'onDestroy'. + * @type {Array} + */ + protected subs: Subscription[] = []; + + /** + * This property represents a sourceId which is used to retrive a topic + * @type {string} + */ + public sourceId: string; + + /** + * Initialize the component variables. + * @param {PaginationService} paginationService + * @param {ActivatedRoute} activatedRoute + * @param {NotificationsStateService} notificationsStateService + * @param {QualityAssuranceTopicsService} qualityAssuranceTopicsService + */ + constructor( + private paginationService: PaginationService, + private activatedRoute: ActivatedRoute, + private notificationsStateService: NotificationsStateService, + private qualityAssuranceTopicsService: QualityAssuranceTopicsService + ) { + } + + /** + * Component initialization. + */ + ngOnInit(): void { + this.sourceId = this.activatedRoute.snapshot.paramMap.get('sourceId'); + this.qualityAssuranceTopicsService.setSourceId(this.sourceId); + this.topics$ = this.notificationsStateService.getQualityAssuranceTopics(); + this.totalElements$ = this.notificationsStateService.getQualityAssuranceTopicsTotals(); + } + + /** + * First Quality Assurance topics loading after view initialization. + */ + ngAfterViewInit(): void { + this.subs.push( + this.notificationsStateService.isQualityAssuranceTopicsLoaded().pipe( + take(1) + ).subscribe(() => { + this.getQualityAssuranceTopics(); + }) + ); + } + + /** + * Returns the information about the loading status of the Quality Assurance topics (if it's running or not). + * + * @return Observable + * 'true' if the topics are loading, 'false' otherwise. + */ + public isTopicsLoading(): Observable { + return this.notificationsStateService.isQualityAssuranceTopicsLoading(); + } + + /** + * Returns the information about the processing status of the Quality Assurance topics (if it's running or not). + * + * @return Observable + * 'true' if there are operations running on the topics (ex.: a REST call), 'false' otherwise. + */ + public isTopicsProcessing(): Observable { + return this.notificationsStateService.isQualityAssuranceTopicsProcessing(); + } + + /** + * Dispatch the Quality Assurance topics retrival. + */ + public getQualityAssuranceTopics(): void { + this.paginationService.getCurrentPagination(this.paginationConfig.id, this.paginationConfig).pipe( + distinctUntilChanged(), + ).subscribe((options: PaginationComponentOptions) => { + this.notificationsStateService.dispatchRetrieveQualityAssuranceTopics( + options.pageSize, + options.currentPage + ); + }); + } + + /** + * Update pagination Config from route params + * + * @param eventsRouteParams + */ + protected updatePaginationFromRouteParams(eventsRouteParams: AdminQualityAssuranceTopicsPageParams) { + if (eventsRouteParams.currentPage) { + this.paginationConfig.currentPage = eventsRouteParams.currentPage; + } + if (eventsRouteParams.pageSize) { + if (this.paginationConfig.pageSizeOptions.includes(eventsRouteParams.pageSize)) { + this.paginationConfig.pageSize = eventsRouteParams.pageSize; + } else { + this.paginationConfig.pageSize = this.paginationConfig.pageSizeOptions[0]; + } + } + } + + /** + * Unsubscribe from all subscriptions. + */ + ngOnDestroy(): void { + this.subs + .filter((sub) => hasValue(sub)) + .forEach((sub) => sub.unsubscribe()); + } +} diff --git a/src/app/notifications/qa/topics/quality-assurance-topics.effects.ts b/src/app/notifications/qa/topics/quality-assurance-topics.effects.ts new file mode 100644 index 00000000000..a7b4dddd629 --- /dev/null +++ b/src/app/notifications/qa/topics/quality-assurance-topics.effects.ts @@ -0,0 +1,92 @@ +import { Injectable } from '@angular/core'; + +import { Store } from '@ngrx/store'; +import { Actions, createEffect, ofType } from '@ngrx/effects'; +import { TranslateService } from '@ngx-translate/core'; +import { catchError, map, switchMap, tap, withLatestFrom } from 'rxjs/operators'; +import { of as observableOf } from 'rxjs'; + +import { + AddTopicsAction, + QualityAssuranceTopicActionTypes, + RetrieveAllTopicsAction, + RetrieveAllTopicsErrorAction, +} from './quality-assurance-topics.actions'; +import { + QualityAssuranceTopicObject +} from '../../../core/notifications/qa/models/quality-assurance-topic.model'; +import { PaginatedList } from '../../../core/data/paginated-list.model'; +import { QualityAssuranceTopicsService } from './quality-assurance-topics.service'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { + QualityAssuranceTopicDataService +} from '../../../core/notifications/qa/topics/quality-assurance-topic-data.service'; + +/** + * Provides effect methods for the Quality Assurance topics actions. + */ +@Injectable() +export class QualityAssuranceTopicsEffects { + + /** + * Retrieve all Quality Assurance topics managing pagination and errors. + */ + retrieveAllTopics$ = createEffect(() => this.actions$.pipe( + ofType(QualityAssuranceTopicActionTypes.RETRIEVE_ALL_TOPICS), + withLatestFrom(this.store$), + switchMap(([action, currentState]: [RetrieveAllTopicsAction, any]) => { + return this.qualityAssuranceTopicService.getTopics( + action.payload.elementsPerPage, + action.payload.currentPage + ).pipe( + map((topics: PaginatedList) => + new AddTopicsAction(topics.page, topics.totalPages, topics.currentPage, topics.totalElements) + ), + catchError((error: Error) => { + if (error) { + console.error(error.message); + } + return observableOf(new RetrieveAllTopicsErrorAction()); + }) + ); + }) + )); + + /** + * Show a notification on error. + */ + retrieveAllTopicsErrorAction$ = createEffect(() => this.actions$.pipe( + ofType(QualityAssuranceTopicActionTypes.RETRIEVE_ALL_TOPICS_ERROR), + tap(() => { + this.notificationsService.error(null, this.translate.get('quality-assurance.topic.error.service.retrieve')); + }) + ), { dispatch: false }); + + /** + * Clear find all topics requests from cache. + */ + addTopicsAction$ = createEffect(() => this.actions$.pipe( + ofType(QualityAssuranceTopicActionTypes.ADD_TOPICS), + tap(() => { + this.qualityAssuranceTopicDataService.clearFindAllTopicsRequests(); + }) + ), { dispatch: false }); + + /** + * Initialize the effect class variables. + * @param {Actions} actions$ + * @param {Store} store$ + * @param {TranslateService} translate + * @param {NotificationsService} notificationsService + * @param {QualityAssuranceTopicsService} qualityAssuranceTopicService + * @param {QualityAssuranceTopicDataService} qualityAssuranceTopicDataService + */ + constructor( + private actions$: Actions, + private store$: Store, + private translate: TranslateService, + private notificationsService: NotificationsService, + private qualityAssuranceTopicService: QualityAssuranceTopicsService, + private qualityAssuranceTopicDataService: QualityAssuranceTopicDataService + ) { } +} diff --git a/src/app/notifications/qa/topics/quality-assurance-topics.reducer.spec.ts b/src/app/notifications/qa/topics/quality-assurance-topics.reducer.spec.ts new file mode 100644 index 00000000000..a1c002d3f25 --- /dev/null +++ b/src/app/notifications/qa/topics/quality-assurance-topics.reducer.spec.ts @@ -0,0 +1,68 @@ +import { + AddTopicsAction, + RetrieveAllTopicsAction, + RetrieveAllTopicsErrorAction +} from './quality-assurance-topics.actions'; +import { qualityAssuranceTopicsReducer, QualityAssuranceTopicState } from './quality-assurance-topics.reducer'; +import { + qualityAssuranceTopicObjectMoreAbstract, + qualityAssuranceTopicObjectMorePid +} from '../../../shared/mocks/notifications.mock'; + +describe('qualityAssuranceTopicsReducer test suite', () => { + let qualityAssuranceTopicInitialState: QualityAssuranceTopicState; + const elementPerPage = 3; + const currentPage = 0; + + beforeEach(() => { + qualityAssuranceTopicInitialState = { + topics: [], + processing: false, + loaded: false, + totalPages: 0, + currentPage: 0, + totalElements: 0 + }; + }); + + it('Action RETRIEVE_ALL_TOPICS should set the State property "processing" to TRUE', () => { + const expectedState = qualityAssuranceTopicInitialState; + expectedState.processing = true; + + const action = new RetrieveAllTopicsAction(elementPerPage, currentPage); + const newState = qualityAssuranceTopicsReducer(qualityAssuranceTopicInitialState, action); + + expect(newState).toEqual(expectedState); + }); + + it('Action RETRIEVE_ALL_TOPICS_ERROR should change the State to initial State but processing, loaded, and currentPage', () => { + const expectedState = qualityAssuranceTopicInitialState; + expectedState.processing = false; + expectedState.loaded = true; + expectedState.currentPage = 0; + + const action = new RetrieveAllTopicsErrorAction(); + const newState = qualityAssuranceTopicsReducer(qualityAssuranceTopicInitialState, action); + + expect(newState).toEqual(expectedState); + }); + + it('Action ADD_TOPICS should populate the State with Quality Assurance topics', () => { + const expectedState = { + topics: [ qualityAssuranceTopicObjectMorePid, qualityAssuranceTopicObjectMoreAbstract ], + processing: false, + loaded: true, + totalPages: 1, + currentPage: 0, + totalElements: 2 + }; + + const action = new AddTopicsAction( + [ qualityAssuranceTopicObjectMorePid, qualityAssuranceTopicObjectMoreAbstract ], + 1, 0, 2 + ); + const newState = qualityAssuranceTopicsReducer(qualityAssuranceTopicInitialState, action); + + expect(newState).toEqual(expectedState); + }); +}); diff --git a/src/app/notifications/qa/topics/quality-assurance-topics.reducer.ts b/src/app/notifications/qa/topics/quality-assurance-topics.reducer.ts new file mode 100644 index 00000000000..ff94f1b8bb1 --- /dev/null +++ b/src/app/notifications/qa/topics/quality-assurance-topics.reducer.ts @@ -0,0 +1,72 @@ +import { QualityAssuranceTopicObject } from '../../../core/notifications/qa/models/quality-assurance-topic.model'; +import { QualityAssuranceTopicActionTypes, QualityAssuranceTopicsActions } from './quality-assurance-topics.actions'; + +/** + * The interface representing the Quality Assurance topic state. + */ +export interface QualityAssuranceTopicState { + topics: QualityAssuranceTopicObject[]; + processing: boolean; + loaded: boolean; + totalPages: number; + currentPage: number; + totalElements: number; +} + +/** + * Used for the Quality Assurance topic state initialization. + */ +const qualityAssuranceTopicInitialState: QualityAssuranceTopicState = { + topics: [], + processing: false, + loaded: false, + totalPages: 0, + currentPage: 0, + totalElements: 0 +}; + +/** + * The Quality Assurance Topic Reducer + * + * @param state + * the current state initialized with qualityAssuranceTopicInitialState + * @param action + * the action to perform on the state + * @return QualityAssuranceTopicState + * the new state + */ +export function qualityAssuranceTopicsReducer(state = qualityAssuranceTopicInitialState, action: QualityAssuranceTopicsActions): QualityAssuranceTopicState { + switch (action.type) { + case QualityAssuranceTopicActionTypes.RETRIEVE_ALL_TOPICS: { + return Object.assign({}, state, { + topics: [], + processing: true + }); + } + + case QualityAssuranceTopicActionTypes.ADD_TOPICS: { + return Object.assign({}, state, { + topics: action.payload.topics, + processing: false, + loaded: true, + totalPages: action.payload.totalPages, + currentPage: state.currentPage, + totalElements: action.payload.totalElements + }); + } + + case QualityAssuranceTopicActionTypes.RETRIEVE_ALL_TOPICS_ERROR: { + return Object.assign({}, state, { + processing: false, + loaded: true, + totalPages: 0, + currentPage: 0, + totalElements: 0 + }); + } + + default: { + return state; + } + } +} diff --git a/src/app/notifications/qa/topics/quality-assurance-topics.service.spec.ts b/src/app/notifications/qa/topics/quality-assurance-topics.service.spec.ts new file mode 100644 index 00000000000..c6aae27a888 --- /dev/null +++ b/src/app/notifications/qa/topics/quality-assurance-topics.service.spec.ts @@ -0,0 +1,72 @@ +import { TestBed } from '@angular/core/testing'; +import { of as observableOf } from 'rxjs'; +import { QualityAssuranceTopicsService } from './quality-assurance-topics.service'; +import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model'; +import { + QualityAssuranceTopicDataService +} from '../../../core/notifications/qa/topics/quality-assurance-topic-data.service'; +import { PageInfo } from '../../../core/shared/page-info.model'; +import { + getMockQualityAssuranceTopicRestService, + qualityAssuranceTopicObjectMoreAbstract, + qualityAssuranceTopicObjectMorePid +} from '../../../shared/mocks/notifications.mock'; +import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; +import { cold } from 'jasmine-marbles'; +import { buildPaginatedList } from '../../../core/data/paginated-list.model'; +import { RequestParam } from '../../../core/cache/models/request-param.model'; +import { FindListOptions } from '../../../core/data/find-list-options.model'; + +describe('QualityAssuranceTopicsService', () => { + let service: QualityAssuranceTopicsService; + let restService: QualityAssuranceTopicDataService; + let serviceAsAny: any; + let restServiceAsAny: any; + + const pageInfo = new PageInfo(); + const array = [ qualityAssuranceTopicObjectMorePid, qualityAssuranceTopicObjectMoreAbstract ]; + const paginatedList = buildPaginatedList(pageInfo, array); + const paginatedListRD = createSuccessfulRemoteDataObject(paginatedList); + const elementsPerPage = 3; + const currentPage = 0; + + beforeEach(async () => { + TestBed.configureTestingModule({ + providers: [ + { provide: QualityAssuranceTopicDataService, useClass: getMockQualityAssuranceTopicRestService }, + { provide: QualityAssuranceTopicsService, useValue: service } + ] + }).compileComponents(); + }); + + beforeEach(() => { + restService = TestBed.inject(QualityAssuranceTopicDataService); + restServiceAsAny = restService; + restServiceAsAny.getTopics.and.returnValue(observableOf(paginatedListRD)); + service = new QualityAssuranceTopicsService(restService); + serviceAsAny = service; + }); + + describe('getTopics', () => { + it('Should proxy the call to qualityAssuranceTopicRestService.getTopics', () => { + const sortOptions = new SortOptions('name', SortDirection.ASC); + const findListOptions: FindListOptions = { + elementsPerPage: elementsPerPage, + currentPage: currentPage, + sort: sortOptions, + searchParams: [new RequestParam('source', 'ENRICH!MORE!ABSTRACT')] + }; + service.setSourceId('ENRICH!MORE!ABSTRACT'); + const result = service.getTopics(elementsPerPage, currentPage); + expect((service as any).qualityAssuranceTopicRestService.getTopics).toHaveBeenCalledWith(findListOptions); + }); + + it('Should return a paginated list of Quality Assurance topics', () => { + const expected = cold('(a|)', { + a: paginatedList + }); + const result = service.getTopics(elementsPerPage, currentPage); + expect(result).toBeObservable(expected); + }); + }); +}); diff --git a/src/app/notifications/qa/topics/quality-assurance-topics.service.ts b/src/app/notifications/qa/topics/quality-assurance-topics.service.ts new file mode 100644 index 00000000000..9dd581ebedc --- /dev/null +++ b/src/app/notifications/qa/topics/quality-assurance-topics.service.ts @@ -0,0 +1,75 @@ +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { + QualityAssuranceTopicDataService +} from '../../../core/notifications/qa/topics/quality-assurance-topic-data.service'; +import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model'; +import { RemoteData } from '../../../core/data/remote-data'; +import { PaginatedList } from '../../../core/data/paginated-list.model'; +import { + QualityAssuranceTopicObject +} from '../../../core/notifications/qa/models/quality-assurance-topic.model'; +import { RequestParam } from '../../../core/cache/models/request-param.model'; +import { FindListOptions } from '../../../core/data/find-list-options.model'; +import { getFirstCompletedRemoteData } from '../../../core/shared/operators'; + +/** + * The service handling all Quality Assurance topic requests to the REST service. + */ +@Injectable() +export class QualityAssuranceTopicsService { + + /** + * Initialize the service variables. + * @param {QualityAssuranceTopicDataService} qualityAssuranceTopicRestService + */ + constructor( + private qualityAssuranceTopicRestService: QualityAssuranceTopicDataService + ) { } + + /** + * sourceId used to get topics + */ + sourceId: string; + + /** + * Return the list of Quality Assurance topics managing pagination and errors. + * + * @param elementsPerPage + * The number of the topics per page + * @param currentPage + * The page number to retrieve + * @return Observable> + * The list of Quality Assurance topics. + */ + public getTopics(elementsPerPage, currentPage): Observable> { + const sortOptions = new SortOptions('name', SortDirection.ASC); + + const findListOptions: FindListOptions = { + elementsPerPage: elementsPerPage, + currentPage: currentPage, + sort: sortOptions, + searchParams: [new RequestParam('source', this.sourceId)] + }; + + return this.qualityAssuranceTopicRestService.getTopics(findListOptions).pipe( + getFirstCompletedRemoteData(), + map((rd: RemoteData>) => { + if (rd.hasSucceeded) { + return rd.payload; + } else { + throw new Error('Can\'t retrieve Quality Assurance topics from the Broker topics REST service'); + } + }) + ); + } + + /** + * set sourceId which is used to get topics + * @param sourceId string + */ + setSourceId(sourceId: string) { + this.sourceId = sourceId; + } +} diff --git a/src/app/notifications/selectors.ts b/src/app/notifications/selectors.ts new file mode 100644 index 00000000000..63b2da7a100 --- /dev/null +++ b/src/app/notifications/selectors.ts @@ -0,0 +1,149 @@ +import { createFeatureSelector, createSelector, MemoizedSelector } from '@ngrx/store'; +import { subStateSelector } from '../shared/selector.util'; +import { suggestionNotificationsSelector, SuggestionNotificationsState } from './notifications.reducer'; +import { QualityAssuranceTopicObject } from '../core/notifications/qa/models/quality-assurance-topic.model'; +import { QualityAssuranceTopicState } from './qa/topics/quality-assurance-topics.reducer'; +import { QualityAssuranceSourceState } from './qa/source/quality-assurance-source.reducer'; +import { + QualityAssuranceSourceObject +} from '../core/notifications/qa/models/quality-assurance-source.model'; + +/** + * Returns the Notifications state. + * @function _getNotificationsState + * @param {AppState} state Top level state. + * @return {SuggestionNotificationsState} + */ +const _getNotificationsState = createFeatureSelector('suggestionNotifications'); + +// Quality Assurance topics +// ---------------------------------------------------------------------------- + +/** + * Returns the Quality Assurance topics State. + * @function qualityAssuranceTopicsStateSelector + * @return {QualityAssuranceTopicState} + */ +export function qualityAssuranceTopicsStateSelector(): MemoizedSelector { + return subStateSelector(suggestionNotificationsSelector, 'qaTopic'); +} + +/** + * Returns the Quality Assurance topics list. + * @function qualityAssuranceTopicsObjectSelector + * @return {QualityAssuranceTopicObject[]} + */ +export function qualityAssuranceTopicsObjectSelector(): MemoizedSelector { + return subStateSelector(qualityAssuranceTopicsStateSelector(), 'topics'); +} + +/** + * Returns true if the Quality Assurance topics are loaded. + * @function isQualityAssuranceTopicsLoadedSelector + * @return {boolean} + */ +export const isQualityAssuranceTopicsLoadedSelector = createSelector(_getNotificationsState, + (state: SuggestionNotificationsState) => state.qaTopic.loaded +); + +/** + * Returns true if the deduplication sets are processing. + * @function isDeduplicationSetsProcessingSelector + * @return {boolean} + */ +export const isQualityAssuranceTopicsProcessingSelector = createSelector(_getNotificationsState, + (state: SuggestionNotificationsState) => state.qaTopic.processing +); + +/** + * Returns the total available pages of Quality Assurance topics. + * @function getQualityAssuranceTopicsTotalPagesSelector + * @return {number} + */ +export const getQualityAssuranceTopicsTotalPagesSelector = createSelector(_getNotificationsState, + (state: SuggestionNotificationsState) => state.qaTopic.totalPages +); + +/** + * Returns the current page of Quality Assurance topics. + * @function getQualityAssuranceTopicsCurrentPageSelector + * @return {number} + */ +export const getQualityAssuranceTopicsCurrentPageSelector = createSelector(_getNotificationsState, + (state: SuggestionNotificationsState) => state.qaTopic.currentPage +); + +/** + * Returns the total number of Quality Assurance topics. + * @function getQualityAssuranceTopicsTotalsSelector + * @return {number} + */ +export const getQualityAssuranceTopicsTotalsSelector = createSelector(_getNotificationsState, + (state: SuggestionNotificationsState) => state.qaTopic.totalElements +); + +// Quality Assurance source +// ---------------------------------------------------------------------------- + +/** + * Returns the Quality Assurance source State. + * @function qualityAssuranceSourceStateSelector + * @return {QualityAssuranceSourceState} + */ + export function qualityAssuranceSourceStateSelector(): MemoizedSelector { + return subStateSelector(suggestionNotificationsSelector, 'qaSource'); +} + +/** + * Returns the Quality Assurance source list. + * @function qualityAssuranceSourceObjectSelector + * @return {QualityAssuranceSourceObject[]} + */ +export function qualityAssuranceSourceObjectSelector(): MemoizedSelector { + return subStateSelector(qualityAssuranceSourceStateSelector(), 'source'); +} + +/** + * Returns true if the Quality Assurance source are loaded. + * @function isQualityAssuranceSourceLoadedSelector + * @return {boolean} + */ +export const isQualityAssuranceSourceLoadedSelector = createSelector(_getNotificationsState, + (state: SuggestionNotificationsState) => state.qaSource.loaded +); + +/** + * Returns true if the deduplication sets are processing. + * @function isDeduplicationSetsProcessingSelector + * @return {boolean} + */ +export const isQualityAssuranceSourceProcessingSelector = createSelector(_getNotificationsState, + (state: SuggestionNotificationsState) => state.qaSource.processing +); + +/** + * Returns the total available pages of Quality Assurance source. + * @function getQualityAssuranceSourceTotalPagesSelector + * @return {number} + */ +export const getQualityAssuranceSourceTotalPagesSelector = createSelector(_getNotificationsState, + (state: SuggestionNotificationsState) => state.qaSource.totalPages +); + +/** + * Returns the current page of Quality Assurance source. + * @function getQualityAssuranceSourceCurrentPageSelector + * @return {number} + */ +export const getQualityAssuranceSourceCurrentPageSelector = createSelector(_getNotificationsState, + (state: SuggestionNotificationsState) => state.qaSource.currentPage +); + +/** + * Returns the total number of Quality Assurance source. + * @function getQualityAssuranceSourceTotalsSelector + * @return {number} + */ +export const getQualityAssuranceSourceTotalsSelector = createSelector(_getNotificationsState, + (state: SuggestionNotificationsState) => state.qaSource.totalElements +); diff --git a/src/app/profile-page/profile-page-metadata-form/profile-page-metadata-form.component.ts b/src/app/profile-page/profile-page-metadata-form/profile-page-metadata-form.component.ts index c4e164a7e8b..53f9acdb458 100644 --- a/src/app/profile-page/profile-page-metadata-form/profile-page-metadata-form.component.ts +++ b/src/app/profile-page/profile-page-metadata-form/profile-page-metadata-form.component.ts @@ -43,7 +43,8 @@ export class ProfilePageMetadataFormComponent implements OnInit { new DynamicInputModel({ id: 'email', name: 'email', - readOnly: true + readOnly: true, + disabled: true, }), new DynamicInputModel({ id: 'firstname', @@ -55,6 +56,7 @@ export class ProfilePageMetadataFormComponent implements OnInit { errorMessages: { required: 'This field is required' }, + autoComplete: 'given-name', }), new DynamicInputModel({ id: 'lastname', @@ -66,10 +68,12 @@ export class ProfilePageMetadataFormComponent implements OnInit { errorMessages: { required: 'This field is required' }, + autoComplete: 'family-name', }), new DynamicInputModel({ id: 'phone', - name: 'eperson.phone' + name: 'eperson.phone', + autoComplete: 'tel', }), new DynamicSelectModel({ id: 'language', diff --git a/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.ts b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.ts index 04292ea96ea..940e382c784 100644 --- a/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.ts +++ b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.ts @@ -39,12 +39,14 @@ export class ProfilePageSecurityFormComponent implements OnInit { new DynamicInputModel({ id: 'password', name: 'password', - inputType: 'password' + inputType: 'password', + autoComplete: 'new-password', }), new DynamicInputModel({ id: 'passwordrepeat', name: 'passwordrepeat', - inputType: 'password' + inputType: 'password', + autoComplete: 'new-password', }) ]; @@ -79,7 +81,8 @@ export class ProfilePageSecurityFormComponent implements OnInit { id: 'current-password', name: 'current-password', inputType: 'password', - required: true + required: true, + autoComplete: 'current-password', })); } if (this.passwordCanBeEmpty) { diff --git a/src/app/profile-page/profile-page.component.html b/src/app/profile-page/profile-page.component.html index 187096b3912..331c3600543 100644 --- a/src/app/profile-page/profile-page.component.html +++ b/src/app/profile-page/profile-page.component.html @@ -1,7 +1,7 @@
- -

{{'profile.head' | translate}}

+

{{'profile.title' | translate}}

+
{{'profile.card.researcher' | translate}}
diff --git a/src/app/shared/abstract-component-loader/abstract-component-loader.component.html b/src/app/shared/abstract-component-loader/abstract-component-loader.component.html new file mode 100644 index 00000000000..2035dbadd08 --- /dev/null +++ b/src/app/shared/abstract-component-loader/abstract-component-loader.component.html @@ -0,0 +1 @@ + diff --git a/src/app/shared/abstract-component-loader/abstract-component-loader.component.ts b/src/app/shared/abstract-component-loader/abstract-component-loader.component.ts new file mode 100644 index 00000000000..76b30fa10b9 --- /dev/null +++ b/src/app/shared/abstract-component-loader/abstract-component-loader.component.ts @@ -0,0 +1,143 @@ +import { Component, ComponentRef, OnChanges, OnDestroy, OnInit, SimpleChanges, ViewChild, ViewContainerRef } from '@angular/core'; +import { ThemeService } from '../theme-support/theme.service'; +import { GenericConstructor } from '../../core/shared/generic-constructor'; +import { hasValue, isNotEmpty } from '../empty.util'; +import { Subscription } from 'rxjs'; +import { DynamicComponentLoaderDirective } from './dynamic-component-loader.directive'; + +/** + * To create a new loader component you will need to: + *
    + *
  • Create a new LoaderComponent component extending this component
  • + *
  • Point the templateUrl to this component's templateUrl
  • + *
  • Add all the @Input()/@Output() names that the dynamically generated components should inherit from the loader to the inputNames/outputNames lists
  • + *
  • Create a decorator file containing the new decorator function, a map containing all the collected {@link Component}s and a function to retrieve the {@link Component}
  • + *
  • Call the function to retrieve the correct {@link Component} in getComponent()
  • + *
  • Add all the @Input()s you had to used in getComponent() in the inputNamesDependentForComponent array
  • + *
+ */ +@Component({ + selector: 'ds-abstract-component-loader', + templateUrl: './abstract-component-loader.component.html', +}) +export abstract class AbstractComponentLoaderComponent implements OnInit, OnChanges, OnDestroy { + + /** + * Directive to determine where the dynamic child component is located + */ + @ViewChild(DynamicComponentLoaderDirective, { static: true }) componentDirective: DynamicComponentLoaderDirective; + + /** + * The reference to the dynamic component + */ + protected compRef: ComponentRef; + + /** + * Array to track all subscriptions and unsubscribe them onDestroy + */ + protected subs: Subscription[] = []; + + /** + * The @Input() that are used to find the matching component using {@link getComponent}. When the value of + * one of these @Input() change this loader needs to retrieve the best matching component again using the + * {@link getComponent} method. + */ + protected inputNamesDependentForComponent: (keyof this & string)[] = []; + + /** + * The list of the @Input() names that should be passed down to the dynamically created components. + */ + protected inputNames: (keyof this & string)[] = []; + + /** + * The list of the @Output() names that should be passed down to the dynamically created components. + */ + protected outputNames: (keyof this & string)[] = []; + + constructor( + protected themeService: ThemeService, + ) { + } + + /** + * Set up the dynamic child component + */ + ngOnInit(): void { + this.instantiateComponent(); + } + + /** + * Whenever the inputs change, update the inputs of the dynamic component + */ + ngOnChanges(changes: SimpleChanges): void { + if (hasValue(this.compRef)) { + if (this.inputNamesDependentForComponent.some((name: keyof this & string) => hasValue(changes[name]) && changes[name].previousValue !== changes[name].currentValue)) { + // Recreate the component when the @Input()s used by getComponent() aren't up-to-date anymore + this.destroyComponentInstance(); + this.instantiateComponent(); + } else { + this.connectInputsAndOutputs(); + } + } + } + + ngOnDestroy(): void { + this.subs + .filter((subscription: Subscription) => hasValue(subscription)) + .forEach((subscription: Subscription) => subscription.unsubscribe()); + this.destroyComponentInstance(); + } + + /** + * Creates the component and connects the @Input() & @Output() from the ThemedComponent to its child Component. + */ + public instantiateComponent(): void { + const component: GenericConstructor = this.getComponent(); + + const viewContainerRef: ViewContainerRef = this.componentDirective.viewContainerRef; + viewContainerRef.clear(); + + this.compRef = viewContainerRef.createComponent( + component, { + index: 0, + injector: undefined, + }, + ); + + this.connectInputsAndOutputs(); + } + + /** + * Destroys the themed component and calls it's `ngOnDestroy` + */ + public destroyComponentInstance(): void { + if (hasValue(this.compRef)) { + this.compRef.destroy(); + this.compRef = null; + } + } + + /** + * Fetch the component depending on the item's entity type, metadata representation type and context + */ + public abstract getComponent(): GenericConstructor; + + /** + * Connect the inputs and outputs of this component to the dynamic component, + * to ensure they're in sync, the ngOnChanges method will automatically be called by setInput + */ + public connectInputsAndOutputs(): void { + if (isNotEmpty(this.inputNames) && hasValue(this.compRef) && hasValue(this.compRef.instance)) { + this.inputNames.filter((name: string) => this[name] !== undefined).filter((name: string) => this[name] !== this.compRef.instance[name]).forEach((name: string) => { + // Using setInput will automatically trigger the ngOnChanges + this.compRef.setInput(name, this[name]); + }); + } + if (isNotEmpty(this.outputNames) && hasValue(this.compRef) && hasValue(this.compRef.instance)) { + this.outputNames.filter((name: string) => this[name] !== undefined).filter((name: string) => this[name] !== this.compRef.instance[name]).forEach((name: string) => { + this.compRef.instance[name] = this[name]; + }); + } + } + +} diff --git a/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-actions-loader/advanced-workflow-actions.directive.ts b/src/app/shared/abstract-component-loader/dynamic-component-loader.directive.ts similarity index 63% rename from src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-actions-loader/advanced-workflow-actions.directive.ts rename to src/app/shared/abstract-component-loader/dynamic-component-loader.directive.ts index e569f6cc6f8..8c77df1cdb8 100644 --- a/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-actions-loader/advanced-workflow-actions.directive.ts +++ b/src/app/shared/abstract-component-loader/dynamic-component-loader.directive.ts @@ -1,12 +1,12 @@ import { Directive, ViewContainerRef } from '@angular/core'; -@Directive({ - selector: '[dsAdvancedWorkflowActions]', -}) /** - * Directive used as a hook to know where to inject the dynamic Advanced Claimed Task Actions component + * Directive used as a hook to know where to inject the dynamic loaded component */ -export class AdvancedWorkflowActionsDirective { +@Directive({ + selector: '[dsDynamicComponentLoader]' +}) +export class DynamicComponentLoaderDirective { constructor( public viewContainerRef: ViewContainerRef, diff --git a/src/app/shared/access-control-form-container/access-control-array-form/access-control-array-form.component.html b/src/app/shared/access-control-form-container/access-control-array-form/access-control-array-form.component.html index e6ebabd707b..2ec3b512197 100644 --- a/src/app/shared/access-control-form-container/access-control-array-form/access-control-array-form.component.html +++ b/src/app/shared/access-control-form-container/access-control-array-form/access-control-array-form.component.html @@ -88,7 +88,7 @@
- +
-
-
+
+ {{ 'access-control-mode' | translate }} -
+
-
+
@@ -78,10 +78,10 @@

-
- {{'access-control-limit-to-specific' | translate}} -
-
+
+ + {{'access-control-limit-to-specific' | translate}} +
-
+
-
-
+
+ {{'access-control-mode' | translate}} -
+
-
+

{{'access-control-access-conditions' | translate}}

diff --git a/src/app/shared/alert/alert.component.scss b/src/app/shared/alert/alert.component.scss index 1a70081367a..907edae24bf 100644 --- a/src/app/shared/alert/alert.component.scss +++ b/src/app/shared/alert/alert.component.scss @@ -1,3 +1,9 @@ -.close:focus { - outline: none !important; +.close { + opacity: 0.75; + &:focus { + outline: none !important; + } +} +button.close { + opacity: 0.6; } diff --git a/src/app/shared/auth-nav-menu/auth-nav-menu.component.scss b/src/app/shared/auth-nav-menu/auth-nav-menu.component.scss index ea64f7e74b9..48b27c4be25 100644 --- a/src/app/shared/auth-nav-menu/auth-nav-menu.component.scss +++ b/src/app/shared/auth-nav-menu/auth-nav-menu.component.scss @@ -4,7 +4,7 @@ } .loginDropdownMenu { - min-height: 260px; + min-height: 75px; } .dropdown-item.active, .dropdown-item:active, diff --git a/src/app/shared/collection-dropdown/collection-dropdown.component.html b/src/app/shared/collection-dropdown/collection-dropdown.component.html index 470291373fb..a27aafa31d1 100644 --- a/src/app/shared/collection-dropdown/collection-dropdown.component.html +++ b/src/app/shared/collection-dropdown/collection-dropdown.component.html @@ -1,4 +1,4 @@ -
+
- -
    + diff --git a/src/app/shared/collection-dropdown/collection-dropdown.component.scss b/src/app/shared/collection-dropdown/collection-dropdown.component.scss index 907a111900c..8a28a3ff7e1 100644 --- a/src/app/shared/collection-dropdown/collection-dropdown.component.scss +++ b/src/app/shared/collection-dropdown/collection-dropdown.component.scss @@ -4,7 +4,8 @@ overflow-x: hidden; } -.collection-item { +li:not(:last-of-type), .dropdown-divider { + border-top: none; border-bottom: var(--bs-dropdown-border-width) solid var(--bs-dropdown-border-color); } diff --git a/src/app/shared/comcol/comcol-forms/comcol-form/comcol-form.component.html b/src/app/shared/comcol/comcol-forms/comcol-form/comcol-form.component.html index b7b3d344b1e..c79dba42b78 100644 --- a/src/app/shared/comcol/comcol-forms/comcol-form/comcol-form.component.html +++ b/src/app/shared/comcol/comcol-forms/comcol-form/comcol-form.component.html @@ -1,13 +1,13 @@
    -
    - +
    + {{type.value + '.edit.logo.label' | translate}}
    - +
    @@ -27,6 +27,7 @@
    diff --git a/src/app/shared/comcol/comcol-forms/edit-comcol-page/edit-comcol-page.component.html b/src/app/shared/comcol/comcol-forms/edit-comcol-page/edit-comcol-page.component.html index c397966fba1..a8ca674c263 100644 --- a/src/app/shared/comcol/comcol-forms/edit-comcol-page/edit-comcol-page.component.html +++ b/src/app/shared/comcol/comcol-forms/edit-comcol-page/edit-comcol-page.component.html @@ -5,8 +5,9 @@

    {{ type + '.edit.head' | translate }}

    - {{type + '.edit.delete' | translate}} + [routerLink]="((type === 'community') ? '/communities/' : '/collections/') + (dsoRD$ | async)?.payload.uuid + '/delete'" + data-test="delete-button"> + {{type + '.edit.delete' | translate}}
    @@ -14,7 +15,8 @@

    {{ type + '.edit.head' | translate }}

    @@ -25,7 +27,7 @@

    {{ type + '.edit.head' | translate }}

    diff --git a/src/app/shared/comcol/comcol-page-logo/comcol-page-logo.component.html b/src/app/shared/comcol/comcol-page-logo/comcol-page-logo.component.html index 057c3582239..cb3feecbe59 100644 --- a/src/app/shared/comcol/comcol-page-logo/comcol-page-logo.component.html +++ b/src/app/shared/comcol/comcol-page-logo/comcol-page-logo.component.html @@ -1,3 +1,3 @@ diff --git a/src/app/shared/comcol/comcol-page-logo/comcol-page-logo.component.scss b/src/app/shared/comcol/comcol-page-logo/comcol-page-logo.component.scss index e69de29bb2d..db6e7daecfd 100644 --- a/src/app/shared/comcol/comcol-page-logo/comcol-page-logo.component.scss +++ b/src/app/shared/comcol/comcol-page-logo/comcol-page-logo.component.scss @@ -0,0 +1,4 @@ +img { + max-width: var(--ds-comcol-logo-max-width); + max-height: var(--ds-comcol-logo-max-height); +} diff --git a/src/app/shared/confirmation-modal/confirmation-modal.component.html b/src/app/shared/confirmation-modal/confirmation-modal.component.html index 02434b1fa1e..ff82f5bc83d 100644 --- a/src/app/shared/confirmation-modal/confirmation-modal.component.html +++ b/src/app/shared/confirmation-modal/confirmation-modal.component.html @@ -1,18 +1,18 @@
    - diff --git a/src/app/shared/confirmation-modal/confirmation-modal.component.ts b/src/app/shared/confirmation-modal/confirmation-modal.component.ts index 46eb4cedc5a..e48986d880a 100644 --- a/src/app/shared/confirmation-modal/confirmation-modal.component.ts +++ b/src/app/shared/confirmation-modal/confirmation-modal.component.ts @@ -1,7 +1,5 @@ import { Component, EventEmitter, Input, Output } from '@angular/core'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; -import { DSpaceObject } from '../../core/shared/dspace-object.model'; -import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; @Component({ selector: 'ds-confirmation-modal', @@ -18,7 +16,7 @@ export class ConfirmationModalComponent { */ @Input() brandColor = 'primary'; - @Input() dso: DSpaceObject; + @Input() name: string; /** * An event fired when the cancel or confirm button is clicked, with respectively false or true @@ -28,7 +26,6 @@ export class ConfirmationModalComponent { constructor( protected activeModal: NgbActiveModal, - public dsoNameService: DSONameService, ) { } diff --git a/src/app/shared/context-help.directive.ts b/src/app/shared/context-help.directive.ts index 41d6daec21c..52a822de2c4 100644 --- a/src/app/shared/context-help.directive.ts +++ b/src/app/shared/context-help.directive.ts @@ -1,5 +1,4 @@ import { - ComponentFactoryResolver, ComponentRef, Directive, Input, @@ -12,6 +11,7 @@ import { PlacementArray } from '@ng-bootstrap/ng-bootstrap/util/positioning'; import { ContextHelpWrapperComponent } from './context-help-wrapper/context-help-wrapper.component'; import { PlacementDir } from './context-help-wrapper/placement-dir.model'; import { ContextHelpService } from './context-help.service'; +import { hasValue } from './empty.util'; export interface ContextHelpDirectiveInput { content: string; @@ -43,7 +43,6 @@ export class ContextHelpDirective implements OnChanges, OnDestroy { constructor( private templateRef: TemplateRef, private viewContainerRef: ViewContainerRef, - private componentFactoryResolver: ComponentFactoryResolver, private contextHelpService: ContextHelpService ) {} @@ -53,19 +52,21 @@ export class ContextHelpDirective implements OnChanges, OnDestroy { this.contextHelpService.add({id: this.dsContextHelp.id, isTooltipVisible: false}); if (this.wrapper === undefined) { - const factory - = this.componentFactoryResolver.resolveComponentFactory(ContextHelpWrapperComponent); - this.wrapper = this.viewContainerRef.createComponent(factory); + this.wrapper = this.viewContainerRef.createComponent(ContextHelpWrapperComponent); } - this.wrapper.instance.templateRef = this.templateRef; - this.wrapper.instance.content = this.dsContextHelp.content; - this.wrapper.instance.id = this.dsContextHelp.id; - this.wrapper.instance.tooltipPlacement = this.dsContextHelp.tooltipPlacement; - this.wrapper.instance.iconPlacement = this.dsContextHelp.iconPlacement; + this.wrapper.setInput('templateRef', this.templateRef); + this.wrapper.setInput('content', this.dsContextHelp.content); + this.wrapper.setInput('id', this.dsContextHelp.id); + this.wrapper.setInput('tooltipPlacement', this.dsContextHelp.tooltipPlacement); + this.wrapper.setInput('iconPlacement', this.dsContextHelp.iconPlacement); } ngOnDestroy() { this.clearMostRecentId(); + if (hasValue(this.wrapper)) { + this.wrapper.destroy(); + this.wrapper = undefined; + } } private clearMostRecentId(): void { diff --git a/src/app/shared/ds-select/ds-select.component.html b/src/app/shared/ds-select/ds-select.component.html index 5a15155d4f4..c12ea613472 100644 --- a/src/app/shared/ds-select/ds-select.component.html +++ b/src/app/shared/ds-select/ds-select.component.html @@ -22,7 +22,7 @@ class="dropdown-menu" id="dsSelectDropdownMenu" aria-labelledby="dsSelectMenuButton"> -
    +
    diff --git a/src/app/shared/dso-page/dso-edit-menu/dso-edit-menu-section/dso-edit-menu-section.component.html b/src/app/shared/dso-page/dso-edit-menu/dso-edit-menu-section/dso-edit-menu-section.component.html index 16fda1caa83..bbc388a6014 100644 --- a/src/app/shared/dso-page/dso-edit-menu/dso-edit-menu-section/dso-edit-menu-section.component.html +++ b/src/app/shared/dso-page/dso-edit-menu/dso-edit-menu-section/dso-edit-menu-section.component.html @@ -1,25 +1,21 @@
    - - + + + {{itemModel.text | translate}} -
    - -
    +
    -
    diff --git a/src/app/shared/dso-page/dso-edit-menu/dso-edit-menu-section/dso-edit-menu-section.component.spec.ts b/src/app/shared/dso-page/dso-edit-menu/dso-edit-menu-section/dso-edit-menu-section.component.spec.ts index f0815c54150..bdf121f7995 100644 --- a/src/app/shared/dso-page/dso-edit-menu/dso-edit-menu-section/dso-edit-menu-section.component.spec.ts +++ b/src/app/shared/dso-page/dso-edit-menu/dso-edit-menu-section/dso-edit-menu-section.component.spec.ts @@ -96,7 +96,7 @@ describe('DsoEditMenuSectionComponent', () => { }); describe('when the section model in a disabled link or text', () => { it('should show just the button', () => { - const textButton = fixture.debugElement.query(By.css('div div button')); + const textButton = fixture.debugElement.query(By.css('div a')); expect(textButton.nativeElement.innerHTML).toContain('fa-' + iconString); }); }); @@ -144,7 +144,7 @@ describe('DsoEditMenuSectionComponent', () => { }); - describe('link model', () => { + describe('when the section model in a non disabled link', () => { initAsync(dummySectionLink, menuService); beforeEach(() => { spyOn(menuService, 'getSubSectionsByParentID').and.returnValue(observableOf([])); @@ -154,11 +154,8 @@ describe('DsoEditMenuSectionComponent', () => { fixture.detectChanges(); }); - describe('when the section model in a non disabled link', () => { - it('should show a link element with the button in it', () => { - const link = fixture.debugElement.query(By.css('a')); - expect(link.nativeElement.innerHTML).toContain('button'); - }); + it('should show the link element', () => { + expect(fixture.debugElement.query(By.css('a'))).not.toBeNull(); }); }); diff --git a/src/app/shared/dso-selector/dso-selector/dso-selector.component.html b/src/app/shared/dso-selector/dso-selector/dso-selector.component.html index c4f5dbc4cd6..295165d171f 100644 --- a/src/app/shared/dso-selector/dso-selector/dso-selector.component.html +++ b/src/app/shared/dso-selector/dso-selector/dso-selector.component.html @@ -2,6 +2,7 @@
    diff --git a/src/app/shared/dso-selector/dso-selector/dso-selector.component.ts b/src/app/shared/dso-selector/dso-selector/dso-selector.component.ts index 503e4c44129..4590e05363d 100644 --- a/src/app/shared/dso-selector/dso-selector/dso-selector.component.ts +++ b/src/app/shared/dso-selector/dso-selector/dso-selector.component.ts @@ -75,6 +75,11 @@ export class DSOSelectorComponent implements OnInit, OnDestroy { */ @Input() sort: SortOptions; + /** + * The id that should be given to the input box, this is required for accessibility reasons + */ + @Input() searchBoxId: string | null = null; + // list of allowed selectable dsoTypes typesString: string; diff --git a/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.html b/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.html index a13be638803..8ae146b42c4 100644 --- a/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.html +++ b/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.html @@ -6,14 +6,14 @@
    -
    {{'dso-selector.create.community.sub-level' | translate}}
    + {{'dso-selector.create.community.sub-level' | translate}}
    diff --git a/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.html b/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.html index 5fccd58f48c..5288f08e02b 100644 --- a/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.html +++ b/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.html @@ -5,7 +5,7 @@
    diff --git a/src/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.html b/src/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.html index 85d8797e660..999a96e7301 100644 --- a/src/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.html +++ b/src/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.html @@ -5,7 +5,7 @@
    diff --git a/src/app/shared/dso-selector/modal-wrappers/export-batch-selector/export-batch-selector.component.ts b/src/app/shared/dso-selector/modal-wrappers/export-batch-selector/export-batch-selector.component.ts index 0645e09029d..e5f5eca350b 100644 --- a/src/app/shared/dso-selector/modal-wrappers/export-batch-selector/export-batch-selector.component.ts +++ b/src/app/shared/dso-selector/modal-wrappers/export-batch-selector/export-batch-selector.component.ts @@ -20,6 +20,7 @@ import { RemoteData } from '../../../../core/data/remote-data'; import { getProcessDetailRoute } from '../../../../process-page/process-page-routing.paths'; import { AuthorizationDataService } from '../../../../core/data/feature-authorization/authorization-data.service'; import { FeatureID } from '../../../../core/data/feature-authorization/feature-id'; +import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service'; /** * Component to wrap a list of existing dso's inside a modal @@ -38,6 +39,7 @@ export class ExportBatchSelectorComponent extends DSOSelectorModalWrapperCompone protected notificationsService: NotificationsService, protected translationService: TranslateService, protected scriptDataService: ScriptDataService, protected authorizationDataService: AuthorizationDataService, + protected dsoNameService: DSONameService, private modalService: NgbModal) { super(activeModal, route); } @@ -49,7 +51,7 @@ export class ExportBatchSelectorComponent extends DSOSelectorModalWrapperCompone navigate(dso: DSpaceObject): Observable { if (dso instanceof Collection) { const modalRef = this.modalService.open(ConfirmationModalComponent); - modalRef.componentInstance.dso = dso; + modalRef.componentInstance.name = this.dsoNameService.getName(dso); modalRef.componentInstance.headerLabel = 'confirmation-modal.export-batch.header'; modalRef.componentInstance.infoLabel = 'confirmation-modal.export-batch.info'; modalRef.componentInstance.cancelLabel = 'confirmation-modal.export-batch.cancel'; diff --git a/src/app/shared/dso-selector/modal-wrappers/export-metadata-selector/export-metadata-selector.component.ts b/src/app/shared/dso-selector/modal-wrappers/export-metadata-selector/export-metadata-selector.component.ts index d4b4314a991..d1252bf485b 100644 --- a/src/app/shared/dso-selector/modal-wrappers/export-metadata-selector/export-metadata-selector.component.ts +++ b/src/app/shared/dso-selector/modal-wrappers/export-metadata-selector/export-metadata-selector.component.ts @@ -21,6 +21,7 @@ import { RemoteData } from '../../../../core/data/remote-data'; import { getProcessDetailRoute } from '../../../../process-page/process-page-routing.paths'; import { AuthorizationDataService } from '../../../../core/data/feature-authorization/authorization-data.service'; import { FeatureID } from '../../../../core/data/feature-authorization/feature-id'; +import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service'; /** * Component to wrap a list of existing dso's inside a modal @@ -39,6 +40,7 @@ export class ExportMetadataSelectorComponent extends DSOSelectorModalWrapperComp protected notificationsService: NotificationsService, protected translationService: TranslateService, protected scriptDataService: ScriptDataService, protected authorizationDataService: AuthorizationDataService, + protected dsoNameService: DSONameService, private modalService: NgbModal) { super(activeModal, route); } @@ -50,7 +52,7 @@ export class ExportMetadataSelectorComponent extends DSOSelectorModalWrapperComp navigate(dso: DSpaceObject): Observable { if (dso instanceof Collection || dso instanceof Community) { const modalRef = this.modalService.open(ConfirmationModalComponent); - modalRef.componentInstance.dso = dso; + modalRef.componentInstance.name = this.dsoNameService.getName(dso); modalRef.componentInstance.headerLabel = 'confirmation-modal.export-metadata.header'; modalRef.componentInstance.infoLabel = 'confirmation-modal.export-metadata.info'; modalRef.componentInstance.cancelLabel = 'confirmation-modal.export-metadata.cancel'; diff --git a/src/app/shared/entity-dropdown/entity-dropdown.component.html b/src/app/shared/entity-dropdown/entity-dropdown.component.html index a4d539625f5..38b4ecc53ef 100644 --- a/src/app/shared/entity-dropdown/entity-dropdown.component.html +++ b/src/app/shared/entity-dropdown/entity-dropdown.component.html @@ -1,6 +1,5 @@ - + + diff --git a/src/app/shared/entity-dropdown/entity-dropdown.component.scss b/src/app/shared/entity-dropdown/entity-dropdown.component.scss index a5f43f359bd..4f9ca623107 100644 --- a/src/app/shared/entity-dropdown/entity-dropdown.component.scss +++ b/src/app/shared/entity-dropdown/entity-dropdown.component.scss @@ -1,5 +1,9 @@ -.list-item:active { - color: white !important; +.dropdown-item { + padding: 0.35rem 1rem; + + &:active { + color: white !important; + } } .scrollable-menu { @@ -8,7 +12,7 @@ overflow-x: hidden; } -.entity-item { +li:not(:last-of-type) .dropdown-item { border-bottom: var(--bs-dropdown-border-width) solid var(--bs-dropdown-border-color); } diff --git a/src/app/shared/entity-dropdown/entity-dropdown.component.spec.ts b/src/app/shared/entity-dropdown/entity-dropdown.component.spec.ts index 13e6dbc9c5d..2f3aa763bcc 100644 --- a/src/app/shared/entity-dropdown/entity-dropdown.component.spec.ts +++ b/src/app/shared/entity-dropdown/entity-dropdown.component.spec.ts @@ -123,7 +123,7 @@ describe('EntityDropdownComponent', () => { scheduler.flush(); spyOn(component, 'onSelect'); - const entityItem = fixture.debugElement.query(By.css('.entity-item:nth-child(2)')); + const entityItem = fixture.debugElement.query(By.css('.entity-item:nth-child(2) button')); entityItem.triggerEventHandler('click', null); scheduler.schedule(() => fixture.detectChanges()); 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 9e1f1d48aa1..606db29db37 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 @@ -45,7 +45,9 @@ 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 26803f3c67b..3bf02c4abd5 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 @@ -4,7 +4,7 @@ {{model.placeholder}} *