diff --git a/.editorconfig b/.editorconfig index 15d4c87b14..590d1dea08 100644 --- a/.editorconfig +++ b/.editorconfig @@ -15,3 +15,6 @@ trim_trailing_whitespace = false [*.ts] quote_type = single + +[*.json5] +ij_json_keep_blank_lines_in_code = 3 diff --git a/.eslintrc.json b/.eslintrc.json index b95b54b979..af1b97849b 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -7,7 +7,8 @@ "eslint-plugin-jsdoc", "eslint-plugin-deprecation", "unused-imports", - "eslint-plugin-lodash" + "eslint-plugin-lodash", + "eslint-plugin-jsonc" ], "overrides": [ { @@ -224,6 +225,42 @@ "@angular-eslint/template/no-negated-async": "off", "@angular-eslint/template/eqeqeq": "off" } + }, + { + "files": [ + "*.json5" + ], + "extends": [ + "plugin:jsonc/recommended-with-jsonc" + ], + "rules": { + "no-irregular-whitespace": "error", + "no-trailing-spaces": "error", + "jsonc/comma-dangle": [ + "error", + "always-multiline" + ], + "jsonc/indent": [ + "error", + 2 + ], + "jsonc/key-spacing": [ + "error", + { + "beforeColon": false, + "afterColon": true, + "mode": "strict" + } + ], + "jsonc/no-dupe-keys": "off", + "jsonc/quotes": [ + "error", + "double", + { + "avoidEscape": false + } + ] + } } ] } diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f3b7aff689..5470b3cc2a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -15,12 +15,19 @@ jobs: env: # The ci step will test the dspace-angular code against DSpace REST. # Direct that step to utilize a DSpace REST service that has been started in docker. + # NOTE: These settings should be kept in sync with those in [src]/docker/docker-compose-ci.yml DSPACE_REST_HOST: 127.0.0.1 DSPACE_REST_PORT: 8080 DSPACE_REST_NAMESPACE: '/server' DSPACE_REST_SSL: false # Spin up UI on 127.0.0.1 to avoid host resolution issues in e2e tests with Node 18+ DSPACE_UI_HOST: 127.0.0.1 + DSPACE_UI_PORT: 4000 + # Ensure all SSR caching is disabled in test environment + DSPACE_CACHE_SERVERSIDE_BOTCACHE_MAX: 0 + DSPACE_CACHE_SERVERSIDE_ANONYMOUSCACHE_MAX: 0 + # Tell Cypress to run e2e tests using the same UI URL + CYPRESS_BASE_URL: http://127.0.0.1:4000 # When Chrome version is specified, we pin to a specific version of Chrome # Comment this out to use the latest release #CHROME_VERSION: "90.0.4430.212-1" diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 908c5c34fd..9a2c838d83 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -88,3 +88,33 @@ jobs: # Use tags / labels provided by 'docker/metadata-action' above tags: ${{ steps.meta_build.outputs.tags }} labels: ${{ steps.meta_build.outputs.labels }} + + ##################################################### + # Build/Push the 'dspace/dspace-angular' image ('-dist' tag) + ##################################################### + # https://github.com/docker/metadata-action + # Get Metadata for docker_build_dist step below + - name: Sync metadata (tags, labels) from GitHub to Docker for 'dspace-angular-dist' image + id: meta_build_dist + uses: docker/metadata-action@v4 + with: + images: dspace/dspace-angular + tags: ${{ env.IMAGE_TAGS }} + # As this is a "dist" image, its tags are all suffixed with "-dist". Otherwise, it uses the same + # tagging logic as the primary 'dspace/dspace-angular' image above. + flavor: ${{ env.TAGS_FLAVOR }} + suffix=-dist + + - name: Build and push 'dspace-angular-dist' image + id: docker_build_dist + uses: docker/build-push-action@v3 + with: + context: . + file: ./Dockerfile.dist + platforms: ${{ env.PLATFORMS }} + # For pull requests, we run the Docker build (to ensure no PR changes break the build), + # but we ONLY do an image push to DockerHub if it's NOT a PR + push: ${{ github.event_name != 'pull_request' }} + # Use tags / labels provided by 'docker/metadata-action' above + tags: ${{ steps.meta_build_dist.outputs.tags }} + labels: ${{ steps.meta_build_dist.outputs.labels }} diff --git a/Dockerfile b/Dockerfile index 61d960e7d3..8fac7495e1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,20 +2,27 @@ # See https://github.com/DSpace/dspace-angular/tree/main/docker for usage details FROM node:18-alpine -WORKDIR /app -ADD . /app/ -EXPOSE 4000 # Ensure Python and other build tools are available # These are needed to install some node modules, especially on linux/arm64 RUN apk add --update python3 make g++ && rm -rf /var/cache/apk/* +WORKDIR /app +ADD . /app/ +EXPOSE 4000 + # We run yarn install with an increased network timeout (5min) to avoid "ESOCKETTIMEDOUT" errors from hub.docker.com # See, for example https://github.com/yarnpkg/yarn/issues/5540 RUN yarn install --network-timeout 300000 +# When running in dev mode, 4GB of memory is required to build & launch the app. +# This default setting can be overridden as needed in your shell, via an env file or in docker-compose. +# See Docker environment var precedence: https://docs.docker.com/compose/environment-variables/envvars-precedence/ +ENV NODE_OPTIONS="--max_old_space_size=4096" + # On startup, run in DEVELOPMENT mode (this defaults to live reloading enabled, etc). # Listen / accept connections from all IP addresses. # NOTE: At this time it is only possible to run Docker container in Production mode -# if you have a public IP. See https://github.com/DSpace/dspace-angular/issues/1485 +# if you have a public URL. See https://github.com/DSpace/dspace-angular/issues/1485 +ENV NODE_ENV development CMD yarn serve --host 0.0.0.0 diff --git a/Dockerfile.dist b/Dockerfile.dist new file mode 100644 index 0000000000..2a6a66fc06 --- /dev/null +++ b/Dockerfile.dist @@ -0,0 +1,31 @@ +# This image will be published as dspace/dspace-angular:$DSPACE_VERSION-dist +# See https://github.com/DSpace/dspace-angular/tree/main/docker for usage details + +# Test build: +# docker build -f Dockerfile.dist -t dspace/dspace-angular:dspace-7_x-dist . + +FROM node:18-alpine as build + +# Ensure Python and other build tools are available +# These are needed to install some node modules, especially on linux/arm64 +RUN apk add --update python3 make g++ && rm -rf /var/cache/apk/* + +WORKDIR /app +COPY package.json yarn.lock ./ +RUN yarn install --network-timeout 300000 + +ADD . /app/ +RUN yarn build:prod + +FROM node:18-alpine +RUN npm install --global pm2 + +COPY --chown=node:node --from=build /app/dist /app/dist +COPY --chown=node:node config /app/config +COPY --chown=node:node docker/dspace-ui.json /app/dspace-ui.json + +WORKDIR /app +USER node +ENV NODE_ENV production +EXPOSE 4000 +CMD pm2-runtime start dspace-ui.json --json diff --git a/angular.json b/angular.json index b32670ad77..56c75d7b2d 100644 --- a/angular.json +++ b/angular.json @@ -266,7 +266,8 @@ "options": { "lintFilePatterns": [ "src/**/*.ts", - "src/**/*.html" + "src/**/*.html", + "src/**/*.json5" ] } } diff --git a/config/config.example.yml b/config/config.example.yml index 500c2c476a..f1e6be76aa 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -369,3 +369,8 @@ vocabularies: - filter: 'subject' vocabulary: 'srsc' enabled: true + +# 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' \ No newline at end of file diff --git a/cypress.config.ts b/cypress.config.ts new file mode 100644 index 0000000000..91eeb9838b --- /dev/null +++ b/cypress.config.ts @@ -0,0 +1,44 @@ +import { defineConfig } from 'cypress'; + +export default defineConfig({ + videosFolder: 'cypress/videos', + screenshotsFolder: 'cypress/screenshots', + fixturesFolder: 'cypress/fixtures', + retries: { + runMode: 2, + 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. + // 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 + DSPACE_TEST_ADMIN_USER: 'dspacedemo+admin@gmail.com', + DSPACE_TEST_ADMIN_PASSWORD: 'dspace', + // 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', + // Search term (should return results) used in search tests + DSPACE_TEST_SEARCH_TERM: 'test', + // Collection used for submission tests + DSPACE_TEST_SUBMIT_COLLECTION_NAME: 'Sample Collection', + DSPACE_TEST_SUBMIT_COLLECTION_UUID: '9d8334e9-25d3-4a67-9cea-3dffdef80144', + // Account used to test basic submission process + DSPACE_TEST_SUBMIT_USER: 'dspacedemo+submit@gmail.com', + DSPACE_TEST_SUBMIT_USER_PASSWORD: 'dspace', + }, + e2e: { + // Setup our plugins for e2e tests + setupNodeEvents(on, config) { + return require('./cypress/plugins/index.ts')(on, config); + }, + // This is the base URL that Cypress will run all tests against + // It can be overridden via the CYPRESS_BASE_URL environment variable + // (By default we set this to a value which should work in most development environments) + baseUrl: 'http://localhost:4000', + }, +}); diff --git a/cypress.json b/cypress.json deleted file mode 100644 index 3adf7839c2..0000000000 --- a/cypress.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "integrationFolder": "cypress/integration", - "supportFile": "cypress/support/index.ts", - "videosFolder": "cypress/videos", - "screenshotsFolder": "cypress/screenshots", - "pluginsFile": "cypress/plugins/index.ts", - "fixturesFolder": "cypress/fixtures", - "baseUrl": "http://127.0.0.1:4000", - "retries": { - "runMode": 2, - "openMode": 0 - }, - "env": { - "DSPACE_TEST_ADMIN_USER": "dspacedemo+admin@gmail.com", - "DSPACE_TEST_ADMIN_PASSWORD": "dspace", - "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_SEARCH_TERM": "test", - "DSPACE_TEST_SUBMIT_COLLECTION_NAME": "Sample Collection", - "DSPACE_TEST_SUBMIT_COLLECTION_UUID": "9d8334e9-25d3-4a67-9cea-3dffdef80144", - "DSPACE_TEST_SUBMIT_USER": "dspacedemo+submit@gmail.com", - "DSPACE_TEST_SUBMIT_USER_PASSWORD": "dspace" - } -} diff --git a/cypress/integration/breadcrumbs.spec.ts b/cypress/e2e/breadcrumbs.cy.ts similarity index 73% rename from cypress/integration/breadcrumbs.spec.ts rename to cypress/e2e/breadcrumbs.cy.ts index 62b9a8ad1d..ea6acdafcd 100644 --- a/cypress/integration/breadcrumbs.spec.ts +++ b/cypress/e2e/breadcrumbs.cy.ts @@ -1,10 +1,10 @@ -import { TEST_ENTITY_PUBLICATION } from 'cypress/support'; +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/' + TEST_ENTITY_PUBLICATION); + cy.visit('/entities/publication/'.concat(TEST_ENTITY_PUBLICATION)); // Wait for breadcrumbs to be visible cy.get('ds-breadcrumbs').should('be.visible'); diff --git a/cypress/integration/browse-by-author.spec.ts b/cypress/e2e/browse-by-author.cy.ts similarity index 100% rename from cypress/integration/browse-by-author.spec.ts rename to cypress/e2e/browse-by-author.cy.ts diff --git a/cypress/integration/browse-by-dateissued.spec.ts b/cypress/e2e/browse-by-dateissued.cy.ts similarity index 100% rename from cypress/integration/browse-by-dateissued.spec.ts rename to cypress/e2e/browse-by-dateissued.cy.ts diff --git a/cypress/integration/browse-by-subject.spec.ts b/cypress/e2e/browse-by-subject.cy.ts similarity index 100% rename from cypress/integration/browse-by-subject.spec.ts rename to cypress/e2e/browse-by-subject.cy.ts diff --git a/cypress/integration/browse-by-title.spec.ts b/cypress/e2e/browse-by-title.cy.ts similarity index 100% rename from cypress/integration/browse-by-title.spec.ts rename to cypress/e2e/browse-by-title.cy.ts diff --git a/cypress/integration/collection-page.spec.ts b/cypress/e2e/collection-page.cy.ts similarity index 64% rename from cypress/integration/collection-page.spec.ts rename to cypress/e2e/collection-page.cy.ts index a0140d8faf..a034b4361d 100644 --- a/cypress/integration/collection-page.spec.ts +++ b/cypress/e2e/collection-page.cy.ts @@ -1,13 +1,13 @@ -import { TEST_COLLECTION } from 'cypress/support'; +import { TEST_COLLECTION } from 'cypress/support/e2e'; import { testA11y } from 'cypress/support/utils'; describe('Collection Page', () => { it('should pass accessibility tests', () => { - cy.visit('/collections/' + TEST_COLLECTION); + cy.visit('/collections/'.concat(TEST_COLLECTION)); // tag must be loaded - cy.get('ds-collection-page').should('exist'); + cy.get('ds-collection-page').should('be.visible'); // Analyze for accessibility issues testA11y('ds-collection-page'); diff --git a/cypress/e2e/collection-statistics.cy.ts b/cypress/e2e/collection-statistics.cy.ts new file mode 100644 index 0000000000..6df4e9a454 --- /dev/null +++ b/cypress/e2e/collection-statistics.cy.ts @@ -0,0 +1,37 @@ +import { REGEX_MATCH_NON_EMPTY_TEXT, TEST_COLLECTION } from 'cypress/support/e2e'; +import { testA11y } from 'cypress/support/utils'; + +describe('Collection Statistics Page', () => { + const COLLECTIONSTATISTICSPAGE = '/statistics/collections/'.concat(TEST_COLLECTION); + + it('should load if you click on "Statistics" from a Collection page', () => { + cy.visit('/collections/'.concat(TEST_COLLECTION)); + cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click(); + cy.location('pathname').should('eq', COLLECTIONSTATISTICSPAGE); + }); + + it('should contain a "Total visits" section', () => { + cy.visit(COLLECTIONSTATISTICSPAGE); + cy.get('table[data-test="TotalVisits"]').should('be.visible'); + }); + + 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'); + }); + + it('should pass accessibility tests', () => { + cy.visit(COLLECTIONSTATISTICSPAGE); + + // tag must be loaded + cy.get('ds-collection-statistics-page').should('be.visible'); + + // Verify / wait until "Total Visits" table's label is non-empty + // (This table loads these labels asynchronously, so we want to wait for them before analyzing page) + cy.get('table[data-test="TotalVisits"] th[data-test="statistics-label"]').contains(REGEX_MATCH_NON_EMPTY_TEXT); + + // Analyze for accessibility issues + testA11y('ds-collection-statistics-page'); + }); +}); diff --git a/cypress/integration/community-list.spec.ts b/cypress/e2e/community-list.cy.ts similarity index 72% rename from cypress/integration/community-list.spec.ts rename to cypress/e2e/community-list.cy.ts index a7ba72b74a..7b60b59dbc 100644 --- a/cypress/integration/community-list.spec.ts +++ b/cypress/e2e/community-list.cy.ts @@ -7,10 +7,10 @@ describe('Community List Page', () => { cy.visit('/community-list'); // tag must be loaded - cy.get('ds-community-list-page').should('exist'); + cy.get('ds-community-list-page').should('be.visible'); - // Open first Community (to show Collections)...that way we scan sub-elements as well - cy.get('ds-community-list :nth-child(1) > .btn-group > .btn').click(); + // Open every expand button on page, so that we can scan sub-elements as well + cy.get('[data-test="expand-button"]').click({ multiple: true }); // Analyze for accessibility issues // Disable heading-order checks until it is fixed diff --git a/cypress/integration/community-page.spec.ts b/cypress/e2e/community-page.cy.ts similarity index 64% rename from cypress/integration/community-page.spec.ts rename to cypress/e2e/community-page.cy.ts index 79e21431ad..6c628e21ce 100644 --- a/cypress/integration/community-page.spec.ts +++ b/cypress/e2e/community-page.cy.ts @@ -1,13 +1,13 @@ -import { TEST_COMMUNITY } from 'cypress/support'; +import { TEST_COMMUNITY } from 'cypress/support/e2e'; import { testA11y } from 'cypress/support/utils'; describe('Community Page', () => { it('should pass accessibility tests', () => { - cy.visit('/communities/' + TEST_COMMUNITY); + cy.visit('/communities/'.concat(TEST_COMMUNITY)); // tag must be loaded - cy.get('ds-community-page').should('exist'); + cy.get('ds-community-page').should('be.visible'); // Analyze for accessibility issues testA11y('ds-community-page',); diff --git a/cypress/e2e/community-statistics.cy.ts b/cypress/e2e/community-statistics.cy.ts new file mode 100644 index 0000000000..710450e797 --- /dev/null +++ b/cypress/e2e/community-statistics.cy.ts @@ -0,0 +1,37 @@ +import { REGEX_MATCH_NON_EMPTY_TEXT, TEST_COMMUNITY } from 'cypress/support/e2e'; +import { testA11y } from 'cypress/support/utils'; + +describe('Community Statistics Page', () => { + const COMMUNITYSTATISTICSPAGE = '/statistics/communities/'.concat(TEST_COMMUNITY); + + it('should load if you click on "Statistics" from a Community page', () => { + cy.visit('/communities/'.concat(TEST_COMMUNITY)); + cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click(); + cy.location('pathname').should('eq', COMMUNITYSTATISTICSPAGE); + }); + + it('should contain a "Total visits" section', () => { + cy.visit(COMMUNITYSTATISTICSPAGE); + cy.get('table[data-test="TotalVisits"]').should('be.visible'); + }); + + 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'); + }); + + it('should pass accessibility tests', () => { + cy.visit(COMMUNITYSTATISTICSPAGE); + + // tag must be loaded + cy.get('ds-community-statistics-page').should('be.visible'); + + // Verify / wait until "Total Visits" table's label is non-empty + // (This table loads these labels asynchronously, so we want to wait for them before analyzing page) + cy.get('table[data-test="TotalVisits"] th[data-test="statistics-label"]').contains(REGEX_MATCH_NON_EMPTY_TEXT); + + // Analyze for accessibility issues + testA11y('ds-community-statistics-page'); + }); +}); diff --git a/cypress/integration/footer.spec.ts b/cypress/e2e/footer.cy.ts similarity index 100% rename from cypress/integration/footer.spec.ts rename to cypress/e2e/footer.cy.ts diff --git a/cypress/integration/header.spec.ts b/cypress/e2e/header.cy.ts similarity index 100% rename from cypress/integration/header.spec.ts rename to cypress/e2e/header.cy.ts diff --git a/cypress/e2e/homepage-statistics.cy.ts b/cypress/e2e/homepage-statistics.cy.ts new file mode 100644 index 0000000000..2a1ab9785a --- /dev/null +++ b/cypress/e2e/homepage-statistics.cy.ts @@ -0,0 +1,31 @@ +import { REGEX_MATCH_NON_EMPTY_TEXT, TEST_ENTITY_PUBLICATION } from 'cypress/support/e2e'; +import { testA11y } from 'cypress/support/utils'; +import '../support/commands'; + +describe('Site Statistics Page', () => { + it('should load if you click on "Statistics" from homepage', () => { + cy.visit('/'); + cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click(); + cy.location('pathname').should('eq', '/statistics'); + }); + + it('should pass accessibility tests', () => { + // generate 2 view events on an Item's page + cy.generateViewEvent(TEST_ENTITY_PUBLICATION, 'item'); + cy.generateViewEvent(TEST_ENTITY_PUBLICATION, 'item'); + + cy.visit('/statistics'); + + // tag must be visable + cy.get('ds-site-statistics-page').should('be.visible'); + + // Verify / wait until "Total Visits" table's *last* label is non-empty + // (This table loads these labels asynchronously, so we want to wait for them before analyzing page) + cy.get('table[data-test="TotalVisits"] th[data-test="statistics-label"]').last().contains(REGEX_MATCH_NON_EMPTY_TEXT); + // Wait an extra 500ms, just so all entries in Total Visits have loaded. + cy.wait(500); + + // Analyze for accessibility issues + testA11y('ds-site-statistics-page'); + }); +}); diff --git a/cypress/integration/homepage.spec.ts b/cypress/e2e/homepage.cy.ts similarity index 100% rename from cypress/integration/homepage.spec.ts rename to cypress/e2e/homepage.cy.ts diff --git a/cypress/integration/item-page.spec.ts b/cypress/e2e/item-page.cy.ts similarity index 76% rename from cypress/integration/item-page.spec.ts rename to cypress/e2e/item-page.cy.ts index 6a454b678d..9eed711776 100644 --- a/cypress/integration/item-page.spec.ts +++ b/cypress/e2e/item-page.cy.ts @@ -1,10 +1,10 @@ import { Options } from 'cypress-axe'; -import { TEST_ENTITY_PUBLICATION } from 'cypress/support'; +import { TEST_ENTITY_PUBLICATION } from 'cypress/support/e2e'; import { testA11y } from 'cypress/support/utils'; describe('Item Page', () => { - const ITEMPAGE = '/items/' + TEST_ENTITY_PUBLICATION; - const ENTITYPAGE = '/entities/publication/' + TEST_ENTITY_PUBLICATION; + const ITEMPAGE = '/items/'.concat(TEST_ENTITY_PUBLICATION); + const ENTITYPAGE = '/entities/publication/'.concat(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', () => { @@ -16,7 +16,7 @@ describe('Item Page', () => { cy.visit(ENTITYPAGE); // tag must be loaded - cy.get('ds-item-page').should('exist'); + cy.get('ds-item-page').should('be.visible'); // Analyze for accessibility issues // Disable heading-order checks until it is fixed diff --git a/cypress/integration/item-statistics.spec.ts b/cypress/e2e/item-statistics.cy.ts similarity index 51% rename from cypress/integration/item-statistics.spec.ts rename to cypress/e2e/item-statistics.cy.ts index 66ebc228db..9b90cb24af 100644 --- a/cypress/integration/item-statistics.spec.ts +++ b/cypress/e2e/item-statistics.cy.ts @@ -1,36 +1,41 @@ -import { TEST_ENTITY_PUBLICATION } from 'cypress/support'; +import { REGEX_MATCH_NON_EMPTY_TEXT, TEST_ENTITY_PUBLICATION } from 'cypress/support/e2e'; import { testA11y } from 'cypress/support/utils'; describe('Item Statistics Page', () => { - const ITEMSTATISTICSPAGE = '/statistics/items/' + TEST_ENTITY_PUBLICATION; + const ITEMSTATISTICSPAGE = '/statistics/items/'.concat(TEST_ENTITY_PUBLICATION); it('should load if you click on "Statistics" from an Item/Entity page', () => { - cy.visit('/entities/publication/' + TEST_ENTITY_PUBLICATION); + cy.visit('/entities/publication/'.concat(TEST_ENTITY_PUBLICATION)); cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click(); cy.location('pathname').should('eq', ITEMSTATISTICSPAGE); }); it('should contain element ds-item-statistics-page when navigating to an item statistics page', () => { cy.visit(ITEMSTATISTICSPAGE); - cy.get('ds-item-statistics-page').should('exist'); + cy.get('ds-item-statistics-page').should('be.visible'); cy.get('ds-item-page').should('not.exist'); }); it('should contain a "Total visits" section', () => { cy.visit(ITEMSTATISTICSPAGE); - cy.get('.' + TEST_ENTITY_PUBLICATION + '_TotalVisits').should('exist'); + cy.get('table[data-test="TotalVisits"]').should('be.visible'); }); it('should contain a "Total visits per month" section', () => { cy.visit(ITEMSTATISTICSPAGE); - cy.get('.' + TEST_ENTITY_PUBLICATION + '_TotalVisitsPerMonth').should('exist'); + // 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'); }); it('should pass accessibility tests', () => { cy.visit(ITEMSTATISTICSPAGE); // tag must be loaded - cy.get('ds-item-statistics-page').should('exist'); + cy.get('ds-item-statistics-page').should('be.visible'); + + // Verify / wait until "Total Visits" table's label is non-empty + // (This table loads these labels asynchronously, so we want to wait for them before analyzing page) + cy.get('table[data-test="TotalVisits"] th[data-test="statistics-label"]').contains(REGEX_MATCH_NON_EMPTY_TEXT); // Analyze for accessibility issues testA11y('ds-item-statistics-page'); diff --git a/cypress/integration/login-modal.spec.ts b/cypress/e2e/login-modal.cy.ts similarity index 97% rename from cypress/integration/login-modal.spec.ts rename to cypress/e2e/login-modal.cy.ts index fece28b425..b169634cfa 100644 --- a/cypress/integration/login-modal.spec.ts +++ b/cypress/e2e/login-modal.cy.ts @@ -1,4 +1,4 @@ -import { TEST_ADMIN_PASSWORD, TEST_ADMIN_USER, TEST_ENTITY_PUBLICATION } from 'cypress/support'; +import { TEST_ADMIN_PASSWORD, TEST_ADMIN_USER, TEST_ENTITY_PUBLICATION } from 'cypress/support/e2e'; const page = { openLoginMenu() { @@ -36,7 +36,7 @@ const page = { describe('Login Modal', () => { it('should login when clicking button & stay on same page', () => { - const ENTITYPAGE = '/entities/publication/' + TEST_ENTITY_PUBLICATION; + const ENTITYPAGE = '/entities/publication/'.concat(TEST_ENTITY_PUBLICATION); cy.visit(ENTITYPAGE); // Login menu should exist diff --git a/cypress/integration/my-dspace.spec.ts b/cypress/e2e/my-dspace.cy.ts similarity index 95% rename from cypress/integration/my-dspace.spec.ts rename to cypress/e2e/my-dspace.cy.ts index 48f44eecb9..79786c298a 100644 --- a/cypress/integration/my-dspace.spec.ts +++ b/cypress/e2e/my-dspace.cy.ts @@ -1,5 +1,5 @@ import { Options } from 'cypress-axe'; -import { TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD, TEST_SUBMIT_COLLECTION_NAME } from 'cypress/support'; +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', () => { @@ -9,7 +9,7 @@ describe('My DSpace page', () => { // 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.get('ds-my-dspace-page').should('exist'); + cy.get('ds-my-dspace-page').should('be.visible'); // At least one recent submission should be displayed cy.get('[data-test="list-object"]').should('be.visible'); @@ -42,12 +42,12 @@ describe('My DSpace page', () => { // 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.get('ds-my-dspace-page').should('exist'); + cy.get('ds-my-dspace-page').should('be.visible'); // Click button in sidebar to display detailed view cy.get('ds-search-sidebar [data-test="detail-view"]').click(); - cy.get('ds-object-detail').should('exist'); + cy.get('ds-object-detail').should('be.visible'); // Analyze for accessibility issues testA11y('ds-my-dspace-page', @@ -80,7 +80,7 @@ describe('My DSpace page', () => { cy.get('ds-authorized-collection-selector input[type="search"]').type(TEST_SUBMIT_COLLECTION_NAME); // Click on the button matching that known Collection name - cy.get('ds-authorized-collection-selector button[title="' + TEST_SUBMIT_COLLECTION_NAME + '"]').click(); + cy.get('ds-authorized-collection-selector button[title="'.concat(TEST_SUBMIT_COLLECTION_NAME).concat('"]')).click(); // New URL should include /workspaceitems, as we've started a new submission cy.url().should('include', '/workspaceitems'); diff --git a/cypress/integration/pagenotfound.spec.ts b/cypress/e2e/pagenotfound.cy.ts similarity index 89% rename from cypress/integration/pagenotfound.spec.ts rename to cypress/e2e/pagenotfound.cy.ts index 48520bcaa3..43e3c3af24 100644 --- a/cypress/integration/pagenotfound.spec.ts +++ b/cypress/e2e/pagenotfound.cy.ts @@ -2,7 +2,7 @@ describe('PageNotFound', () => { it('should contain element ds-pagenotfound when navigating to page that doesnt exist', () => { // request an invalid page (UUIDs at root path aren't valid) cy.visit('/e9019a69-d4f1-4773-b6a3-bd362caa46f2', { failOnStatusCode: false }); - cy.get('ds-pagenotfound').should('exist'); + cy.get('ds-pagenotfound').should('be.visible'); }); it('should not contain element ds-pagenotfound when navigating to existing page', () => { diff --git a/cypress/integration/search-navbar.spec.ts b/cypress/e2e/search-navbar.cy.ts similarity index 92% rename from cypress/integration/search-navbar.spec.ts rename to cypress/e2e/search-navbar.cy.ts index babd9b9dfd..648db17fe6 100644 --- a/cypress/integration/search-navbar.spec.ts +++ b/cypress/e2e/search-navbar.cy.ts @@ -1,4 +1,4 @@ -import { TEST_SEARCH_TERM } from 'cypress/support'; +import { TEST_SEARCH_TERM } from 'cypress/support/e2e'; const page = { fillOutQueryInNavBar(query) { @@ -27,7 +27,7 @@ describe('Search from Navigation Bar', () => { page.fillOutQueryInNavBar(query); page.submitQueryByPressingEnter(); // New URL should include query param - cy.url().should('include', 'query=' + query); + cy.url().should('include', 'query='.concat(query)); // Wait for search results to come back from the above GET command cy.wait('@search-results'); // At least one search result should be displayed @@ -42,7 +42,7 @@ describe('Search from Navigation Bar', () => { page.fillOutQueryInNavBar(query); page.submitQueryByPressingEnter(); // New URL should include query param - cy.url().should('include', 'query=' + query); + cy.url().should('include', 'query='.concat(query)); // Wait for search results to come back from the above GET command cy.wait('@search-results'); // At least one search result should be displayed @@ -57,7 +57,7 @@ describe('Search from Navigation Bar', () => { page.fillOutQueryInNavBar(query); page.submitQueryByPressingIcon(); // New URL should include query param - cy.url().should('include', 'query=' + query); + cy.url().should('include', 'query='.concat(query)); // Wait for search results to come back from the above GET command cy.wait('@search-results'); // At least one search result should be displayed diff --git a/cypress/integration/search-page.spec.ts b/cypress/e2e/search-page.cy.ts similarity index 89% rename from cypress/integration/search-page.spec.ts rename to cypress/e2e/search-page.cy.ts index 623c370c56..24519cc236 100644 --- a/cypress/integration/search-page.spec.ts +++ b/cypress/e2e/search-page.cy.ts @@ -1,5 +1,5 @@ import { Options } from 'cypress-axe'; -import { TEST_SEARCH_TERM } from 'cypress/support'; +import { TEST_SEARCH_TERM } from 'cypress/support/e2e'; import { testA11y } from 'cypress/support/utils'; describe('Search Page', () => { @@ -13,11 +13,11 @@ describe('Search Page', () => { }); it('should load results and pass accessibility tests', () => { - cy.visit('/search?query=' + TEST_SEARCH_TERM); + cy.visit('/search?query='.concat(TEST_SEARCH_TERM)); cy.get('[data-test="search-box"]').should('have.value', TEST_SEARCH_TERM); // tag must be loaded - cy.get('ds-search-page').should('exist'); + cy.get('ds-search-page').should('be.visible'); // At least one search result should be displayed cy.get('[data-test="list-object"]').should('be.visible'); @@ -45,13 +45,13 @@ describe('Search Page', () => { }); it('should have a working grid view that passes accessibility tests', () => { - cy.visit('/search?query=' + TEST_SEARCH_TERM); + cy.visit('/search?query='.concat(TEST_SEARCH_TERM)); // Click button in sidebar to display grid view cy.get('ds-search-sidebar [data-test="grid-view"]').click(); // tag must be loaded - cy.get('ds-search-page').should('exist'); + cy.get('ds-search-page').should('be.visible'); // At least one grid object (card) should be displayed cy.get('[data-test="grid-object"]').should('be.visible'); diff --git a/cypress/integration/submission.spec.ts b/cypress/e2e/submission.cy.ts similarity index 92% rename from cypress/integration/submission.spec.ts rename to cypress/e2e/submission.cy.ts index 9eef596b02..ed10b2d13a 100644 --- a/cypress/integration/submission.spec.ts +++ b/cypress/e2e/submission.cy.ts @@ -1,13 +1,11 @@ -import { Options } from 'cypress-axe'; -import { TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD, TEST_SUBMIT_COLLECTION_NAME, TEST_SUBMIT_COLLECTION_UUID } from 'cypress/support'; -import { testA11y } from 'cypress/support/utils'; +import { TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD, TEST_SUBMIT_COLLECTION_NAME, TEST_SUBMIT_COLLECTION_UUID } from 'cypress/support/e2e'; describe('New Submission page', () => { // NOTE: We already test that new 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=' + TEST_SUBMIT_COLLECTION_UUID + '&entityType=none'); + cy.visit('/submit?collection='.concat(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); @@ -35,7 +33,7 @@ describe('New Submission page', () => { it('should block submission & show errors if required fields are missing', () => { // Create a new submission - cy.visit('/submit?collection=' + TEST_SUBMIT_COLLECTION_UUID + '&entityType=none'); + cy.visit('/submit?collection='.concat(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); @@ -95,7 +93,7 @@ describe('New Submission page', () => { it('should allow for deposit if all required fields completed & file uploaded', () => { // Create a new submission - cy.visit('/submit?collection=' + TEST_SUBMIT_COLLECTION_UUID + '&entityType=none'); + cy.visit('/submit?collection='.concat(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); @@ -124,8 +122,6 @@ describe('New Submission page', () => { // Wait for upload to complete before proceeding cy.wait('@upload'); - // Close the upload success notice - cy.get('[data-dismiss="alert"]').click({multiple: true}); // Wait for deposit button to not be disabled & click it. cy.get('button#deposit').should('not.be.disabled').click(); diff --git a/cypress/integration/collection-statistics.spec.ts b/cypress/integration/collection-statistics.spec.ts deleted file mode 100644 index 90b569c824..0000000000 --- a/cypress/integration/collection-statistics.spec.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { TEST_COLLECTION } from 'cypress/support'; -import { testA11y } from 'cypress/support/utils'; - -describe('Collection Statistics Page', () => { - const COLLECTIONSTATISTICSPAGE = '/statistics/collections/' + TEST_COLLECTION; - - it('should load if you click on "Statistics" from a Collection page', () => { - cy.visit('/collections/' + TEST_COLLECTION); - cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click(); - cy.location('pathname').should('eq', COLLECTIONSTATISTICSPAGE); - }); - - it('should contain a "Total visits" section', () => { - cy.visit(COLLECTIONSTATISTICSPAGE); - cy.get('.' + TEST_COLLECTION + '_TotalVisits').should('exist'); - }); - - it('should contain a "Total visits per month" section', () => { - cy.visit(COLLECTIONSTATISTICSPAGE); - cy.get('.' + TEST_COLLECTION + '_TotalVisitsPerMonth').should('exist'); - }); - - it('should pass accessibility tests', () => { - cy.visit(COLLECTIONSTATISTICSPAGE); - - // tag must be loaded - cy.get('ds-collection-statistics-page').should('exist'); - - // Analyze for accessibility issues - testA11y('ds-collection-statistics-page'); - }); -}); diff --git a/cypress/integration/community-statistics.spec.ts b/cypress/integration/community-statistics.spec.ts deleted file mode 100644 index cbf1783c0b..0000000000 --- a/cypress/integration/community-statistics.spec.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { TEST_COMMUNITY } from 'cypress/support'; -import { testA11y } from 'cypress/support/utils'; - -describe('Community Statistics Page', () => { - const COMMUNITYSTATISTICSPAGE = '/statistics/communities/' + TEST_COMMUNITY; - - it('should load if you click on "Statistics" from a Community page', () => { - cy.visit('/communities/' + TEST_COMMUNITY); - cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click(); - cy.location('pathname').should('eq', COMMUNITYSTATISTICSPAGE); - }); - - it('should contain a "Total visits" section', () => { - cy.visit(COMMUNITYSTATISTICSPAGE); - cy.get('.' + TEST_COMMUNITY + '_TotalVisits').should('exist'); - }); - - it('should contain a "Total visits per month" section', () => { - cy.visit(COMMUNITYSTATISTICSPAGE); - cy.get('.' + TEST_COMMUNITY + '_TotalVisitsPerMonth').should('exist'); - }); - - it('should pass accessibility tests', () => { - cy.visit(COMMUNITYSTATISTICSPAGE); - - // tag must be loaded - cy.get('ds-community-statistics-page').should('exist'); - - // Analyze for accessibility issues - testA11y('ds-community-statistics-page'); - }); -}); diff --git a/cypress/integration/homepage-statistics.spec.ts b/cypress/integration/homepage-statistics.spec.ts deleted file mode 100644 index fe0311f87e..0000000000 --- a/cypress/integration/homepage-statistics.spec.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { testA11y } from 'cypress/support/utils'; - -describe('Site Statistics Page', () => { - it('should load if you click on "Statistics" from homepage', () => { - cy.visit('/'); - cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click(); - cy.location('pathname').should('eq', '/statistics'); - }); - - it('should pass accessibility tests', () => { - cy.visit('/statistics'); - - // tag must be loaded - cy.get('ds-site-statistics-page').should('exist'); - - // Analyze for accessibility issues - testA11y('ds-site-statistics-page'); - }); -}); diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 04c217aa0f..c70c4e37e1 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -4,12 +4,17 @@ // *********************************************** import { AuthTokenInfo, TOKENITEM } from 'src/app/core/auth/models/auth-token-info.model'; -import { FALLBACK_TEST_REST_BASE_URL } from '.'; +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'; // Declare Cypress namespace to help with Intellisense & code completion in IDEs // ALL custom commands MUST be listed here for code completion to work -// tslint:disable-next-line:no-namespace declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace namespace Cypress { interface Chainable { /** @@ -27,6 +32,15 @@ declare global { * @param password password to login as */ loginViaForm(email: string, password: string): typeof loginViaForm; + + /** + * Generate view event for given object. Useful for testing statistics pages with + * pre-generated statistics. This just generates a single "hit", but can be called multiple times to + * generate multiple hits. + * @param uuid UUID of object + * @param dsoType type of DSpace Object (e.g. "item", "collection", "community") + */ + generateViewEvent(uuid: string, dsoType: string): typeof generateViewEvent; } } } @@ -53,52 +67,57 @@ function login(email: string, password: string): void { 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: " + config.rest.baseUrl); + //console.log("Found 'rest.baseUrl' in config.json. Using this REST API for login: ".concat(config.rest.baseUrl)); baseRestUrl = config.rest.baseUrl; } - // To login via REST, first we have to do a GET to obtain a valid CSRF token - cy.request( baseRestUrl + '/api/authn/status' ) - .then((response) => { - // We should receive a CSRF token returned in a response header - expect(response.headers).to.have.property('dspace-xsrf-token'); - const csrfToken = response.headers['dspace-xsrf-token']; + // 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; + } - // Now, send login POST request including that CSRF token - cy.request({ - method: 'POST', - url: baseRestUrl + '/api/authn/login', - headers: { 'X-XSRF-TOKEN' : 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'); + // Create a fake CSRF Token. Set it in the required server-side cookie + const csrfToken = 'fakeLoginCSRFToken'; + cy.setCookie(DSPACE_XSRF_COOKIE, csrfToken, { 'domain': baseDomain }); - // Initialize our AuthTokenInfo object from the authorization header. - const authheader = resp.headers.authorization as string; - const authinfo: AuthTokenInfo = new AuthTokenInfo(authheader); + // 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'); - // 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)); - }); + // Initialize our AuthTokenInfo object from the authorization header. + const authheader = resp.headers.authorization as string; + const authinfo: AuthTokenInfo = new AuthTokenInfo(authheader); + + // Save our AuthTokenInfo object to our dsAuthInfo UI cookie + // This ensures the UI will recognize we are logged in on next "visit()" + cy.setCookie(TOKENITEM, JSON.stringify(authinfo)); }); + // Remove cookie with fake CSRF token, as it's no longer needed + cy.clearCookie(DSPACE_XSRF_COOKIE); }); } // Add as a Cypress command (i.e. assign to 'cy.login') Cypress.Commands.add('login', login); - /** * Login user via displayed login form * @param email email to login as * @param password password to login as */ - function loginViaForm(email: string, password: string): void { +function loginViaForm(email: string, password: string): void { // Enter email cy.get('ds-log-in [data-test="email"]').type(email); // Enter password @@ -107,4 +126,69 @@ Cypress.Commands.add('login', login); cy.get('ds-log-in [data-test="login-button"]').click(); } // Add as a Cypress command (i.e. assign to 'cy.loginViaForm') -Cypress.Commands.add('loginViaForm', loginViaForm); \ No newline at end of file +Cypress.Commands.add('loginViaForm', loginViaForm); + + +/** + * Generate statistic view event for given object. Useful for testing statistics pages with + * pre-generated statistics. This just generates a single "hit", but can be called multiple times to + * generate multiple hits. + * + * NOTE: This requires that "solr-statistics.autoCommit=false" be set on the DSpace backend + * (as it is in our docker-compose-ci.yml used in CI). + * Otherwise, by default, new statistical events won't be saved until Solr's autocommit next triggers. + * @param uuid UUID of object + * @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', + }, + //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); + diff --git a/cypress/support/index.ts b/cypress/support/e2e.ts similarity index 92% rename from cypress/support/index.ts rename to cypress/support/e2e.ts index 70da23f044..dd7ee1824c 100644 --- a/cypress/support/index.ts +++ b/cypress/support/e2e.ts @@ -30,11 +30,11 @@ beforeEach(() => { // 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(() => { +/*afterEach(() => { cy.window().then((win) => { win.location.href = 'about:blank'; }); -}); +});*/ // Global constants used in tests @@ -43,10 +43,6 @@ afterEach(() => { // https://github.com/DSpace-Labs/AIP-Files/releases/tag/demo-entities-data // (This is the data set used in our CI environment) -// 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 'getBaseRESTUrl()' in commands.ts -export const FALLBACK_TEST_REST_BASE_URL = 'http://localhost:8080/server'; - // 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'; @@ -61,3 +57,10 @@ export const TEST_SUBMIT_COLLECTION_NAME = Cypress.env('DSPACE_TEST_SUBMIT_COLLE 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'; + + +// USEFUL REGEX for testing + +// Match any string that contains at least one non-space character +// Can be used with "contains()" to determine if an element has a non-empty text value +export const REGEX_MATCH_NON_EMPTY_TEXT = /^(?!\s*$).+/; diff --git a/docker/README.md b/docker/README.md index 1a9fee0a81..42deb793f9 100644 --- a/docker/README.md +++ b/docker/README.md @@ -6,7 +6,20 @@ If you wish to run DSpace on Docker in production, we recommend building your own Docker images. You are welcome to borrow ideas/concepts from the below images in doing so. But, the below images should not be used "as is" in any production scenario. *** -## 'Dockerfile' in root directory +## Overview +The scripts in this directory can be used to start the DSpace User Interface (frontend) in Docker. +Optionally, the backend (REST API) might also be started in Docker. + +For additional options/settings in starting the backend (REST API) in Docker, see the Docker Compose +documentation for the backend: https://github.com/DSpace/DSpace/blob/main/dspace/src/main/docker-compose/README.md + +## Root directory + +The root directory of this project contains all the Dockerfiles which may be referenced by +the Docker compose scripts in this 'docker' folder. + +### Dockerfile + This Dockerfile is used to build a *development* DSpace 7 Angular UI image, published as 'dspace/dspace-angular' ``` @@ -20,7 +33,18 @@ Admins to our DockerHub repo can manually publish with the following command. docker push dspace/dspace-angular:dspace-7_x ``` -## docker directory +### Dockerfile.dist + +The `Dockerfile.dist` is used to generate a *production* build and runtime environment. + +```bash +# build the latest image +docker build -f Dockerfile.dist -t dspace/dspace-angular:dspace-7_x-dist . +``` + +A default/demo version of this image is built *automatically*. + +## 'docker' directory - docker-compose.yml - Starts DSpace Angular with Docker Compose from the current branch. This file assumes that a DSpace 7 REST instance will also be started in Docker. - docker-compose-rest.yml @@ -45,23 +69,47 @@ docker-compose -f docker/docker-compose.yml build ## To start DSpace (REST and Angular) from your branch +This command provides a quick way to start both the frontend & backend from this single codebase ``` docker-compose -p d7 -f docker/docker-compose.yml -f docker/docker-compose-rest.yml up -d ``` +Keep in mind, you may also start the backend by cloning the 'DSpace/DSpace' GitHub repository separately. See the next section. + + ## Run DSpace REST and DSpace Angular from local branches. + +This section assumes that you have clones *both* the 'DSpace/DSpace' and 'DSpace/dspace-angular' GitHub +repositories. When both are available locally, you can spin up both in Docker and have them work together. + _The system will be started in 2 steps. Each step shares the same docker network._ -From DSpace/DSpace (build as needed) +From 'DSpace/DSpace' clone (build first as needed): ``` docker-compose -p d7 up -d ``` -From DSpace/DSpace-angular +NOTE: More detailed instructions on starting the backend via Docker can be found in the [Docker Compose instructions for the Backend](https://github.com/DSpace/DSpace/blob/main/dspace/src/main/docker-compose/README.md). + +From 'DSpace/dspace-angular' clone (build first as needed) ``` docker-compose -p d7 -f docker/docker-compose.yml up -d ``` +At this point, you should be able to access the UI from http://localhost:4000, +and the backend at http://localhost:8080/server/ + +## Run DSpace Angular dist build with DSpace Demo site backend + +This allows you to run the Angular UI in *production* mode, pointing it at the demo backend +(https://api7.dspace.org/server/). + +``` +docker-compose -f docker/docker-compose-dist.yml pull +docker-compose -f docker/docker-compose-dist.yml build +docker-compose -p d7 -f docker/docker-compose-dist.yml up -d +``` + ## Ingest test data from AIPDIR Create an administrator @@ -87,9 +135,10 @@ Load assetstore content and trigger a re-index of the repository docker-compose -p d7 -f docker/cli.yml -f docker/cli.assetstore.yml run --rm dspace-cli ``` -## End to end testing of the rest api (runs in travis). -_In this instance, only the REST api runs in Docker using the Entities dataset. Travis will perform CI testing of Angular using Node to drive the tests._ +## End to end testing of the REST API (runs in GitHub Actions CI). +_In this instance, only the REST api runs in Docker using the Entities dataset. GitHub Actions will perform CI testing of Angular using Node to drive the tests. See `.github/workflows/build.yml` for more details._ +This command is only really useful for testing our Continuous Integration process. ``` -docker-compose -p d7ci -f docker/docker-compose-travis.yml up -d +docker-compose -p d7ci -f docker/docker-compose-ci.yml up -d ``` diff --git a/docker/docker-compose-ci.yml b/docker/docker-compose-ci.yml index ef84c14f43..9ec8fe664a 100644 --- a/docker/docker-compose-ci.yml +++ b/docker/docker-compose-ci.yml @@ -30,6 +30,9 @@ services: db__P__url: 'jdbc:postgresql://dspacedb:5432/dspace' # solr.server: Ensure we are using the 'dspacesolr' image for Solr solr__P__server: http://dspacesolr:8983/solr + # 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' depends_on: - dspacedb image: dspace/dspace:dspace-7_x-test diff --git a/docker/docker-compose-dist.yml b/docker/docker-compose-dist.yml new file mode 100644 index 0000000000..1c75539da9 --- /dev/null +++ b/docker/docker-compose-dist.yml @@ -0,0 +1,40 @@ +# +# The contents of this file are subject to the license and copyright +# detailed in the LICENSE and NOTICE files at the root of the source +# tree and available online at +# +# http://www.dspace.org/license/ +# + +# Docker Compose for running the DSpace Angular UI dist build +# for previewing with the DSpace Demo site backend +version: '3.7' +networks: + dspacenet: +services: + dspace-angular: + container_name: dspace-angular + environment: + DSPACE_UI_SSL: 'false' + DSPACE_UI_HOST: dspace-angular + DSPACE_UI_PORT: '4000' + DSPACE_UI_NAMESPACE: / + # NOTE: When running the UI in production mode (which the -dist image does), + # these DSPACE_REST_* variables MUST point at a public, HTTPS URL. + # This is because Server Side Rendering (SSR) currently requires a public URL, + # see this bug: https://github.com/DSpace/dspace-angular/issues/1485 + DSPACE_REST_SSL: 'true' + DSPACE_REST_HOST: api7.dspace.org + DSPACE_REST_PORT: 443 + DSPACE_REST_NAMESPACE: /server + image: dspace/dspace-angular:dspace-7_x-dist + build: + context: .. + dockerfile: Dockerfile.dist + networks: + dspacenet: + ports: + - published: 4000 + target: 4000 + stdin_open: true + tty: true diff --git a/docker/docker-compose-rest.yml b/docker/docker-compose-rest.yml index b73f1b7a39..e31757527a 100644 --- a/docker/docker-compose-rest.yml +++ b/docker/docker-compose-rest.yml @@ -39,7 +39,7 @@ services: # proxies.trusted.ipranges: This setting is required for a REST API running in Docker to trust requests # from the host machine. This IP range MUST correspond to the 'dspacenet' subnet defined above. proxies__P__trusted__P__ipranges: '172.23.0' - image: dspace/dspace:dspace-7_x-test + image: "${DOCKER_OWNER:-dspace}/dspace:${DSPACE_VER:-dspace-7_x-test}" depends_on: - dspacedb networks: @@ -82,8 +82,7 @@ services: # DSpace Solr container dspacesolr: container_name: dspacesolr - # Uses official Solr image at https://hub.docker.com/_/solr/ - image: solr:8.11-slim + image: "${DOCKER_OWNER:-dspace}/dspace-solr:${DSPACE_VER:-dspace-7_x}" # Needs main 'dspace' container to start first to guarantee access to solr_configs depends_on: - dspace @@ -96,28 +95,26 @@ 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 local configsets (see above), then start Solr # * First, run precreate-core to create the core (if it doesn't yet exist). If exists already, this is a no-op - # * Second, copy updated configs from mounted configsets to this core. If it already existed, this updates core - # to the latest configs. If it's a newly created core, this is a no-op. + # * Second, copy configsets to this core: + # Updates to Solr configs require the container to be rebuilt/restarted: + # `docker-compose -p d7 -f docker/docker-compose.yml -f docker/docker-compose-rest.yml up -d --build dspacesolr` entrypoint: - /bin/bash - '-c' - | init-var-solr precreate-core authority /opt/solr/server/solr/configsets/dspace/authority - cp -r -u /opt/solr/server/solr/configsets/dspace/authority/* authority + cp -r /opt/solr/server/solr/configsets/dspace/authority/* authority precreate-core oai /opt/solr/server/solr/configsets/dspace/oai - cp -r -u /opt/solr/server/solr/configsets/dspace/oai/* oai + cp -r /opt/solr/server/solr/configsets/dspace/oai/* oai precreate-core search /opt/solr/server/solr/configsets/dspace/search - cp -r -u /opt/solr/server/solr/configsets/dspace/search/* search + cp -r /opt/solr/server/solr/configsets/dspace/search/* search precreate-core statistics /opt/solr/server/solr/configsets/dspace/statistics - cp -r -u /opt/solr/server/solr/configsets/dspace/statistics/* statistics + cp -r /opt/solr/server/solr/configsets/dspace/statistics/* statistics exec solr -f volumes: assetstore: diff --git a/docker/dspace-ui.json b/docker/dspace-ui.json new file mode 100644 index 0000000000..0758679ab8 --- /dev/null +++ b/docker/dspace-ui.json @@ -0,0 +1,11 @@ +{ + "apps": [ + { + "name": "dspace-ui", + "cwd": "/app", + "script": "dist/server/main.js", + "instances": "max", + "exec_mode": "cluster" + } + ] +} \ No newline at end of file diff --git a/package.json b/package.json index 9249884c8e..e1bedeb02b 100644 --- a/package.json +++ b/package.json @@ -163,13 +163,14 @@ "compression-webpack-plugin": "^9.2.0", "copy-webpack-plugin": "^6.4.1", "cross-env": "^7.0.3", - "cypress": "9.7.0", - "cypress-axe": "^0.14.0", + "cypress": "12.9.0", + "cypress-axe": "^1.1.0", "deep-freeze": "0.0.1", "eslint": "^8.2.0", "eslint-plugin-deprecation": "^1.3.2", "eslint-plugin-import": "^2.25.4", "eslint-plugin-jsdoc": "^39.6.4", + "eslint-plugin-jsonc": "^2.6.0", "eslint-plugin-lodash": "^7.4.0", "eslint-plugin-unused-imports": "^2.0.0", "express-static-gzip": "^2.1.5", @@ -198,7 +199,7 @@ "sass-resources-loader": "^2.1.1", "ts-node": "^8.10.2", "typescript": "~4.5.5", - "webpack": "^5.69.1", + "webpack": "^5.76.0", "webpack-bundle-analyzer": "^4.4.0", "webpack-cli": "^4.2.0", "webpack-dev-server": "^4.5.0" diff --git a/src/app/access-control/epeople-registry/epeople-registry.component.html b/src/app/access-control/epeople-registry/epeople-registry.component.html index 2d87f21d26..3a7806a231 100644 --- a/src/app/access-control/epeople-registry/epeople-registry.component.html +++ b/src/app/access-control/epeople-registry/epeople-registry.component.html @@ -8,7 +8,7 @@ @@ -30,7 +30,7 @@
diff --git a/src/app/access-control/epeople-registry/epeople-registry.component.spec.ts b/src/app/access-control/epeople-registry/epeople-registry.component.spec.ts index c0d70fd0b2..4a09913862 100644 --- a/src/app/access-control/epeople-registry/epeople-registry.component.spec.ts +++ b/src/app/access-control/epeople-registry/epeople-registry.component.spec.ts @@ -1,7 +1,7 @@ import { Router } from '@angular/router'; import { Observable, of as observableOf } from 'rxjs'; import { CommonModule } from '@angular/common'; -import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { BrowserModule, By } from '@angular/platform-browser'; @@ -42,6 +42,7 @@ describe('EPeopleRegistryComponent', () => { let paginationService; beforeEach(waitForAsync(() => { + jasmine.getEnv().allowRespy(true); mockEPeople = [EPersonMock, EPersonMock2]; ePersonDataServiceStub = { activeEPerson: null, @@ -98,7 +99,7 @@ describe('EPeopleRegistryComponent', () => { deleteEPerson(ePerson: EPerson): Observable { this.allEpeople = this.allEpeople.filter((ePerson2: EPerson) => { return (ePerson2.uuid !== ePerson.uuid); - }); + }); return observableOf(true); }, editEPerson(ePerson: EPerson) { @@ -260,17 +261,16 @@ describe('EPeopleRegistryComponent', () => { describe('delete EPerson button when the isAuthorized returns false', () => { let ePeopleDeleteButton; beforeEach(() => { - authorizationService = jasmine.createSpyObj('authorizationService', { - isAuthorized: observableOf(false) - }); + spyOn(authorizationService, 'isAuthorized').and.returnValue(observableOf(false)); + component.initialisePage(); + fixture.detectChanges(); }); it('should be disabled', () => { ePeopleDeleteButton = fixture.debugElement.queryAll(By.css('#epeople tr td div button.delete-button')); - ePeopleDeleteButton.forEach((deleteButton) => { + ePeopleDeleteButton.forEach((deleteButton: DebugElement) => { expect(deleteButton.nativeElement.disabled).toBe(true); }); - }); }); }); diff --git a/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.html b/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.html index e9cc48aee3..7386177ea0 100644 --- a/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.html +++ b/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.html @@ -13,12 +13,13 @@ [formGroup]="formGroup" [formLayout]="formLayout" [displayCancel]="false" + [submitLabel]="submitLabel" (submitForm)="onSubmit()">
-
+
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 c5c9f8f954..5a7ab735ca 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 @@ -165,6 +165,15 @@ export class EPersonFormComponent implements OnInit, OnDestroy { */ isImpersonated = false; + /** + * A boolean that indicate if to display EPersonForm's Rest password button + */ + displayResetPassword = false; + + /** + * A string that indicate the label of Submit button + */ + submitLabel = 'form.create'; /** * Subscription to email field value change */ @@ -188,6 +197,8 @@ export class EPersonFormComponent implements OnInit, OnDestroy { this.epersonInitial = eperson; if (hasValue(eperson)) { this.isImpersonated = this.authService.isImpersonatingUser(eperson.id); + this.displayResetPassword = true; + this.submitLabel = 'form.submit'; } })); } 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 b7536177cd..4b65535fce 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 @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, fakeAsync, flush, inject, TestBed, tick, waitForAsync } from '@angular/core/testing'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { BrowserModule, By } from '@angular/platform-browser'; @@ -37,10 +37,10 @@ describe('MembersListComponent', () => { let ePersonDataServiceStub: any; let groupsDataServiceStub: any; let activeGroup; - let allEPersons; - let allGroups; - let epersonMembers; - let subgroupMembers; + let allEPersons: EPerson[]; + let allGroups: Group[]; + let epersonMembers: EPerson[]; + let subgroupMembers: Group[]; let paginationService; beforeEach(waitForAsync(() => { @@ -53,7 +53,7 @@ describe('MembersListComponent', () => { activeGroup: activeGroup, epersonMembers: epersonMembers, subgroupMembers: subgroupMembers, - findListByHref(href: string): Observable>> { + findListByHref(_href: string): Observable>> { return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), groupsDataServiceStub.getEPersonMembers())); }, searchByScope(scope: string, query: string): Observable>> { @@ -147,6 +147,7 @@ describe('MembersListComponent', () => { }); afterEach(fakeAsync(() => { fixture.destroy(); + fixture.debugElement.nativeElement.remove(); flush(); component = null; fixture.debugElement.nativeElement.remove(); @@ -168,12 +169,19 @@ describe('MembersListComponent', () => { describe('search', () => { describe('when searching without query', () => { - let epersonsFound; + let epersonsFound: DebugElement[]; beforeEach(fakeAsync(() => { + spyOn(component, 'isMemberOfGroup').and.callFake((ePerson: EPerson) => { + return observableOf(activeGroup.epersons.includes(ePerson)); + }); component.search({ scope: 'metadata', query: '' }); tick(); fixture.detectChanges(); epersonsFound = fixture.debugElement.queryAll(By.css('#epersonsSearch tbody tr')); + // Stop using the fake spy function (because otherwise the clicking on the buttons will not change anything + // because they don't change the value of activeGroup.epersons) + jasmine.getEnv().allowRespy(true); + spyOn(component, 'isMemberOfGroup').and.callThrough(); })); it('should display all epersons', () => { @@ -182,62 +190,56 @@ describe('MembersListComponent', () => { describe('if eperson is already a eperson', () => { it('should have delete button, else it should have add button', () => { - activeGroup.epersons.map((eperson: EPerson) => { - epersonsFound.map((foundEPersonRowElement) => { - if (foundEPersonRowElement.debugElement !== undefined) { - const epersonId = foundEPersonRowElement.debugElement.query(By.css('td:first-child')); - const addButton = foundEPersonRowElement.debugElement.query(By.css('td:last-child .fa-plus')); - const deleteButton = foundEPersonRowElement.debugElement.query(By.css('td:last-child .fa-trash-alt')); - if (epersonId.nativeElement.textContent === eperson.id) { - expect(addButton).toBeUndefined(); - expect(deleteButton).toBeDefined(); - } else { - expect(deleteButton).toBeUndefined(); - expect(addButton).toBeDefined(); - } - } - }); + const memberIds: string[] = activeGroup.epersons.map((ePerson: EPerson) => ePerson.id); + epersonsFound.map((foundEPersonRowElement: DebugElement) => { + const epersonId: DebugElement = foundEPersonRowElement.query(By.css('td:first-child')); + const addButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-plus')); + const deleteButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-trash-alt')); + if (memberIds.includes(epersonId.nativeElement.textContent)) { + expect(addButton).toBeNull(); + expect(deleteButton).not.toBeNull(); + } else { + expect(deleteButton).toBeNull(); + expect(addButton).not.toBeNull(); + } }); }); }); describe('if first add button is pressed', () => { beforeEach(fakeAsync(() => { - const addButton = fixture.debugElement.query(By.css('#epersonsSearch tbody .fa-plus')); + const addButton: DebugElement = fixture.debugElement.query(By.css('#epersonsSearch tbody .fa-plus')); addButton.nativeElement.click(); tick(); fixture.detectChanges(); })); - it('all groups in search member of selected group', () => { + it('then all the ePersons are member of the active group', () => { epersonsFound = fixture.debugElement.queryAll(By.css('#epersonsSearch tbody tr')); expect(epersonsFound.length).toEqual(2); - epersonsFound.map((foundEPersonRowElement) => { - if (foundEPersonRowElement.debugElement !== undefined) { - const addButton = foundEPersonRowElement.debugElement.query(By.css('td:last-child .fa-plus')); - const deleteButton = foundEPersonRowElement.debugElement.query(By.css('td:last-child .fa-trash-alt')); - expect(addButton).toBeUndefined(); - expect(deleteButton).toBeDefined(); - } + epersonsFound.map((foundEPersonRowElement: DebugElement) => { + const addButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-plus')); + const deleteButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-trash-alt')); + expect(addButton).toBeNull(); + expect(deleteButton).not.toBeNull(); }); }); }); describe('if first delete button is pressed', () => { beforeEach(fakeAsync(() => { - const addButton = fixture.debugElement.query(By.css('#epersonsSearch tbody .fa-trash-alt')); - addButton.nativeElement.click(); + const deleteButton: DebugElement = fixture.debugElement.query(By.css('#epersonsSearch tbody .fa-trash-alt')); + deleteButton.nativeElement.click(); tick(); fixture.detectChanges(); })); - it('first eperson in search delete button, because now member', () => { + it('then no ePerson is member of the active group', () => { epersonsFound = fixture.debugElement.queryAll(By.css('#epersonsSearch tbody tr')); - epersonsFound.map((foundEPersonRowElement) => { - if (foundEPersonRowElement.debugElement !== undefined) { - const addButton = foundEPersonRowElement.debugElement.query(By.css('td:last-child .fa-plus')); - const deleteButton = foundEPersonRowElement.debugElement.query(By.css('td:last-child .fa-trash-alt')); - expect(deleteButton).toBeUndefined(); - expect(addButton).toBeDefined(); - } + expect(epersonsFound.length).toEqual(2); + epersonsFound.map((foundEPersonRowElement: DebugElement) => { + const addButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-plus')); + const deleteButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-trash-alt')); + expect(deleteButton).toBeNull(); + expect(addButton).not.toBeNull(); }); }); }); 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 58d252f0b4..d0fc046093 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 @@ -249,6 +249,7 @@ export class MembersListComponent implements OnInit, OnDestroy { * @param ePerson EPerson we want to delete as member from group that is currently being edited */ deleteMemberFromGroup(ePerson: EpersonDtoModel) { + ePerson.memberOfGroup = false; this.groupDataService.getActiveGroup().pipe(take(1)).subscribe((activeGroup: Group) => { if (activeGroup != null) { const response = this.groupDataService.deleteMemberFromGroup(activeGroup, ePerson.eperson); diff --git a/src/app/access-control/group-registry/group-form/subgroup-list/subgroups-list.component.html b/src/app/access-control/group-registry/group-form/subgroup-list/subgroups-list.component.html index 3be45c4452..d1574b0dba 100644 --- a/src/app/access-control/group-registry/group-form/subgroup-list/subgroups-list.component.html +++ b/src/app/access-control/group-registry/group-form/subgroup-list/subgroups-list.component.html @@ -65,7 +65,7 @@ -

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

+ {{ messagePrefix + '.table.edit.currentGroup' | translate }}
@@ -17,7 +17,7 @@
diff --git a/src/app/core/auth/server-auth-request.service.spec.ts b/src/app/core/auth/server-auth-request.service.spec.ts index df6d78256b..5b0221e5df 100644 --- a/src/app/core/auth/server-auth-request.service.spec.ts +++ b/src/app/core/auth/server-auth-request.service.spec.ts @@ -8,7 +8,7 @@ import { PostRequest } from '../data/request.models'; import { XSRF_REQUEST_HEADER, XSRF_RESPONSE_HEADER -} from '../xsrf/xsrf.interceptor'; +} from '../xsrf/xsrf.constants'; describe(`ServerAuthRequestService`, () => { let href: string; diff --git a/src/app/core/auth/server-auth-request.service.ts b/src/app/core/auth/server-auth-request.service.ts index d6302081bc..058322acce 100644 --- a/src/app/core/auth/server-auth-request.service.ts +++ b/src/app/core/auth/server-auth-request.service.ts @@ -13,7 +13,7 @@ import { XSRF_REQUEST_HEADER, XSRF_RESPONSE_HEADER, DSPACE_XSRF_COOKIE -} from '../xsrf/xsrf.interceptor'; +} from '../xsrf/xsrf.constants'; import { map } from 'rxjs/operators'; import { Observable } from 'rxjs'; diff --git a/src/app/core/browse/browse.service.ts b/src/app/core/browse/browse.service.ts index be28015069..989213a978 100644 --- a/src/app/core/browse/browse.service.ts +++ b/src/app/core/browse/browse.service.ts @@ -22,6 +22,7 @@ import { BrowseEntrySearchOptions } from './browse-entry-search-options.model'; import { HrefOnlyDataService } from '../data/href-only-data.service'; import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { BrowseDefinitionDataService } from './browse-definition-data.service'; +import { SortDirection } from '../cache/models/sort-options.model'; export const BROWSE_LINKS_TO_FOLLOW: FollowLinkConfig[] = [ @@ -160,8 +161,9 @@ export class BrowseService { * Get the first item for a metadata definition in an optional scope * @param definition * @param scope + * @param sortDirection optional sort parameter */ - getFirstItemFor(definition: string, scope?: string): Observable> { + getFirstItemFor(definition: string, scope?: string, sortDirection?: SortDirection): Observable> { const href$ = this.getBrowseDefinitions().pipe( getBrowseDefinitionLinks(definition), hasValueOperator(), @@ -177,6 +179,9 @@ export class BrowseService { } args.push('page=0'); args.push('size=1'); + if (sortDirection) { + args.push('sort=default,' + sortDirection); + } if (isNotEmpty(args)) { href = new URLCombiner(href, `?${args.join('&')}`).toString(); } diff --git a/src/app/core/locale/locale.interceptor.spec.ts b/src/app/core/locale/locale.interceptor.spec.ts index 9e298fefcc..e96126d19c 100644 --- a/src/app/core/locale/locale.interceptor.spec.ts +++ b/src/app/core/locale/locale.interceptor.spec.ts @@ -52,7 +52,7 @@ describe(`LocaleInterceptor`, () => { expect(httpRequest.request.headers.has('Accept-Language')); const lang = httpRequest.request.headers.get('Accept-Language'); - expect(lang).toBeDefined(); + expect(lang).not.toBeNull(); expect(lang).toBe(languageList.toString()); }); diff --git a/src/app/core/locale/locale.service.spec.ts b/src/app/core/locale/locale.service.spec.ts index c7c59802fd..e6a4de0327 100644 --- a/src/app/core/locale/locale.service.spec.ts +++ b/src/app/core/locale/locale.service.spec.ts @@ -62,25 +62,33 @@ describe('LocaleService test suite', () => { }); describe('getCurrentLanguageCode', () => { - it('should return language saved on cookie', () => { + beforeEach(() => { + spyOn(translateService, 'getLangs').and.returnValue(langList); + }); + + it('should return the language saved on cookie if it\'s a valid & active language', () => { spyOnGet.and.returnValue('de'); expect(service.getCurrentLanguageCode()).toBe('de'); }); - describe('', () => { - beforeEach(() => { - spyOn(translateService, 'getLangs').and.returnValue(langList); - }); + it('should return the default language if the cookie language is disabled', () => { + spyOnGet.and.returnValue('disabled'); + expect(service.getCurrentLanguageCode()).toBe('en'); + }); - it('should return language from browser setting', () => { - spyOn(translateService, 'getBrowserLang').and.returnValue('it'); - expect(service.getCurrentLanguageCode()).toBe('it'); - }); + it('should return the default language if the cookie language does not exist', () => { + spyOnGet.and.returnValue('does-not-exist'); + expect(service.getCurrentLanguageCode()).toBe('en'); + }); - it('should return default language from config', () => { - spyOn(translateService, 'getBrowserLang').and.returnValue('fr'); - expect(service.getCurrentLanguageCode()).toBe('en'); - }); + it('should return language from browser setting', () => { + spyOn(translateService, 'getBrowserLang').and.returnValue('it'); + expect(service.getCurrentLanguageCode()).toBe('it'); + }); + + it('should return default language from config', () => { + spyOn(translateService, 'getBrowserLang').and.returnValue('fr'); + expect(service.getCurrentLanguageCode()).toBe('en'); }); }); diff --git a/src/app/core/locale/locale.service.ts b/src/app/core/locale/locale.service.ts index 16a35b8ae5..5c080d8c16 100644 --- a/src/app/core/locale/locale.service.ts +++ b/src/app/core/locale/locale.service.ts @@ -11,6 +11,7 @@ import { map, mergeMap, take } from 'rxjs/operators'; import { NativeWindowRef, NativeWindowService } from '../services/window.service'; import { RouteService } from '../services/route.service'; import { DOCUMENT } from '@angular/common'; +import { LangConfig } from '../../../config/lang-config.interface'; export const LANG_COOKIE = 'dsLanguage'; @@ -52,8 +53,7 @@ export class LocaleService { getCurrentLanguageCode(): string { // Attempt to get the language from a cookie let lang = this.getLanguageCodeFromCookie(); - if (isEmpty(lang)) { - // Cookie not found + if (isEmpty(lang) || environment.languages.find((langConfig: LangConfig) => langConfig.code === lang && langConfig.active) === undefined) { // Attempt to get the browser language from the user if (this.translate.getLangs().includes(this.translate.getBrowserLang())) { lang = this.translate.getBrowserLang(); diff --git a/src/app/core/xsrf/xsrf.constants.ts b/src/app/core/xsrf/xsrf.constants.ts new file mode 100644 index 0000000000..64da5d674e --- /dev/null +++ b/src/app/core/xsrf/xsrf.constants.ts @@ -0,0 +1,33 @@ +/** + * XSRF / CSRF related constants + */ + +/** + * Name of CSRF/XSRF header we (client) may SEND in requests to backend. + * (This is a standard header name for XSRF/CSRF defined by Angular) + */ +export const XSRF_REQUEST_HEADER = 'X-XSRF-TOKEN'; + +/** + * Name of CSRF/XSRF header we (client) may RECEIVE in responses from backend + * This header is defined by DSpace backend, see https://github.com/DSpace/RestContract/blob/main/csrf-tokens.md + */ +export const XSRF_RESPONSE_HEADER = 'DSPACE-XSRF-TOKEN'; + +/** + * Name of client-side Cookie where we store the CSRF/XSRF token between requests. + * This cookie is only available to client, and should be updated whenever a new XSRF_RESPONSE_HEADER + * is found in a response from the backend. + */ +export const XSRF_COOKIE = 'XSRF-TOKEN'; + +/** + * Name of server-side cookie the backend expects the XSRF token to be in. + * When the backend receives a modifying request, it will validate the CSRF/XSRF token by looking + * for a match between the XSRF_REQUEST_HEADER and this Cookie. For more details see + * https://github.com/DSpace/RestContract/blob/main/csrf-tokens.md + * + * NOTE: This Cookie is NOT readable to the client/UI. It is only readable to the backend and will + * be sent along automatically by the user's browser. + */ +export const DSPACE_XSRF_COOKIE = 'DSPACE-XSRF-COOKIE'; diff --git a/src/app/core/xsrf/xsrf.interceptor.spec.ts b/src/app/core/xsrf/xsrf.interceptor.spec.ts index 742c4a4a45..4a78b60fc1 100644 --- a/src/app/core/xsrf/xsrf.interceptor.spec.ts +++ b/src/app/core/xsrf/xsrf.interceptor.spec.ts @@ -67,7 +67,7 @@ describe(`XsrfInterceptor`, () => { expect(httpRequest.request.headers.has('X-XSRF-TOKEN')).toBeTrue(); expect(httpRequest.request.withCredentials).toBeTrue(); const token = httpRequest.request.headers.get('X-XSRF-TOKEN'); - expect(token).toBeDefined(); + expect(token).not.toBeNull(); expect(token).toBe(testToken.toString()); httpRequest.flush(mockPayload, { status: mockStatusCode, statusText: mockStatusText }); @@ -116,11 +116,11 @@ describe(`XsrfInterceptor`, () => { // ensure mock XSRF token is in response expect(response.headers.has('DSPACE-XSRF-TOKEN')).toBeTrue(); const token = response.headers.get('DSPACE-XSRF-TOKEN'); - expect(token).toBeDefined(); + expect(token).not.toBeNull(); expect(token).toBe(mockNewXSRFToken.toString()); // ensure our XSRF-TOKEN cookie exists & has the same value as the new DSPACE-XSRF-TOKEN header - expect(cookieService.get('XSRF-TOKEN')).toBeDefined(); + expect(cookieService.get('XSRF-TOKEN')).not.toBeNull(); expect(cookieService.get('XSRF-TOKEN')).toBe(mockNewXSRFToken.toString()); done(); @@ -153,7 +153,7 @@ describe(`XsrfInterceptor`, () => { expect(error.statusText).toBe(mockErrorText); // ensure our XSRF-TOKEN cookie exists & has the same value as the new DSPACE-XSRF-TOKEN header - expect(cookieService.get('XSRF-TOKEN')).toBeDefined(); + expect(cookieService.get('XSRF-TOKEN')).not.toBeNull(); expect(cookieService.get('XSRF-TOKEN')).toBe(mockNewXSRFToken.toString()); done(); diff --git a/src/app/core/xsrf/xsrf.interceptor.ts b/src/app/core/xsrf/xsrf.interceptor.ts index cded432397..c728a5cbd0 100644 --- a/src/app/core/xsrf/xsrf.interceptor.ts +++ b/src/app/core/xsrf/xsrf.interceptor.ts @@ -12,15 +12,7 @@ import { Observable, throwError } from 'rxjs'; import { tap, catchError } from 'rxjs/operators'; import { RESTURLCombiner } from '../url-combiner/rest-url-combiner'; import { CookieService } from '../services/cookie.service'; - -// Name of XSRF header we may send in requests to backend (this is a standard name defined by Angular) -export const XSRF_REQUEST_HEADER = 'X-XSRF-TOKEN'; -// Name of XSRF header we may receive in responses from backend -export const XSRF_RESPONSE_HEADER = 'DSPACE-XSRF-TOKEN'; -// Name of cookie where we store the XSRF token -export const XSRF_COOKIE = 'XSRF-TOKEN'; -// Name of cookie the backend expects the XSRF token to be in -export const DSPACE_XSRF_COOKIE = 'DSPACE-XSRF-COOKIE'; +import { XSRF_COOKIE, XSRF_REQUEST_HEADER, XSRF_RESPONSE_HEADER } from './xsrf.constants'; /** * Custom Http Interceptor intercepting Http Requests & Responses to diff --git a/src/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.html b/src/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.html index fce2b5c162..6dfc806c72 100644 --- a/src/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.html +++ b/src/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.html @@ -1,7 +1,7 @@
- - + +
diff --git a/src/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.html b/src/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.html index 772fb162c8..1b9ffcdb82 100644 --- a/src/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.html +++ b/src/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.html @@ -1,7 +1,7 @@
- - + +
diff --git a/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.html b/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.html index bf176a3857..ae65bd32cf 100644 --- a/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.html +++ b/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.html @@ -1,7 +1,7 @@
- - + +
diff --git a/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.html b/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.html index 8251f6b465..0aec93a884 100644 --- a/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.html +++ b/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.html @@ -1,7 +1,7 @@
- - + +
diff --git a/src/app/entity-groups/research-entities/item-pages/person/person.component.html b/src/app/entity-groups/research-entities/item-pages/person/person.component.html index 5cf30d9260..ee6bdb51b0 100644 --- a/src/app/entity-groups/research-entities/item-pages/person/person.component.html +++ b/src/app/entity-groups/research-entities/item-pages/person/person.component.html @@ -1,7 +1,7 @@
- - + +
diff --git a/src/app/entity-groups/research-entities/item-pages/project/project.component.html b/src/app/entity-groups/research-entities/item-pages/project/project.component.html index ea5b710085..fe35cc121b 100644 --- a/src/app/entity-groups/research-entities/item-pages/project/project.component.html +++ b/src/app/entity-groups/research-entities/item-pages/project/project.component.html @@ -1,7 +1,7 @@
- - + +
@@ -18,12 +18,12 @@ - - + diff --git a/src/app/home-page/home-page.component.html b/src/app/home-page/home-page.component.html index a7a55a8e15..caa86ac290 100644 --- a/src/app/home-page/home-page.component.html +++ b/src/app/home-page/home-page.component.html @@ -3,7 +3,7 @@ - +
diff --git a/src/app/info/feedback/feedback-form/themed-feedback-form.component.ts b/src/app/info/feedback/feedback-form/themed-feedback-form.component.ts new file mode 100644 index 0000000000..9b42db629f --- /dev/null +++ b/src/app/info/feedback/feedback-form/themed-feedback-form.component.ts @@ -0,0 +1,27 @@ +import { Component } from '@angular/core'; +import { ThemedComponent } from '../../../shared/theme-support/themed.component'; +import { FeedbackFormComponent } from './feedback-form.component'; + +/** + * Themed wrapper for {@link FeedbackFormComponent} + */ +@Component({ + selector: 'ds-themed-feedback-form', + styleUrls: [], + templateUrl: '../../../shared/theme-support/themed.component.html', +}) +export class ThemedFeedbackFormComponent extends ThemedComponent { + + protected getComponentName(): string { + return 'FeedbackFormComponent'; + } + + protected importThemedComponent(themeName: string): Promise { + return import(`../../../../themes/${themeName}/app/info/feedback/feedback-form/feedback-form.component`); + } + + protected importUnthemedComponent(): Promise { + return import('./feedback-form.component'); + } + +} diff --git a/src/app/info/feedback/feedback.component.html b/src/app/info/feedback/feedback.component.html index 210bdcf1d7..ad42f0f90e 100644 --- a/src/app/info/feedback/feedback.component.html +++ b/src/app/info/feedback/feedback.component.html @@ -1,3 +1,3 @@
- -
\ No newline at end of file + +
diff --git a/src/app/info/info.module.ts b/src/app/info/info.module.ts index 61eee71f3a..ccc4af0a7d 100644 --- a/src/app/info/info.module.ts +++ b/src/app/info/info.module.ts @@ -10,6 +10,7 @@ import { ThemedEndUserAgreementComponent } from './end-user-agreement/themed-end import { ThemedPrivacyComponent } from './privacy/themed-privacy.component'; import { FeedbackComponent } from './feedback/feedback.component'; import { FeedbackFormComponent } from './feedback/feedback-form/feedback-form.component'; +import { ThemedFeedbackFormComponent } from './feedback/feedback-form/themed-feedback-form.component'; import { ThemedFeedbackComponent } from './feedback/themed-feedback.component'; import { FeedbackGuard } from '../core/feedback/feedback.guard'; @@ -23,6 +24,7 @@ const DECLARATIONS = [ ThemedPrivacyComponent, FeedbackComponent, FeedbackFormComponent, + ThemedFeedbackFormComponent, ThemedFeedbackComponent ]; diff --git a/src/app/item-page/alerts/themed-item-alerts.component.ts b/src/app/item-page/alerts/themed-item-alerts.component.ts new file mode 100644 index 0000000000..9ed9b51404 --- /dev/null +++ b/src/app/item-page/alerts/themed-item-alerts.component.ts @@ -0,0 +1,30 @@ +import { Component, Input } from '@angular/core'; +import { Item } from '../../core/shared/item.model'; +import { ItemAlertsComponent } from './item-alerts.component'; +import { ThemedComponent } from '../../shared/theme-support/themed.component'; + +/** + * Themed wrapper for {@link ItemAlertsComponent} + */ +@Component({ + selector: 'ds-themed-item-alerts', + styleUrls: [], + templateUrl: '../../shared/theme-support/themed.component.html', +}) +export class ThemedItemAlertsComponent extends ThemedComponent { + protected inAndOutputNames: (keyof ItemAlertsComponent & keyof this)[] = ['item']; + + @Input() item: Item; + + protected getComponentName(): string { + return 'ItemAlertsComponent'; + } + + protected importThemedComponent(themeName: string): Promise { + return import(`../../../themes/${themeName}/app/item-page/alerts/item-alerts.component`); + } + + protected importUnthemedComponent(): Promise { + return import('./item-alerts.component'); + } +} diff --git a/src/app/item-page/edit-item-page/item-collection-mapper/item-collection-mapper.component.html b/src/app/item-page/edit-item-page/item-collection-mapper/item-collection-mapper.component.html index c779c4aafa..507e35e630 100644 --- a/src/app/item-page/edit-item-page/item-collection-mapper/item-collection-mapper.component.html +++ b/src/app/item-page/edit-item-page/item-collection-mapper/item-collection-mapper.component.html @@ -27,13 +27,13 @@
- - +
diff --git a/src/app/item-page/full/field-components/file-section/themed-full-file-section.component.ts b/src/app/item-page/full/field-components/file-section/themed-full-file-section.component.ts new file mode 100644 index 0000000000..015eec285c --- /dev/null +++ b/src/app/item-page/full/field-components/file-section/themed-full-file-section.component.ts @@ -0,0 +1,32 @@ +import { Component, Input } from '@angular/core'; +import { ThemedComponent } from '../../../../shared/theme-support/themed.component'; +import { FullFileSectionComponent } from './full-file-section.component'; +import { Item } from '../../../../core/shared/item.model'; + +/** + * Themed wrapper for {@link FullFileSectionComponent} + */ +@Component({ + selector: 'ds-themed-item-page-full-file-section', + styleUrls: [], + templateUrl: './../../../../shared/theme-support/themed.component.html', +}) +export class ThemedFullFileSectionComponent extends ThemedComponent { + + @Input() item: Item; + + protected inAndOutputNames: (keyof FullFileSectionComponent & keyof this)[] = ['item']; + + protected getComponentName(): string { + return 'FullFileSectionComponent'; + } + + protected importThemedComponent(themeName: string): Promise { + return import(`../../../../../themes/${themeName}/app/item-page/full/field-components/file-section/full-file-section.component`); + } + + protected importUnthemedComponent(): Promise { + return import('./full-file-section.component'); + } + +} diff --git a/src/app/item-page/full/full-item-page.component.html b/src/app/item-page/full/full-item-page.component.html index 562ee52f2a..1d83181395 100644 --- a/src/app/item-page/full/full-item-page.component.html +++ b/src/app/item-page/full/full-item-page.component.html @@ -1,14 +1,13 @@
- +
- - - + +
- +
diff --git a/src/app/item-page/full/full-item-page.component.spec.ts b/src/app/item-page/full/full-item-page.component.spec.ts index 66c6488b8e..53e36be1d1 100644 --- a/src/app/item-page/full/full-item-page.component.spec.ts +++ b/src/app/item-page/full/full-item-page.component.spec.ts @@ -104,9 +104,13 @@ describe('FullItemPageComponent', () => { fixture.detectChanges(); })); + afterEach(() => { + fixture.debugElement.nativeElement.remove(); + }); + it('should display the item\'s metadata', () => { const table = fixture.debugElement.query(By.css('table')); - for (const metadatum of mockItem.allMetadata([])) { + for (const metadatum of mockItem.allMetadata(Object.keys(mockItem.metadata))) { expect(table.nativeElement.innerHTML).toContain(metadatum.value); } }); @@ -137,7 +141,7 @@ describe('FullItemPageComponent', () => { it('should display the item', () => { const objectLoader = fixture.debugElement.query(By.css('.full-item-info')); - expect(objectLoader.nativeElement).toBeDefined(); + expect(objectLoader.nativeElement).not.toBeNull(); }); }); describe('when the item is withdrawn and the user is not an admin', () => { @@ -161,7 +165,7 @@ describe('FullItemPageComponent', () => { it('should display the item', () => { const objectLoader = fixture.debugElement.query(By.css('.full-item-info')); - expect(objectLoader.nativeElement).toBeDefined(); + expect(objectLoader).not.toBeNull(); }); }); @@ -173,7 +177,7 @@ describe('FullItemPageComponent', () => { it('should display the item', () => { const objectLoader = fixture.debugElement.query(By.css('.full-item-info')); - expect(objectLoader.nativeElement).toBeDefined(); + expect(objectLoader).not.toBeNull(); }); }); }); diff --git a/src/app/item-page/item-page.module.ts b/src/app/item-page/item-page.module.ts index c30438a75f..a8d41d1535 100644 --- a/src/app/item-page/item-page.module.ts +++ b/src/app/item-page/item-page.module.ts @@ -34,8 +34,11 @@ import { ResearchEntitiesModule } from '../entity-groups/research-entities/resea import { ThemedItemPageComponent } from './simple/themed-item-page.component'; import { ThemedFullItemPageComponent } from './full/themed-full-item-page.component'; import { MediaViewerComponent } from './media-viewer/media-viewer.component'; +import { ThemedMediaViewerComponent } from './media-viewer/themed-media-viewer.component'; import { MediaViewerVideoComponent } from './media-viewer/media-viewer-video/media-viewer-video.component'; +import { ThemedMediaViewerVideoComponent } from './media-viewer/media-viewer-video/themed-media-viewer-video.component'; import { MediaViewerImageComponent } from './media-viewer/media-viewer-image/media-viewer-image.component'; +import { ThemedMediaViewerImageComponent } from './media-viewer/media-viewer-image/themed-media-viewer-image.component'; import { NgxGalleryModule } from '@kolkov/ngx-gallery'; import { MiradorViewerComponent } from './mirador-viewer/mirador-viewer.component'; import { VersionPageComponent } from './version-page/version-page/version-page.component'; @@ -53,7 +56,10 @@ import { BitstreamRequestACopyPageComponent } from './bitstreams/request-a-copy/ import { FileSectionComponent } from './simple/field-components/file-section/file-section.component'; import { ItemSharedModule } from './item-shared.module'; import { DsoPageModule } from '../shared/dso-page/dso-page.module'; - +import { ThemedItemAlertsComponent } from './alerts/themed-item-alerts.component'; +import { + ThemedFullFileSectionComponent +} from './full/field-components/file-section/themed-full-file-section.component'; const ENTRY_COMPONENTS = [ // put only entry components that use custom decorator @@ -76,14 +82,18 @@ const DECLARATIONS = [ ItemPageFieldComponent, CollectionsComponent, FullFileSectionComponent, + ThemedFullFileSectionComponent, PublicationComponent, UntypedItemComponent, ItemComponent, UploadBitstreamComponent, AbstractIncrementalListComponent, MediaViewerComponent, + ThemedMediaViewerComponent, MediaViewerVideoComponent, + ThemedMediaViewerVideoComponent, MediaViewerImageComponent, + ThemedMediaViewerImageComponent, MiradorViewerComponent, VersionPageComponent, OrcidPageComponent, @@ -91,6 +101,7 @@ const DECLARATIONS = [ OrcidSyncSettingsComponent, OrcidQueueComponent, ItemAlertsComponent, + ThemedItemAlertsComponent, BitstreamRequestACopyPageComponent, ]; diff --git a/src/app/item-page/item-shared.module.ts b/src/app/item-page/item-shared.module.ts index 0249e3cf22..9c2bbba619 100644 --- a/src/app/item-page/item-shared.module.ts +++ b/src/app/item-page/item-shared.module.ts @@ -13,6 +13,9 @@ import { MetadataValuesComponent } from './field-components/metadata-values/meta import { GenericItemPageFieldComponent } from './simple/field-components/specific-field/generic/generic-item-page-field.component'; import { MetadataRepresentationListComponent } from './simple/metadata-representation-list/metadata-representation-list.component'; import { RelatedItemsComponent } from './simple/related-items/related-items-component'; +import { + ThemedMetadataRepresentationListComponent +} from './simple/metadata-representation-list/themed-metadata-representation-list.component'; const ENTRY_COMPONENTS = [ ItemVersionsDeleteModalComponent, @@ -27,6 +30,7 @@ const COMPONENTS = [ MetadataValuesComponent, GenericItemPageFieldComponent, MetadataRepresentationListComponent, + ThemedMetadataRepresentationListComponent, RelatedItemsComponent, ]; diff --git a/src/app/item-page/media-viewer/media-viewer-image/media-viewer-image.component.scss b/src/app/item-page/media-viewer/media-viewer-image/media-viewer-image.component.scss index 72ce4b04d9..cba963b6fa 100644 --- a/src/app/item-page/media-viewer/media-viewer-image/media-viewer-image.component.scss +++ b/src/app/item-page/media-viewer/media-viewer-image/media-viewer-image.component.scss @@ -1,6 +1,20 @@ -.ngx-gallery { - display: inline-block; - margin-bottom: 20px; - width: 340px !important; - height: 279px !important; +:host ::ng-deep { + .ngx-gallery { + width: unset !important; + height: unset !important; + } + + ngx-gallery-image { + max-width: 340px !important; + + .ngx-gallery-image { + background-position: left; + } + } + + ngx-gallery-image:after { + padding-top: 75%; + display: block; + content: ''; + } } diff --git a/src/app/item-page/media-viewer/media-viewer-image/media-viewer-image.component.ts b/src/app/item-page/media-viewer/media-viewer-image/media-viewer-image.component.ts index 0c32b5603d..4c32ba5376 100644 --- a/src/app/item-page/media-viewer/media-viewer-image/media-viewer-image.component.ts +++ b/src/app/item-page/media-viewer/media-viewer-image/media-viewer-image.component.ts @@ -18,7 +18,7 @@ export class MediaViewerImageComponent implements OnInit { @Input() preview?: boolean; @Input() image?: string; - loggedin: boolean; + thumbnailPlaceholder = './assets/images/replacement_image.svg'; galleryOptions: NgxGalleryOptions[]; galleryImages: NgxGalleryImage[]; @@ -28,7 +28,10 @@ export class MediaViewerImageComponent implements OnInit { */ isAuthenticated$: Observable; - constructor(private authService: AuthService) {} + constructor( + protected authService: AuthService, + ) { + } /** * Thi method sets up the gallery settings and data @@ -69,20 +72,20 @@ export class MediaViewerImageComponent implements OnInit { * @param medias input NgxGalleryImage array */ convertToGalleryImage(medias: MediaViewerItem[]): NgxGalleryImage[] { - const mappadImages = []; + const mappedImages = []; for (const image of medias) { if (image.format === 'image') { - mappadImages.push({ + mappedImages.push({ small: image.thumbnail ? image.thumbnail - : './assets/images/replacement_image.svg', + : this.thumbnailPlaceholder, medium: image.thumbnail ? image.thumbnail - : './assets/images/replacement_image.svg', + : this.thumbnailPlaceholder, big: image.bitstream._links.content.href, }); } } - return mappadImages; + return mappedImages; } } diff --git a/src/app/item-page/media-viewer/media-viewer-image/themed-media-viewer-image.component.ts b/src/app/item-page/media-viewer/media-viewer-image/themed-media-viewer-image.component.ts new file mode 100644 index 0000000000..85ac779817 --- /dev/null +++ b/src/app/item-page/media-viewer/media-viewer-image/themed-media-viewer-image.component.ts @@ -0,0 +1,38 @@ +import { Component, Input } from '@angular/core'; +import { ThemedComponent } from '../../../shared/theme-support/themed.component'; +import { MediaViewerImageComponent } from './media-viewer-image.component'; +import { MediaViewerItem } from '../../../core/shared/media-viewer-item.model'; + +/** + * Themed wrapper for {@link MediaViewerImageComponent}. + */ +@Component({ + selector: 'ds-themed-media-viewer-image', + styleUrls: [], + templateUrl: '../../../shared/theme-support/themed.component.html', +}) +export class ThemedMediaViewerImageComponent extends ThemedComponent { + + @Input() images: MediaViewerItem[]; + @Input() preview?: boolean; + @Input() image?: string; + + protected inAndOutputNames: (keyof MediaViewerImageComponent & keyof this)[] = [ + 'images', + 'preview', + 'image', + ]; + + protected getComponentName(): string { + return 'MediaViewerImageComponent'; + } + + protected importThemedComponent(themeName: string): Promise { + return import(`../../../../themes/${themeName}/app/item-page/media-viewer/media-viewer-image/media-viewer-image.component`); + } + + protected importUnthemedComponent(): Promise { + return import('./media-viewer-image.component'); + } + +} diff --git a/src/app/item-page/media-viewer/media-viewer-video/media-viewer-video.component.scss b/src/app/item-page/media-viewer/media-viewer-video/media-viewer-video.component.scss index 7702da7361..bb8b9d360e 100644 --- a/src/app/item-page/media-viewer/media-viewer-video/media-viewer-video.component.scss +++ b/src/app/item-page/media-viewer/media-viewer-video/media-viewer-video.component.scss @@ -1,4 +1,10 @@ video { - width: 340px; - height: 279px; + width: 100%; + height: auto; + max-width: 340px; +} + +.buttons { + display: flex; + gap: .25rem; } diff --git a/src/app/item-page/media-viewer/media-viewer-video/media-viewer-video.component.ts b/src/app/item-page/media-viewer/media-viewer-video/media-viewer-video.component.ts index 647bbacdc3..0524d31b8b 100644 --- a/src/app/item-page/media-viewer/media-viewer-video/media-viewer-video.component.ts +++ b/src/app/item-page/media-viewer/media-viewer-video/media-viewer-video.component.ts @@ -4,7 +4,7 @@ import { languageHelper } from './language-helper'; import { CaptionInfo} from './caption-info'; /** - * This componenet renders a video viewer and playlist for the media viewer + * This component renders a video viewer and playlist for the media viewer */ @Component({ selector: 'ds-media-viewer-video', @@ -24,8 +24,6 @@ export class MediaViewerVideoComponent implements OnInit { audio: './assets/images/replacement_audio.svg', }; - replacementThumbnail: string; - ngOnInit() { this.isCollapsed = false; this.filteredMedias = this.medias.filter((media) => media.format === 'audio' || media.format === 'video'); diff --git a/src/app/item-page/media-viewer/media-viewer-video/themed-media-viewer-video.component.ts b/src/app/item-page/media-viewer/media-viewer-video/themed-media-viewer-video.component.ts new file mode 100644 index 0000000000..9715837fdc --- /dev/null +++ b/src/app/item-page/media-viewer/media-viewer-video/themed-media-viewer-video.component.ts @@ -0,0 +1,34 @@ +import { Component, Input } from '@angular/core'; +import { ThemedComponent } from '../../../shared/theme-support/themed.component'; +import { MediaViewerItem } from '../../../core/shared/media-viewer-item.model'; +import { MediaViewerVideoComponent } from './media-viewer-video.component'; + +/** + * Themed wrapper for {@link MediaViewerVideoComponent}. + */ +@Component({ + selector: 'ds-themed-media-viewer-video', + styleUrls: [], + templateUrl: '../../../shared/theme-support/themed.component.html', +}) +export class ThemedMediaViewerVideoComponent extends ThemedComponent { + + @Input() medias: MediaViewerItem[]; + + protected inAndOutputNames: (keyof MediaViewerVideoComponent & keyof this)[] = [ + 'medias', + ]; + + protected getComponentName(): string { + return 'MediaViewerVideoComponent'; + } + + protected importThemedComponent(themeName: string): Promise { + return import(`../../../../themes/${themeName}/app/item-page/media-viewer/media-viewer-video/media-viewer-video.component`); + } + + protected importUnthemedComponent(): Promise { + return import('./media-viewer-video.component'); + } + +} diff --git a/src/app/item-page/media-viewer/media-viewer.component.html b/src/app/item-page/media-viewer/media-viewer.component.html index 4259af5250..231c48c86d 100644 --- a/src/app/item-page/media-viewer/media-viewer.component.html +++ b/src/app/item-page/media-viewer/media-viewer.component.html @@ -12,11 +12,11 @@ mediaList[0]?.format === 'video' || mediaList[0]?.format === 'audio' " > - + - + - + >
diff --git a/src/app/item-page/media-viewer/media-viewer.component.spec.ts b/src/app/item-page/media-viewer/media-viewer.component.spec.ts index 3369574f20..7703f14bd1 100644 --- a/src/app/item-page/media-viewer/media-viewer.component.spec.ts +++ b/src/app/item-page/media-viewer/media-viewer.component.spec.ts @@ -135,7 +135,7 @@ describe('MediaViewerComponent', () => { it('should display a default, thumbnail', () => { const defaultThumbnail = fixture.debugElement.query( - By.css('ds-media-viewer-image') + By.css('ds-themed-media-viewer-image') ); expect(defaultThumbnail.nativeElement).toBeDefined(); }); diff --git a/src/app/item-page/media-viewer/media-viewer.component.ts b/src/app/item-page/media-viewer/media-viewer.component.ts index 233ae0e6f6..e4113ed931 100644 --- a/src/app/item-page/media-viewer/media-viewer.component.ts +++ b/src/app/item-page/media-viewer/media-viewer.component.ts @@ -13,9 +13,8 @@ import { hasValue } from '../../shared/empty.util'; import { followLink } from '../../shared/utils/follow-link-config.model'; /** - * This componenet renders the media viewers + * This component renders the media viewers */ - @Component({ selector: 'ds-media-viewer', templateUrl: './media-viewer.component.html', diff --git a/src/app/item-page/media-viewer/themed-media-viewer.component.ts b/src/app/item-page/media-viewer/themed-media-viewer.component.ts new file mode 100644 index 0000000000..9886a258f5 --- /dev/null +++ b/src/app/item-page/media-viewer/themed-media-viewer.component.ts @@ -0,0 +1,36 @@ +import { Component, Input } from '@angular/core'; +import { ThemedComponent } from '../../shared/theme-support/themed.component'; +import { MediaViewerComponent } from './media-viewer.component'; +import { Item } from '../../core/shared/item.model'; + +/** + * Themed wrapper for {@link MediaViewerComponent}. + */ +@Component({ + selector: 'ds-themed-media-viewer', + styleUrls: [], + templateUrl: '../../shared/theme-support/themed.component.html', +}) +export class ThemedMediaViewerComponent extends ThemedComponent { + + @Input() item: Item; + @Input() videoOptions: boolean; + + protected inAndOutputNames: (keyof MediaViewerComponent & keyof this)[] = [ + 'item', + 'videoOptions', + ]; + + protected getComponentName(): string { + return 'MediaViewerComponent'; + } + + protected importThemedComponent(themeName: string): Promise { + return import(`../../../themes/${themeName}/app/item-page/media-viewer/media-viewer.component`); + } + + protected importUnthemedComponent(): Promise { + return import('./media-viewer.component'); + } + +} diff --git a/src/app/item-page/mirador-viewer/mirador-viewer.component.spec.ts b/src/app/item-page/mirador-viewer/mirador-viewer.component.spec.ts index 40ad0fd5d0..2727391dff 100644 --- a/src/app/item-page/mirador-viewer/mirador-viewer.component.spec.ts +++ b/src/app/item-page/mirador-viewer/mirador-viewer.component.spec.ts @@ -253,7 +253,7 @@ describe('MiradorViewerComponent in development mode', () => { it('should show message', (() => { const value = fixture.debugElement .nativeElement.querySelector('#viewer-message'); - expect(value).toBeDefined(); + expect(value).not.toBeNull(); })); }); diff --git a/src/app/item-page/simple/field-components/file-section/file-section.component.html b/src/app/item-page/simple/field-components/file-section/file-section.component.html index 8e9fb63eda..8e4f4dfcb0 100644 --- a/src/app/item-page/simple/field-components/file-section/file-section.component.html +++ b/src/app/item-page/simple/field-components/file-section/file-section.component.html @@ -3,7 +3,7 @@
{{file?.name}} - ({{(file?.sizeBytes) | dsFileSize }}) + ({{(file?.sizeBytes) | dsFileSize }}) diff --git a/src/app/item-page/simple/field-components/file-section/file-section.component.spec.ts b/src/app/item-page/simple/field-components/file-section/file-section.component.spec.ts index ded3ea054b..8acf405b55 100644 --- a/src/app/item-page/simple/field-components/file-section/file-section.component.spec.ts +++ b/src/app/item-page/simple/field-components/file-section/file-section.component.spec.ts @@ -146,7 +146,7 @@ describe('FileSectionComponent', () => { it('should contain a view less link', () => { const viewLess = fixture.debugElement.query(By.css('.bitstream-collapse')); - expect(viewLess).toBeDefined(); + expect(viewLess).not.toBeNull(); }); it('clicking on the view less link should reset the pages and call getNextPage()', () => { diff --git a/src/app/item-page/simple/field-components/specific-field/abstract/item-page-abstract-field.component.spec.ts b/src/app/item-page/simple/field-components/specific-field/abstract/item-page-abstract-field.component.spec.ts index bfed3847c5..4fb8889440 100644 --- a/src/app/item-page/simple/field-components/specific-field/abstract/item-page-abstract-field.component.spec.ts +++ b/src/app/item-page/simple/field-components/specific-field/abstract/item-page-abstract-field.component.spec.ts @@ -44,6 +44,6 @@ describe('ItemPageAbstractFieldComponent', () => { })); it('should render a ds-metadata-values', () => { - expect(fixture.debugElement.query(By.css('ds-metadata-values'))).toBeDefined(); + expect(fixture.debugElement.query(By.css('ds-metadata-values'))).not.toBeNull(); }); }); diff --git a/src/app/item-page/simple/field-components/specific-field/title/themed-item-page-field.component.ts b/src/app/item-page/simple/field-components/specific-field/title/themed-item-page-field.component.ts new file mode 100644 index 0000000000..7007b8fed3 --- /dev/null +++ b/src/app/item-page/simple/field-components/specific-field/title/themed-item-page-field.component.ts @@ -0,0 +1,33 @@ +import { Component, Input } from '@angular/core'; +import { ThemedComponent } from '../../../../../shared/theme-support/themed.component'; +import { ItemPageTitleFieldComponent } from './item-page-title-field.component'; +import { Item } from '../../../../../core/shared/item.model'; + +/** + * Themed wrapper for {@link ItemPageTitleFieldComponent} + */ +@Component({ + selector: 'ds-themed-item-page-title-field', + styleUrls: [], + templateUrl: '../../../../../shared/theme-support/themed.component.html', +}) +export class ThemedItemPageTitleFieldComponent extends ThemedComponent { + + protected inAndOutputNames: (keyof ItemPageTitleFieldComponent & keyof this)[] = [ + 'item', + ]; + + @Input() item: Item; + + protected getComponentName(): string { + return 'ItemPageTitleFieldComponent'; + } + + protected importThemedComponent(themeName: string): Promise { + return import(`../../../../../../themes/${themeName}/app/item-page/simple/field-components/specific-field/title/item-page-title-field.component`); + } + + protected importUnthemedComponent(): Promise { + return import('./item-page-title-field.component'); + } +} diff --git a/src/app/item-page/simple/item-page.component.html b/src/app/item-page/simple/item-page.component.html index 0fc423c195..cc9983bb35 100644 --- a/src/app/item-page/simple/item-page.component.html +++ b/src/app/item-page/simple/item-page.component.html @@ -1,7 +1,7 @@
- + diff --git a/src/app/item-page/simple/item-types/publication/publication.component.html b/src/app/item-page/simple/item-types/publication/publication.component.html index 214ce23045..5dacf87cfa 100644 --- a/src/app/item-page/simple/item-types/publication/publication.component.html +++ b/src/app/item-page/simple/item-types/publication/publication.component.html @@ -9,9 +9,9 @@
- - - + + +
@@ -20,17 +20,17 @@ - - - +
+ +
- - + diff --git a/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.html b/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.html index 70bbf39bac..9c0039d263 100644 --- a/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.html +++ b/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.html @@ -10,8 +10,8 @@
- - + +
@@ -21,17 +21,17 @@ - - - +
+ +
- - + diff --git a/src/app/item-page/simple/metadata-representation-list/metadata-representation-list.component.ts b/src/app/item-page/simple/metadata-representation-list/metadata-representation-list.component.ts index d5e6547778..59a5377f77 100644 --- a/src/app/item-page/simple/metadata-representation-list/metadata-representation-list.component.ts +++ b/src/app/item-page/simple/metadata-representation-list/metadata-representation-list.component.ts @@ -59,8 +59,10 @@ export class MetadataRepresentationListComponent extends AbstractIncrementalList */ total: number; - constructor(public relationshipService: RelationshipDataService, - private browseDefinitionDataService: BrowseDefinitionDataService) { + constructor( + public relationshipService: RelationshipDataService, + protected browseDefinitionDataService: BrowseDefinitionDataService, + ) { super(); } diff --git a/src/app/item-page/simple/metadata-representation-list/themed-metadata-representation-list.component.ts b/src/app/item-page/simple/metadata-representation-list/themed-metadata-representation-list.component.ts new file mode 100644 index 0000000000..e7a526bb05 --- /dev/null +++ b/src/app/item-page/simple/metadata-representation-list/themed-metadata-representation-list.component.ts @@ -0,0 +1,35 @@ +import { ThemedComponent } from '../../../shared/theme-support/themed.component'; +import { MetadataRepresentationListComponent } from './metadata-representation-list.component'; +import { Component, Input } from '@angular/core'; +import { Item } from '../../../core/shared/item.model'; + +@Component({ + selector: 'ds-themed-metadata-representation-list', + styleUrls: [], + templateUrl: '../../../shared/theme-support/themed.component.html', +}) +export class ThemedMetadataRepresentationListComponent extends ThemedComponent { + protected inAndOutputNames: (keyof MetadataRepresentationListComponent & keyof this)[] = ['parentItem', 'itemType', 'metadataFields', 'label', 'incrementBy']; + + @Input() parentItem: Item; + + @Input() itemType: string; + + @Input() metadataFields: string[]; + + @Input() label: string; + + @Input() incrementBy = 10; + + protected getComponentName(): string { + return 'MetadataRepresentationListComponent'; + } + + protected importThemedComponent(themeName: string): Promise { + return import(`../../../../themes/${themeName}/app/item-page/simple/metadata-representation-list/metadata-representation-list.component`); + } + + protected importUnthemedComponent(): Promise { + return import(`./metadata-representation-list.component`); + } +} diff --git a/src/app/menu.resolver.spec.ts b/src/app/menu.resolver.spec.ts index eef5c2d5af..838d5a53c5 100644 --- a/src/app/menu.resolver.spec.ts +++ b/src/app/menu.resolver.spec.ts @@ -41,6 +41,7 @@ describe('MenuResolver', () => { beforeEach(waitForAsync(() => { menuService = new MenuServiceStub(); spyOn(menuService, 'getMenu').and.returnValue(observableOf(MENU_STATE)); + spyOn(menuService, 'addSection'); browseService = jasmine.createSpyObj('browseService', { getBrowseDefinitions: createSuccessfulRemoteDataObject$(createPaginatedList(BROWSE_DEFINITIONS)) @@ -70,8 +71,6 @@ describe('MenuResolver', () => { schemas: [NO_ERRORS_SCHEMA] }); resolver = TestBed.inject(MenuResolver); - - spyOn(menuService, 'addSection'); })); it('should be created', () => { diff --git a/src/app/shared/browse-by/browse-by.component.spec.ts b/src/app/shared/browse-by/browse-by.component.spec.ts index 74e7c25b21..9317a68007 100644 --- a/src/app/shared/browse-by/browse-by.component.spec.ts +++ b/src/app/shared/browse-by/browse-by.component.spec.ts @@ -50,7 +50,8 @@ import { AccessControlRoutingModule } from '../../access-control/access-control- @listableObjectComponent(BrowseEntry, ViewMode.ListElement, DEFAULT_CONTEXT, 'custom') @Component({ - selector: 'ds-browse-entry-list-element', + // eslint-disable-next-line @angular-eslint/component-selector + selector: '', template: '' }) class MockThemedBrowseEntryListElementComponent { @@ -153,19 +154,21 @@ describe('BrowseByComponent', () => { it('should display a loading message when objects is empty', () => { (comp as any).objects = undefined; fixture.detectChanges(); - expect(fixture.debugElement.query(By.css('ds-themed-loading'))).toBeDefined(); + expect(fixture.debugElement.query(By.css('ds-themed-loading'))).not.toBeNull(); }); it('should display results when objects is not empty', () => { - (comp as any).objects = observableOf({ - payload: { - page: { - length: 1 - } - } - }); + comp.objects$ = createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [ + Object.assign(new BrowseEntry(), { + type: ITEM, + authority: 'authority key 1', + value: 'browse entry 1', + language: null, + count: 1, + }), + ])); fixture.detectChanges(); - expect(fixture.debugElement.query(By.css('ds-viewable-collection'))).toBeDefined(); + expect(fixture.debugElement.query(By.css('ds-viewable-collection'))).not.toBeNull(); }); describe('when showPaginator is true and browseEntries are provided', () => { @@ -237,15 +240,24 @@ describe('BrowseByComponent', () => { describe('reset filters button', () => { it('should not be present when no startsWith or value is present ', () => { - const button = fixture.debugElement.query(By.css('reset')); + const button = fixture.debugElement.query(By.css('.reset')); expect(button).toBeNull(); }); it('should be present when a startsWith or value is present ', () => { + comp.objects$ = createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [ + Object.assign(new BrowseEntry(), { + type: ITEM, + authority: 'authority key 1', + value: 'browse entry 1', + language: null, + count: 1, + }), + ])); comp.shouldDisplayResetButton$ = observableOf(true); fixture.detectChanges(); - const button = fixture.debugElement.query(By.css('reset')); - expect(button).toBeDefined(); + const button = fixture.debugElement.query(By.css('.reset')); + expect(button).not.toBeNull(); }); }); diff --git a/src/app/shared/collection-dropdown/collection-dropdown.component.html b/src/app/shared/collection-dropdown/collection-dropdown.component.html index db6c1fb41d..470291373f 100644 --- a/src/app/shared/collection-dropdown/collection-dropdown.component.html +++ b/src/app/shared/collection-dropdown/collection-dropdown.component.html @@ -7,7 +7,7 @@ #searchFieldEl>
-
- + - +
+
{{ listItem.collection.name}}
+
+ -
+ diff --git a/src/app/shared/dso-selector/dso-selector/dso-selector.component.spec.ts b/src/app/shared/dso-selector/dso-selector/dso-selector.component.spec.ts index 7c28859388..e2acd17bc0 100644 --- a/src/app/shared/dso-selector/dso-selector/dso-selector.component.spec.ts +++ b/src/app/shared/dso-selector/dso-selector/dso-selector.component.spec.ts @@ -11,6 +11,7 @@ import { PaginatedSearchOptions } from '../../search/models/paginated-search-opt import { hasValue } from '../../empty.util'; import { createPaginatedList } from '../../testing/utils.test'; import { NotificationsService } from '../../notifications/notifications.service'; +import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model'; describe('DSOSelectorComponent', () => { let component: DSOSelectorComponent; @@ -34,7 +35,7 @@ describe('DSOSelectorComponent', () => { ]; const searchService = { - search: (options: PaginatedSearchOptions) => { + search: (options: PaginatedSearchOptions, responseMsToLive?: number, useCachedVersionIfAvailable = true) => { if (hasValue(options.query) && options.query.startsWith('search.resourceid')) { return createSuccessfulRemoteDataObject$(createPaginatedList([searchResult])); } else if (options.pagination.currentPage === 1) { @@ -120,6 +121,43 @@ describe('DSOSelectorComponent', () => { }); }); + describe('search', () => { + beforeEach(() => { + spyOn(searchService, 'search').and.callThrough(); + }); + + it('should specify how to sort if no query is given', () => { + component.sort = new SortOptions('dc.title', SortDirection.ASC); + component.search(undefined, 0); + + expect(searchService.search).toHaveBeenCalledWith( + jasmine.objectContaining({ + query: undefined, + sort: jasmine.objectContaining({ + field: 'dc.title', + direction: SortDirection.ASC, + }), + }), + null, + true + ); + }); + + it('should not specify how to sort if a query is given', () => { + component.sort = new SortOptions('dc.title', SortDirection.ASC); + component.search('testQuery', 0); + + expect(searchService.search).toHaveBeenCalledWith( + jasmine.objectContaining({ + query: 'testQuery', + sort: null, + }), + null, + true + ); + }); + }); + describe('when search returns an error', () => { beforeEach(() => { spyOn(searchService, 'search').and.returnValue(createFailedRemoteDataObject$()); diff --git a/src/app/shared/dso-selector/dso-selector/dso-selector.component.ts b/src/app/shared/dso-selector/dso-selector/dso-selector.component.ts index c8d11891ba..fe64c0a41e 100644 --- a/src/app/shared/dso-selector/dso-selector/dso-selector.component.ts +++ b/src/app/shared/dso-selector/dso-selector/dso-selector.component.ts @@ -31,6 +31,7 @@ import { getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload } from import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../empty.util'; import { buildPaginatedList, PaginatedList } from '../../../core/data/paginated-list.model'; import { SearchResult } from '../../search/models/search-result.model'; +import { SortOptions } from '../../../core/cache/models/sort-options.model'; import { RemoteData } from '../../../core/data/remote-data'; import { NotificationsService } from '../../notifications/notifications.service'; import { TranslateService } from '@ngx-translate/core'; @@ -69,6 +70,11 @@ export class DSOSelectorComponent implements OnInit, OnDestroy { */ @Input() types: DSpaceObjectType[]; + /** + * The sorting options + */ + @Input() sort: SortOptions; + // list of allowed selectable dsoTypes typesString: string; @@ -221,13 +227,16 @@ export class DSOSelectorComponent implements OnInit, OnDestroy { * @param useCache Whether or not to use the cache */ search(query: string, page: number, useCache: boolean = true): Observable>>> { + // default sort is only used when there is not query + let efectiveSort = query ? null : this.sort; return this.searchService.search( new PaginatedSearchOptions({ query: query, dsoTypes: this.types, pagination: Object.assign({}, this.defaultPagination, { currentPage: page - }) + }), + sort: efectiveSort }), null, useCache, diff --git a/src/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.ts b/src/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.ts index 8b38b62378..e0b7c1675b 100644 --- a/src/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.ts +++ b/src/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.ts @@ -8,7 +8,8 @@ import { getCollectionCreateRoute, COLLECTION_PARENT_PARAMETER } from '../../../../collection-page/collection-page-routing-paths'; - +import { SortDirection, SortOptions } from '../../../../core/cache/models/sort-options.model'; +import { environment } from '../../../../../environments/environment'; /** * Component to wrap a list of existing communities inside a modal * Used to choose a community from to create a new collection in @@ -23,6 +24,7 @@ export class CreateCollectionParentSelectorComponent extends DSOSelectorModalWra selectorTypes = [DSpaceObjectType.COMMUNITY]; action = SelectorActionType.CREATE; header = 'dso-selector.create.collection.sub-level'; + defaultSort = new SortOptions(environment.comcolSelectionSort.sortField, environment.comcolSelectionSort.sortDirection as SortDirection); constructor(protected activeModal: NgbActiveModal, protected route: ActivatedRoute, private router: Router) { super(activeModal, route); diff --git a/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.html b/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.html index 4a22672988..a13be63880 100644 --- a/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.html +++ b/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.html @@ -14,6 +14,6 @@
{{'dso-selector.create.community.sub-level' | translate}}
- +
diff --git a/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.ts b/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.ts index a7f583df50..77458d9802 100644 --- a/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.ts +++ b/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.ts @@ -12,6 +12,8 @@ import { getCommunityCreateRoute, COMMUNITY_PARENT_PARAMETER } from '../../../../community-page/community-page-routing-paths'; +import { SortDirection, SortOptions } from '../../../../core/cache/models/sort-options.model'; +import { environment } from '../../../../../environments/environment'; /** * Component to wrap a button - for top communities - @@ -29,6 +31,7 @@ export class CreateCommunityParentSelectorComponent extends DSOSelectorModalWrap objectType = DSpaceObjectType.COMMUNITY; selectorTypes = [DSpaceObjectType.COMMUNITY]; action = SelectorActionType.CREATE; + defaultSort = new SortOptions(environment.comcolSelectionSort.sortField, environment.comcolSelectionSort.sortDirection as SortDirection); constructor(protected activeModal: NgbActiveModal, protected route: ActivatedRoute, private router: Router) { super(activeModal, route); diff --git a/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.ts b/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.ts index b109be0af2..ed8a7b0780 100644 --- a/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.ts +++ b/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.ts @@ -4,6 +4,8 @@ import { DSpaceObjectType } from '../../../../core/shared/dspace-object-type.mod import { DSpaceObject } from '../../../../core/shared/dspace-object.model'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { DSOSelectorModalWrapperComponent, SelectorActionType } from '../dso-selector-modal-wrapper.component'; +import { SortDirection, SortOptions } from '../../../../core/cache/models/sort-options.model'; +import { environment } from '../../../../../environments/environment'; /** * Component to wrap a list of existing collections inside a modal @@ -21,6 +23,7 @@ export class CreateItemParentSelectorComponent extends DSOSelectorModalWrapperCo selectorTypes = [DSpaceObjectType.COLLECTION]; action = SelectorActionType.CREATE; header = 'dso-selector.create.item.sub-level'; + defaultSort = new SortOptions(environment.comcolSelectionSort.sortField, environment.comcolSelectionSort.sortDirection as SortDirection); /** * If present this value is used to filter collection list by entity type diff --git a/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.html b/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.html index 85d8797e66..54044f5d79 100644 --- a/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.html +++ b/src/app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.html @@ -6,6 +6,6 @@
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 113ca518fd..3f81687c9f 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 @@ -5,6 +5,7 @@ import { RemoteData } from '../../../core/data/remote-data'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { DSpaceObjectType } from '../../../core/shared/dspace-object-type.model'; import { hasValue, isNotEmpty } from '../../empty.util'; +import { SortOptions } from '../../../core/cache/models/sort-options.model'; export enum SelectorActionType { CREATE = 'create', @@ -49,6 +50,11 @@ export abstract class DSOSelectorModalWrapperComponent implements OnInit { */ action: SelectorActionType; + /** + * Default DSO ordering + */ + defaultSort: SortOptions; + constructor(protected activeModal: NgbActiveModal, protected route: ActivatedRoute) { } diff --git a/src/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.ts b/src/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.ts index cfc2ea282d..fd54cd44ed 100644 --- a/src/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.ts +++ b/src/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.ts @@ -8,6 +8,8 @@ import { SelectorActionType } from '../dso-selector-modal-wrapper.component'; import { getCollectionEditRoute } from '../../../../collection-page/collection-page-routing-paths'; +import { SortDirection, SortOptions } from '../../../../core/cache/models/sort-options.model'; +import { environment } from '../../../../../environments/environment'; /** * Component to wrap a list of existing collections inside a modal @@ -22,6 +24,7 @@ export class EditCollectionSelectorComponent extends DSOSelectorModalWrapperComp objectType = DSpaceObjectType.COLLECTION; selectorTypes = [DSpaceObjectType.COLLECTION]; action = SelectorActionType.EDIT; + defaultSort = new SortOptions(environment.comcolSelectionSort.sortField, environment.comcolSelectionSort.sortDirection as SortDirection); constructor(protected activeModal: NgbActiveModal, protected route: ActivatedRoute, private router: Router) { super(activeModal, route); diff --git a/src/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.ts b/src/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.ts index d73a7b48c5..cf2f97c6d3 100644 --- a/src/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.ts +++ b/src/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.ts @@ -8,6 +8,8 @@ import { SelectorActionType } from '../dso-selector-modal-wrapper.component'; import { getCommunityEditRoute } from '../../../../community-page/community-page-routing-paths'; +import { SortDirection, SortOptions } from '../../../../core/cache/models/sort-options.model'; +import { environment } from '../../../../../environments/environment'; /** * Component to wrap a list of existing communities inside a modal @@ -23,6 +25,7 @@ export class EditCommunitySelectorComponent extends DSOSelectorModalWrapperCompo objectType = DSpaceObjectType.COMMUNITY; selectorTypes = [DSpaceObjectType.COMMUNITY]; action = SelectorActionType.EDIT; + defaultSort = new SortOptions(environment.comcolSelectionSort.sortField, environment.comcolSelectionSort.sortDirection as SortDirection); constructor(protected activeModal: NgbActiveModal, protected route: ActivatedRoute, private router: Router) { super(activeModal, route); 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 new file mode 100644 index 0000000000..85d8797e66 --- /dev/null +++ b/src/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.html @@ -0,0 +1,11 @@ +
+ + +
diff --git a/src/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.ts b/src/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.ts index 4822849e4c..c1ae583908 100644 --- a/src/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.ts +++ b/src/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.ts @@ -14,7 +14,7 @@ import { Item } from '../../../../core/shared/item.model'; @Component({ selector: 'ds-edit-item-selector', - templateUrl: '../dso-selector-modal-wrapper.component.html', + templateUrl: 'edit-item-selector.component.html', }) export class EditItemSelectorComponent extends DSOSelectorModalWrapperComponent implements OnInit { objectType = DSpaceObjectType.ITEM; diff --git a/src/app/shared/file-download-link/file-download-link.component.html b/src/app/shared/file-download-link/file-download-link.component.html index ba81ee3d20..8ebe622a5b 100644 --- a/src/app/shared/file-download-link/file-download-link.component.html +++ b/src/app/shared/file-download-link/file-download-link.component.html @@ -1,5 +1,5 @@
- + 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 2381ada66d..9e1f1d48aa 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 @@ -16,7 +16,7 @@
-
-
+
diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list-checkbox-group.model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list-checkbox-group.model.ts index 7cffdfe801..ba7074a2a8 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list-checkbox-group.model.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list-checkbox-group.model.ts @@ -15,8 +15,10 @@ export interface DynamicListCheckboxGroupModelConfig extends DynamicFormGroupMod vocabularyOptions: VocabularyOptions; groupLength?: number; repeatable: boolean; - value?: any; + value?: VocabularyEntry[]; typeBindRelations?: DynamicFormControlRelation[]; + required: boolean; + hint?: string; } export class DynamicListCheckboxGroupModel extends DynamicCheckboxGroupModel { @@ -26,6 +28,8 @@ export class DynamicListCheckboxGroupModel extends DynamicCheckboxGroupModel { @serializable() groupLength: number; @serializable() _value: VocabularyEntry[]; @serializable() typeBindRelations: DynamicFormControlRelation[]; + @serializable() required: boolean; + @serializable() hint: string; isListGroup = true; valueUpdates: Subject; @@ -36,6 +40,8 @@ export class DynamicListCheckboxGroupModel extends DynamicCheckboxGroupModel { this.groupLength = config.groupLength || 5; this._value = []; this.repeatable = config.repeatable; + this.required = config.required; + this.hint = config.hint; this.valueUpdates = new Subject(); this.valueUpdates.subscribe((value: VocabularyEntry | VocabularyEntry[]) => this.value = value); @@ -56,9 +62,8 @@ export class DynamicListCheckboxGroupModel extends DynamicCheckboxGroupModel { if (Array.isArray(value)) { this._value = value; } else { - // _value is non extendible so assign it a new array - const newValue = (this.value as VocabularyEntry[]).concat([value]); - this._value = newValue; + // _value is non-extendable so assign it a new array + this._value = (this.value as VocabularyEntry[]).concat([value]); } } } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list-radio-group.model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list-radio-group.model.ts index 6f51eed2ac..0a32498173 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list-radio-group.model.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list-radio-group.model.ts @@ -6,12 +6,15 @@ import { } from '@ng-dynamic-forms/core'; import { VocabularyOptions } from '../../../../../../core/submission/vocabularies/models/vocabulary-options.model'; import { hasValue } from '../../../../../empty.util'; +import { VocabularyEntry } from '../../../../../../core/submission/vocabularies/models/vocabulary-entry.model'; export interface DynamicListModelConfig extends DynamicRadioGroupModelConfig { vocabularyOptions: VocabularyOptions; groupLength?: number; repeatable: boolean; - value?: any; + value?: VocabularyEntry[]; + required: boolean; + hint?: string; } export class DynamicListRadioGroupModel extends DynamicRadioGroupModel { @@ -19,6 +22,8 @@ export class DynamicListRadioGroupModel extends DynamicRadioGroupModel { @serializable() vocabularyOptions: VocabularyOptions; @serializable() repeatable: boolean; @serializable() groupLength: number; + @serializable() required: boolean; + @serializable() hint: string; isListGroup = true; constructor(config: DynamicListModelConfig, layout?: DynamicFormControlLayout) { @@ -27,6 +32,8 @@ export class DynamicListRadioGroupModel extends DynamicRadioGroupModel { this.vocabularyOptions = config.vocabularyOptions; this.groupLength = config.groupLength || 5; this.repeatable = config.repeatable; + this.required = config.required; + this.hint = config.hint; this.value = config.value; } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list.component.html index 7e5cad353b..728c59aa46 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list.component.html @@ -17,7 +17,6 @@ [id]="item.id" [formControlName]="item.id" [name]="model.name" - [required]="model.required" [value]="item.value" (blur)="onBlur($event)" (change)="onChange($event)" diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list.component.ts index d44d24f8b8..88158f9414 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list.component.ts @@ -1,6 +1,5 @@ import { ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; -import { FormGroup } from '@angular/forms'; - +import { FormGroup, ValidatorFn, ValidationErrors, AbstractControl } from '@angular/forms'; import { DynamicCheckboxModel, DynamicFormControlComponent, @@ -110,6 +109,9 @@ export class DsDynamicListComponent extends DynamicFormControlComponent implemen protected setOptionsFromVocabulary() { if (this.model.vocabularyOptions.name && this.model.vocabularyOptions.name.length > 0) { const listGroup = this.group.controls[this.model.id] as FormGroup; + if (this.model.repeatable && this.model.required) { + listGroup.addValidators(this.hasAtLeastOneVocabularyEntry()); + } const pageInfo: PageInfo = new PageInfo({ elementsPerPage: 9999, currentPage: 1 } as PageInfo); @@ -121,7 +123,7 @@ export class DsDynamicListComponent extends DynamicFormControlComponent implemen let tempList: ListItem[] = []; this.optionsList = entries.page; // Make a list of available options (checkbox/radio) and split in groups of 'model.groupLength' - entries.page.forEach((option, key) => { + entries.page.forEach((option: VocabularyEntry, key: number) => { const value = option.authority || option.value; const checked: boolean = isNotEmpty(findKey( this.model.value, @@ -156,4 +158,13 @@ export class DsDynamicListComponent extends DynamicFormControlComponent implemen } } + /** + * Checks if at least one {@link VocabularyEntry} has been selected. + */ + hasAtLeastOneVocabularyEntry(): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + return control && control.value && Object.values(control.value).find((checked: boolean) => checked === true) ? null : this.model.errorMessages; + }; + } + } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.html index c82c1bd928..b72a8722ae 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.html @@ -12,7 +12,7 @@
  • {{'submission.sections.describe.relationship-lookup.search-tab.tab-title.' + relationshipOptions.relationshipType | translate : { count: (totalInternal$ | async)} }} - - +
  • {{'submission.sections.describe.relationship-lookup.search-tab.tab-title.' + source.id | translate : { count: (totalExternal$ | async)[idx] } }} - - +
  • diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/dynamic-lookup-relation-external-source-tab.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/dynamic-lookup-relation-external-source-tab.component.html index dac0e03444..cf8cb56577 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/dynamic-lookup-relation-external-source-tab.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/dynamic-lookup-relation-external-source-tab.component.html @@ -4,10 +4,10 @@
  • - - +

    {{ 'submission.sections.describe.relationship-lookup.selection-tab.title.' + externalSource.id | translate}}

    diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/dynamic-lookup-relation-external-source-tab.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/dynamic-lookup-relation-external-source-tab.component.ts index 22fcc4e8bb..601bd8cce8 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/dynamic-lookup-relation-external-source-tab.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/dynamic-lookup-relation-external-source-tab.component.ts @@ -124,12 +124,13 @@ export class DsDynamicLookupRelationExternalSourceTabComponent implements OnInit */ relatedEntityType: ItemType; - constructor(private router: Router, - public searchConfigService: SearchConfigurationService, - private externalSourceService: ExternalSourceDataService, - private modalService: NgbModal, - private selectableListService: SelectableListService, - private paginationService: PaginationService + constructor( + protected router: Router, + public searchConfigService: SearchConfigurationService, + protected externalSourceService: ExternalSourceDataService, + protected modalService: NgbModal, + protected selectableListService: SelectableListService, + protected paginationService: PaginationService, ) { } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/themed-dynamic-lookup-relation-external-source-tab.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/themed-dynamic-lookup-relation-external-source-tab.component.ts new file mode 100644 index 0000000000..f3d8421365 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/themed-dynamic-lookup-relation-external-source-tab.component.ts @@ -0,0 +1,62 @@ +import { ThemedComponent } from '../../../../../theme-support/themed.component'; +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { RelationshipOptions } from '../../../models/relationship-options.model'; +import { ListableObject } from '../../../../../object-collection/shared/listable-object.model'; +import { Context } from '../../../../../../core/shared/context.model'; +import { Item } from '../../../../../../core/shared/item.model'; +import { SEARCH_CONFIG_SERVICE } from '../../../../../../my-dspace-page/my-dspace-page.component'; +import { SearchConfigurationService } from '../../../../../../core/shared/search/search-configuration.service'; +import { Collection } from '../../../../../../core/shared/collection.model'; +import { ExternalSource } from '../../../../../../core/shared/external-source.model'; +import { DsDynamicLookupRelationExternalSourceTabComponent } from './dynamic-lookup-relation-external-source-tab.component'; +import { fadeIn, fadeInOut } from '../../../../../animations/fade'; + +@Component({ + selector: 'ds-themed-dynamic-lookup-relation-external-source-tab', + styleUrls: [], + templateUrl: '../../../../../theme-support/themed.component.html', + providers: [ + { + provide: SEARCH_CONFIG_SERVICE, + useClass: SearchConfigurationService + } + ], + animations: [ + fadeIn, + fadeInOut + ] +}) +export class ThemedDynamicLookupRelationExternalSourceTabComponent extends ThemedComponent { + protected inAndOutputNames: (keyof DsDynamicLookupRelationExternalSourceTabComponent & keyof this)[] = ['label', 'listId', + 'item', 'collection', 'relationship', 'context', 'repeatable', 'importedObject', 'externalSource']; + + @Input() label: string; + + @Input() listId: string; + + @Input() item: Item; + + @Input() collection: Collection; + + @Input() relationship: RelationshipOptions; + + @Input() context: Context; + + @Input() repeatable: boolean; + + @Output() importedObject: EventEmitter = new EventEmitter(); + + @Input() externalSource: ExternalSource; + + protected getComponentName(): string { + return 'DsDynamicLookupRelationExternalSourceTabComponent'; + } + + protected importThemedComponent(themeName: string): Promise { + return import(`../../../../../../../themes/${themeName}/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/dynamic-lookup-relation-external-source-tab.component`); + } + + protected importUnthemedComponent(): Promise { + return import(`./dynamic-lookup-relation-external-source-tab.component`); + } +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/search-tab/dynamic-lookup-relation-search-tab.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/search-tab/dynamic-lookup-relation-search-tab.component.html index 376900609e..17aafae5eb 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/search-tab/dynamic-lookup-relation-search-tab.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/search-tab/dynamic-lookup-relation-search-tab.component.html @@ -9,6 +9,7 @@ [selectionConfig]="{ repeatable: repeatable, listId: listId }" [showScopeSelector]="false" [showViewModes]="false" + [query]="query" (resultFound)="onResultFound($event)" (deselectObject)="deselectObject.emit($event)" (selectObject)="selectObject.emit($event)"> diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/search-tab/dynamic-lookup-relation-search-tab.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/search-tab/dynamic-lookup-relation-search-tab.component.ts index cd4a8f7690..9452918a97 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/search-tab/dynamic-lookup-relation-search-tab.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/search-tab/dynamic-lookup-relation-search-tab.component.ts @@ -147,12 +147,12 @@ export class DsDynamicLookupRelationSearchTabComponent implements OnInit, OnDest @Output() resultFound: EventEmitter> = new EventEmitter>(); constructor( - private searchService: SearchService, - private selectableListService: SelectableListService, + protected searchService: SearchService, + protected selectableListService: SelectableListService, public searchConfigService: SearchConfigurationService, public lookupRelationService: LookupRelationService, - private relationshipService: RelationshipDataService, - private paginationService: PaginationService + protected relationshipService: RelationshipDataService, + protected paginationService: PaginationService, ) { } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/search-tab/themed-dynamic-lookup-relation-search-tab.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/search-tab/themed-dynamic-lookup-relation-search-tab.component.ts new file mode 100644 index 0000000000..da998ed5a6 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/search-tab/themed-dynamic-lookup-relation-search-tab.component.ts @@ -0,0 +1,71 @@ +import { ThemedComponent } from '../../../../../theme-support/themed.component'; +import { DsDynamicLookupRelationSearchTabComponent } from './dynamic-lookup-relation-search-tab.component'; +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { RelationshipOptions } from '../../../models/relationship-options.model'; +import { Observable } from 'rxjs'; +import { ListableObject } from '../../../../../object-collection/shared/listable-object.model'; +import { Context } from '../../../../../../core/shared/context.model'; +import { RelationshipType } from '../../../../../../core/shared/item-relationships/relationship-type.model'; +import { Item } from '../../../../../../core/shared/item.model'; +import { SearchResult } from '../../../../../search/models/search-result.model'; +import { SearchObjects } from '../../../../../search/models/search-objects.model'; +import { DSpaceObject } from '../../../../../../core/shared/dspace-object.model'; +import { SEARCH_CONFIG_SERVICE } from '../../../../../../my-dspace-page/my-dspace-page.component'; +import { SearchConfigurationService } from '../../../../../../core/shared/search/search-configuration.service'; + +@Component({ + selector: 'ds-themed-dynamic-lookup-relation-search-tab', + styleUrls: [], + templateUrl: '../../../../../theme-support/themed.component.html', + providers: [ + { + provide: SEARCH_CONFIG_SERVICE, + useClass: SearchConfigurationService + } + ] +}) +export class ThemedDynamicLookupRelationSearchTabComponent extends ThemedComponent { + protected inAndOutputNames: (keyof DsDynamicLookupRelationSearchTabComponent & keyof this)[] = ['relationship', 'listId', + 'query', 'repeatable', 'selection$', 'context', 'relationshipType', 'item', 'isLeft', 'toRemove', 'isEditRelationship', + 'deselectObject', 'selectObject', 'resultFound']; + + @Input() relationship: RelationshipOptions; + + @Input() listId: string; + + @Input() query: string; + + @Input() repeatable: boolean; + + @Input() selection$: Observable; + + @Input() context: Context; + + @Input() relationshipType: RelationshipType; + + @Input() item: Item; + + @Input() isLeft: boolean; + + @Input() toRemove: SearchResult[]; + + @Input() isEditRelationship: boolean; + + @Output() deselectObject: EventEmitter = new EventEmitter(); + + @Output() selectObject: EventEmitter = new EventEmitter(); + + @Output() resultFound: EventEmitter> = new EventEmitter>(); + + protected getComponentName(): string { + return 'DsDynamicLookupRelationSearchTabComponent'; + } + + protected importThemedComponent(themeName: string): Promise { + return import(`../../../../../../../themes/${themeName}/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/search-tab/dynamic-lookup-relation-search-tab.component`); + } + + protected importUnthemedComponent(): Promise { + return import(`./dynamic-lookup-relation-search-tab.component`); + } +} diff --git a/src/app/shared/form/builder/form-builder.service.spec.ts b/src/app/shared/form/builder/form-builder.service.spec.ts index b68963e5ad..7d1a9a3986 100644 --- a/src/app/shared/form/builder/form-builder.service.spec.ts +++ b/src/app/shared/form/builder/form-builder.service.spec.ts @@ -235,10 +235,16 @@ describe('FormBuilderService test suite', () => { new DynamicListCheckboxGroupModel({ id: 'testCheckboxList', vocabularyOptions: vocabularyOptions, - repeatable: true + repeatable: true, + required: false, }), - new DynamicListRadioGroupModel({ id: 'testRadioList', vocabularyOptions: vocabularyOptions, repeatable: false }), + new DynamicListRadioGroupModel({ + id: 'testRadioList', + vocabularyOptions: vocabularyOptions, + repeatable: false, + required: false, + }), new DynamicRelationGroupModel({ submissionId, diff --git a/src/app/shared/form/chips/chips.component.html b/src/app/shared/form/chips/chips.component.html index 2233c1bd16..a6b90b23aa 100644 --- a/src/app/shared/form/chips/chips.component.html +++ b/src/app/shared/form/chips/chips.component.html @@ -1,5 +1,5 @@
    - +
    diff --git a/src/app/shared/form/chips/chips.component.spec.ts b/src/app/shared/form/chips/chips.component.spec.ts index 2b8a469bd1..050950ed4d 100644 --- a/src/app/shared/form/chips/chips.component.spec.ts +++ b/src/app/shared/form/chips/chips.component.spec.ts @@ -122,7 +122,7 @@ describe('ChipsComponent test suite', () => { })); it('should save chips item index when drag and drop start', fakeAsync(() => { - const de = chipsFixture.debugElement.query(By.css('li.nav-item')); + const de = chipsFixture.debugElement.query(By.css('div.nav-item')); de.triggerEventHandler('dragstart', null); @@ -131,7 +131,7 @@ describe('ChipsComponent test suite', () => { it('should update chips item order when drag and drop end', fakeAsync(() => { spyOn(chipsComp.chips, 'updateOrder'); - const de = chipsFixture.debugElement.query(By.css('li.nav-item')); + const de = chipsFixture.debugElement.query(By.css('div.nav-item')); de.triggerEventHandler('dragend', null); @@ -158,7 +158,7 @@ describe('ChipsComponent test suite', () => { }); it('should show icon for every field that has a configured icon', () => { - const de = chipsFixture.debugElement.query(By.css('li.nav-item')); + const de = chipsFixture.debugElement.query(By.css('div.nav-item')); const icons = de.queryAll(By.css('i.fas')); expect(icons.length).toBe(4); @@ -166,7 +166,7 @@ describe('ChipsComponent test suite', () => { }); it('should show tooltip on mouse over an icon', () => { - const de = chipsFixture.debugElement.query(By.css('li.nav-item')); + const de = chipsFixture.debugElement.query(By.css('div.nav-item')); const icons = de.queryAll(By.css('i.fas')); icons[0].triggerEventHandler('mouseover', null); diff --git a/src/app/shared/form/form.module.ts b/src/app/shared/form/form.module.ts index 51ebaee1b8..de18c53363 100644 --- a/src/app/shared/form/form.module.ts +++ b/src/app/shared/form/form.module.ts @@ -40,6 +40,8 @@ import { NgxMaskModule } from 'ngx-mask'; import { ThemedExternalSourceEntryImportModalComponent } from './builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/external-source-entry-import-modal/themed-external-source-entry-import-modal.component'; import { NgbDatepickerModule, NgbTimepickerModule } from '@ng-bootstrap/ng-bootstrap'; import { CdkTreeModule } from '@angular/cdk/tree'; +import { ThemedDynamicLookupRelationSearchTabComponent } from './builder/ds-dynamic-form-ui/relation-lookup-modal/search-tab/themed-dynamic-lookup-relation-search-tab.component'; +import { ThemedDynamicLookupRelationExternalSourceTabComponent } from './builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/themed-dynamic-lookup-relation-external-source-tab.component'; const COMPONENTS = [ CustomSwitchComponent, @@ -48,8 +50,10 @@ const COMPONENTS = [ DsDynamicListComponent, DsDynamicLookupComponent, DsDynamicLookupRelationSearchTabComponent, + ThemedDynamicLookupRelationSearchTabComponent, DsDynamicLookupRelationSelectionTabComponent, DsDynamicLookupRelationExternalSourceTabComponent, + ThemedDynamicLookupRelationExternalSourceTabComponent, DsDynamicDisabledComponent, DsDynamicLookupRelationModalComponent, DsDynamicScrollableDropdownComponent, diff --git a/src/app/shared/lang-switch/lang-switch.component.spec.ts b/src/app/shared/lang-switch/lang-switch.component.spec.ts index 7757622f4c..6d3c847086 100644 --- a/src/app/shared/lang-switch/lang-switch.component.spec.ts +++ b/src/app/shared/lang-switch/lang-switch.component.spec.ts @@ -109,7 +109,7 @@ describe('LangSwitchComponent', () => { })); it('should define the main A HREF in the UI', (() => { - expect(langSwitchElement.querySelector('a')).toBeDefined(); + expect(langSwitchElement.querySelector('a')).not.toBeNull(); })); describe('when selecting a language', () => { diff --git a/src/app/shared/log-in/log-in.component.html b/src/app/shared/log-in/log-in.component.html index 36f7034f4d..6b1fdb9ff6 100644 --- a/src/app/shared/log-in/log-in.component.html +++ b/src/app/shared/log-in/log-in.component.html @@ -1,5 +1,5 @@ -