diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e2680420a2..52f20470a3 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/config/config.example.yml b/config/config.example.yml index 69a9ffd320..4b9c1a27ac 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -75,7 +75,7 @@ cache: anonymousCache: # Maximum number of pages to cache. Default is zero (0) which means anonymous user cache is disabled. # As all pages are cached in server memory, increasing this value will increase memory needs. - # Individual cached pages are usually small (<100KB), so a value of max=1000 would only require ~100MB of memory. + # Individual cached pages are usually small (<100KB), so a value of max=1000 would only require ~100MB of memory. max: 0 # Amount of time after which cached pages are considered stale (in ms). After becoming stale, the cached # copy is automatically refreshed on the next request. @@ -382,7 +382,13 @@ vocabularies: vocabulary: 'srsc' enabled: true -# Default collection/community sorting order at Advanced search, Create/update community and collection when there are not a query. +# Default collection/community sorting order at Advanced search, Create/update community and collection when there are not a query. comcolSelectionSort: sortField: 'dc.title' sortDirection: 'ASC' + +# Example of fallback collection for suggestions import +# suggestion: + # - collectionId: 8f7df5ca-f9c2-47a4-81ec-8a6393d6e5af + # source: "openaire" + diff --git a/cypress.config.ts b/cypress.config.ts index 91eeb9838b..458b035a48 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 0000000000..7612eb5313 --- /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 ea6acdafcd..0cddbc723c 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/browse-by-author.cy.ts b/cypress/e2e/browse-by-author.cy.ts index 07c20ad7c9..32c470231d 100644 --- a/cypress/e2e/browse-by-author.cy.ts +++ b/cypress/e2e/browse-by-author.cy.ts @@ -5,9 +5,9 @@ describe('Browse By Author', () => { cy.visit('/browse/author'); // Wait for to be visible - cy.get('ds-browse-by-metadata-page').should('be.visible'); + cy.get('ds-browse-by-metadata').should('be.visible'); // Analyze for accessibility - testA11y('ds-browse-by-metadata-page'); + testA11y('ds-browse-by-metadata'); }); }); diff --git a/cypress/e2e/browse-by-dateissued.cy.ts b/cypress/e2e/browse-by-dateissued.cy.ts index 4d22420227..7966f1c82e 100644 --- a/cypress/e2e/browse-by-dateissued.cy.ts +++ b/cypress/e2e/browse-by-dateissued.cy.ts @@ -5,9 +5,9 @@ describe('Browse By Date Issued', () => { cy.visit('/browse/dateissued'); // Wait for to be visible - cy.get('ds-browse-by-date-page').should('be.visible'); + cy.get('ds-browse-by-date').should('be.visible'); // Analyze for accessibility - testA11y('ds-browse-by-date-page'); + testA11y('ds-browse-by-date'); }); }); diff --git a/cypress/e2e/browse-by-subject.cy.ts b/cypress/e2e/browse-by-subject.cy.ts index 89b791f03c..57ca88d103 100644 --- a/cypress/e2e/browse-by-subject.cy.ts +++ b/cypress/e2e/browse-by-subject.cy.ts @@ -5,9 +5,9 @@ describe('Browse By Subject', () => { cy.visit('/browse/subject'); // Wait for to be visible - cy.get('ds-browse-by-metadata-page').should('be.visible'); + cy.get('ds-browse-by-metadata').should('be.visible'); // Analyze for accessibility - testA11y('ds-browse-by-metadata-page'); + testA11y('ds-browse-by-metadata'); }); }); diff --git a/cypress/e2e/browse-by-title.cy.ts b/cypress/e2e/browse-by-title.cy.ts index e4e027586a..09195c30df 100644 --- a/cypress/e2e/browse-by-title.cy.ts +++ b/cypress/e2e/browse-by-title.cy.ts @@ -5,9 +5,9 @@ describe('Browse By Title', () => { cy.visit('/browse/title'); // Wait for to be visible - cy.get('ds-browse-by-title-page').should('be.visible'); + cy.get('ds-browse-by-title').should('be.visible'); // Analyze for accessibility - testA11y('ds-browse-by-title-page'); + testA11y('ds-browse-by-title'); }); }); diff --git a/cypress/e2e/collection-edit.cy.ts b/cypress/e2e/collection-edit.cy.ts new file mode 100644 index 0000000000..3e7ecf6141 --- /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 a034b4361d..55c10cc6e2 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 6df4e9a454..43bf67ce51 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 0000000000..8fc1a7733e --- /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 6c628e21ce..386bb592a0 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 710450e797..ca306eff5c 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 2a1ab9785a..ff7dbeb852 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 0000000000..b4c01a1a94 --- /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 9dba6eb8ce..a6a208e9f4 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 9b90cb24af..b856744cba 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 d29c13c2f9..c56b98fd26 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 13f4a1b547..c48656ffcc 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 648db17fe6..9dd93c7a2d 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 755f8eaac6..429f4e6da4 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 ed10b2d13a..4402410f23 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 ead38afb92..cc3dccba38 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 92f0b1aeeb..7da454e2d0 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); + // 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'); - // 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; - } + // Initialize our AuthTokenInfo object from the authorization header. + const authheader = resp.headers.authorization as string; + const authinfo: AuthTokenInfo = new AuthTokenInfo(authheader); - // 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)); + // 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); - - // 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; - } - - // 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); + // 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); + }); }); - - // 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); + +/** + * 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 + cy.setCookie(DSPACE_XSRF_COOKIE, csrfToken, { 'domain': baseDomain }); + }); + + // 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 dd7ee1824c..f6c6865052 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}'); + + // Remove any CSRF cookies saved from prior tests + cy.clearCookie(DSPACE_XSRF_COOKIE); }); -// 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'; - +// 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 40e4974c7c..31bc53f64d 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 223ec356b9..cc266b186f 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 edbb5b0759..07993e20c6 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 ea766600ef..e1577ec837 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 382caf85f4..c164cc5c31 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 4596eec98e..41a6648479 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 d7d5a0b49c..94bca29d31 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 925a8bb859..0cad1b1dd8 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 7f01e1b720..fd7e776472 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 @@ {{eperson.id}} - + {{ dsoNameService.getName(eperson) }} @@ -106,8 +105,7 @@ {{eperson.id}} - + {{ dsoNameService.getName(eperson) }} diff --git a/src/app/access-control/group-registry/group-form/members-list/members-list.component.spec.ts b/src/app/access-control/group-registry/group-form/members-list/members-list.component.spec.ts index 5d97dcade8..99ee9827e8 100644 --- a/src/app/access-control/group-registry/group-form/members-list/members-list.component.spec.ts +++ b/src/app/access-control/group-registry/group-form/members-list/members-list.component.spec.ts @@ -68,9 +68,6 @@ describe('MembersListComponent', () => { clearLinkRequests() { // empty }, - getEPeoplePageRouterLink(): string { - return '/access-control/epeople'; - } }; groupsDataServiceStub = { activeGroup: activeGroup, diff --git a/src/app/access-control/group-registry/group-form/members-list/members-list.component.ts b/src/app/access-control/group-registry/group-form/members-list/members-list.component.ts index feb90b52b3..6129d4d02d 100644 --- a/src/app/access-control/group-registry/group-form/members-list/members-list.component.ts +++ b/src/app/access-control/group-registry/group-form/members-list/members-list.component.ts @@ -23,6 +23,7 @@ import { NotificationsService } from '../../../../shared/notifications/notificat import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model'; import { PaginationService } from '../../../../core/pagination/pagination.service'; import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service'; +import { getEPersonEditRoute } from '../../../access-control-routing-paths'; /** * Keys to keep track of specific subscriptions @@ -131,6 +132,8 @@ export class MembersListComponent implements OnInit, OnDestroy { // current active group being edited groupBeingEdited: Group; + readonly getEPersonEditRoute = getEPersonEditRoute; + constructor( protected groupDataService: GroupDataService, public ePersonDataService: EPersonDataService, diff --git a/src/app/admin/admin-import-batch-page/batch-import-page.component.html b/src/app/admin/admin-import-batch-page/batch-import-page.component.html index 1092443436..c51ee7597c 100644 --- a/src/app/admin/admin-import-batch-page/batch-import-page.component.html +++ b/src/app/admin/admin-import-batch-page/batch-import-page.component.html @@ -1,5 +1,5 @@
- +

{{'admin.batch-import.page.header' | translate}}

{{'admin.batch-import.page.help' | translate}}

selected collection: {{getDspaceObjectName()}}  @@ -28,7 +28,7 @@ {{'admin.batch-import.page.toggle.help' | translate}} - + { + + /** + * Method for resolving the parameters in the current route. + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + * @param {RouterStateSnapshot} state The current RouterStateSnapshot + * @returns AdminNotificationsSuggestionTargetsPageParams Emits the route parameters + */ + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): AdminNotificationsPublicationClaimPageParams { + return { + pageId: route.queryParams.pageId, + pageSize: parseInt(route.queryParams.pageSize, 10), + currentPage: parseInt(route.queryParams.page, 10) + }; + } +} diff --git a/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.html b/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.html new file mode 100644 index 0000000000..b04e7132f1 --- /dev/null +++ b/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.html @@ -0,0 +1 @@ + diff --git a/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.scss b/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.scss similarity index 100% rename from src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.scss rename to src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.scss diff --git a/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.spec.ts b/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.spec.ts new file mode 100644 index 0000000000..c5406023ce --- /dev/null +++ b/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.spec.ts @@ -0,0 +1,38 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AdminNotificationsPublicationClaimPageComponent } from './admin-notifications-publication-claim-page.component'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { TranslateModule } from '@ngx-translate/core'; + +describe('AdminNotificationsPublicationClaimPageComponent', () => { + let component: AdminNotificationsPublicationClaimPageComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + CommonModule, + TranslateModule.forRoot() + ], + declarations: [ + AdminNotificationsPublicationClaimPageComponent + ], + providers: [ + AdminNotificationsPublicationClaimPageComponent + ], + schemas: [NO_ERRORS_SCHEMA] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AdminNotificationsPublicationClaimPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.ts b/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.ts new file mode 100644 index 0000000000..2256a1bc36 --- /dev/null +++ b/src/app/admin/admin-notifications/admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component.ts @@ -0,0 +1,10 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'ds-admin-notifications-publication-claim-page', + templateUrl: './admin-notifications-publication-claim-page.component.html', + styleUrls: ['./admin-notifications-publication-claim-page.component.scss'] +}) +export class AdminNotificationsPublicationClaimPageComponent { + +} diff --git a/src/app/admin/admin-notifications/admin-notifications-routing-paths.ts b/src/app/admin/admin-notifications/admin-notifications-routing-paths.ts new file mode 100644 index 0000000000..f92a96d242 --- /dev/null +++ b/src/app/admin/admin-notifications/admin-notifications-routing-paths.ts @@ -0,0 +1,9 @@ +import { URLCombiner } from '../../core/url-combiner/url-combiner'; +import { getNotificationsModuleRoute } from '../admin-routing-paths'; + +export const QUALITY_ASSURANCE_EDIT_PATH = 'quality-assurance'; +export const PUBLICATION_CLAIMS_PATH = 'publication-claim'; + +export function getQualityAssuranceRoute(id: string) { + return new URLCombiner(getNotificationsModuleRoute(), QUALITY_ASSURANCE_EDIT_PATH, id).toString(); +} diff --git a/src/app/admin/admin-notifications/admin-notifications-routing.module.ts b/src/app/admin/admin-notifications/admin-notifications-routing.module.ts new file mode 100644 index 0000000000..07a98aa080 --- /dev/null +++ b/src/app/admin/admin-notifications/admin-notifications-routing.module.ts @@ -0,0 +1,107 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; + +import { AuthenticatedGuard } from '../../core/auth/authenticated.guard'; +import { I18nBreadcrumbResolver } from '../../core/breadcrumbs/i18n-breadcrumb.resolver'; +import { I18nBreadcrumbsService } from '../../core/breadcrumbs/i18n-breadcrumbs.service'; +import { PUBLICATION_CLAIMS_PATH } from './admin-notifications-routing-paths'; +import { AdminNotificationsPublicationClaimPageComponent } from './admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component'; +import { AdminNotificationsPublicationClaimPageResolver } from './admin-notifications-publication-claim-page/admin-notifications-publication-claim-page-resolver.service'; +import { QUALITY_ASSURANCE_EDIT_PATH } from './admin-notifications-routing-paths'; +import { AdminQualityAssuranceTopicsPageComponent } from './admin-quality-assurance-topics-page/admin-quality-assurance-topics-page.component'; +import { AdminQualityAssuranceEventsPageComponent } from './admin-quality-assurance-events-page/admin-quality-assurance-events-page.component'; +import { AdminQualityAssuranceTopicsPageResolver } from './admin-quality-assurance-topics-page/admin-quality-assurance-topics-page-resolver.service'; +import { AdminQualityAssuranceEventsPageResolver } from './admin-quality-assurance-events-page/admin-quality-assurance-events-page.resolver'; +import { AdminQualityAssuranceSourcePageComponent } from './admin-quality-assurance-source-page-component/admin-quality-assurance-source-page.component'; +import { AdminQualityAssuranceSourcePageResolver } from './admin-quality-assurance-source-page-component/admin-quality-assurance-source-page-resolver.service'; +import { QualityAssuranceBreadcrumbResolver } from '../../core/breadcrumbs/quality-assurance-breadcrumb.resolver'; +import { QualityAssuranceBreadcrumbService } from '../../core/breadcrumbs/quality-assurance-breadcrumb.service'; +import { + SourceDataResolver +} from './admin-quality-assurance-source-page-component/admin-quality-assurance-source-data.resolver'; + +@NgModule({ + imports: [ + RouterModule.forChild([ + { + canActivate: [ AuthenticatedGuard ], + path: `${PUBLICATION_CLAIMS_PATH}`, + component: AdminNotificationsPublicationClaimPageComponent, + pathMatch: 'full', + resolve: { + breadcrumb: I18nBreadcrumbResolver, + suggestionTargetParams: AdminNotificationsPublicationClaimPageResolver + }, + data: { + title: 'admin.notifications.publicationclaim.page.title', + breadcrumbKey: 'admin.notifications.publicationclaim', + showBreadcrumbsFluid: false + } + }, + { + canActivate: [ AuthenticatedGuard ], + path: `${QUALITY_ASSURANCE_EDIT_PATH}/:sourceId`, + component: AdminQualityAssuranceTopicsPageComponent, + pathMatch: 'full', + resolve: { + breadcrumb: QualityAssuranceBreadcrumbResolver, + openaireQualityAssuranceTopicsParams: AdminQualityAssuranceTopicsPageResolver + }, + data: { + title: 'admin.quality-assurance.page.title', + breadcrumbKey: 'admin.quality-assurance', + showBreadcrumbsFluid: false + } + }, + { + canActivate: [ AuthenticatedGuard ], + path: `${QUALITY_ASSURANCE_EDIT_PATH}`, + component: AdminQualityAssuranceSourcePageComponent, + pathMatch: 'full', + resolve: { + breadcrumb: I18nBreadcrumbResolver, + openaireQualityAssuranceSourceParams: AdminQualityAssuranceSourcePageResolver, + sourceData: SourceDataResolver + }, + data: { + title: 'admin.notifications.source.breadcrumbs', + breadcrumbKey: 'admin.notifications.source', + showBreadcrumbsFluid: false + } + }, + { + canActivate: [ AuthenticatedGuard ], + path: `${QUALITY_ASSURANCE_EDIT_PATH}/:sourceId/:topicId`, + component: AdminQualityAssuranceEventsPageComponent, + pathMatch: 'full', + resolve: { + breadcrumb: QualityAssuranceBreadcrumbResolver, + openaireQualityAssuranceEventsParams: AdminQualityAssuranceEventsPageResolver + }, + data: { + title: 'admin.notifications.event.page.title', + breadcrumbKey: 'admin.notifications.event', + showBreadcrumbsFluid: false + } + } + ]) + ], + providers: [ + I18nBreadcrumbResolver, + I18nBreadcrumbsService, + AdminNotificationsPublicationClaimPageResolver, + SourceDataResolver, + AdminQualityAssuranceSourcePageResolver, + AdminQualityAssuranceTopicsPageResolver, + AdminQualityAssuranceEventsPageResolver, + AdminQualityAssuranceSourcePageResolver, + QualityAssuranceBreadcrumbResolver, + QualityAssuranceBreadcrumbService + ] +}) +/** + * Routing module for the Notifications section of the admin sidebar + */ +export class AdminNotificationsRoutingModule { + +} diff --git a/src/app/admin/admin-notifications/admin-notifications.module.ts b/src/app/admin/admin-notifications/admin-notifications.module.ts new file mode 100644 index 0000000000..d9efb4c288 --- /dev/null +++ b/src/app/admin/admin-notifications/admin-notifications.module.ts @@ -0,0 +1,33 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { CoreModule } from '../../core/core.module'; +import { SharedModule } from '../../shared/shared.module'; +import { AdminNotificationsRoutingModule } from './admin-notifications-routing.module'; +import { AdminNotificationsPublicationClaimPageComponent } from './admin-notifications-publication-claim-page/admin-notifications-publication-claim-page.component'; +import { AdminQualityAssuranceTopicsPageComponent } from './admin-quality-assurance-topics-page/admin-quality-assurance-topics-page.component'; +import { AdminQualityAssuranceEventsPageComponent } from './admin-quality-assurance-events-page/admin-quality-assurance-events-page.component'; +import { AdminQualityAssuranceSourcePageComponent } from './admin-quality-assurance-source-page-component/admin-quality-assurance-source-page.component'; +import { NotificationsModule } from '../../notifications/notifications.module'; + +@NgModule({ + imports: [ + CommonModule, + SharedModule, + CoreModule.forRoot(), + AdminNotificationsRoutingModule, + NotificationsModule + ], + declarations: [ + AdminNotificationsPublicationClaimPageComponent, + AdminQualityAssuranceTopicsPageComponent, + AdminQualityAssuranceEventsPageComponent, + AdminQualityAssuranceSourcePageComponent + ], + entryComponents: [] +}) +/** + * This module handles all components related to the notifications pages + */ +export class AdminNotificationsModule { + +} diff --git a/src/app/admin/admin-notifications/admin-quality-assurance-events-page/admin-quality-assurance-events-page.component.html b/src/app/admin/admin-notifications/admin-quality-assurance-events-page/admin-quality-assurance-events-page.component.html new file mode 100644 index 0000000000..315209d342 --- /dev/null +++ b/src/app/admin/admin-notifications/admin-quality-assurance-events-page/admin-quality-assurance-events-page.component.html @@ -0,0 +1 @@ + diff --git a/src/app/admin/admin-notifications/admin-quality-assurance-events-page/admin-quality-assurance-events-page.component.spec.ts b/src/app/admin/admin-notifications/admin-quality-assurance-events-page/admin-quality-assurance-events-page.component.spec.ts new file mode 100644 index 0000000000..b952078215 --- /dev/null +++ b/src/app/admin/admin-notifications/admin-quality-assurance-events-page/admin-quality-assurance-events-page.component.spec.ts @@ -0,0 +1,26 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { AdminQualityAssuranceEventsPageComponent } from './admin-quality-assurance-events-page.component'; + +describe('AdminQualityAssuranceEventsPageComponent', () => { + let component: AdminQualityAssuranceEventsPageComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ AdminQualityAssuranceEventsPageComponent ], + schemas: [NO_ERRORS_SCHEMA] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AdminQualityAssuranceEventsPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create AdminQualityAssuranceEventsPageComponent', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/admin/admin-notifications/admin-quality-assurance-events-page/admin-quality-assurance-events-page.component.ts b/src/app/admin/admin-notifications/admin-quality-assurance-events-page/admin-quality-assurance-events-page.component.ts new file mode 100644 index 0000000000..bd3470f301 --- /dev/null +++ b/src/app/admin/admin-notifications/admin-quality-assurance-events-page/admin-quality-assurance-events-page.component.ts @@ -0,0 +1,12 @@ +import { Component } from '@angular/core'; + +/** + * Component for the page that show the QA events related to a specific topic. + */ +@Component({ + selector: 'ds-quality-assurance-events-page', + templateUrl: './admin-quality-assurance-events-page.component.html' +}) +export class AdminQualityAssuranceEventsPageComponent { + +} diff --git a/src/app/admin/admin-notifications/admin-quality-assurance-events-page/admin-quality-assurance-events-page.resolver.ts b/src/app/admin/admin-notifications/admin-quality-assurance-events-page/admin-quality-assurance-events-page.resolver.ts new file mode 100644 index 0000000000..3139355629 --- /dev/null +++ b/src/app/admin/admin-notifications/admin-quality-assurance-events-page/admin-quality-assurance-events-page.resolver.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@angular/core'; +import { Resolve, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; + +/** + * Interface for the route parameters. + */ +export interface AdminQualityAssuranceEventsPageParams { + pageId?: string; + pageSize?: number; + currentPage?: number; +} + +/** + * This class represents a resolver that retrieve the route data before the route is activated. + */ +@Injectable() +export class AdminQualityAssuranceEventsPageResolver implements Resolve { + + /** + * Method for resolving the parameters in the current route. + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + * @param {RouterStateSnapshot} state The current RouterStateSnapshot + * @returns AdminQualityAssuranceEventsPageParams Emits the route parameters + */ + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): AdminQualityAssuranceEventsPageParams { + return { + pageId: route.queryParams.pageId, + pageSize: parseInt(route.queryParams.pageSize, 10), + currentPage: parseInt(route.queryParams.page, 10) + }; + } +} diff --git a/src/app/admin/admin-notifications/admin-quality-assurance-source-page-component/admin-quality-assurance-source-data.resolver.ts b/src/app/admin/admin-notifications/admin-quality-assurance-source-page-component/admin-quality-assurance-source-data.resolver.ts new file mode 100644 index 0000000000..a6bfd6e7fe --- /dev/null +++ b/src/app/admin/admin-notifications/admin-quality-assurance-source-page-component/admin-quality-assurance-source-data.resolver.ts @@ -0,0 +1,47 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot, Router } from '@angular/router'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { PaginatedList } from '../../../core/data/paginated-list.model'; +import { QualityAssuranceSourceObject } from '../../../core/notifications/qa/models/quality-assurance-source.model'; +import { QualityAssuranceSourceService } from '../../../notifications/qa/source/quality-assurance-source.service'; +import {environment} from '../../../../environments/environment'; +/** + * This class represents a resolver that retrieve the route data before the route is activated. + */ +@Injectable() +export class SourceDataResolver implements Resolve> { + private pageSize = environment.qualityAssuranceConfig.pageSize; + /** + * Initialize the effect class variables. + * @param {QualityAssuranceSourceService} qualityAssuranceSourceService + */ + constructor( + private qualityAssuranceSourceService: QualityAssuranceSourceService, + private router: Router + ) { } + /** + * Method for resolving the parameters in the current route. + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + * @param {RouterStateSnapshot} state The current RouterStateSnapshot + * @returns Observable + */ + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + return this.qualityAssuranceSourceService.getSources(this.pageSize, 0).pipe( + map((sources: PaginatedList) => { + if (sources.page.length === 1) { + this.router.navigate([this.getResolvedUrl(route) + '/' + sources.page[0].id]); + } + return sources.page; + })); + } + + /** + * + * @param route url path + * @returns url path + */ + getResolvedUrl(route: ActivatedRouteSnapshot): string { + return route.pathFromRoot.map(v => v.url.map(segment => segment.toString()).join('/')).join('/'); + } +} diff --git a/src/app/admin/admin-notifications/admin-quality-assurance-source-page-component/admin-quality-assurance-source-page-resolver.service.ts b/src/app/admin/admin-notifications/admin-quality-assurance-source-page-component/admin-quality-assurance-source-page-resolver.service.ts new file mode 100644 index 0000000000..ac9bdb48d6 --- /dev/null +++ b/src/app/admin/admin-notifications/admin-quality-assurance-source-page-component/admin-quality-assurance-source-page-resolver.service.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; + +/** + * Interface for the route parameters. + */ +export interface AdminQualityAssuranceSourcePageParams { + pageId?: string; + pageSize?: number; + currentPage?: number; +} + +/** + * This class represents a resolver that retrieve the route data before the route is activated. + */ +@Injectable() +export class AdminQualityAssuranceSourcePageResolver implements Resolve { + + /** + * Method for resolving the parameters in the current route. + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + * @param {RouterStateSnapshot} state The current RouterStateSnapshot + * @returns AdminQualityAssuranceSourcePageParams Emits the route parameters + */ + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): AdminQualityAssuranceSourcePageParams { + return { + pageId: route.queryParams.pageId, + pageSize: parseInt(route.queryParams.pageSize, 10), + currentPage: parseInt(route.queryParams.page, 10) + }; + } +} diff --git a/src/app/admin/admin-notifications/admin-quality-assurance-source-page-component/admin-quality-assurance-source-page.component.html b/src/app/admin/admin-notifications/admin-quality-assurance-source-page-component/admin-quality-assurance-source-page.component.html new file mode 100644 index 0000000000..709103cf3d --- /dev/null +++ b/src/app/admin/admin-notifications/admin-quality-assurance-source-page-component/admin-quality-assurance-source-page.component.html @@ -0,0 +1 @@ + diff --git a/src/app/admin/admin-notifications/admin-quality-assurance-source-page-component/admin-quality-assurance-source-page.component.spec.ts b/src/app/admin/admin-notifications/admin-quality-assurance-source-page-component/admin-quality-assurance-source-page.component.spec.ts new file mode 100644 index 0000000000..451c911c4c --- /dev/null +++ b/src/app/admin/admin-notifications/admin-quality-assurance-source-page-component/admin-quality-assurance-source-page.component.spec.ts @@ -0,0 +1,27 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AdminQualityAssuranceSourcePageComponent } from './admin-quality-assurance-source-page.component'; + +describe('AdminQualityAssuranceSourcePageComponent', () => { + let component: AdminQualityAssuranceSourcePageComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ AdminQualityAssuranceSourcePageComponent ], + schemas: [NO_ERRORS_SCHEMA] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(AdminQualityAssuranceSourcePageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create AdminQualityAssuranceSourcePageComponent', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/admin/admin-notifications/admin-quality-assurance-source-page-component/admin-quality-assurance-source-page.component.ts b/src/app/admin/admin-notifications/admin-quality-assurance-source-page-component/admin-quality-assurance-source-page.component.ts new file mode 100644 index 0000000000..447e5a2e55 --- /dev/null +++ b/src/app/admin/admin-notifications/admin-quality-assurance-source-page-component/admin-quality-assurance-source-page.component.ts @@ -0,0 +1,10 @@ +import { Component } from '@angular/core'; + +/** + * Component for the page that show the QA sources. + */ +@Component({ + selector: 'ds-admin-quality-assurance-source-page-component', + templateUrl: './admin-quality-assurance-source-page.component.html', +}) +export class AdminQualityAssuranceSourcePageComponent {} diff --git a/src/app/admin/admin-notifications/admin-quality-assurance-topics-page/admin-quality-assurance-topics-page-resolver.service.ts b/src/app/admin/admin-notifications/admin-quality-assurance-topics-page/admin-quality-assurance-topics-page-resolver.service.ts new file mode 100644 index 0000000000..47500d1878 --- /dev/null +++ b/src/app/admin/admin-notifications/admin-quality-assurance-topics-page/admin-quality-assurance-topics-page-resolver.service.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; + +/** + * Interface for the route parameters. + */ +export interface AdminQualityAssuranceTopicsPageParams { + pageId?: string; + pageSize?: number; + currentPage?: number; +} + +/** + * This class represents a resolver that retrieve the route data before the route is activated. + */ +@Injectable() +export class AdminQualityAssuranceTopicsPageResolver implements Resolve { + + /** + * Method for resolving the parameters in the current route. + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + * @param {RouterStateSnapshot} state The current RouterStateSnapshot + * @returns AdminQualityAssuranceTopicsPageParams Emits the route parameters + */ + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): AdminQualityAssuranceTopicsPageParams { + return { + pageId: route.queryParams.pageId, + pageSize: parseInt(route.queryParams.pageSize, 10), + currentPage: parseInt(route.queryParams.page, 10) + }; + } +} diff --git a/src/app/admin/admin-notifications/admin-quality-assurance-topics-page/admin-quality-assurance-topics-page.component.html b/src/app/admin/admin-notifications/admin-quality-assurance-topics-page/admin-quality-assurance-topics-page.component.html new file mode 100644 index 0000000000..fc905ad724 --- /dev/null +++ b/src/app/admin/admin-notifications/admin-quality-assurance-topics-page/admin-quality-assurance-topics-page.component.html @@ -0,0 +1 @@ + diff --git a/src/app/admin/admin-notifications/admin-quality-assurance-topics-page/admin-quality-assurance-topics-page.component.spec.ts b/src/app/admin/admin-notifications/admin-quality-assurance-topics-page/admin-quality-assurance-topics-page.component.spec.ts new file mode 100644 index 0000000000..a32f60f017 --- /dev/null +++ b/src/app/admin/admin-notifications/admin-quality-assurance-topics-page/admin-quality-assurance-topics-page.component.spec.ts @@ -0,0 +1,26 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { AdminQualityAssuranceTopicsPageComponent } from './admin-quality-assurance-topics-page.component'; + +describe('AdminQualityAssuranceTopicsPageComponent', () => { + let component: AdminQualityAssuranceTopicsPageComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ AdminQualityAssuranceTopicsPageComponent ], + schemas: [NO_ERRORS_SCHEMA] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AdminQualityAssuranceTopicsPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create AdminQualityAssuranceTopicsPageComponent', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/admin/admin-notifications/admin-quality-assurance-topics-page/admin-quality-assurance-topics-page.component.ts b/src/app/admin/admin-notifications/admin-quality-assurance-topics-page/admin-quality-assurance-topics-page.component.ts new file mode 100644 index 0000000000..f17d3448d5 --- /dev/null +++ b/src/app/admin/admin-notifications/admin-quality-assurance-topics-page/admin-quality-assurance-topics-page.component.ts @@ -0,0 +1,12 @@ +import { Component } from '@angular/core'; + +/** + * Component for the page that show the QA topics related to a specific source. + */ +@Component({ + selector: 'ds-notification-qa-page', + templateUrl: './admin-quality-assurance-topics-page.component.html' +}) +export class AdminQualityAssuranceTopicsPageComponent { + +} diff --git a/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.html b/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.html index 37323d41d0..894ef3dc7e 100644 --- a/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.html +++ b/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.html @@ -19,7 +19,7 @@ - + @@ -35,6 +35,7 @@ [checked]="isSelected(bitstreamFormat) | async" (change)="selectBitStreamFormat(bitstreamFormat, $event)" > + {{'admin.registries.bitstream-formats.select' | translate}}} diff --git a/src/app/admin/admin-registries/metadata-registry/metadata-registry.component.html b/src/app/admin/admin-registries/metadata-registry/metadata-registry.component.html index 35bffad185..d3be5df08e 100644 --- a/src/app/admin/admin-registries/metadata-registry/metadata-registry.component.html +++ b/src/app/admin/admin-registries/metadata-registry/metadata-registry.component.html @@ -2,7 +2,7 @@
{{'admin.registries.bitstream-formats.table.selected' | translate}} {{'admin.registries.bitstream-formats.table.id' | translate}} {{'admin.registries.bitstream-formats.table.name' | translate}} {{'admin.registries.bitstream-formats.table.mimetype' | translate}} {{bitstreamFormat.id}}
- + @@ -34,6 +34,7 @@ [checked]="isSelected(schema) | async" (change)="selectMetadataSchema(schema, $event)" > + {{((isSelected(schema) | async) ? 'admin.registries.metadata.schemas.deselect' : 'admin.registries.metadata.schemas.select') | translate}} diff --git a/src/app/admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.html b/src/app/admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.html index a4a4613565..85d1e90692 100644 --- a/src/app/admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.html +++ b/src/app/admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.html @@ -1,11 +1,11 @@
-

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

+

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

-

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

+

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

-

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

+

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

-

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

+

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

- + @@ -33,12 +33,11 @@ - diff --git a/src/app/admin/admin-routing-paths.ts b/src/app/admin/admin-routing-paths.ts index 3168ea93c9..30f801cecb 100644 --- a/src/app/admin/admin-routing-paths.ts +++ b/src/app/admin/admin-routing-paths.ts @@ -2,7 +2,12 @@ import { URLCombiner } from '../core/url-combiner/url-combiner'; import { getAdminModuleRoute } from '../app-routing-paths'; export const REGISTRIES_MODULE_PATH = 'registries'; +export const NOTIFICATIONS_MODULE_PATH = 'notifications'; export function getRegistriesModuleRoute() { return new URLCombiner(getAdminModuleRoute(), REGISTRIES_MODULE_PATH).toString(); } + +export function getNotificationsModuleRoute() { + return new URLCombiner(getAdminModuleRoute(), NOTIFICATIONS_MODULE_PATH).toString(); +} diff --git a/src/app/admin/admin-routing.module.ts b/src/app/admin/admin-routing.module.ts index 8e4f13b164..a7d19a6935 100644 --- a/src/app/admin/admin-routing.module.ts +++ b/src/app/admin/admin-routing.module.ts @@ -6,12 +6,17 @@ import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.reso import { AdminWorkflowPageComponent } from './admin-workflow-page/admin-workflow-page.component'; import { I18nBreadcrumbsService } from '../core/breadcrumbs/i18n-breadcrumbs.service'; import { AdminCurationTasksComponent } from './admin-curation-tasks/admin-curation-tasks.component'; -import { REGISTRIES_MODULE_PATH } from './admin-routing-paths'; +import { REGISTRIES_MODULE_PATH, NOTIFICATIONS_MODULE_PATH } from './admin-routing-paths'; import { BatchImportPageComponent } from './admin-import-batch-page/batch-import-page.component'; @NgModule({ imports: [ RouterModule.forChild([ + { + path: NOTIFICATIONS_MODULE_PATH, + loadChildren: () => import('./admin-notifications/admin-notifications.module') + .then((m) => m.AdminNotificationsModule), + }, { path: REGISTRIES_MODULE_PATH, loadChildren: () => import('./admin-registries/admin-registries.module') diff --git a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.html b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.html index c4b737849b..639c47f7f8 100644 --- a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.html +++ b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.html @@ -1,4 +1,4 @@ - +
diff --git a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.ts b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.ts index dab6694f36..0e41a20d84 100644 --- a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.ts +++ b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.ts @@ -1,4 +1,4 @@ -import { Component, ComponentFactoryResolver, ElementRef, OnInit, ViewChild } from '@angular/core'; +import { Component, ComponentRef, ElementRef, OnDestroy, OnInit, ViewChild } from '@angular/core'; import { Item } from '../../../../../core/shared/item.model'; import { ViewMode } from '../../../../../core/shared/view-mode.model'; import { @@ -11,9 +11,10 @@ import { SearchResultGridElementComponent } from '../../../../../shared/object-g import { TruncatableService } from '../../../../../shared/truncatable/truncatable.service'; import { BitstreamDataService } from '../../../../../core/data/bitstream-data.service'; import { GenericConstructor } from '../../../../../core/shared/generic-constructor'; -import { ListableObjectDirective } from '../../../../../shared/object-collection/shared/listable-object/listable-object.directive'; +import { DynamicComponentLoaderDirective } from '../../../../../shared/abstract-component-loader/dynamic-component-loader.directive'; import { ThemeService } from '../../../../../shared/theme-support/theme.service'; import { DSONameService } from '../../../../../core/breadcrumbs/dso-name.service'; +import { hasValue } from '../../../../../shared/empty.util'; @listableObjectComponent(ItemSearchResult, ViewMode.GridElement, Context.AdminSearch) @Component({ @@ -24,17 +25,18 @@ import { DSONameService } from '../../../../../core/breadcrumbs/dso-name.service /** * The component for displaying a list element for an item search result on the admin search page */ -export class ItemAdminSearchResultGridElementComponent extends SearchResultGridElementComponent implements OnInit { - @ViewChild(ListableObjectDirective, { static: true }) listableObjectDirective: ListableObjectDirective; +export class ItemAdminSearchResultGridElementComponent extends SearchResultGridElementComponent implements OnDestroy, OnInit { + @ViewChild(DynamicComponentLoaderDirective, { static: true }) dynamicComponentLoaderDirective: DynamicComponentLoaderDirective; @ViewChild('badges', { static: true }) badges: ElementRef; @ViewChild('buttons', { static: true }) buttons: ElementRef; + protected compRef: ComponentRef; + constructor( public dsoNameService: DSONameService, protected truncatableService: TruncatableService, protected bitstreamDataService: BitstreamDataService, private themeService: ThemeService, - private componentFactoryResolver: ComponentFactoryResolver, ) { super(dsoNameService, truncatableService, bitstreamDataService); } @@ -44,23 +46,32 @@ export class ItemAdminSearchResultGridElementComponent extends SearchResultGridE */ ngOnInit(): void { super.ngOnInit(); - const componentFactory = this.componentFactoryResolver.resolveComponentFactory(this.getComponent()); + const component: GenericConstructor = this.getComponent(); - const viewContainerRef = this.listableObjectDirective.viewContainerRef; + const viewContainerRef = this.dynamicComponentLoaderDirective.viewContainerRef; viewContainerRef.clear(); - const componentRef = viewContainerRef.createComponent( - componentFactory, - 0, - undefined, - [ - [this.badges.nativeElement], - [this.buttons.nativeElement] - ]); - (componentRef.instance as any).object = this.object; - (componentRef.instance as any).index = this.index; - (componentRef.instance as any).linkType = this.linkType; - (componentRef.instance as any).listID = this.listID; + this.compRef = viewContainerRef.createComponent( + component, { + index: 0, + injector: undefined, + projectableNodes: [ + [this.badges.nativeElement], + [this.buttons.nativeElement], + ], + }, + ); + this.compRef.setInput('object',this.object); + this.compRef.setInput('index', this.index); + this.compRef.setInput('linkType', this.linkType); + this.compRef.setInput('listID', this.listID); + } + + ngOnDestroy(): void { + if (hasValue(this.compRef)) { + this.compRef.destroy(); + this.compRef = undefined; + } } /** diff --git a/src/app/admin/admin-workflow-page/admin-workflow-search-results/actions/workspace-item/workspace-item-admin-workflow-actions.component.ts b/src/app/admin/admin-workflow-page/admin-workflow-search-results/actions/workspace-item/workspace-item-admin-workflow-actions.component.ts index 36678460da..8db862dd5a 100644 --- a/src/app/admin/admin-workflow-page/admin-workflow-search-results/actions/workspace-item/workspace-item-admin-workflow-actions.component.ts +++ b/src/app/admin/admin-workflow-page/admin-workflow-search-results/actions/workspace-item/workspace-item-admin-workflow-actions.component.ts @@ -124,7 +124,7 @@ export class WorkspaceItemAdminWorkflowActionsComponent implements OnInit { */ deleteSupervisionOrder(supervisionOrderEntry: SupervisionOrderListEntry) { const modalRef = this.modalService.open(ConfirmationModalComponent); - modalRef.componentInstance.dso = supervisionOrderEntry.group; + modalRef.componentInstance.name = this.dsoNameService.getName(supervisionOrderEntry.group); modalRef.componentInstance.headerLabel = this.messagePrefix + '.delete-supervision.modal.header'; modalRef.componentInstance.infoLabel = this.messagePrefix + '.delete-supervision.modal.info'; modalRef.componentInstance.cancelLabel = this.messagePrefix + '.delete-supervision.modal.cancel'; diff --git a/src/app/admin/admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-grid-element/workflow-item/workflow-item-search-result-admin-workflow-grid-element.component.html b/src/app/admin/admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-grid-element/workflow-item/workflow-item-search-result-admin-workflow-grid-element.component.html index 87bae0c261..c5c2a5331a 100644 --- a/src/app/admin/admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-grid-element/workflow-item/workflow-item-search-result-admin-workflow-grid-element.component.html +++ b/src/app/admin/admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-grid-element/workflow-item/workflow-item-search-result-admin-workflow-grid-element.component.html @@ -1,4 +1,4 @@ - +
diff --git a/src/app/admin/admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-grid-element/workflow-item/workflow-item-search-result-admin-workflow-grid-element.component.spec.ts b/src/app/admin/admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-grid-element/workflow-item/workflow-item-search-result-admin-workflow-grid-element.component.spec.ts index 8035c53547..b02fa476ea 100644 --- a/src/app/admin/admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-grid-element/workflow-item/workflow-item-search-result-admin-workflow-grid-element.component.spec.ts +++ b/src/app/admin/admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-grid-element/workflow-item/workflow-item-search-result-admin-workflow-grid-element.component.spec.ts @@ -18,8 +18,8 @@ import { ItemGridElementComponent } from '../../../../../shared/object-grid/item-grid-element/item-types/item/item-grid-element.component'; import { - ListableObjectDirective -} from '../../../../../shared/object-collection/shared/listable-object/listable-object.directive'; + DynamicComponentLoaderDirective +} from '../../../../../shared/abstract-component-loader/dynamic-component-loader.directive'; import { WorkflowItemSearchResult } from '../../../../../shared/object-collection/shared/workflow-item-search-result.model'; @@ -38,7 +38,7 @@ describe('WorkflowItemSearchResultAdminWorkflowGridElementComponent', () => { let itemRD$; let linkService; let object; - let themeService; + let themeService: ThemeService; function init() { itemRD$ = createSuccessfulRemoteDataObject$(new Item()); @@ -55,7 +55,11 @@ describe('WorkflowItemSearchResultAdminWorkflowGridElementComponent', () => { init(); TestBed.configureTestingModule( { - declarations: [WorkflowItemSearchResultAdminWorkflowGridElementComponent, ItemGridElementComponent, ListableObjectDirective], + declarations: [ + WorkflowItemSearchResultAdminWorkflowGridElementComponent, + ItemGridElementComponent, + DynamicComponentLoaderDirective, + ], imports: [ NoopAnimationsModule, TranslateModule.forRoot(), diff --git a/src/app/admin/admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-grid-element/workflow-item/workflow-item-search-result-admin-workflow-grid-element.component.ts b/src/app/admin/admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-grid-element/workflow-item/workflow-item-search-result-admin-workflow-grid-element.component.ts index fd9d21e227..401140fd82 100644 --- a/src/app/admin/admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-grid-element/workflow-item/workflow-item-search-result-admin-workflow-grid-element.component.ts +++ b/src/app/admin/admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-grid-element/workflow-item/workflow-item-search-result-admin-workflow-grid-element.component.ts @@ -1,4 +1,4 @@ -import { Component, ComponentFactoryResolver, ElementRef, ViewChild } from '@angular/core'; +import { Component, ElementRef, ViewChild, ComponentRef, OnDestroy, OnInit } from '@angular/core'; import { Item } from '../../../../../core/shared/item.model'; import { ViewMode } from '../../../../../core/shared/view-mode.model'; import { @@ -10,7 +10,7 @@ import { SearchResultGridElementComponent } from '../../../../../shared/object-g import { TruncatableService } from '../../../../../shared/truncatable/truncatable.service'; import { BitstreamDataService } from '../../../../../core/data/bitstream-data.service'; import { GenericConstructor } from '../../../../../core/shared/generic-constructor'; -import { ListableObjectDirective } from '../../../../../shared/object-collection/shared/listable-object/listable-object.directive'; +import { DynamicComponentLoaderDirective } from '../../../../../shared/abstract-component-loader/dynamic-component-loader.directive'; import { WorkflowItem } from '../../../../../core/submission/models/workflowitem.model'; import { Observable } from 'rxjs'; import { LinkService } from '../../../../../core/cache/builders/link.service'; @@ -24,6 +24,7 @@ import { take } from 'rxjs/operators'; import { WorkflowItemSearchResult } from '../../../../../shared/object-collection/shared/workflow-item-search-result.model'; import { ThemeService } from '../../../../../shared/theme-support/theme.service'; import { DSONameService } from '../../../../../core/breadcrumbs/dso-name.service'; +import { hasValue } from '../../../../../shared/empty.util'; @listableObjectComponent(WorkflowItemSearchResult, ViewMode.GridElement, Context.AdminWorkflowSearch) @Component({ @@ -34,11 +35,11 @@ import { DSONameService } from '../../../../../core/breadcrumbs/dso-name.service /** * The component for displaying a grid element for an workflow item on the admin workflow search page */ -export class WorkflowItemSearchResultAdminWorkflowGridElementComponent extends SearchResultGridElementComponent { +export class WorkflowItemSearchResultAdminWorkflowGridElementComponent extends SearchResultGridElementComponent implements OnDestroy, OnInit { /** * Directive used to render the dynamic component in */ - @ViewChild(ListableObjectDirective, { static: true }) listableObjectDirective: ListableObjectDirective; + @ViewChild(DynamicComponentLoaderDirective, { static: true }) dynamicComponentLoaderDirective: DynamicComponentLoaderDirective; /** * The html child that contains the badges html @@ -55,9 +56,10 @@ export class WorkflowItemSearchResultAdminWorkflowGridElementComponent extends S */ public item$: Observable; + protected compRef: ComponentRef; + constructor( public dsoNameService: DSONameService, - private componentFactoryResolver: ComponentFactoryResolver, private linkService: LinkService, protected truncatableService: TruncatableService, private themeService: ThemeService, @@ -75,26 +77,34 @@ export class WorkflowItemSearchResultAdminWorkflowGridElementComponent extends S this.dso = this.linkService.resolveLink(this.dso, followLink('item')); this.item$ = (this.dso.item as Observable>).pipe(getAllSucceededRemoteData(), getRemoteDataPayload()); this.item$.pipe(take(1)).subscribe((item: Item) => { - const componentFactory = this.componentFactoryResolver.resolveComponentFactory(this.getComponent(item)); + const component: GenericConstructor = this.getComponent(item); - const viewContainerRef = this.listableObjectDirective.viewContainerRef; - viewContainerRef.clear(); + const viewContainerRef = this.dynamicComponentLoaderDirective.viewContainerRef; + viewContainerRef.clear(); - const componentRef = viewContainerRef.createComponent( - componentFactory, - 0, - undefined, - [ + this.compRef = viewContainerRef.createComponent( + component, { + index: 0, + injector: undefined, + projectableNodes: [ [this.badges.nativeElement], - [this.buttons.nativeElement] - ]); - (componentRef.instance as any).object = item; - (componentRef.instance as any).index = this.index; - (componentRef.instance as any).linkType = this.linkType; - (componentRef.instance as any).listID = this.listID; - componentRef.changeDetectorRef.detectChanges(); - } - ); + [this.buttons.nativeElement], + ], + }, + ); + this.compRef.setInput('object', item); + this.compRef.setInput('index', this.index); + this.compRef.setInput('linkType', this.linkType); + this.compRef.setInput('listID', this.listID); + this.compRef.changeDetectorRef.detectChanges(); + }); + } + + ngOnDestroy(): void { + if (hasValue(this.compRef)) { + this.compRef.destroy(); + this.compRef = undefined; + } } /** diff --git a/src/app/admin/admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-grid-element/workspace-item/workspace-item-search-result-admin-workflow-grid-element.component.html b/src/app/admin/admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-grid-element/workspace-item/workspace-item-search-result-admin-workflow-grid-element.component.html index 767ad79786..f78a0a3ca4 100644 --- a/src/app/admin/admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-grid-element/workspace-item/workspace-item-search-result-admin-workflow-grid-element.component.html +++ b/src/app/admin/admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-grid-element/workspace-item/workspace-item-search-result-admin-workflow-grid-element.component.html @@ -1,4 +1,4 @@ - +
diff --git a/src/app/admin/admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-grid-element/workspace-item/workspace-item-search-result-admin-workflow-grid-element.component.spec.ts b/src/app/admin/admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-grid-element/workspace-item/workspace-item-search-result-admin-workflow-grid-element.component.spec.ts index b9e752c104..d023e57709 100644 --- a/src/app/admin/admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-grid-element/workspace-item/workspace-item-search-result-admin-workflow-grid-element.component.spec.ts +++ b/src/app/admin/admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-grid-element/workspace-item/workspace-item-search-result-admin-workflow-grid-element.component.spec.ts @@ -20,8 +20,8 @@ import { ItemGridElementComponent } from '../../../../../shared/object-grid/item-grid-element/item-types/item/item-grid-element.component'; import { - ListableObjectDirective -} from '../../../../../shared/object-collection/shared/listable-object/listable-object.directive'; + DynamicComponentLoaderDirective +} from '../../../../../shared/abstract-component-loader/dynamic-component-loader.directive'; import { WorkflowItemSearchResult } from '../../../../../shared/object-collection/shared/workflow-item-search-result.model'; @@ -45,7 +45,7 @@ describe('WorkspaceItemSearchResultAdminWorkflowGridElementComponent', () => { let itemRD$; let linkService; let object; - let themeService; + let themeService: ThemeService; let supervisionOrderDataService; function init() { @@ -67,7 +67,11 @@ describe('WorkspaceItemSearchResultAdminWorkflowGridElementComponent', () => { init(); TestBed.configureTestingModule( { - declarations: [WorkspaceItemSearchResultAdminWorkflowGridElementComponent, ItemGridElementComponent, ListableObjectDirective], + declarations: [ + WorkspaceItemSearchResultAdminWorkflowGridElementComponent, + ItemGridElementComponent, + DynamicComponentLoaderDirective, + ], imports: [ NoopAnimationsModule, TranslateModule.forRoot(), diff --git a/src/app/admin/admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-grid-element/workspace-item/workspace-item-search-result-admin-workflow-grid-element.component.ts b/src/app/admin/admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-grid-element/workspace-item/workspace-item-search-result-admin-workflow-grid-element.component.ts index d6f39e79fe..0fe36056a9 100644 --- a/src/app/admin/admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-grid-element/workspace-item/workspace-item-search-result-admin-workflow-grid-element.component.ts +++ b/src/app/admin/admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-grid-element/workspace-item/workspace-item-search-result-admin-workflow-grid-element.component.ts @@ -1,4 +1,4 @@ -import { Component, ComponentFactoryResolver, ElementRef, ViewChild, OnInit } from '@angular/core'; +import { Component, ElementRef, ViewChild, OnInit, OnDestroy, ComponentRef } from '@angular/core'; import { BehaviorSubject, Observable } from 'rxjs'; import { map, mergeMap, take, tap } from 'rxjs/operators'; @@ -16,9 +16,7 @@ import { import { TruncatableService } from '../../../../../shared/truncatable/truncatable.service'; import { BitstreamDataService } from '../../../../../core/data/bitstream-data.service'; import { GenericConstructor } from '../../../../../core/shared/generic-constructor'; -import { - ListableObjectDirective -} from '../../../../../shared/object-collection/shared/listable-object/listable-object.directive'; +import { DynamicComponentLoaderDirective } from '../../../../../shared/abstract-component-loader/dynamic-component-loader.directive'; import { WorkspaceItem } from '../../../../../core/submission/models/workspaceitem.model'; import { LinkService } from '../../../../../core/cache/builders/link.service'; import { followLink } from '../../../../../shared/utils/follow-link-config.model'; @@ -37,6 +35,7 @@ import { SupervisionOrder } from '../../../../../core/supervision-order/models/s import { PaginatedList } from '../../../../../core/data/paginated-list.model'; import { SupervisionOrderDataService } from '../../../../../core/supervision-order/supervision-order-data.service'; import { DSONameService } from '../../../../../core/breadcrumbs/dso-name.service'; +import { hasValue } from '../../../../../shared/empty.util'; @listableObjectComponent(WorkspaceItemSearchResult, ViewMode.GridElement, Context.AdminWorkflowSearch) @Component({ @@ -47,7 +46,7 @@ import { DSONameService } from '../../../../../core/breadcrumbs/dso-name.service /** * The component for displaying a grid element for an workflow item on the admin workflow search page */ -export class WorkspaceItemSearchResultAdminWorkflowGridElementComponent extends SearchResultGridElementComponent implements OnInit { +export class WorkspaceItemSearchResultAdminWorkflowGridElementComponent extends SearchResultGridElementComponent implements OnDestroy, OnInit { /** * The item linked to the workspace item @@ -67,7 +66,7 @@ export class WorkspaceItemSearchResultAdminWorkflowGridElementComponent extends /** * Directive used to render the dynamic component in */ - @ViewChild(ListableObjectDirective, { static: true }) listableObjectDirective: ListableObjectDirective; + @ViewChild(DynamicComponentLoaderDirective, { static: true }) dynamicComponentLoaderDirective: DynamicComponentLoaderDirective; /** * The html child that contains the badges html @@ -79,9 +78,13 @@ export class WorkspaceItemSearchResultAdminWorkflowGridElementComponent extends */ @ViewChild('buttons', { static: true }) buttons: ElementRef; + /** + * The reference to the dynamic component + */ + protected compRef: ComponentRef; + constructor( public dsoNameService: DSONameService, - private componentFactoryResolver: ComponentFactoryResolver, private linkService: LinkService, protected truncatableService: TruncatableService, private themeService: ThemeService, @@ -100,24 +103,24 @@ export class WorkspaceItemSearchResultAdminWorkflowGridElementComponent extends this.dso = this.linkService.resolveLink(this.dso, followLink('item')); this.item$ = (this.dso.item as Observable>).pipe(getAllSucceededRemoteData(), getRemoteDataPayload()); this.item$.pipe(take(1)).subscribe((item: Item) => { - const componentFactory = this.componentFactoryResolver.resolveComponentFactory(this.getComponent(item)); + const component: GenericConstructor = this.getComponent(item); - const viewContainerRef = this.listableObjectDirective.viewContainerRef; + const viewContainerRef = this.dynamicComponentLoaderDirective.viewContainerRef; viewContainerRef.clear(); - const componentRef = viewContainerRef.createComponent( - componentFactory, - 0, - undefined, - [ + this.compRef = viewContainerRef.createComponent( + component, { + index: 0, + projectableNodes: [ [this.badges.nativeElement], [this.buttons.nativeElement] - ]); - (componentRef.instance as any).object = item; - (componentRef.instance as any).index = this.index; - (componentRef.instance as any).linkType = this.linkType; - (componentRef.instance as any).listID = this.listID; - componentRef.changeDetectorRef.detectChanges(); + ], + }); + this.compRef.setInput('object', item); + this.compRef.setInput('index', this.index); + this.compRef.setInput('linkType', this.linkType); + this.compRef.setInput('listID', this.listID); + this.compRef.changeDetectorRef.detectChanges(); } ); @@ -130,6 +133,13 @@ export class WorkspaceItemSearchResultAdminWorkflowGridElementComponent extends }); } + ngOnDestroy(): void { + if (hasValue(this.compRef)) { + this.compRef.destroy(); + this.compRef = undefined; + } + } + /** * Fetch the component depending on the item's entity type, view mode and context * @returns {GenericConstructor} diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index deb68f1ea9..a14000aef4 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -1,5 +1,5 @@ import { NgModule } from '@angular/core'; -import { RouterModule, NoPreloading } from '@angular/router'; +import { NoPreloading, RouterModule } from '@angular/router'; import { AuthBlockingGuard } from './core/auth/auth-blocking.guard'; import { AuthenticatedGuard } from './core/auth/authenticated.guard'; @@ -38,8 +38,10 @@ import { ThemedPageInternalServerErrorComponent } from './page-internal-server-error/themed-page-internal-server-error.component'; import { ServerCheckGuard } from './core/server-check/server-check.guard'; +import { SUGGESTION_MODULE_PATH } from './suggestions-page/suggestions-page-routing-paths'; import { MenuResolver } from './menu.resolver'; import { ThemedPageErrorComponent } from './page-error/themed-page-error.component'; +import { ForgotPasswordCheckGuard } from './core/rest-property/forgot-password-check-guard.guard'; @NgModule({ imports: [ @@ -94,7 +96,10 @@ import { ThemedPageErrorComponent } from './page-error/themed-page-error.compone path: FORGOT_PASSWORD_PATH, loadChildren: () => import('./forgot-password/forgot-password.module') .then((m) => m.ForgotPasswordModule), - canActivate: [EndUserAgreementCurrentUserGuard] + canActivate: [ + ForgotPasswordCheckGuard, + EndUserAgreementCurrentUserGuard + ] }, { path: COMMUNITY_MODULE_PATH, @@ -202,6 +207,11 @@ import { ThemedPageErrorComponent } from './page-error/themed-page-error.compone .then((m) => m.ProcessPageModule), canActivate: [AuthenticatedGuard, EndUserAgreementCurrentUserGuard] }, + { path: SUGGESTION_MODULE_PATH, + loadChildren: () => import('./suggestions-page/suggestions-page.module') + .then((m) => m.SuggestionsPageModule), + canActivate: [AuthenticatedGuard, EndUserAgreementCurrentUserGuard] + }, { path: INFO_MODULE_PATH, loadChildren: () => import('./info/info.module').then((m) => m.InfoModule) diff --git a/src/app/browse-by/browse-by-date-page/themed-browse-by-date-page.component.ts b/src/app/browse-by/browse-by-date-page/themed-browse-by-date-page.component.ts deleted file mode 100644 index 8eeae0c5de..0000000000 --- a/src/app/browse-by/browse-by-date-page/themed-browse-by-date-page.component.ts +++ /dev/null @@ -1,29 +0,0 @@ -import {Component} from '@angular/core'; -import { ThemedComponent } from '../../shared/theme-support/themed.component'; -import { BrowseByDatePageComponent } from './browse-by-date-page.component'; -import {BrowseByDataType, rendersBrowseBy} from '../browse-by-switcher/browse-by-decorator'; - -/** - * Themed wrapper for BrowseByDatePageComponent - * */ -@Component({ - selector: 'ds-themed-browse-by-metadata-page', - styleUrls: [], - templateUrl: '../../shared/theme-support/themed.component.html', -}) - -@rendersBrowseBy(BrowseByDataType.Date) -export class ThemedBrowseByDatePageComponent - extends ThemedComponent { - protected getComponentName(): string { - return 'BrowseByDatePageComponent'; - } - - protected importThemedComponent(themeName: string): Promise { - return import(`../../../themes/${themeName}/app/browse-by/browse-by-date-page/browse-by-date-page.component`); - } - - protected importUnthemedComponent(): Promise { - return import(`./browse-by-date-page.component`); - } -} diff --git a/src/app/browse-by/browse-by-date-page/browse-by-date-page.component.spec.ts b/src/app/browse-by/browse-by-date/browse-by-date.component.spec.ts similarity index 88% rename from src/app/browse-by/browse-by-date-page/browse-by-date-page.component.spec.ts rename to src/app/browse-by/browse-by-date/browse-by-date.component.spec.ts index e41d3a45b2..c5ef9020f1 100644 --- a/src/app/browse-by/browse-by-date-page/browse-by-date-page.component.spec.ts +++ b/src/app/browse-by/browse-by-date/browse-by-date.component.spec.ts @@ -1,4 +1,4 @@ -import { BrowseByDatePageComponent } from './browse-by-date-page.component'; +import { BrowseByDateComponent } from './browse-by-date.component'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { CommonModule } from '@angular/common'; import { RouterTestingModule } from '@angular/router/testing'; @@ -15,7 +15,7 @@ import { ActivatedRouteStub } from '../../shared/testing/active-router.stub'; import { Community } from '../../core/shared/community.model'; import { Item } from '../../core/shared/item.model'; import { BrowseEntrySearchOptions } from '../../core/browse/browse-entry-search-options.model'; -import { toRemoteData } from '../browse-by-metadata-page/browse-by-metadata-page.component.spec'; +import { toRemoteData } from '../browse-by-metadata/browse-by-metadata.component.spec'; import { VarDirective } from '../../shared/utils/var.directive'; import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { PaginationService } from '../../core/pagination/pagination.service'; @@ -23,10 +23,11 @@ import { PaginationServiceStub } from '../../shared/testing/pagination-service.s import { APP_CONFIG } from '../../../config/app-config.interface'; import { environment } from '../../../environments/environment'; import { SortDirection } from '../../core/cache/models/sort-options.model'; +import { cold } from 'jasmine-marbles'; -describe('BrowseByDatePageComponent', () => { - let comp: BrowseByDatePageComponent; - let fixture: ComponentFixture; +describe('BrowseByDateComponent', () => { + let comp: BrowseByDateComponent; + let fixture: ComponentFixture; let route: ActivatedRoute; let paginationService; @@ -86,7 +87,7 @@ describe('BrowseByDatePageComponent', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule], - declarations: [BrowseByDatePageComponent, EnumKeysPipe, VarDirective], + declarations: [BrowseByDateComponent, EnumKeysPipe, VarDirective], providers: [ { provide: ActivatedRoute, useValue: activatedRouteStub }, { provide: BrowseService, useValue: mockBrowseService }, @@ -101,7 +102,7 @@ describe('BrowseByDatePageComponent', () => { })); beforeEach(() => { - fixture = TestBed.createComponent(BrowseByDatePageComponent); + fixture = TestBed.createComponent(BrowseByDateComponent); const browseService = fixture.debugElement.injector.get(BrowseService); spyOn(browseService, 'getFirstItemFor') // ok to expect the default browse as first param since we just need the mock items obtained via sort direction. @@ -112,9 +113,13 @@ describe('BrowseByDatePageComponent', () => { fixture.detectChanges(); }); - it('should initialize the list of items', () => { + it('should initialize the list of items', (done: DoneFn) => { + expect(comp.loading$).toBeObservable(cold('(a|)', { + a: false, + })); comp.items$.subscribe((result) => { expect(result.payload.page).toEqual([firstItem]); + done(); }); }); diff --git a/src/app/browse-by/browse-by-date-page/browse-by-date-page.component.ts b/src/app/browse-by/browse-by-date/browse-by-date.component.ts similarity index 68% rename from src/app/browse-by/browse-by-date-page/browse-by-date-page.component.ts rename to src/app/browse-by/browse-by-date/browse-by-date.component.ts index c52731a421..3485519929 100644 --- a/src/app/browse-by/browse-by-date-page/browse-by-date-page.component.ts +++ b/src/app/browse-by/browse-by-date/browse-by-date.component.ts @@ -1,10 +1,6 @@ -import { ChangeDetectorRef, Component, Inject } from '@angular/core'; -import { - BrowseByMetadataPageComponent, - browseParamsToOptions, - getBrowseSearchOptions -} from '../browse-by-metadata-page/browse-by-metadata-page.component'; -import { combineLatest as observableCombineLatest } from 'rxjs'; +import { ChangeDetectorRef, Component, Inject, OnInit } from '@angular/core'; +import { BrowseByMetadataComponent, browseParamsToOptions, getBrowseSearchOptions } from '../browse-by-metadata/browse-by-metadata.component'; +import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { ActivatedRoute, Params, Router } from '@angular/router'; import { BrowseService } from '../../core/browse/browse.service'; @@ -19,32 +15,36 @@ import { APP_CONFIG, AppConfig } from '../../../config/app-config.interface'; import { RemoteData } from '../../core/data/remote-data'; import { Item } from '../../core/shared/item.model'; import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { rendersBrowseBy } from '../browse-by-switcher/browse-by-decorator'; +import { BrowseByDataType } from '../browse-by-switcher/browse-by-data-type'; @Component({ - selector: 'ds-browse-by-date-page', - styleUrls: ['../browse-by-metadata-page/browse-by-metadata-page.component.scss'], - templateUrl: '../browse-by-metadata-page/browse-by-metadata-page.component.html' + selector: 'ds-browse-by-date', + styleUrls: ['../browse-by-metadata/browse-by-metadata.component.scss'], + templateUrl: '../browse-by-metadata/browse-by-metadata.component.html', }) /** * Component for browsing items by metadata definition of type 'date' * A metadata definition (a.k.a. browse id) is a short term used to describe one or multiple metadata fields. * An example would be 'dateissued' for 'dc.date.issued' */ -export class BrowseByDatePageComponent extends BrowseByMetadataPageComponent { +@rendersBrowseBy(BrowseByDataType.Date) +export class BrowseByDateComponent extends BrowseByMetadataComponent implements OnInit { /** * The default metadata keys to use for determining the lower limit of the StartsWith dropdown options */ defaultMetadataKeys = ['dc.date.issued']; - public constructor(protected route: ActivatedRoute, - protected browseService: BrowseService, - protected dsoService: DSpaceObjectDataService, - protected router: Router, - protected paginationService: PaginationService, - protected cdRef: ChangeDetectorRef, - @Inject(APP_CONFIG) public appConfig: AppConfig, - public dsoNameService: DSONameService, + public constructor( + protected route: ActivatedRoute, + protected browseService: BrowseService, + protected dsoService: DSpaceObjectDataService, + protected paginationService: PaginationService, + protected router: Router, + @Inject(APP_CONFIG) public appConfig: AppConfig, + public dsoNameService: DSONameService, + protected cdRef: ChangeDetectorRef, ) { super(route, browseService, dsoService, paginationService, router, appConfig, dsoNameService); } @@ -57,19 +57,17 @@ export class BrowseByDatePageComponent extends BrowseByMetadataPageComponent { this.currentPagination$ = this.paginationService.getCurrentPagination(this.paginationConfig.id, this.paginationConfig); this.currentSort$ = this.paginationService.getCurrentSort(this.paginationConfig.id, sortConfig); this.subs.push( - observableCombineLatest([this.route.params, this.route.queryParams, this.route.data, + observableCombineLatest([this.route.params, this.route.queryParams, this.scope$, this.route.data, this.currentPagination$, this.currentSort$]).pipe( - map(([routeParams, queryParams, data, currentPage, currentSort]) => { - return [Object.assign({}, routeParams, queryParams, data), currentPage, currentSort]; + map(([routeParams, queryParams, scope, data, currentPage, currentSort]) => { + return [Object.assign({}, routeParams, queryParams, data), scope, currentPage, currentSort]; }) - ).subscribe(([params, currentPage, currentSort]: [Params, PaginationComponentOptions, SortOptions]) => { + ).subscribe(([params, scope, currentPage, currentSort]: [Params, string, PaginationComponentOptions, SortOptions]) => { const metadataKeys = params.browseDefinition ? params.browseDefinition.metadataKeys : this.defaultMetadataKeys; this.browseId = params.id || this.defaultBrowseId; this.startsWith = +params.startsWith || params.startsWith; - const searchOptions = browseParamsToOptions(params, currentPage, currentSort, this.browseId, this.fetchThumbnails); + const searchOptions = browseParamsToOptions(params, scope, currentPage, currentSort, this.browseId, this.fetchThumbnails); this.updatePageWithItems(searchOptions, this.value, undefined); - this.updateParent(params.scope); - this.updateLogo(); this.updateStartsWithOptions(this.browseId, metadataKeys, params.scope); })); } @@ -85,12 +83,21 @@ export class BrowseByDatePageComponent extends BrowseByMetadataPageComponent { * @param scope The scope under which to fetch the earliest item for */ updateStartsWithOptions(definition: string, metadataKeys: string[], scope?: string) { - const firstItemRD = this.browseService.getFirstItemFor(definition, scope, SortDirection.ASC); - const lastItemRD = this.browseService.getFirstItemFor(definition, scope, SortDirection.DESC); + const firstItemRD$: Observable> = this.browseService.getFirstItemFor(definition, scope, SortDirection.ASC); + const lastItemRD$: Observable> = this.browseService.getFirstItemFor(definition, scope, SortDirection.DESC); + this.loading$ = observableCombineLatest([ + firstItemRD$, + lastItemRD$, + ]).pipe( + map(([firstItemRD, lastItemRD]: [RemoteData, RemoteData]) => firstItemRD.isLoading || lastItemRD.isLoading) + ); this.subs.push( - observableCombineLatest([firstItemRD, lastItemRD]).subscribe(([firstItem, lastItem]) => { - let lowerLimit: number = this.getLimit(firstItem, metadataKeys, this.appConfig.browseBy.defaultLowerLimit); - let upperLimit: number = this.getLimit(lastItem, metadataKeys, new Date().getUTCFullYear()); + observableCombineLatest([ + firstItemRD$, + lastItemRD$, + ]).subscribe(([firstItemRD, lastItemRD]: [RemoteData, RemoteData]) => { + let lowerLimit: number = this.getLimit(firstItemRD, metadataKeys, this.appConfig.browseBy.defaultLowerLimit); + let upperLimit: number = this.getLimit(lastItemRD, metadataKeys, new Date().getUTCFullYear()); const options: number[] = []; const oneYearBreak: number = Math.floor((upperLimit - this.appConfig.browseBy.oneYearLimit) / 5) * 5; const fiveYearBreak: number = Math.floor((upperLimit - this.appConfig.browseBy.fiveYearLimit) / 10) * 10; diff --git a/src/app/browse-by/browse-by-guard.spec.ts b/src/app/browse-by/browse-by-guard.spec.ts index aac5ba2723..c3840470c6 100644 --- a/src/app/browse-by/browse-by-guard.spec.ts +++ b/src/app/browse-by/browse-by-guard.spec.ts @@ -1,17 +1,13 @@ import { first } from 'rxjs/operators'; import { BrowseByGuard } from './browse-by-guard'; -import { of as observableOf } from 'rxjs'; import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils'; -import { BrowseByDataType } from './browse-by-switcher/browse-by-decorator'; +import { BrowseByDataType } from './browse-by-switcher/browse-by-data-type'; import { ValueListBrowseDefinition } from '../core/shared/value-list-browse-definition.model'; -import { DSONameServiceMock } from '../shared/mocks/dso-name.service.mock'; -import { DSONameService } from '../core/breadcrumbs/dso-name.service'; import { RouterStub } from '../shared/testing/router.stub'; describe('BrowseByGuard', () => { describe('canActivate', () => { let guard: BrowseByGuard; - let dsoService: any; let translateService: any; let browseDefinitionService: any; let router: any; @@ -25,10 +21,6 @@ describe('BrowseByGuard', () => { const browseDefinition = Object.assign(new ValueListBrowseDefinition(), { type: BrowseByDataType.Metadata, metadataKeys: ['dc.contributor'] }); beforeEach(() => { - dsoService = { - findById: (dsoId: string) => observableOf({ payload: { name: name }, hasSucceeded: true }) - }; - translateService = { instant: () => field }; @@ -39,7 +31,7 @@ describe('BrowseByGuard', () => { router = new RouterStub() as any; - guard = new BrowseByGuard(dsoService, translateService, browseDefinitionService, new DSONameServiceMock() as DSONameService, router); + guard = new BrowseByGuard(translateService, browseDefinitionService, router); }); it('should return true, and sets up the data correctly, with a scope and value', () => { @@ -48,6 +40,7 @@ describe('BrowseByGuard', () => { title: field, browseDefinition, }, + parent: null, params: { id, }, @@ -64,7 +57,7 @@ describe('BrowseByGuard', () => { title, id, browseDefinition, - collection: name, + scope, field, value: '"' + value + '"' }; @@ -97,7 +90,7 @@ describe('BrowseByGuard', () => { title, id, browseDefinition, - collection: name, + scope, field, value: '' }; @@ -108,12 +101,48 @@ describe('BrowseByGuard', () => { ); }); + it('should return true, and sets up the data correctly using the community/collection page id, with a scope and without value', () => { + const scopedNoValueRoute = { + data: { + title: field, + browseDefinition, + }, + parent: { + params: { + id: scope, + }, + }, + params: { + id, + }, + queryParams: { + }, + }; + + guard.canActivate(scopedNoValueRoute as any, undefined).pipe( + first(), + ).subscribe((canActivate) => { + const result = { + title, + id, + browseDefinition, + scope, + field, + value: '', + }; + expect(scopedNoValueRoute.data).toEqual(result); + expect(router.navigate).not.toHaveBeenCalled(); + expect(canActivate).toEqual(true); + }); + }); + it('should return true, and sets up the data correctly, without a scope and with a value', () => { const route = { data: { title: field, browseDefinition, }, + parent: null, params: { id, }, @@ -129,7 +158,7 @@ describe('BrowseByGuard', () => { title, id, browseDefinition, - collection: '', + scope: undefined, field, value: '"' + value + '"' }; @@ -147,6 +176,7 @@ describe('BrowseByGuard', () => { data: { title: field, }, + parent: null, params: { id, }, diff --git a/src/app/browse-by/browse-by-guard.ts b/src/app/browse-by/browse-by-guard.ts index dd87699a84..cca9a6a675 100644 --- a/src/app/browse-by/browse-by-guard.ts +++ b/src/app/browse-by/browse-by-guard.ts @@ -1,15 +1,12 @@ -import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot } from '@angular/router'; +import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, Data } from '@angular/router'; import { Injectable } from '@angular/core'; -import { DSpaceObjectDataService } from '../core/data/dspace-object-data.service'; import { hasNoValue, hasValue } from '../shared/empty.util'; import { map, switchMap } from 'rxjs/operators'; -import { getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload } from '../core/shared/operators'; +import { getFirstCompletedRemoteData } from '../core/shared/operators'; import { TranslateService } from '@ngx-translate/core'; import { Observable, of as observableOf } from 'rxjs'; import { BrowseDefinitionDataService } from '../core/browse/browse-definition-data.service'; import { BrowseDefinition } from '../core/shared/browse-definition.model'; -import { DSONameService } from '../core/breadcrumbs/dso-name.service'; -import { DSpaceObject } from '../core/shared/dspace-object.model'; import { RemoteData } from '../core/data/remote-data'; import { PAGE_NOT_FOUND_PATH } from '../app-routing-paths'; @@ -19,11 +16,10 @@ import { PAGE_NOT_FOUND_PATH } from '../app-routing-paths'; */ export class BrowseByGuard implements CanActivate { - constructor(protected dsoService: DSpaceObjectDataService, - protected translate: TranslateService, - protected browseDefinitionService: BrowseDefinitionDataService, - protected dsoNameService: DSONameService, - protected router: Router, + constructor( + protected translate: TranslateService, + protected browseDefinitionService: BrowseDefinitionDataService, + protected router: Router, ) { } @@ -39,25 +35,14 @@ export class BrowseByGuard implements CanActivate { } else { browseDefinition$ = observableOf(route.data.browseDefinition); } - const scope = route.queryParams.scope; + const scope = route.queryParams.scope ?? route.parent?.params.id; const value = route.queryParams.value; const metadataTranslated = this.translate.instant(`browse.metadata.${id}`); return browseDefinition$.pipe( switchMap((browseDefinition: BrowseDefinition | undefined) => { if (hasValue(browseDefinition)) { - if (hasValue(scope)) { - const dso$: Observable = this.dsoService.findById(scope).pipe(getFirstSucceededRemoteDataPayload()); - return dso$.pipe( - map((dso: DSpaceObject) => { - const name = this.dsoNameService.getName(dso); - route.data = this.createData(title, id, browseDefinition, name, metadataTranslated, value, route); - return true; - }) - ); - } else { - route.data = this.createData(title, id, browseDefinition, '', metadataTranslated, value, route); - return observableOf(true); - } + route.data = this.createData(title, id, browseDefinition, metadataTranslated, value, route, scope); + return observableOf(true); } else { void this.router.navigate([PAGE_NOT_FOUND_PATH]); return observableOf(false); @@ -66,14 +51,14 @@ export class BrowseByGuard implements CanActivate { ); } - private createData(title, id, browseDefinition, collection, field, value, route) { + private createData(title: string, id: string, browseDefinition: BrowseDefinition, field: string, value: string, route: ActivatedRouteSnapshot, scope: string): Data { return Object.assign({}, route.data, { title: title, id: id, browseDefinition: browseDefinition, - collection: collection, field: field, - value: hasValue(value) ? `"${value}"` : '' + value: hasValue(value) ? `"${value}"` : '', + scope: scope, }); } } diff --git a/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.html b/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.html deleted file mode 100644 index cfc2cbe305..0000000000 --- a/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.html +++ /dev/null @@ -1,67 +0,0 @@ -
- - -
- -
- - - - - - - - - - - - - - - -
- -
- - -
- -
- -
- - -
-
- - - - -
-
-
-
-
diff --git a/src/app/browse-by/browse-by-metadata-page/themed-browse-by-metadata-page.component.ts b/src/app/browse-by/browse-by-metadata-page/themed-browse-by-metadata-page.component.ts deleted file mode 100644 index b0679258e9..0000000000 --- a/src/app/browse-by/browse-by-metadata-page/themed-browse-by-metadata-page.component.ts +++ /dev/null @@ -1,29 +0,0 @@ -import {Component} from '@angular/core'; -import { ThemedComponent } from '../../shared/theme-support/themed.component'; -import { BrowseByMetadataPageComponent } from './browse-by-metadata-page.component'; -import {BrowseByDataType, rendersBrowseBy} from '../browse-by-switcher/browse-by-decorator'; - -/** - * Themed wrapper for BrowseByMetadataPageComponent - **/ -@Component({ - selector: 'ds-themed-browse-by-metadata-page', - styleUrls: [], - templateUrl: '../../shared/theme-support/themed.component.html', -}) - -@rendersBrowseBy(BrowseByDataType.Metadata) -export class ThemedBrowseByMetadataPageComponent - extends ThemedComponent { - protected getComponentName(): string { - return 'BrowseByMetadataPageComponent'; - } - - protected importThemedComponent(themeName: string): Promise { - return import(`../../../themes/${themeName}/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component`); - } - - protected importUnthemedComponent(): Promise { - return import(`./browse-by-metadata-page.component`); - } -} diff --git a/src/app/browse-by/browse-by-metadata/browse-by-metadata.component.html b/src/app/browse-by/browse-by-metadata/browse-by-metadata.component.html new file mode 100644 index 0000000000..52ef06206f --- /dev/null +++ b/src/app/browse-by/browse-by-metadata/browse-by-metadata.component.html @@ -0,0 +1,21 @@ +
+ +
diff --git a/src/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.scss b/src/app/browse-by/browse-by-metadata/browse-by-metadata.component.scss similarity index 100% rename from src/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.scss rename to src/app/browse-by/browse-by-metadata/browse-by-metadata.component.scss diff --git a/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.spec.ts b/src/app/browse-by/browse-by-metadata/browse-by-metadata.component.spec.ts similarity index 91% rename from src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.spec.ts rename to src/app/browse-by/browse-by-metadata/browse-by-metadata.component.spec.ts index a5beeb8a45..9fef2b1a35 100644 --- a/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.spec.ts +++ b/src/app/browse-by/browse-by-metadata/browse-by-metadata.component.spec.ts @@ -1,8 +1,8 @@ import { - BrowseByMetadataPageComponent, + BrowseByMetadataComponent, browseParamsToOptions, getBrowseSearchOptions -} from './browse-by-metadata-page.component'; +} from './browse-by-metadata.component'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { BrowseService } from '../../core/browse/browse.service'; import { CommonModule } from '@angular/common'; @@ -30,10 +30,11 @@ import { PaginationService } from '../../core/pagination/pagination.service'; import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; import { PaginationServiceStub } from '../../shared/testing/pagination-service.stub'; import { APP_CONFIG } from '../../../config/app-config.interface'; +import { cold } from 'jasmine-marbles'; -describe('BrowseByMetadataPageComponent', () => { - let comp: BrowseByMetadataPageComponent; - let fixture: ComponentFixture; +describe('BrowseByMetadataComponent', () => { + let comp: BrowseByMetadataComponent; + let fixture: ComponentFixture; let browseService: BrowseService; let route: ActivatedRoute; let paginationService; @@ -103,7 +104,7 @@ describe('BrowseByMetadataPageComponent', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule], - declarations: [BrowseByMetadataPageComponent, EnumKeysPipe, VarDirective], + declarations: [BrowseByMetadataComponent, EnumKeysPipe, VarDirective], providers: [ { provide: ActivatedRoute, useValue: activatedRouteStub }, { provide: BrowseService, useValue: mockBrowseService }, @@ -117,7 +118,7 @@ describe('BrowseByMetadataPageComponent', () => { })); beforeEach(() => { - fixture = TestBed.createComponent(BrowseByMetadataPageComponent); + fixture = TestBed.createComponent(BrowseByMetadataComponent); comp = fixture.componentInstance; fixture.detectChanges(); browseService = (comp as any).browseService; @@ -144,20 +145,18 @@ describe('BrowseByMetadataPageComponent', () => { route.params = observableOf(paramsWithValue); comp.ngOnInit(); - comp.updateParent('fake-scope'); - comp.updateLogo(); fixture.detectChanges(); }); - it('should fetch items', () => { + it('should fetch items', (done: DoneFn) => { + expect(comp.loading$).toBeObservable(cold('(a|)', { + a: false, + })); comp.items$.subscribe((result) => { expect(result.payload.page).toEqual(mockItems); + done(); }); }); - - it('should fetch the logo', () => { - expect(comp.logo$).toBeTruthy(); - }); }); describe('when calling browseParamsToOptions', () => { @@ -176,7 +175,7 @@ describe('BrowseByMetadataPageComponent', () => { field: 'fake-field', }; - result = browseParamsToOptions(paramsScope, paginationOptions, sortOptions, 'author', comp.fetchThumbnails); + result = browseParamsToOptions(paramsScope, 'fake-scope', paginationOptions, sortOptions, 'author', comp.fetchThumbnails); }); it('should return BrowseEntrySearchOptions with the correct properties', () => { diff --git a/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.ts b/src/app/browse-by/browse-by-metadata/browse-by-metadata.component.ts similarity index 76% rename from src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.ts rename to src/app/browse-by/browse-by-metadata/browse-by-metadata.component.ts index 75dbfcfc82..00cfedba88 100644 --- a/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.ts +++ b/src/app/browse-by/browse-by-metadata/browse-by-metadata.component.ts @@ -1,5 +1,5 @@ -import { combineLatest as observableCombineLatest, Observable, Subscription } from 'rxjs'; -import { Component, Inject, OnInit, OnDestroy } from '@angular/core'; +import { BehaviorSubject, combineLatest as observableCombineLatest, Observable, Subscription, of as observableOf } from 'rxjs'; +import { Component, Inject, OnInit, OnDestroy, Input, OnChanges } from '@angular/core'; import { RemoteData } from '../../core/data/remote-data'; import { PaginatedList } from '../../core/data/paginated-list.model'; import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; @@ -12,23 +12,21 @@ import { Item } from '../../core/shared/item.model'; import { BrowseEntrySearchOptions } from '../../core/browse/browse-entry-search-options.model'; import { getFirstSucceededRemoteData } from '../../core/shared/operators'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; -import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { StartsWithType } from '../../shared/starts-with/starts-with-decorator'; import { PaginationService } from '../../core/pagination/pagination.service'; -import { filter, map, mergeMap } from 'rxjs/operators'; -import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { Bitstream } from '../../core/shared/bitstream.model'; -import { Collection } from '../../core/shared/collection.model'; -import { Community } from '../../core/shared/community.model'; +import { map } from 'rxjs/operators'; import { APP_CONFIG, AppConfig } from '../../../config/app-config.interface'; import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { rendersBrowseBy } from '../browse-by-switcher/browse-by-decorator'; +import { BrowseByDataType } from '../browse-by-switcher/browse-by-data-type'; +import { Context } from '../../core/shared/context.model'; export const BBM_PAGINATION_ID = 'bbm'; @Component({ - selector: 'ds-browse-by-metadata-page', - styleUrls: ['./browse-by-metadata-page.component.scss'], - templateUrl: './browse-by-metadata-page.component.html' + selector: 'ds-browse-by-metadata', + styleUrls: ['./browse-by-metadata.component.scss'], + templateUrl: './browse-by-metadata.component.html', }) /** * Component for browsing (items) by metadata definition. @@ -36,7 +34,30 @@ export const BBM_PAGINATION_ID = 'bbm'; * or multiple metadata fields. An example would be 'author' for * 'dc.contributor.*' */ -export class BrowseByMetadataPageComponent implements OnInit, OnDestroy { +@rendersBrowseBy(BrowseByDataType.Metadata) +export class BrowseByMetadataComponent implements OnInit, OnChanges, OnDestroy { + + /** + * The optional context + */ + @Input() context: Context; + + /** + * The {@link BrowseByDataType} of this Component + */ + @Input() browseByType: BrowseByDataType; + + /** + * The ID of the {@link Community} or {@link Collection} of the scope to display + */ + @Input() scope: string; + + /** + * Display the h1 title in the section + */ + @Input() displayTitle = true; + + scope$: BehaviorSubject = new BehaviorSubject(undefined); /** * The list of browse-entries to display @@ -48,16 +69,6 @@ export class BrowseByMetadataPageComponent implements OnInit, OnDestroy { */ items$: Observable>>; - /** - * The current Community or Collection we're browsing metadata/items in - */ - parent$: Observable>; - - /** - * The logo of the current Community or Collection - */ - logo$: Observable>; - /** * The pagination config used to display the values */ @@ -98,7 +109,7 @@ export class BrowseByMetadataPageComponent implements OnInit, OnDestroy { * The list of StartsWith options * Should be defined after ngOnInit is called! */ - startsWithOptions; + startsWithOptions: (string | number)[]; /** * The value we're browsing items for @@ -122,6 +133,11 @@ export class BrowseByMetadataPageComponent implements OnInit, OnDestroy { */ fetchThumbnails: boolean; + /** + * Observable determining if the loading animation needs to be shown + */ + loading$ = observableOf(true); + public constructor(protected route: ActivatedRoute, protected browseService: BrowseService, protected dsoService: DSpaceObjectDataService, @@ -130,7 +146,6 @@ export class BrowseByMetadataPageComponent implements OnInit, OnDestroy { @Inject(APP_CONFIG) public appConfig: AppConfig, public dsoNameService: DSONameService, ) { - this.fetchThumbnails = this.appConfig.browseBy.showThumbnails; this.paginationConfig = Object.assign(new PaginationComponentOptions(), { id: BBM_PAGINATION_ID, @@ -147,12 +162,12 @@ export class BrowseByMetadataPageComponent implements OnInit, OnDestroy { this.currentPagination$ = this.paginationService.getCurrentPagination(this.paginationConfig.id, this.paginationConfig); this.currentSort$ = this.paginationService.getCurrentSort(this.paginationConfig.id, sortConfig); this.subs.push( - observableCombineLatest([this.route.params, this.route.queryParams, this.currentPagination$, this.currentSort$]).pipe( - map(([routeParams, queryParams, currentPage, currentSort]) => { - return [Object.assign({}, routeParams, queryParams),currentPage,currentSort]; + observableCombineLatest([this.route.params, this.route.queryParams, this.scope$, this.currentPagination$, this.currentSort$]).pipe( + map(([routeParams, queryParams, scope, currentPage, currentSort]) => { + return [Object.assign({}, routeParams, queryParams), scope, currentPage, currentSort]; }) - ).subscribe(([params, currentPage, currentSort]: [Params, PaginationComponentOptions, SortOptions]) => { - this.browseId = params.id || this.defaultBrowseId; + ).subscribe(([params, scope, currentPage, currentSort]: [Params, string, PaginationComponentOptions, SortOptions]) => { + this.browseId = params.id || this.defaultBrowseId; this.authority = params.authority; if (typeof params.value === 'string'){ @@ -170,18 +185,19 @@ export class BrowseByMetadataPageComponent implements OnInit, OnDestroy { } if (isNotEmpty(this.value)) { - this.updatePageWithItems( - browseParamsToOptions(params, currentPage, currentSort, this.browseId, this.fetchThumbnails), this.value, this.authority); + this.updatePageWithItems(browseParamsToOptions(params, scope, currentPage, currentSort, this.browseId, this.fetchThumbnails), this.value, this.authority); } else { - this.updatePage(browseParamsToOptions(params, currentPage, currentSort, this.browseId, false)); + this.updatePage(browseParamsToOptions(params, scope, currentPage, currentSort, this.browseId, false)); } - this.updateParent(params.scope); - this.updateLogo(); })); this.updateStartsWithTextOptions(); } + ngOnChanges(): void { + this.scope$.next(this.scope); + } + /** * Update the StartsWith options with text values * It adds the value "0-9" as well as all letters from A to Z @@ -200,6 +216,9 @@ export class BrowseByMetadataPageComponent implements OnInit, OnDestroy { */ updatePage(searchOptions: BrowseEntrySearchOptions) { this.browseEntries$ = this.browseService.getBrowseEntriesFor(searchOptions); + this.loading$ = this.browseEntries$.pipe( + map((browseEntriesRD: RemoteData>) => browseEntriesRD.isLoading), + ); this.items$ = undefined; } @@ -214,37 +233,9 @@ export class BrowseByMetadataPageComponent implements OnInit, OnDestroy { */ updatePageWithItems(searchOptions: BrowseEntrySearchOptions, value: string, authority: string) { this.items$ = this.browseService.getBrowseItemsFor(value, authority, searchOptions); - } - - /** - * Update the parent Community or Collection using their scope - * @param scope The UUID of the Community or Collection to fetch - */ - updateParent(scope: string) { - if (hasValue(scope)) { - const linksToFollow = () => { - return [followLink('logo')]; - }; - this.parent$ = this.dsoService.findById(scope, - true, - true, - ...linksToFollow() as FollowLinkConfig[]).pipe( - getFirstSucceededRemoteData() - ); - } - } - - /** - * Update the parent Community or Collection logo - */ - updateLogo() { - if (hasValue(this.parent$)) { - this.logo$ = this.parent$.pipe( - map((rd: RemoteData) => rd.payload), - filter((collectionOrCommunity: Collection | Community) => hasValue(collectionOrCommunity.logo)), - mergeMap((collectionOrCommunity: Collection | Community) => collectionOrCommunity.logo) - ); - } + this.loading$ = this.items$.pipe( + map((itemsRD: RemoteData>) => itemsRD.isLoading), + ); } /** @@ -278,7 +269,7 @@ export class BrowseByMetadataPageComponent implements OnInit, OnDestroy { } ngOnDestroy(): void { - this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); + this.subs.filter((sub: Subscription) => hasValue(sub)).forEach((sub: Subscription) => sub.unsubscribe()); this.paginationService.clearPagination(this.paginationConfig.id); } @@ -307,12 +298,14 @@ export function getBrowseSearchOptions(defaultBrowseId: string, /** * Function to transform query and url parameters into searchOptions used to fetch browse entries or items * @param params URL and query parameters + * @param scope The scope to show the results * @param paginationConfig Pagination configuration * @param sortConfig Sorting configuration * @param metadata Optional metadata definition to fetch browse entries/items for * @param fetchThumbnail Optional parameter for requesting thumbnail images */ export function browseParamsToOptions(params: any, + scope: string, paginationConfig: PaginationComponentOptions, sortConfig: SortOptions, metadata?: string, @@ -322,7 +315,7 @@ export function browseParamsToOptions(params: any, paginationConfig, sortConfig, params.startsWith, - params.scope, + scope, fetchThumbnail ); } diff --git a/src/app/browse-by/browse-by-page.module.ts b/src/app/browse-by/browse-by-page.module.ts index 554a6c4f46..8a010f7105 100644 --- a/src/app/browse-by/browse-by-page.module.ts +++ b/src/app/browse-by/browse-by-page.module.ts @@ -5,12 +5,19 @@ import { ItemDataService } from '../core/data/item-data.service'; import { BrowseService } from '../core/browse/browse.service'; import { BrowseByGuard } from './browse-by-guard'; import { SharedBrowseByModule } from '../shared/browse-by/shared-browse-by.module'; +import { BrowseByPageComponent } from './browse-by-page/browse-by-page.component'; +import { SharedModule } from '../shared/shared.module'; + +const DECLARATIONS = [ + BrowseByPageComponent, +]; @NgModule({ imports: [ SharedBrowseByModule, BrowseByRoutingModule, - BrowseByModule.withEntryComponents(), + BrowseByModule, + SharedModule, ], providers: [ ItemDataService, @@ -18,8 +25,11 @@ import { SharedBrowseByModule } from '../shared/browse-by/shared-browse-by.modul BrowseByGuard, ], declarations: [ - - ] + ...DECLARATIONS, + ], + exports: [ + ...DECLARATIONS, + ], }) export class BrowseByPageModule { diff --git a/src/app/browse-by/browse-by-page/browse-by-page.component.html b/src/app/browse-by/browse-by-page/browse-by-page.component.html new file mode 100644 index 0000000000..6e9476e1e9 --- /dev/null +++ b/src/app/browse-by/browse-by-page/browse-by-page.component.html @@ -0,0 +1,4 @@ +
+ + +
diff --git a/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.scss b/src/app/browse-by/browse-by-page/browse-by-page.component.scss similarity index 100% rename from src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.scss rename to src/app/browse-by/browse-by-page/browse-by-page.component.scss diff --git a/src/app/browse-by/browse-by-page/browse-by-page.component.spec.ts b/src/app/browse-by/browse-by-page/browse-by-page.component.spec.ts new file mode 100644 index 0000000000..25483028eb --- /dev/null +++ b/src/app/browse-by/browse-by-page/browse-by-page.component.spec.ts @@ -0,0 +1,69 @@ +// eslint-disable-next-line max-classes-per-file +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { BrowseByPageComponent } from './browse-by-page.component'; +import { BrowseBySwitcherComponent } from '../browse-by-switcher/browse-by-switcher.component'; +import { DynamicComponentLoaderDirective } from '../../shared/abstract-component-loader/dynamic-component-loader.directive'; +import { ActivatedRoute } from '@angular/router'; +import { ActivatedRouteStub } from '../../shared/testing/active-router.stub'; +import { getMockThemeService } from '../../shared/mocks/theme-service.mock'; +import { ThemeService } from '../../shared/theme-support/theme.service'; +import { rendersBrowseBy } from '../browse-by-switcher/browse-by-decorator'; +import { Component } from '@angular/core'; +import { BrowseDefinition } from '../../core/shared/browse-definition.model'; +import { By } from '@angular/platform-browser'; +import { BrowseByDataType } from '../browse-by-switcher/browse-by-data-type'; + +@rendersBrowseBy('BrowseByPageComponent' as BrowseByDataType) +@Component({ + // eslint-disable-next-line @angular-eslint/component-selector + selector: '', + template: '', +}) +class BrowseByTestComponent { +} + +class TestBrowseByPageBrowseDefinition extends BrowseDefinition { + getRenderType(): BrowseByDataType { + return 'BrowseByPageComponent' as BrowseByDataType; + } +} + +describe('BrowseByPageComponent', () => { + let component: BrowseByPageComponent; + let fixture: ComponentFixture; + + let activatedRoute: ActivatedRouteStub; + let themeService: ThemeService; + + beforeEach(async () => { + activatedRoute = new ActivatedRouteStub(); + themeService = getMockThemeService(); + + await TestBed.configureTestingModule({ + declarations: [ + BrowseByPageComponent, + BrowseBySwitcherComponent, + DynamicComponentLoaderDirective, + ], + providers: [ + BrowseByTestComponent, + { provide: ActivatedRoute, useValue: activatedRoute }, + { provide: ThemeService, useValue: themeService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(BrowseByPageComponent); + component = fixture.componentInstance; + }); + + it('should create the correct browse section based on the route browseDefinition', () => { + activatedRoute.testData = { + browseDefinition: new TestBrowseByPageBrowseDefinition(), + }; + + fixture.detectChanges(); + + expect(component).toBeTruthy(); + expect(fixture.debugElement.query(By.css('#BrowseByTestComponent'))).not.toBeNull(); + }); +}); diff --git a/src/app/browse-by/browse-by-page/browse-by-page.component.ts b/src/app/browse-by/browse-by-page/browse-by-page.component.ts new file mode 100644 index 0000000000..9df02562c6 --- /dev/null +++ b/src/app/browse-by/browse-by-page/browse-by-page.component.ts @@ -0,0 +1,31 @@ +import { Component, OnInit } from '@angular/core'; +import { map } from 'rxjs/operators'; +import { BrowseDefinition } from '../../core/shared/browse-definition.model'; +import { ActivatedRoute } from '@angular/router'; +import { Observable } from 'rxjs'; +import { BrowseByDataType } from '../browse-by-switcher/browse-by-data-type'; + +@Component({ + selector: 'ds-browse-by-page', + templateUrl: './browse-by-page.component.html', + styleUrls: ['./browse-by-page.component.scss'], +}) +export class BrowseByPageComponent implements OnInit { + + browseByType$: Observable; + + constructor( + protected route: ActivatedRoute, + ) { + } + + /** + * Fetch the correct browse-by component by using the relevant config from the route data + */ + ngOnInit(): void { + this.browseByType$ = this.route.data.pipe( + map((data: { browseDefinition: BrowseDefinition }) => data.browseDefinition.getRenderType()), + ); + } + +} diff --git a/src/app/browse-by/browse-by-routing.module.ts b/src/app/browse-by/browse-by-routing.module.ts index bb67dc65ae..f07df26b32 100644 --- a/src/app/browse-by/browse-by-routing.module.ts +++ b/src/app/browse-by/browse-by-routing.module.ts @@ -3,7 +3,7 @@ import { NgModule } from '@angular/core'; import { BrowseByGuard } from './browse-by-guard'; import { BrowseByDSOBreadcrumbResolver } from './browse-by-dso-breadcrumb.resolver'; import { BrowseByI18nBreadcrumbResolver } from './browse-by-i18n-breadcrumb.resolver'; -import { ThemedBrowseBySwitcherComponent } from './browse-by-switcher/themed-browse-by-switcher.component'; +import { BrowseByPageComponent } from './browse-by-page/browse-by-page.component'; import { DSOEditMenuResolver } from '../shared/dso-page/dso-edit-menu.resolver'; @NgModule({ @@ -18,7 +18,7 @@ import { DSOEditMenuResolver } from '../shared/dso-page/dso-edit-menu.resolver'; children: [ { path: ':id', - component: ThemedBrowseBySwitcherComponent, + component: BrowseByPageComponent, canActivate: [BrowseByGuard], resolve: { breadcrumb: BrowseByI18nBreadcrumbResolver }, data: { title: 'browse.title.page', breadcrumbKey: 'browse.metadata' } diff --git a/src/app/browse-by/browse-by-switcher/browse-by-data-type.ts b/src/app/browse-by/browse-by-switcher/browse-by-data-type.ts new file mode 100644 index 0000000000..5324018b34 --- /dev/null +++ b/src/app/browse-by/browse-by-switcher/browse-by-data-type.ts @@ -0,0 +1,6 @@ +export enum BrowseByDataType { + Title = 'title', + Metadata = 'text', + Date = 'date', + Hierarchy = 'hierarchy', +} diff --git a/src/app/browse-by/browse-by-switcher/browse-by-decorator.spec.ts b/src/app/browse-by/browse-by-switcher/browse-by-decorator.spec.ts index 19a6277151..64604cdc04 100644 --- a/src/app/browse-by/browse-by-switcher/browse-by-decorator.spec.ts +++ b/src/app/browse-by/browse-by-switcher/browse-by-decorator.spec.ts @@ -1,4 +1,5 @@ -import { BrowseByDataType, rendersBrowseBy } from './browse-by-decorator'; +import { BrowseByDataType } from './browse-by-data-type'; +import { rendersBrowseBy } from './browse-by-decorator'; describe('BrowseByDecorator', () => { const titleDecorator = rendersBrowseBy(BrowseByDataType.Title); diff --git a/src/app/browse-by/browse-by-switcher/browse-by-decorator.ts b/src/app/browse-by/browse-by-switcher/browse-by-decorator.ts index b59a46cae1..62e666227d 100644 --- a/src/app/browse-by/browse-by-switcher/browse-by-decorator.ts +++ b/src/app/browse-by/browse-by-switcher/browse-by-decorator.ts @@ -1,40 +1,36 @@ +import { Component } from '@angular/core'; import { hasNoValue } from '../../shared/empty.util'; -import { InjectionToken } from '@angular/core'; +import { DEFAULT_THEME, resolveTheme } from '../../shared/object-collection/shared/listable-object/listable-object.decorator'; +import { Context } from '../../core/shared/context.model'; import { GenericConstructor } from '../../core/shared/generic-constructor'; -import { - DEFAULT_THEME, - resolveTheme -} from '../../shared/object-collection/shared/listable-object/listable-object.decorator'; - -export enum BrowseByDataType { - Title = 'title', - Metadata = 'text', - Date = 'date' -} +import { BrowseByDataType } from './browse-by-data-type'; export const DEFAULT_BROWSE_BY_TYPE = BrowseByDataType.Metadata; +export const DEFAULT_BROWSE_BY_CONTEXT = Context.Any; -export const BROWSE_BY_COMPONENT_FACTORY = new InjectionToken<(browseByType, theme) => GenericConstructor>('getComponentByBrowseByType', { - providedIn: 'root', - factory: () => getComponentByBrowseByType -}); - -const map = new Map(); +const map: Map>>> = new Map(); /** * Decorator used for rendering Browse-By pages by type * @param browseByType The type of page + * @param context The optional context for the component * @param theme The optional theme for the component */ -export function rendersBrowseBy(browseByType: string, theme = DEFAULT_THEME) { +export function rendersBrowseBy(browseByType: BrowseByDataType, context = DEFAULT_BROWSE_BY_CONTEXT, theme = DEFAULT_THEME) { return function decorator(component: any) { + if (hasNoValue(browseByType)) { + return; + } if (hasNoValue(map.get(browseByType))) { map.set(browseByType, new Map()); } - if (hasNoValue(map.get(browseByType).get(theme))) { - map.get(browseByType).set(theme, component); + if (hasNoValue(map.get(browseByType).get(context))) { + map.get(browseByType).set(context, new Map()); + } + if (hasNoValue(map.get(browseByType).get(context).get(theme))) { + map.get(browseByType).get(context).set(theme, component); } else { - throw new Error(`There can't be more than one component to render Browse-By of type "${browseByType}" and theme "${theme}"`); + throw new Error(`There can't be more than one component to render Browse-By of type "${browseByType}", context "${context}" and theme "${theme}"`); } }; } @@ -42,12 +38,17 @@ export function rendersBrowseBy(browseByType: string, theme = DEFAULT_THEME) { /** * Get the component used for rendering a Browse-By page by type * @param browseByType The type of page + * @param context The context to match * @param theme the theme to match */ -export function getComponentByBrowseByType(browseByType, theme) { - let themeMap = map.get(browseByType); +export function getComponentByBrowseByType(browseByType: BrowseByDataType, context: Context, theme: string): GenericConstructor { + let contextMap: Map>> = map.get(browseByType); + if (hasNoValue(contextMap)) { + contextMap = map.get(DEFAULT_BROWSE_BY_TYPE); + } + let themeMap: Map> = contextMap.get(context); if (hasNoValue(themeMap)) { - themeMap = map.get(DEFAULT_BROWSE_BY_TYPE); + themeMap = contextMap.get(DEFAULT_BROWSE_BY_CONTEXT); } const comp = resolveTheme(themeMap, theme); if (hasNoValue(comp)) { diff --git a/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.html b/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.html deleted file mode 100644 index afe79cf2b1..0000000000 --- a/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.html +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.spec.ts b/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.spec.ts index c13405dd4d..65e8c87c61 100644 --- a/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.spec.ts +++ b/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.spec.ts @@ -1,13 +1,23 @@ import { BrowseBySwitcherComponent } from './browse-by-switcher.component'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; -import { BROWSE_BY_COMPONENT_FACTORY, BrowseByDataType } from './browse-by-decorator'; -import { BehaviorSubject } from 'rxjs'; +import { SimpleChange, Component } from '@angular/core'; +import { rendersBrowseBy } from './browse-by-decorator'; import { ThemeService } from '../../shared/theme-support/theme.service'; import { FlatBrowseDefinition } from '../../core/shared/flat-browse-definition.model'; import { ValueListBrowseDefinition } from '../../core/shared/value-list-browse-definition.model'; import { NonHierarchicalBrowseDefinition } from '../../core/shared/non-hierarchical-browse-definition'; +import { getMockThemeService } from '../../shared/mocks/theme-service.mock'; +import { DynamicComponentLoaderDirective } from '../../shared/abstract-component-loader/dynamic-component-loader.directive'; +import { BrowseByDataType } from './browse-by-data-type'; + +@rendersBrowseBy('BrowseBySwitcherComponent' as BrowseByDataType) +@Component({ + // eslint-disable-next-line @angular-eslint/component-selector + selector: '', + template: '', +}) +class BrowseByTestComponent { +} describe('BrowseBySwitcherComponent', () => { let comp: BrowseBySwitcherComponent; @@ -41,46 +51,45 @@ describe('BrowseBySwitcherComponent', () => { ), ]; - const data = new BehaviorSubject(createDataWithBrowseDefinition(new FlatBrowseDefinition())); - - const activatedRouteStub = { - data - }; - let themeService: ThemeService; - let themeName: string; + const themeName = 'dspace'; beforeEach(waitForAsync(() => { - themeName = 'dspace'; - themeService = jasmine.createSpyObj('themeService', { - getThemeName: themeName, - }); + themeService = getMockThemeService(themeName); - TestBed.configureTestingModule({ - declarations: [BrowseBySwitcherComponent], - providers: [ - { provide: ActivatedRoute, useValue: activatedRouteStub }, - { provide: ThemeService, useValue: themeService }, - { provide: BROWSE_BY_COMPONENT_FACTORY, useValue: jasmine.createSpy('getComponentByBrowseByType').and.returnValue(null) } + void TestBed.configureTestingModule({ + declarations: [ + BrowseBySwitcherComponent, + DynamicComponentLoaderDirective, + ], + providers: [ + BrowseByTestComponent, + { provide: ThemeService, useValue: themeService }, ], - schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); })); beforeEach(waitForAsync(() => { fixture = TestBed.createComponent(BrowseBySwitcherComponent); comp = fixture.componentInstance; + spyOn(comp, 'getComponent').and.returnValue(BrowseByTestComponent); + spyOn(comp, 'connectInputsAndOutputs').and.callThrough(); })); types.forEach((type: NonHierarchicalBrowseDefinition) => { describe(`when switching to a browse-by page for "${type.id}"`, () => { - beforeEach(() => { - data.next(createDataWithBrowseDefinition(type)); + beforeEach(async () => { + comp.browseByType = type.dataType; + comp.ngOnChanges({ + browseByType: new SimpleChange(undefined, type.dataType, true), + }); fixture.detectChanges(); + await fixture.whenStable(); }); - it(`should call getComponentByBrowseByType with type "${type.dataType}"`, () => { - expect((comp as any).getComponentByBrowseByType).toHaveBeenCalledWith(type.dataType, themeName); + it(`should call getComponent with type "${type.dataType}"`, () => { + expect(comp.getComponent).toHaveBeenCalled(); + expect(comp.connectInputsAndOutputs).toHaveBeenCalled(); }); }); }); diff --git a/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.ts b/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.ts index 35e4edf900..99b969417e 100644 --- a/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.ts +++ b/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.ts @@ -1,38 +1,38 @@ -import { Component, Inject, OnInit } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; -import { Observable } from 'rxjs'; -import { map } from 'rxjs/operators'; -import { BROWSE_BY_COMPONENT_FACTORY } from './browse-by-decorator'; +import { Component, Input } from '@angular/core'; +import { getComponentByBrowseByType } from './browse-by-decorator'; import { GenericConstructor } from '../../core/shared/generic-constructor'; -import { BrowseDefinition } from '../../core/shared/browse-definition.model'; -import { ThemeService } from '../../shared/theme-support/theme.service'; +import { AbstractComponentLoaderComponent } from '../../shared/abstract-component-loader/abstract-component-loader.component'; +import { BrowseByDataType } from './browse-by-data-type'; +import { Context } from '../../core/shared/context.model'; @Component({ selector: 'ds-browse-by-switcher', - templateUrl: './browse-by-switcher.component.html' + templateUrl: '../../shared/abstract-component-loader/abstract-component-loader.component.html' }) -/** - * Component for determining what Browse-By component to use depending on the metadata (browse ID) provided - */ -export class BrowseBySwitcherComponent implements OnInit { +export class BrowseBySwitcherComponent extends AbstractComponentLoaderComponent { - /** - * Resolved browse-by component - */ - browseByComponent: Observable; + @Input() context: Context; - public constructor(protected route: ActivatedRoute, - protected themeService: ThemeService, - @Inject(BROWSE_BY_COMPONENT_FACTORY) private getComponentByBrowseByType: (browseByType, theme) => GenericConstructor) { - } + @Input() browseByType: BrowseByDataType; - /** - * Fetch the correct browse-by component by using the relevant config from the route data - */ - ngOnInit(): void { - this.browseByComponent = this.route.data.pipe( - map((data: { browseDefinition: BrowseDefinition }) => this.getComponentByBrowseByType(data.browseDefinition.getRenderType(), this.themeService.getThemeName())) - ); + @Input() displayTitle: boolean; + + @Input() scope: string; + + protected inputNamesDependentForComponent: (keyof this & string)[] = [ + 'context', + 'browseByType', + ]; + + protected inputNames: (keyof this & string)[] = [ + 'context', + 'browseByType', + 'displayTitle', + 'scope', + ]; + + public getComponent(): GenericConstructor { + return getComponentByBrowseByType(this.browseByType, this.context, this.themeService.getThemeName()); } } diff --git a/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-actions-loader/advanced-workflow-actions.directive.ts b/src/app/browse-by/browse-by-switcher/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/browse-by/browse-by-switcher/dynamic-component-loader.directive.ts index e569f6cc6f..8c77df1cdb 100644 --- a/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-actions-loader/advanced-workflow-actions.directive.ts +++ b/src/app/browse-by/browse-by-switcher/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/browse-by/browse-by-switcher/themed-browse-by-switcher.component.ts b/src/app/browse-by/browse-by-switcher/themed-browse-by-switcher.component.ts deleted file mode 100644 index 0187d4e3c5..0000000000 --- a/src/app/browse-by/browse-by-switcher/themed-browse-by-switcher.component.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Component } from '@angular/core'; - -import { ThemedComponent } from '../../shared/theme-support/themed.component'; -import { BrowseBySwitcherComponent } from './browse-by-switcher.component'; - -/** - * Themed wrapper for BrowseBySwitcherComponent - */ -@Component({ - selector: 'ds-themed-browse-by-switcher', - styleUrls: [], - templateUrl: '../../shared/theme-support/themed.component.html' -}) -export class ThemedBrowseBySwitcherComponent extends ThemedComponent { - protected getComponentName(): string { - return 'BrowseBySwitcherComponent'; - } - - protected importThemedComponent(themeName: string): Promise { - return import(`../../../themes/${themeName}/app/browse-by/browse-by-switcher/browse-by-switcher.component`); - } - - protected importUnthemedComponent(): Promise { - return import(`./browse-by-switcher.component`); - } - - -} diff --git a/src/app/browse-by/browse-by-taxonomy-page/themed-browse-by-taxonomy-page.component.ts b/src/app/browse-by/browse-by-taxonomy-page/themed-browse-by-taxonomy-page.component.ts deleted file mode 100644 index 212044b853..0000000000 --- a/src/app/browse-by/browse-by-taxonomy-page/themed-browse-by-taxonomy-page.component.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Component } from '@angular/core'; -import { ThemedComponent } from '../../shared/theme-support/themed.component'; -import { rendersBrowseBy } from '../browse-by-switcher/browse-by-decorator'; -import { BrowseByTaxonomyPageComponent } from './browse-by-taxonomy-page.component'; - -@Component({ - selector: 'ds-themed-browse-by-taxonomy-page', - templateUrl: '../../shared/theme-support/themed.component.html', - styleUrls: [] -}) -/** - * Themed wrapper for BrowseByTaxonomyPageComponent - */ -@rendersBrowseBy('hierarchy') -export class ThemedBrowseByTaxonomyPageComponent extends ThemedComponent{ - - protected getComponentName(): string { - return 'BrowseByTaxonomyPageComponent'; - } - - protected importThemedComponent(themeName: string): Promise { - return import(`../../../themes/${themeName}/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component`); - } - - protected importUnthemedComponent(): Promise { - return import(`./browse-by-taxonomy-page.component`); - } -} diff --git a/src/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.html b/src/app/browse-by/browse-by-taxonomy/browse-by-taxonomy.component.html similarity index 70% rename from src/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.html rename to src/app/browse-by/browse-by-taxonomy/browse-by-taxonomy.component.html index c24ca93403..3dd9b6b25a 100644 --- a/src/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.html +++ b/src/app/browse-by/browse-by-taxonomy/browse-by-taxonomy.component.html @@ -1,5 +1,11 @@ -
-

{{ ('browse.taxonomy_' + vocabularyName + '.title') | translate }}

+
+

+ {{ ('browse.title') | translate:{ + field: 'browse.metadata.' + vocabularyName | translate, + startsWith: '', + value: '', + } }} +

{{ 'browse.taxonomy.button' | translate }} -
+
diff --git a/src/app/community-page/sub-community-list/community-page-sub-community-list.component.scss b/src/app/browse-by/browse-by-taxonomy/browse-by-taxonomy.component.scss similarity index 100% rename from src/app/community-page/sub-community-list/community-page-sub-community-list.component.scss rename to src/app/browse-by/browse-by-taxonomy/browse-by-taxonomy.component.scss diff --git a/src/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.spec.ts b/src/app/browse-by/browse-by-taxonomy/browse-by-taxonomy.component.spec.ts similarity index 68% rename from src/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.spec.ts rename to src/app/browse-by/browse-by-taxonomy/browse-by-taxonomy.component.spec.ts index c724017b1f..ac8dbff598 100644 --- a/src/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.spec.ts +++ b/src/app/browse-by/browse-by-taxonomy/browse-by-taxonomy.component.spec.ts @@ -1,6 +1,5 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { BrowseByTaxonomyPageComponent } from './browse-by-taxonomy-page.component'; +import { BrowseByTaxonomyComponent } from './browse-by-taxonomy.component'; import { VocabularyEntryDetail } from '../../core/submission/vocabularies/models/vocabulary-entry-detail.model'; import { TranslateModule } from '@ngx-translate/core'; import { NO_ERRORS_SCHEMA } from '@angular/core'; @@ -10,9 +9,9 @@ import { createDataWithBrowseDefinition } from '../browse-by-switcher/browse-by- import { HierarchicalBrowseDefinition } from '../../core/shared/hierarchical-browse-definition.model'; import { ThemeService } from '../../shared/theme-support/theme.service'; -describe('BrowseByTaxonomyPageComponent', () => { - let component: BrowseByTaxonomyPageComponent; - let fixture: ComponentFixture; +describe('BrowseByTaxonomyComponent', () => { + let component: BrowseByTaxonomyComponent; + let fixture: ComponentFixture; let themeService: ThemeService; let detail1: VocabularyEntryDetail; let detail2: VocabularyEntryDetail; @@ -29,7 +28,9 @@ describe('BrowseByTaxonomyPageComponent', () => { await TestBed.configureTestingModule({ imports: [ TranslateModule.forRoot() ], - declarations: [ BrowseByTaxonomyPageComponent ], + declarations: [ + BrowseByTaxonomyComponent, + ], providers: [ { provide: ActivatedRoute, useValue: activatedRouteStub }, { provide: ThemeService, useValue: themeService }, @@ -40,8 +41,9 @@ describe('BrowseByTaxonomyPageComponent', () => { }); beforeEach(() => { - fixture = TestBed.createComponent(BrowseByTaxonomyPageComponent); + fixture = TestBed.createComponent(BrowseByTaxonomyComponent); component = fixture.componentInstance; + spyOn(component, 'updateQueryParams').and.callThrough(); fixture.detectChanges(); detail1 = new VocabularyEntryDetail(); detail2 = new VocabularyEntryDetail(); @@ -61,6 +63,7 @@ describe('BrowseByTaxonomyPageComponent', () => { expect(component.selectedItems).toContain(detail1); expect(component.selectedItems.length).toBe(1); expect(component.filterValues).toEqual(['HUMANITIES and RELIGION,equals'] ); + expect(component.updateQueryParams).toHaveBeenCalled(); }); it('should handle select event with multiple selected items', () => { @@ -70,6 +73,7 @@ describe('BrowseByTaxonomyPageComponent', () => { expect(component.selectedItems).toContain(detail1, detail2); expect(component.selectedItems.length).toBe(2); expect(component.filterValues).toEqual(['HUMANITIES and RELIGION,equals', 'TECHNOLOGY,equals'] ); + expect(component.updateQueryParams).toHaveBeenCalled(); }); it('should handle deselect event', () => { @@ -82,6 +86,33 @@ describe('BrowseByTaxonomyPageComponent', () => { expect(component.selectedItems).toContain(detail2); expect(component.selectedItems.length).toBe(1); expect(component.filterValues).toEqual(['TECHNOLOGY,equals'] ); + expect(component.updateQueryParams).toHaveBeenCalled(); + }); + + describe('updateQueryParams', () => { + beforeEach(() => { + component.facetType = 'subject'; + component.filterValues = ['HUMANITIES and RELIGION,equals', 'TECHNOLOGY,equals']; + }); + + it('should update the queryParams with the selected filterValues', () => { + component.updateQueryParams(); + + expect(component.queryParams).toEqual({ + 'f.subject': ['HUMANITIES and RELIGION,equals', 'TECHNOLOGY,equals'], + }); + }); + + it('should include the scope if present', () => { + component.scope = '67f849f1-2499-4872-8c61-9e2b47d71068'; + + component.updateQueryParams(); + + expect(component.queryParams).toEqual({ + 'f.subject': ['HUMANITIES and RELIGION,equals', 'TECHNOLOGY,equals'], + 'scope': '67f849f1-2499-4872-8c61-9e2b47d71068', + }); + }); }); afterEach(() => { diff --git a/src/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.ts b/src/app/browse-by/browse-by-taxonomy/browse-by-taxonomy.component.ts similarity index 50% rename from src/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.ts rename to src/app/browse-by/browse-by-taxonomy/browse-by-taxonomy.component.ts index cf6345bf39..d23a3dd15c 100644 --- a/src/app/browse-by/browse-by-taxonomy-page/browse-by-taxonomy-page.component.ts +++ b/src/app/browse-by/browse-by-taxonomy/browse-by-taxonomy.component.ts @@ -1,24 +1,48 @@ -import { Component, OnInit, Inject, OnDestroy } from '@angular/core'; +import { Component, OnInit, OnChanges, OnDestroy, Input } from '@angular/core'; import { VocabularyOptions } from '../../core/submission/vocabularies/models/vocabulary-options.model'; import { VocabularyEntryDetail } from '../../core/submission/vocabularies/models/vocabulary-entry-detail.model'; -import { ActivatedRoute } from '@angular/router'; -import { Observable, Subscription } from 'rxjs'; +import { ActivatedRoute, Params } from '@angular/router'; +import { BehaviorSubject, Observable, Subscription } from 'rxjs'; import { BrowseDefinition } from '../../core/shared/browse-definition.model'; -import { GenericConstructor } from '../../core/shared/generic-constructor'; -import { BROWSE_BY_COMPONENT_FACTORY } from '../browse-by-switcher/browse-by-decorator'; +import { rendersBrowseBy } from '../browse-by-switcher/browse-by-decorator'; import { map } from 'rxjs/operators'; -import { ThemeService } from 'src/app/shared/theme-support/theme.service'; import { HierarchicalBrowseDefinition } from '../../core/shared/hierarchical-browse-definition.model'; +import { BrowseByDataType } from '../browse-by-switcher/browse-by-data-type'; +import { Context } from '../../core/shared/context.model'; +import { hasValue } from '../../shared/empty.util'; @Component({ - selector: 'ds-browse-by-taxonomy-page', - templateUrl: './browse-by-taxonomy-page.component.html', - styleUrls: ['./browse-by-taxonomy-page.component.scss'] + selector: 'ds-browse-by-taxonomy', + templateUrl: './browse-by-taxonomy.component.html', + styleUrls: ['./browse-by-taxonomy.component.scss'], }) /** * Component for browsing items by metadata in a hierarchical controlled vocabulary */ -export class BrowseByTaxonomyPageComponent implements OnInit, OnDestroy { +@rendersBrowseBy(BrowseByDataType.Hierarchy) +export class BrowseByTaxonomyComponent implements OnInit, OnChanges, OnDestroy { + + /** + * The optional context + */ + @Input() context: Context; + + /** + * The {@link BrowseByDataType} of this Component + */ + @Input() browseByType: BrowseByDataType; + + /** + * The ID of the {@link Community} or {@link Collection} of the scope to display + */ + @Input() scope: string; + + /** + * Display the h1 title in the section + */ + @Input() displayTitle = true; + + scope$: BehaviorSubject = new BehaviorSubject(undefined); /** * The {@link VocabularyOptions} object @@ -48,35 +72,41 @@ export class BrowseByTaxonomyPageComponent implements OnInit, OnDestroy { /** * The parameters used in the URL */ - queryParams: any; + queryParams: Params; /** - * Resolved browse-by component + * Resolved browse-by definition */ - browseByComponent: Observable; + browseDefinition$: Observable; /** * Subscriptions to track */ - browseByComponentSubs: Subscription[] = []; + subs: Subscription[] = []; - public constructor( protected route: ActivatedRoute, - protected themeService: ThemeService, - @Inject(BROWSE_BY_COMPONENT_FACTORY) private getComponentByBrowseByType: (browseByType, theme) => GenericConstructor) { + public constructor( + protected route: ActivatedRoute, + ) { } ngOnInit(): void { - this.browseByComponent = this.route.data.pipe( + this.browseDefinition$ = this.route.data.pipe( map((data: { browseDefinition: BrowseDefinition }) => { - this.getComponentByBrowseByType(data.browseDefinition.getRenderType(), this.themeService.getThemeName()); return data.browseDefinition; }) ); - this.browseByComponentSubs.push(this.browseByComponent.subscribe((browseDefinition: HierarchicalBrowseDefinition) => { + this.subs.push(this.browseDefinition$.subscribe((browseDefinition: HierarchicalBrowseDefinition) => { this.facetType = browseDefinition.facetType; this.vocabularyName = browseDefinition.vocabulary; this.vocabularyOptions = { name: this.vocabularyName, closed: true }; })); + this.subs.push(this.scope$.subscribe(() => { + this.updateQueryParams(); + })); + } + + ngOnChanges(): void { + this.scope$.next(this.scope); } /** @@ -86,9 +116,9 @@ export class BrowseByTaxonomyPageComponent implements OnInit, OnDestroy { * @param detail VocabularyEntryDetail to be added */ onSelect(detail: VocabularyEntryDetail): void { - this.selectedItems.push(detail); - this.filterValues = this.selectedItems - .map((item: VocabularyEntryDetail) => `${item.value},equals`); + this.selectedItems.push(detail); + this.filterValues = this.selectedItems + .map((item: VocabularyEntryDetail) => `${item.value},equals`); this.updateQueryParams(); } @@ -98,21 +128,28 @@ export class BrowseByTaxonomyPageComponent implements OnInit, OnDestroy { * @param detail VocabularyEntryDetail to be removed */ onDeselect(detail: VocabularyEntryDetail): void { - this.selectedItems = this.selectedItems.filter((entry: VocabularyEntryDetail) => { return entry.id !== detail.id; }); - this.filterValues = this.filterValues.filter((value: string) => { return value !== `${detail.value},equals`; }); + this.selectedItems = this.selectedItems.filter((entry: VocabularyEntryDetail) => { + return entry.id !== detail.id; + }); + this.filterValues = this.filterValues.filter((value: string) => { + return value !== `${detail.value},equals`; + }); this.updateQueryParams(); } /** * Updates queryParams based on the current facetType and filterValues. */ - private updateQueryParams(): void { + updateQueryParams(): void { this.queryParams = { ['f.' + this.facetType]: this.filterValues }; + if (hasValue(this.scope)) { + this.queryParams.scope = this.scope; + } } ngOnDestroy(): void { - this.browseByComponentSubs.forEach((sub: Subscription) => sub.unsubscribe()); + this.subs.forEach((sub: Subscription) => sub.unsubscribe()); } } diff --git a/src/app/browse-by/browse-by-title-page/browse-by-title-page.component.ts b/src/app/browse-by/browse-by-title-page/browse-by-title-page.component.ts deleted file mode 100644 index 58df79ebe8..0000000000 --- a/src/app/browse-by/browse-by-title-page/browse-by-title-page.component.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { combineLatest as observableCombineLatest } from 'rxjs'; -import { Component, Inject } from '@angular/core'; -import { ActivatedRoute, Params, Router } from '@angular/router'; -import { hasValue } from '../../shared/empty.util'; -import { - BrowseByMetadataPageComponent, - browseParamsToOptions, getBrowseSearchOptions -} from '../browse-by-metadata-page/browse-by-metadata-page.component'; -import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; -import { BrowseService } from '../../core/browse/browse.service'; -import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; -import { PaginationService } from '../../core/pagination/pagination.service'; -import { map } from 'rxjs/operators'; -import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; -import { AppConfig, APP_CONFIG } from '../../../config/app-config.interface'; -import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; - -@Component({ - selector: 'ds-browse-by-title-page', - styleUrls: ['../browse-by-metadata-page/browse-by-metadata-page.component.scss'], - templateUrl: '../browse-by-metadata-page/browse-by-metadata-page.component.html' -}) -/** - * Component for browsing items by title (dc.title) - */ -export class BrowseByTitlePageComponent extends BrowseByMetadataPageComponent { - - public constructor(protected route: ActivatedRoute, - protected browseService: BrowseService, - protected dsoService: DSpaceObjectDataService, - protected paginationService: PaginationService, - protected router: Router, - @Inject(APP_CONFIG) public appConfig: AppConfig, - public dsoNameService: DSONameService, - ) { - super(route, browseService, dsoService, paginationService, router, appConfig, dsoNameService); - } - - ngOnInit(): void { - const sortConfig = new SortOptions('dc.title', SortDirection.ASC); - // include the thumbnail configuration in browse search options - this.updatePage(getBrowseSearchOptions(this.defaultBrowseId, this.paginationConfig, sortConfig, this.fetchThumbnails)); - this.currentPagination$ = this.paginationService.getCurrentPagination(this.paginationConfig.id, this.paginationConfig); - this.currentSort$ = this.paginationService.getCurrentSort(this.paginationConfig.id, sortConfig); - this.subs.push( - observableCombineLatest([this.route.params, this.route.queryParams, this.currentPagination$, this.currentSort$]).pipe( - map(([routeParams, queryParams, currentPage, currentSort]) => { - return [Object.assign({}, routeParams, queryParams),currentPage,currentSort]; - }) - ).subscribe(([params, currentPage, currentSort]: [Params, PaginationComponentOptions, SortOptions]) => { - this.startsWith = +params.startsWith || params.startsWith; - this.browseId = params.id || this.defaultBrowseId; - this.updatePageWithItems(browseParamsToOptions(params, currentPage, currentSort, this.browseId, this.fetchThumbnails), undefined, undefined); - this.updateParent(params.scope); - this.updateLogo(); - })); - this.updateStartsWithTextOptions(); - } - - ngOnDestroy(): void { - this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); - } - -} diff --git a/src/app/browse-by/browse-by-title-page/themed-browse-by-title-page.component.ts b/src/app/browse-by/browse-by-title-page/themed-browse-by-title-page.component.ts deleted file mode 100644 index 4a1bcc0bc1..0000000000 --- a/src/app/browse-by/browse-by-title-page/themed-browse-by-title-page.component.ts +++ /dev/null @@ -1,29 +0,0 @@ -import {Component} from '@angular/core'; -import { ThemedComponent } from '../../shared/theme-support/themed.component'; -import { BrowseByTitlePageComponent } from './browse-by-title-page.component'; -import {BrowseByDataType, rendersBrowseBy} from '../browse-by-switcher/browse-by-decorator'; - -/** - * Themed wrapper for BrowseByTitlePageComponent - */ -@Component({ - selector: 'ds-themed-browse-by-title-page', - styleUrls: [], - templateUrl: '../../shared/theme-support/themed.component.html', -}) - -@rendersBrowseBy(BrowseByDataType.Title) -export class ThemedBrowseByTitlePageComponent - extends ThemedComponent { - protected getComponentName(): string { - return 'BrowseByTitlePageComponent'; - } - - protected importThemedComponent(themeName: string): Promise { - return import(`../../../themes/${themeName}/app/browse-by/browse-by-title-page/browse-by-title-page.component`); - } - - protected importUnthemedComponent(): Promise { - return import(`./browse-by-title-page.component`); - } -} diff --git a/src/app/browse-by/browse-by-title-page/browse-by-title-page.component.spec.ts b/src/app/browse-by/browse-by-title/browse-by-title.component.spec.ts similarity index 87% rename from src/app/browse-by/browse-by-title-page/browse-by-title-page.component.spec.ts rename to src/app/browse-by/browse-by-title/browse-by-title.component.spec.ts index e32c0ac430..54394087ec 100644 --- a/src/app/browse-by/browse-by-title-page/browse-by-title-page.component.spec.ts +++ b/src/app/browse-by/browse-by-title/browse-by-title.component.spec.ts @@ -9,8 +9,8 @@ import { TranslateModule } from '@ngx-translate/core'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { EnumKeysPipe } from '../../shared/utils/enum-keys-pipe'; import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { toRemoteData } from '../browse-by-metadata-page/browse-by-metadata-page.component.spec'; -import { BrowseByTitlePageComponent } from './browse-by-title-page.component'; +import { toRemoteData } from '../browse-by-metadata/browse-by-metadata.component.spec'; +import { BrowseByTitleComponent } from './browse-by-title.component'; import { ItemDataService } from '../../core/data/item-data.service'; import { Community } from '../../core/shared/community.model'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; @@ -24,9 +24,9 @@ import { APP_CONFIG } from '../../../config/app-config.interface'; import { environment } from '../../../environments/environment'; -describe('BrowseByTitlePageComponent', () => { - let comp: BrowseByTitlePageComponent; - let fixture: ComponentFixture; +describe('BrowseByTitleComponent', () => { + let comp: BrowseByTitleComponent; + let fixture: ComponentFixture; let itemDataService: ItemDataService; let route: ActivatedRoute; @@ -71,7 +71,7 @@ describe('BrowseByTitlePageComponent', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule], - declarations: [BrowseByTitlePageComponent, EnumKeysPipe, VarDirective], + declarations: [BrowseByTitleComponent, EnumKeysPipe, VarDirective], providers: [ { provide: ActivatedRoute, useValue: activatedRouteStub }, { provide: BrowseService, useValue: mockBrowseService }, @@ -85,7 +85,7 @@ describe('BrowseByTitlePageComponent', () => { })); beforeEach(() => { - fixture = TestBed.createComponent(BrowseByTitlePageComponent); + fixture = TestBed.createComponent(BrowseByTitleComponent); comp = fixture.componentInstance; fixture.detectChanges(); itemDataService = (comp as any).itemDataService; diff --git a/src/app/browse-by/browse-by-title/browse-by-title.component.ts b/src/app/browse-by/browse-by-title/browse-by-title.component.ts new file mode 100644 index 0000000000..7b603af48b --- /dev/null +++ b/src/app/browse-by/browse-by-title/browse-by-title.component.ts @@ -0,0 +1,44 @@ +import { combineLatest as observableCombineLatest } from 'rxjs'; +import { Component, OnInit } from '@angular/core'; +import { Params } from '@angular/router'; +import { + BrowseByMetadataComponent, + browseParamsToOptions, getBrowseSearchOptions +} from '../browse-by-metadata/browse-by-metadata.component'; +import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; +import { map } from 'rxjs/operators'; +import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; +import { rendersBrowseBy } from '../browse-by-switcher/browse-by-decorator'; +import { BrowseByDataType } from '../browse-by-switcher/browse-by-data-type'; + +@Component({ + selector: 'ds-browse-by-title', + styleUrls: ['../browse-by-metadata/browse-by-metadata.component.scss'], + templateUrl: '../browse-by-metadata/browse-by-metadata.component.html' +}) +/** + * Component for browsing items by title (dc.title) + */ +@rendersBrowseBy(BrowseByDataType.Title) +export class BrowseByTitleComponent extends BrowseByMetadataComponent implements OnInit { + + ngOnInit(): void { + const sortConfig = new SortOptions('dc.title', SortDirection.ASC); + // include the thumbnail configuration in browse search options + this.updatePage(getBrowseSearchOptions(this.defaultBrowseId, this.paginationConfig, sortConfig, this.fetchThumbnails)); + this.currentPagination$ = this.paginationService.getCurrentPagination(this.paginationConfig.id, this.paginationConfig); + this.currentSort$ = this.paginationService.getCurrentSort(this.paginationConfig.id, sortConfig); + this.subs.push( + observableCombineLatest([this.route.params, this.route.queryParams, this.scope$, this.currentPagination$, this.currentSort$]).pipe( + map(([routeParams, queryParams, scope, currentPage, currentSort]) => { + return [Object.assign({}, routeParams, queryParams), scope, currentPage, currentSort]; + }) + ).subscribe(([params, scope, currentPage, currentSort]: [Params, string, PaginationComponentOptions, SortOptions]) => { + this.startsWith = +params.startsWith || params.startsWith; + this.browseId = params.id || this.defaultBrowseId; + this.updatePageWithItems(browseParamsToOptions(params, scope, currentPage, currentSort, this.browseId, this.fetchThumbnails), undefined, undefined); + })); + this.updateStartsWithTextOptions(); + } + +} diff --git a/src/app/browse-by/browse-by.module.ts b/src/app/browse-by/browse-by.module.ts index ec9f22347f..b1263595b3 100644 --- a/src/app/browse-by/browse-by.module.ts +++ b/src/app/browse-by/browse-by.module.ts @@ -1,50 +1,42 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { BrowseByTitlePageComponent } from './browse-by-title-page/browse-by-title-page.component'; -import { BrowseByMetadataPageComponent } from './browse-by-metadata-page/browse-by-metadata-page.component'; -import { BrowseByDatePageComponent } from './browse-by-date-page/browse-by-date-page.component'; +import { BrowseByTitleComponent } from './browse-by-title/browse-by-title.component'; +import { BrowseByMetadataComponent } from './browse-by-metadata/browse-by-metadata.component'; +import { BrowseByDateComponent } from './browse-by-date/browse-by-date.component'; import { BrowseBySwitcherComponent } from './browse-by-switcher/browse-by-switcher.component'; -import { BrowseByTaxonomyPageComponent } from './browse-by-taxonomy-page/browse-by-taxonomy-page.component'; -import { ThemedBrowseBySwitcherComponent } from './browse-by-switcher/themed-browse-by-switcher.component'; -import { ComcolModule } from '../shared/comcol/comcol.module'; -import { ThemedBrowseByMetadataPageComponent } from './browse-by-metadata-page/themed-browse-by-metadata-page.component'; -import { ThemedBrowseByDatePageComponent } from './browse-by-date-page/themed-browse-by-date-page.component'; -import { ThemedBrowseByTitlePageComponent } from './browse-by-title-page/themed-browse-by-title-page.component'; -import { ThemedBrowseByTaxonomyPageComponent } from './browse-by-taxonomy-page/themed-browse-by-taxonomy-page.component'; +import { BrowseByTaxonomyComponent } from './browse-by-taxonomy/browse-by-taxonomy.component'; import { SharedBrowseByModule } from '../shared/browse-by/shared-browse-by.module'; import { DsoPageModule } from '../shared/dso-page/dso-page.module'; import { FormModule } from '../shared/form/form.module'; import { SharedModule } from '../shared/shared.module'; +const DECLARATIONS = [ + BrowseBySwitcherComponent, +]; + const ENTRY_COMPONENTS = [ // put only entry components that use custom decorator - BrowseByTitlePageComponent, - BrowseByMetadataPageComponent, - BrowseByDatePageComponent, - BrowseByTaxonomyPageComponent, - - ThemedBrowseByMetadataPageComponent, - ThemedBrowseByDatePageComponent, - ThemedBrowseByTitlePageComponent, - ThemedBrowseByTaxonomyPageComponent, + BrowseByTitleComponent, + BrowseByMetadataComponent, + BrowseByDateComponent, + BrowseByTaxonomyComponent, ]; @NgModule({ imports: [ SharedBrowseByModule, CommonModule, - ComcolModule, DsoPageModule, FormModule, SharedModule, ], declarations: [ - BrowseBySwitcherComponent, - ThemedBrowseBySwitcherComponent, + ...DECLARATIONS, ...ENTRY_COMPONENTS ], exports: [ - BrowseBySwitcherComponent + ...DECLARATIONS, + ...ENTRY_COMPONENTS, ] }) export class BrowseByModule { diff --git a/src/app/collection-page/collection-item-mapper/collection-item-mapper.component.html b/src/app/collection-page/collection-item-mapper/collection-item-mapper.component.html index 649aa9b43d..77f85d5f78 100644 --- a/src/app/collection-page/collection-item-mapper/collection-item-mapper.component.html +++ b/src/app/collection-page/collection-item-mapper/collection-item-mapper.component.html @@ -6,7 +6,7 @@

{{'collection.edit.item-mapper.description' | translate}}

{{'admin.registries.metadata.schemas.table.selected' | translate}} {{'admin.registries.metadata.schemas.table.id' | translate}} {{'admin.registries.metadata.schemas.table.namespace' | translate}} {{'admin.registries.metadata.schemas.table.name' | translate}} {{schema.id}}
{{'admin.registries.schema.fields.table.selected' | translate}} {{'admin.registries.schema.fields.table.id' | translate}} {{'admin.registries.schema.fields.table.field' | translate}} {{'admin.registries.schema.fields.table.scopenote' | translate}}
- + + {{field.id}} {{schema?.prefix}}.{{field.element}}{{field.qualifier ? '.' + field.qualifier : ''}}
+ + + + + + + + + + + + + + + + + + + + +
{{'quality-assurance.event.table.trust' | translate}}{{'quality-assurance.event.table.publication' | translate}} + {{'quality-assurance.event.table.details' | translate}} + + {{'quality-assurance.event.table.project-details' | translate}} + {{'quality-assurance.event.table.actions' | translate}}
{{eventElement?.event?.trust}} + {{eventElement.title}} + {{eventElement.title}} + +

{{'quality-assurance.event.table.pidtype' | translate}} {{eventElement.event.message.type}}

+

{{'quality-assurance.event.table.pidvalue' | translate}}
+ + {{eventElement.event.message.value}} + + {{eventElement.event.message.value}} +

+
+

{{'quality-assurance.event.table.subjectValue' | translate}}
{{eventElement.event.message.value}}

+
+

+ {{'quality-assurance.event.table.abstract' | translate}}
+ {{eventElement.event.message.abstract}} +

+ +
+

+ {{'quality-assurance.event.table.suggestedProject' | translate}} +

+

+ {{'quality-assurance.event.table.project' | translate}}
+ {{eventElement.event.message.title}} +

+

+ {{'quality-assurance.event.table.acronym' | translate}} {{eventElement.event.message.acronym}}
+ {{'quality-assurance.event.table.code' | translate}} {{eventElement.event.message.code}}
+ {{'quality-assurance.event.table.funder' | translate}} {{eventElement.event.message.funder}}
+ {{'quality-assurance.event.table.fundingProgram' | translate}} {{eventElement.event.message.fundingProgram}}
+ {{'quality-assurance.event.table.jurisdiction' | translate}} {{eventElement.event.message.jurisdiction}} +

+
+
+ {{(eventElement.hasProject ? 'quality-assurance.event.project.found' : 'quality-assurance.event.project.notFound') | translate}} + {{eventElement.handle}} +
+ + +
+
+
+
+ + + + +
+
+

+ + +
+ + + + + + + + + + + + + + + + + + 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 0000000000..29c16328c3 --- /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 0000000000..3349dd3154 --- /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 0000000000..c22c28f41e --- /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 0000000000..0622e08ab0 --- /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 0000000000..7db9839e38 --- /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 0000000000..42a57c2ac5 --- /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 0000000000..ad9c1035a5 --- /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 0000000000..f6d9c19eaa --- /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 0000000000..0f6cf18402 --- /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-date-page/browse-by-date-page.component.scss b/src/app/notifications/qa/source/quality-assurance-source.component.scss similarity index 100% rename from src/themes/custom/app/browse-by/browse-by-date-page/browse-by-date-page.component.scss 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 0000000000..ba3a903cc5 --- /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 0000000000..aef56d09c7 --- /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 0000000000..bd85fb18a0 --- /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 0000000000..fcb717067d --- /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 0000000000..08e26a177a --- /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 0000000000..5ce2ed8ee0 --- /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 0000000000..ea0cb2e5c5 --- /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 0000000000..6b83b1d349 --- /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 0000000000..db8586f264 --- /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-metadata-page/browse-by-metadata-page.component.html b/src/app/notifications/qa/topics/quality-assurance-topics.component.scss similarity index 100% rename from src/themes/custom/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.html 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 0000000000..fd64b82ce7 --- /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 0000000000..542d36a9ed --- /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 0000000000..a7b4dddd62 --- /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 0000000000..a1c002d3f2 --- /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 0000000000..ff94f1b8bb --- /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 0000000000..c6aae27a88 --- /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 0000000000..9dd581ebed --- /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 0000000000..63b2da7a10 --- /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 c4e164a7e8..53f9acdb45 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 04292ea96e..940e382c78 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 187096b391..b5703c47a3 100644 --- a/src/app/profile-page/profile-page.component.html +++ b/src/app/profile-page/profile-page.component.html @@ -1,13 +1,14 @@
- -

{{'profile.head' | translate}}

+

{{'profile.title' | translate}}

+
{{'profile.card.researcher' | translate}}
+
diff --git a/src/app/profile-page/profile-page.module.ts b/src/app/profile-page/profile-page.module.ts index 0e2902de33..126cf7bd8a 100644 --- a/src/app/profile-page/profile-page.module.ts +++ b/src/app/profile-page/profile-page.module.ts @@ -12,7 +12,7 @@ import { ThemedProfilePageComponent } from './themed-profile-page.component'; import { FormModule } from '../shared/form/form.module'; import { UiSwitchModule } from 'ngx-ui-switch'; import { ProfileClaimItemModalComponent } from './profile-claim-item-modal/profile-claim-item-modal.component'; - +import { NotificationsModule } from '../notifications/notifications.module'; @NgModule({ imports: [ @@ -20,7 +20,8 @@ import { ProfileClaimItemModalComponent } from './profile-claim-item-modal/profi CommonModule, SharedModule, FormModule, - UiSwitchModule + UiSwitchModule, + NotificationsModule ], exports: [ ProfilePageComponent, 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 0000000000..2035dbadd0 --- /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 0000000000..76b30fa10b --- /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/shared/abstract-component-loader/dynamic-component-loader.directive.ts b/src/app/shared/abstract-component-loader/dynamic-component-loader.directive.ts new file mode 100644 index 0000000000..8c77df1cdb --- /dev/null +++ b/src/app/shared/abstract-component-loader/dynamic-component-loader.directive.ts @@ -0,0 +1,16 @@ +import { Directive, ViewContainerRef } from '@angular/core'; + +/** + * Directive used as a hook to know where to inject the dynamic loaded component + */ +@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 e6ebabd707..2ec3b51219 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 1a70081367..907edae24b 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 ea64f7e74b..48b27c4be2 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/browse-by/browse-by.component.html b/src/app/shared/browse-by/browse-by.component.html index 37799e805f..7d7e726659 100644 --- a/src/app/shared/browse-by/browse-by.component.html +++ b/src/app/shared/browse-by/browse-by.component.html @@ -1,6 +1,7 @@ -

{{title | translate}}

- +

{{title | translate}}

+ +
diff --git a/src/app/shared/browse-by/browse-by.component.ts b/src/app/shared/browse-by/browse-by.component.ts index 0dc20033f8..465d1e6ceb 100644 --- a/src/app/shared/browse-by/browse-by.component.ts +++ b/src/app/shared/browse-by/browse-by.component.ts @@ -1,4 +1,4 @@ -import { Component, EventEmitter, Injector, Input, OnDestroy, OnInit, Output } from '@angular/core'; +import { Component, EventEmitter, Injector, Input, OnDestroy, OnInit, Output, OnChanges } from '@angular/core'; import { RemoteData } from '../../core/data/remote-data'; import { PaginatedList } from '../../core/data/paginated-list.model'; import { PaginationComponentOptions } from '../pagination/pagination-component-options.model'; @@ -6,7 +6,7 @@ import { SortDirection, SortOptions } from '../../core/cache/models/sort-options import { fadeIn, fadeInOut } from '../animations/fade'; import { BehaviorSubject, combineLatest as observableCombineLatest, Observable, Subscription } from 'rxjs'; import { ListableObject } from '../object-collection/shared/listable-object.model'; -import { getStartsWithComponent, StartsWithType } from '../starts-with/starts-with-decorator'; +import { StartsWithType } from '../starts-with/starts-with-decorator'; import { PaginationService } from '../../core/pagination/pagination.service'; import { ViewMode } from '../../core/shared/view-mode.model'; import { RouteService } from '../../core/services/route.service'; @@ -26,7 +26,7 @@ import { TranslateService } from '@ngx-translate/core'; /** * Component to display a browse-by page for any ListableObject */ -export class BrowseByComponent implements OnInit, OnDestroy { +export class BrowseByComponent implements OnInit, OnChanges, OnDestroy { /** * ViewMode that should be passed to {@link ListableObjectComponentLoaderComponent}. @@ -39,9 +39,10 @@ export class BrowseByComponent implements OnInit, OnDestroy { @Input() title: string; /** - * The parent name + * Whether the title should be displayed */ - @Input() parentname: string; + @Input() displayTitle = true; + /** * The list of objects to display */ @@ -66,7 +67,7 @@ export class BrowseByComponent implements OnInit, OnDestroy { /** * The list of options to render for the StartsWith component */ - @Input() startsWithOptions = []; + @Input() startsWithOptions: (string | number)[] = []; /** * Whether or not the pagination should be rendered as simple previous and next buttons instead of the normal pagination @@ -98,16 +99,6 @@ export class BrowseByComponent implements OnInit, OnDestroy { */ @Output() sortDirectionChange = new EventEmitter(); - /** - * An object injector used to inject the startsWithOptions to the switchable StartsWith component - */ - objectInjector: Injector; - - /** - * Declare SortDirection enumeration to use it in the template - */ - public sortDirections = SortDirection; - /** * Observable that tracks if the back button should be displayed based on the path parameters */ @@ -141,7 +132,7 @@ export class BrowseByComponent implements OnInit, OnDestroy { */ back = () => { const page = +this.previousPage$.value > 1 ? +this.previousPage$.value : 1; - this.paginationService.updateRoute(this.paginationConfig.id, {page: page}, {[this.paginationConfig.id + '.return']: null, value: null, startsWith: null}); + this.paginationService.updateRoute(this.paginationConfig.id, { page: page }, { [this.paginationConfig.id + '.return']: null, value: null, startsWith: null }); }; /** @@ -158,44 +149,19 @@ export class BrowseByComponent implements OnInit, OnDestroy { this.next.emit(true); } - /** - * Change the page size - * @param size - */ - doPageSizeChange(size) { - this.paginationService.updateRoute(this.paginationConfig.id,{pageSize: size}); - } - - /** - * Change the sort direction - * @param direction - */ - doSortDirectionChange(direction) { - this.paginationService.updateRoute(this.paginationConfig.id,{sortDirection: direction}); - } - - /** - * Get the switchable StartsWith component dependant on the type - */ - getStartsWithComponent() { - return getStartsWithComponent(this.type); - } - ngOnInit(): void { - this.objectInjector = Injector.create({ - providers: [ - { provide: 'startsWithOptions', useFactory: () => (this.startsWithOptions), deps:[] }, - { provide: 'paginationId', useFactory: () => (this.paginationConfig?.id), deps:[] } - ], - parent: this.injector - }); - const startsWith$ = this.routeService.getQueryParameterValue('startsWith'); const value$ = this.routeService.getQueryParameterValue('value'); this.shouldDisplayResetButton$ = observableCombineLatest([startsWith$, value$]).pipe( map(([startsWith, value]) => hasValue(startsWith) || hasValue(value)) ); + } + + ngOnChanges(): void { + if (this.sub) { + this.sub.unsubscribe(); + } this.sub = this.routeService.getQueryParameterValue(this.paginationConfig.id + '.return').subscribe(this.previousPage$); } diff --git a/src/app/shared/browse-by/themed-browse-by.component.ts b/src/app/shared/browse-by/themed-browse-by.component.ts index eaa17ebf16..780a860004 100644 --- a/src/app/shared/browse-by/themed-browse-by.component.ts +++ b/src/app/shared/browse-by/themed-browse-by.component.ts @@ -21,7 +21,7 @@ export class ThemedBrowseByComponent extends ThemedComponent @Input() title: string; - @Input() parentname: string; + @Input() displayTitle: boolean; @Input() objects$: Observable>>; @@ -31,7 +31,7 @@ export class ThemedBrowseByComponent extends ThemedComponent @Input() type: StartsWithType; - @Input() startsWithOptions: number[]; + @Input() startsWithOptions: (string | number)[]; @Input() showPaginator: boolean; @@ -47,7 +47,7 @@ export class ThemedBrowseByComponent extends ThemedComponent protected inAndOutputNames: (keyof BrowseByComponent & keyof this)[] = [ 'title', - 'parentname', + 'displayTitle', 'objects$', 'paginationConfig', 'sortConfig', diff --git a/src/app/shared/collection-dropdown/collection-dropdown.component.html b/src/app/shared/collection-dropdown/collection-dropdown.component.html index 470291373f..a27aafa31d 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 907a111900..8a28a3ff7e 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 b7b3d344b1..c79dba42b7 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 c397966fba..a8ca674c26 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}}
    diff --git a/src/app/shared/comcol/comcol-page-browse-by/comcol-page-browse-by.component.html b/src/app/shared/comcol/comcol-page-browse-by/comcol-page-browse-by.component.html index 504d9f4bcd..b0d72f4a72 100644 --- a/src/app/shared/comcol/comcol-page-browse-by/comcol-page-browse-by.component.html +++ b/src/app/shared/comcol/comcol-page-browse-by/comcol-page-browse-by.component.html @@ -1,13 +1,17 @@ -

    {{'browse.comcol.head' | translate}}

    -
    -
    {{'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 5fccd58f48..5288f08e02 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/dso-selector-modal-wrapper.component.spec.ts b/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.spec.ts index e2dbaaa0ff..edefff8592 100644 --- a/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.spec.ts +++ b/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.spec.ts @@ -5,13 +5,14 @@ import { DSpaceObjectType } from '../../../core/shared/dspace-object-type.model' import { Item } from '../../../core/shared/item.model'; import { DSOSelectorModalWrapperComponent, SelectorActionType } from './dso-selector-modal-wrapper.component'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, ActivatedRouteSnapshot } from '@angular/router'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; import { By } from '@angular/platform-browser'; import { DSOSelectorComponent } from '../dso-selector/dso-selector.component'; import { MockComponent } from 'ng-mocks'; import { MetadataValue } from '../../../core/shared/metadata.models'; import { createSuccessfulRemoteDataObject } from '../../remote-data.utils'; +import { hasValue } from '../../empty.util'; describe('DSOSelectorModalWrapperComponent', () => { let component: DSOSelectorModalWrapperComponent; @@ -83,6 +84,20 @@ describe('DSOSelectorModalWrapperComponent', () => { }); }); + describe('selectObject with emit only', () => { + beforeEach(() => { + spyOn(component, 'navigate'); + spyOn(component, 'close'); + spyOn(component.select, 'emit'); + component.emitOnly = true; + component.selectObject(item); + }); + it('should call the close and navigate method on the component with the given DSO', () => { + expect(component.close).toHaveBeenCalled(); + expect(component.select.emit).toHaveBeenCalledWith(item); + }); + }); + describe('close', () => { beforeEach(() => { component.close(); @@ -113,6 +128,19 @@ describe('DSOSelectorModalWrapperComponent', () => { expect(component.close).toHaveBeenCalled(); }); }); + + describe('should find route data', () => { + beforeEach(() => { + spyOn(component, 'findRouteData'); + component.ngOnInit(); + }); + it('should call the findRouteData method on the component', () => { + expect(component.findRouteData).toHaveBeenCalled(); + }); + it('should return undefined', () => { + expect(component.findRouteData((route) => hasValue(route.data), {} as unknown as ActivatedRouteSnapshot)).toEqual(undefined); + }); + }); }); @Component({ diff --git a/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.ts b/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.ts index 3f81687c9f..6798449094 100644 --- a/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.ts +++ b/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.ts @@ -1,4 +1,4 @@ -import { Component, Input, OnInit } from '@angular/core'; +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; import { ActivatedRoute, ActivatedRouteSnapshot } from '@angular/router'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; import { RemoteData } from '../../../core/data/remote-data'; @@ -29,6 +29,11 @@ export abstract class DSOSelectorModalWrapperComponent implements OnInit { */ @Input() dsoRD: RemoteData; + /** + * Representing if component should emit value of selected entries or navigate + */ + @Input() emitOnly = false; + /** * Optional header to display above the selection list * Supports i18n keys @@ -50,6 +55,11 @@ export abstract class DSOSelectorModalWrapperComponent implements OnInit { */ action: SelectorActionType; + /** + * Event emitted when a DSO entry is selected if emitOnly is set to true + */ + @Output() select: EventEmitter = new EventEmitter(); + /** * Default DSO ordering */ @@ -93,7 +103,11 @@ export abstract class DSOSelectorModalWrapperComponent implements OnInit { */ selectObject(dso: DSpaceObject) { this.close(); - this.navigate(dso); + if (this.emitOnly) { + this.select.emit(dso); + } else { + this.navigate(dso); + } } /** 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 85d8797e66..999a96e730 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 0645e09029..e5f5eca350 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 d4b4314a99..d1252bf485 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 a4d539625f..38b4ecc53e 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 a5f43f359b..4f9ca62310 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 13e6dbc9c5..2f3aa763bc 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/eperson-group-list/eperson-group-list.component.ts b/src/app/shared/eperson-group-list/eperson-group-list.component.ts index 7cad7a9783..1a08740a94 100644 --- a/src/app/shared/eperson-group-list/eperson-group-list.component.ts +++ b/src/app/shared/eperson-group-list/eperson-group-list.component.ts @@ -96,7 +96,7 @@ export class EpersonGroupListComponent implements OnInit, OnDestroy { private pageConfigSub: Subscription; /** - * Initialize instance variables and inject the properly DataService + * Initialize instance variables and inject the properly UpdateDataServiceImpl * * @param {DSONameService} dsoNameService * @param {Injector} parentInjector 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 9e1f1d48aa..606db29db3 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 26803f3c67..3bf02c4abd 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}} *