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..6fae54f1f9 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" @@ -61,7 +68,7 @@ jobs: # https://github.com/actions/cache/blob/main/examples.md#node---yarn - name: Get Yarn cache directory id: yarn-cache-dir-path - run: echo "::set-output name=dir::$(yarn cache dir)" + run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT - name: Cache Yarn dependencies uses: actions/cache@v3 with: @@ -86,12 +93,16 @@ jobs: - name: Run specs (unit tests) run: yarn run test:headless + # Upload code coverage report to artifact (for one version of Node only), + # so that it can be shared with the 'codecov' job (see below) # NOTE: Angular CLI only supports code coverage for specs. See https://github.com/angular/angular-cli/issues/6286 - # Upload coverage reports to Codecov (for one version of Node only) - # https://github.com/codecov/codecov-action - - name: Upload coverage to Codecov.io - uses: codecov/codecov-action@v3 - if: matrix.node-version == '16.x' + - name: Upload code coverage report to Artifact + uses: actions/upload-artifact@v3 + if: matrix.node-version == '18.x' + with: + name: dspace-angular coverage report + path: 'coverage/dspace-angular/lcov.info' + retention-days: 14 # Using docker-compose start backend using CI configuration # and load assetstore from a cached copy @@ -105,11 +116,10 @@ jobs: # https://github.com/cypress-io/github-action # (NOTE: to run these e2e tests locally, just use 'ng e2e') - name: Run e2e tests (integration tests) - uses: cypress-io/github-action@v4 + uses: cypress-io/github-action@v5 with: - # Run tests in Chrome, headless mode + # Run tests in Chrome, headless mode (default) browser: chrome - headless: true # Start app before running tests (will be stopped automatically after tests finish) start: yarn run serve:ssr # Wait for backend & frontend to be available @@ -169,3 +179,32 @@ jobs: - name: Shutdown Docker containers run: docker-compose -f ./docker/docker-compose-ci.yml down + + # Codecov upload is a separate job in order to allow us to restart this separate from the entire build/test + # job above. This is necessary because Codecov uploads seem to randomly fail at times. + # See https://community.codecov.com/t/upload-issues-unable-to-locate-build-via-github-actions-api/3954 + codecov: + # Must run after 'tests' job above + needs: tests + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + # Download artifacts from previous 'tests' job + - name: Download coverage artifacts + uses: actions/download-artifact@v3 + + # Now attempt upload to Codecov using its action. + # NOTE: We use a retry action to retry the Codecov upload if it fails the first time. + # + # Retry action: https://github.com/marketplace/actions/retry-action + # Codecov action: https://github.com/codecov/codecov-action + - name: Upload coverage to Codecov.io + uses: Wandalen/wretry.action@v1.0.36 + with: + action: codecov/codecov-action@v3 + # Try upload 5 times max + attempt_limit: 5 + # Run again in 30 seconds + attempt_delay: 30000 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/.github/workflows/issue_opened.yml b/.github/workflows/issue_opened.yml index 5d7c1c30f7..b4436dca3a 100644 --- a/.github/workflows/issue_opened.yml +++ b/.github/workflows/issue_opened.yml @@ -16,7 +16,7 @@ jobs: # Only add to project board if issue is flagged as "needs triage" or has no labels # NOTE: By default we flag new issues as "needs triage" in our issue template if: (contains(github.event.issue.labels.*.name, 'needs triage') || join(github.event.issue.labels.*.name) == '') - uses: actions/add-to-project@v0.3.0 + uses: actions/add-to-project@v0.5.0 # Note, the authentication token below is an ORG level Secret. # It must be created/recreated manually via a personal access token with admin:org, project, public_repo permissions # See: https://docs.github.com/en/actions/configuring-and-managing-workflows/authenticating-with-the-github_token#permissions-for-the-github_token diff --git a/.github/workflows/label_merge_conflicts.yml b/.github/workflows/label_merge_conflicts.yml index a840a4fd17..c1396b6f45 100644 --- a/.github/workflows/label_merge_conflicts.yml +++ b/.github/workflows/label_merge_conflicts.yml @@ -23,7 +23,7 @@ jobs: steps: # See: https://github.com/prince-chrismc/label-merge-conflicts-action - name: Auto-label PRs with merge conflicts - uses: prince-chrismc/label-merge-conflicts-action@v2 + uses: prince-chrismc/label-merge-conflicts-action@v3 # Add "merge conflict" label if a merge conflict is detected. Remove it when resolved. # Note, the authentication token is created automatically # See: https://docs.github.com/en/actions/configuring-and-managing-workflows/authenticating-with-the-github_token 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/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..e5f62600e7 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 - precreate-core oai /opt/solr/server/solr/configsets/dspace/oai - cp -r -u /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 - precreate-core statistics /opt/solr/server/solr/configsets/dspace/statistics - cp -r -u /opt/solr/server/solr/configsets/dspace/statistics/* statistics + precreate-core authority /opt/solr/server/solr/configsets/authority + cp -r /opt/solr/server/solr/configsets/authority/* authority + precreate-core oai /opt/solr/server/solr/configsets/oai + cp -r /opt/solr/server/solr/configsets/oai/* oai + precreate-core search /opt/solr/server/solr/configsets/search + cp -r /opt/solr/server/solr/configsets/search/* search + precreate-core statistics /opt/solr/server/solr/configsets/statistics + cp -r /opt/solr/server/solr/configsets/statistics/* statistics 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/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/admin/admin-import-batch-page/batch-import-page.component.ts b/src/app/admin/admin-import-batch-page/batch-import-page.component.ts index 744b3aecce..673e1f23f5 100644 --- a/src/app/admin/admin-import-batch-page/batch-import-page.component.ts +++ b/src/app/admin/admin-import-batch-page/batch-import-page.component.ts @@ -117,9 +117,15 @@ export class BatchImportPageComponent { this.router.navigateByUrl(getProcessDetailRoute(rd.payload.processId)); } } else { - const title = this.translate.get('process.new.notification.error.title'); - const content = this.translate.get('process.new.notification.error.content'); - this.notificationsService.error(title, content); + if (rd.statusCode === 413) { + const title = this.translate.get('process.new.notification.error.title'); + const content = this.translate.get('process.new.notification.error.max-upload.content'); + this.notificationsService.error(title, content); + } else { + const title = this.translate.get('process.new.notification.error.title'); + const content = this.translate.get('process.new.notification.error.content'); + this.notificationsService.error(title, content); + } } }); } diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 9779c2ab27..b69e47c82c 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -218,7 +218,8 @@ import { ThemedPageErrorComponent } from './page-error/themed-page-error.compone { path: 'statistics', loadChildren: () => import('./statistics-page/statistics-page-routing.module') - .then((m) => m.StatisticsPageRoutingModule) + .then((m) => m.StatisticsPageRoutingModule), + canActivate: [EndUserAgreementCurrentUserGuard], }, { path: HEALTH_PAGE_PATH, @@ -228,7 +229,7 @@ import { ThemedPageErrorComponent } from './page-error/themed-page-error.compone { path: ACCESS_CONTROL_MODULE_PATH, loadChildren: () => import('./access-control/access-control.module').then((m) => m.AccessControlModule), - canActivate: [GroupAdministratorGuard], + canActivate: [GroupAdministratorGuard, EndUserAgreementCurrentUserGuard], }, { path: 'subscriptions', diff --git a/src/app/browse-by/browse-by-date-page/browse-by-date-page.component.spec.ts b/src/app/browse-by/browse-by-date-page/browse-by-date-page.component.spec.ts index 5c2a6d820e..e41d3a45b2 100644 --- a/src/app/browse-by/browse-by-date-page/browse-by-date-page.component.spec.ts +++ b/src/app/browse-by/browse-by-date-page/browse-by-date-page.component.spec.ts @@ -22,6 +22,7 @@ import { PaginationService } from '../../core/pagination/pagination.service'; import { PaginationServiceStub } from '../../shared/testing/pagination-service.stub'; import { APP_CONFIG } from '../../../config/app-config.interface'; import { environment } from '../../../environments/environment'; +import { SortDirection } from '../../core/cache/models/sort-options.model'; describe('BrowseByDatePageComponent', () => { let comp: BrowseByDatePageComponent; @@ -49,12 +50,22 @@ describe('BrowseByDatePageComponent', () => { ] } }); + const lastItem = Object.assign(new Item(), { + id: 'last-item-id', + metadata: { + 'dc.date.issued': [ + { + value: '1960-01-01' + } + ] + } + }); - const mockBrowseService = { - getBrowseEntriesFor: (options: BrowseEntrySearchOptions) => toRemoteData([]), - getBrowseItemsFor: (value: string, options: BrowseEntrySearchOptions) => toRemoteData([firstItem]), - getFirstItemFor: () => createSuccessfulRemoteDataObject$(firstItem) - }; + const mockBrowseService = { + getBrowseEntriesFor: (options: BrowseEntrySearchOptions) => toRemoteData([]), + getBrowseItemsFor: (value: string, options: BrowseEntrySearchOptions) => toRemoteData([firstItem]), + getFirstItemFor: (definition: string, scope?: string, sortDirection?: SortDirection) => null + }; const mockDsoService = { findById: () => createSuccessfulRemoteDataObject$(mockCommunity) @@ -91,9 +102,14 @@ describe('BrowseByDatePageComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(BrowseByDatePageComponent); + const browseService = fixture.debugElement.injector.get(BrowseService); + spyOn(browseService, 'getFirstItemFor') + // ok to expect the default browse as first param since we just need the mock items obtained via sort direction. + .withArgs('author', undefined, SortDirection.ASC).and.returnValue(createSuccessfulRemoteDataObject$(firstItem)) + .withArgs('author', undefined, SortDirection.DESC).and.returnValue(createSuccessfulRemoteDataObject$(lastItem)); comp = fixture.componentInstance; - fixture.detectChanges(); route = (comp as any).route; + fixture.detectChanges(); }); it('should initialize the list of items', () => { @@ -107,6 +123,7 @@ describe('BrowseByDatePageComponent', () => { }); it('should create a list of startsWith options with the current year first', () => { - expect(comp.startsWithOptions[0]).toEqual(new Date().getUTCFullYear()); + //expect(comp.startsWithOptions[0]).toEqual(new Date().getUTCFullYear()); + expect(comp.startsWithOptions[0]).toEqual(1960); }); }); diff --git a/src/app/browse-by/browse-by-date-page/browse-by-date-page.component.ts b/src/app/browse-by/browse-by-date-page/browse-by-date-page.component.ts index ffa7e882af..01cd3a003e 100644 --- a/src/app/browse-by/browse-by-date-page/browse-by-date-page.component.ts +++ b/src/app/browse-by/browse-by-date-page/browse-by-date-page.component.ts @@ -1,11 +1,10 @@ import { ChangeDetectorRef, Component, Inject } from '@angular/core'; import { BrowseByMetadataPageComponent, - browseParamsToOptions, getBrowseSearchOptions + browseParamsToOptions, + getBrowseSearchOptions } from '../browse-by-metadata-page/browse-by-metadata-page.component'; import { combineLatest as observableCombineLatest } from 'rxjs'; -import { RemoteData } from '../../core/data/remote-data'; -import { Item } from '../../core/shared/item.model'; import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { ActivatedRoute, Params, Router } from '@angular/router'; import { BrowseService } from '../../core/browse/browse.service'; @@ -16,7 +15,9 @@ import { map } from 'rxjs/operators'; import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; import { isValidDate } from '../../shared/date.util'; -import { AppConfig, APP_CONFIG } from '../../../config/app-config.interface'; +import { APP_CONFIG, AppConfig } from '../../../config/app-config.interface'; +import { RemoteData } from '../../core/data/remote-data'; +import { Item } from '../../core/shared/item.model'; @Component({ selector: 'ds-browse-by-date-page', @@ -72,30 +73,24 @@ export class BrowseByDatePageComponent extends BrowseByMetadataPageComponent { /** * Update the StartsWith options - * In this implementation, it creates a list of years starting from now, going all the way back to the earliest - * date found on an item within this scope. The further back in time, the bigger the change in years become to avoid - * extremely long lists with a one-year difference. + * In this implementation, it creates a list of years starting from the most recent item or the current year, going + * all the way back to the earliest date found on an item within this scope. The further back in time, the bigger + * the change in years become to avoid extremely long lists with a one-year difference. * To determine the change in years, the config found under GlobalConfig.BrowseBy is used for this. * @param definition The metadata definition to fetch the first item for * @param metadataKeys The metadata fields to fetch the earliest date from (expects a date field) * @param scope The scope under which to fetch the earliest item for */ updateStartsWithOptions(definition: string, metadataKeys: string[], scope?: string) { + const firstItemRD = this.browseService.getFirstItemFor(definition, scope, SortDirection.ASC); + const lastItemRD = this.browseService.getFirstItemFor(definition, scope, SortDirection.DESC); this.subs.push( - this.browseService.getFirstItemFor(definition, scope).subscribe((firstItemRD: RemoteData) => { - let lowerLimit = this.appConfig.browseBy.defaultLowerLimit; - if (hasValue(firstItemRD.payload)) { - const date = firstItemRD.payload.firstMetadataValue(metadataKeys); - if (isNotEmpty(date) && isValidDate(date)) { - const dateObj = new Date(date); - // TODO: it appears that getFullYear (based on local time) is sometimes unreliable. Switching to UTC. - lowerLimit = isNaN(dateObj.getUTCFullYear()) ? lowerLimit : dateObj.getUTCFullYear(); - } - } + observableCombineLatest([firstItemRD, lastItemRD]).subscribe(([firstItem, lastItem]) => { + let lowerLimit = this.getLimit(firstItem, metadataKeys, this.appConfig.browseBy.defaultLowerLimit); + let upperLimit = this.getLimit(lastItem, metadataKeys, new Date().getUTCFullYear()); const options = []; - const currentYear = new Date().getUTCFullYear(); - const oneYearBreak = Math.floor((currentYear - this.appConfig.browseBy.oneYearLimit) / 5) * 5; - const fiveYearBreak = Math.floor((currentYear - this.appConfig.browseBy.fiveYearLimit) / 10) * 10; + const oneYearBreak = Math.floor((upperLimit - this.appConfig.browseBy.oneYearLimit) / 5) * 5; + const fiveYearBreak = Math.floor((upperLimit - this.appConfig.browseBy.fiveYearLimit) / 10) * 10; if (lowerLimit <= fiveYearBreak) { lowerLimit -= 10; } else if (lowerLimit <= oneYearBreak) { @@ -103,7 +98,7 @@ export class BrowseByDatePageComponent extends BrowseByMetadataPageComponent { } else { lowerLimit -= 1; } - let i = currentYear; + let i = upperLimit; while (i > lowerLimit) { options.push(i); if (i <= fiveYearBreak) { @@ -121,4 +116,24 @@ export class BrowseByDatePageComponent extends BrowseByMetadataPageComponent { }) ); } + + /** + * Returns the year from the item metadata field or the limit. + * @param itemRD the item remote data + * @param metadataKeys The metadata fields to fetch the earliest date from (expects a date field) + * @param limit the limit to use if the year can't be found in metadata + * @private + */ + private getLimit(itemRD: RemoteData, metadataKeys: string[], limit: number): number { + if (hasValue(itemRD.payload)) { + const date = itemRD.payload.firstMetadataValue(metadataKeys); + if (isNotEmpty(date) && isValidDate(date)) { + const dateObj = new Date(date); + // TODO: it appears that getFullYear (based on local time) is sometimes unreliable. Switching to UTC. + return isNaN(dateObj.getUTCFullYear()) ? limit : dateObj.getUTCFullYear(); + } else { + return new Date().getUTCFullYear(); + } + } + } } diff --git a/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.ts b/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.ts index 5de6c7d856..3e8a6db816 100644 --- a/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.ts +++ b/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.ts @@ -151,8 +151,17 @@ export class BrowseByMetadataPageComponent implements OnInit, OnDestroy { ).subscribe(([params, currentPage, currentSort]: [Params, PaginationComponentOptions, SortOptions]) => { this.browseId = params.id || this.defaultBrowseId; this.authority = params.authority; - this.value = +params.value || params.value || ''; - this.startsWith = +params.startsWith || params.startsWith; + + if (typeof params.value === 'string'){ + this.value = params.value.trim(); + } else { + this.value = ''; + } + + if (typeof params.startsWith === 'string'){ + this.startsWith = params.startsWith.trim(); + } + if (isNotEmpty(this.value)) { this.updatePageWithItems( browseParamsToOptions(params, currentPage, currentSort, this.browseId, this.fetchThumbnails), this.value, this.authority); @@ -305,7 +314,7 @@ export function browseParamsToOptions(params: any, metadata, paginationConfig, sortConfig, - +params.startsWith || params.startsWith, + params.startsWith, params.scope, fetchThumbnail ); diff --git a/src/app/community-list-page/community-list/community-list.component.html b/src/app/community-list-page/community-list/community-list.component.html index 821cb58473..ea772bb891 100644 --- a/src/app/community-list-page/community-list/community-list.component.html +++ b/src/app/community-list-page/community-list/community-list.component.html @@ -28,7 +28,8 @@ [title]="'toggle ' + node.name" [attr.aria-label]="'toggle ' + node.name" (click)="toggleExpanded(node)" - [ngClass]="(hasChild(null, node)| async) ? 'visible' : 'invisible'"> + [ngClass]="(hasChild(null, node)| async) ? 'visible' : 'invisible'" + [attr.data-test]="(hasChild(null, node)| async) ? 'expand-button' : ''"> diff --git a/src/app/core/auth/auth.actions.ts b/src/app/core/auth/auth.actions.ts index 60440d371e..6bc4565682 100644 --- a/src/app/core/auth/auth.actions.ts +++ b/src/app/core/auth/auth.actions.ts @@ -17,6 +17,7 @@ export const AuthActionTypes = { AUTHENTICATED_SUCCESS: type('dspace/auth/AUTHENTICATED_SUCCESS'), CHECK_AUTHENTICATION_TOKEN: type('dspace/auth/CHECK_AUTHENTICATION_TOKEN'), CHECK_AUTHENTICATION_TOKEN_COOKIE: type('dspace/auth/CHECK_AUTHENTICATION_TOKEN_COOKIE'), + SET_AUTH_COOKIE_STATUS: type('dspace/auth/SET_AUTH_COOKIE_STATUS'), RETRIEVE_AUTH_METHODS: type('dspace/auth/RETRIEVE_AUTH_METHODS'), RETRIEVE_AUTH_METHODS_SUCCESS: type('dspace/auth/RETRIEVE_AUTH_METHODS_SUCCESS'), RETRIEVE_AUTH_METHODS_ERROR: type('dspace/auth/RETRIEVE_AUTH_METHODS_ERROR'), @@ -150,6 +151,19 @@ export class CheckAuthenticationTokenCookieAction implements Action { public type: string = AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE; } +/** + * Sets the authentication cookie status to flag an external authentication response. + */ +export class SetAuthCookieStatus implements Action { + public type: string = AuthActionTypes.SET_AUTH_COOKIE_STATUS; + + payload = false; + + constructor(exists: boolean) { + this.payload = exists; + } +} + /** * Sign out. * @class LogOutAction @@ -425,6 +439,7 @@ export type AuthActions | AuthenticationSuccessAction | CheckAuthenticationTokenAction | CheckAuthenticationTokenCookieAction + | SetAuthCookieStatus | RedirectWhenAuthenticationIsRequiredAction | RedirectWhenTokenExpiredAction | AddAuthenticationMessageAction diff --git a/src/app/core/auth/auth.effects.spec.ts b/src/app/core/auth/auth.effects.spec.ts index f09db04d99..2e6ba917aa 100644 --- a/src/app/core/auth/auth.effects.spec.ts +++ b/src/app/core/auth/auth.effects.spec.ts @@ -214,12 +214,15 @@ describe('AuthEffects', () => { authenticated: true }) ); + spyOn((authEffects as any).authService, 'setExternalAuthStatus'); actions = hot('--a-', { a: { type: AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE } }); const expected = cold('--b-', { b: new RetrieveTokenAction() }); expect(authEffects.checkTokenCookie$).toBeObservable(expected); authEffects.checkTokenCookie$.subscribe(() => { + expect(authServiceStub.setExternalAuthStatus).toHaveBeenCalled(); + expect(authServiceStub.isExternalAuthentication).toBeTrue(); expect((authEffects as any).authorizationsService.invalidateAuthorizationsRequestCache).toHaveBeenCalled(); }); }); diff --git a/src/app/core/auth/auth.effects.ts b/src/app/core/auth/auth.effects.ts index 22d1bf35e7..281355b769 100644 --- a/src/app/core/auth/auth.effects.ts +++ b/src/app/core/auth/auth.effects.ts @@ -153,6 +153,7 @@ export class AuthEffects { return this.authService.checkAuthenticationCookie().pipe( map((response: AuthStatus) => { if (response.authenticated) { + this.authService.setExternalAuthStatus(true); this.authorizationsService.invalidateAuthorizationsRequestCache(); return new RetrieveTokenAction(); } else { diff --git a/src/app/core/auth/auth.reducer.spec.ts b/src/app/core/auth/auth.reducer.spec.ts index 8ebc9f6cb0..c0619adf79 100644 --- a/src/app/core/auth/auth.reducer.spec.ts +++ b/src/app/core/auth/auth.reducer.spec.ts @@ -8,6 +8,7 @@ import { AuthenticationErrorAction, AuthenticationSuccessAction, CheckAuthenticationTokenAction, + SetAuthCookieStatus, CheckAuthenticationTokenCookieAction, LogOutAction, LogOutErrorAction, @@ -219,6 +220,28 @@ describe('authReducer', () => { expect(newState).toEqual(state); }); + it('should set the authentication cookie status in response to a SET_AUTH_COOKIE_STATUS action', () => { + initialState = { + authenticated: true, + loaded: false, + blocking: false, + loading: true, + externalAuth: false, + idle: false + }; + const action = new SetAuthCookieStatus(true); + const newState = authReducer(initialState, action); + state = { + authenticated: true, + loaded: false, + blocking: false, + loading: true, + externalAuth: true, + idle: false + }; + expect(newState).toEqual(state); + }); + it('should properly set the state, in response to a LOG_OUT action', () => { initialState = { authenticated: true, diff --git a/src/app/core/auth/auth.reducer.ts b/src/app/core/auth/auth.reducer.ts index acdb8ef812..ba9c41326a 100644 --- a/src/app/core/auth/auth.reducer.ts +++ b/src/app/core/auth/auth.reducer.ts @@ -10,7 +10,7 @@ import { RedirectWhenTokenExpiredAction, RefreshTokenSuccessAction, RetrieveAuthenticatedEpersonSuccessAction, - RetrieveAuthMethodsSuccessAction, + RetrieveAuthMethodsSuccessAction, SetAuthCookieStatus, SetRedirectUrlAction } from './auth.actions'; // import models @@ -59,6 +59,8 @@ export interface AuthState { // all authentication Methods enabled at the backend authMethods?: AuthMethod[]; + externalAuth?: boolean, + // true when the current user is idle idle: boolean; @@ -73,6 +75,7 @@ const initialState: AuthState = { blocking: true, loading: false, authMethods: [], + externalAuth: false, idle: false }; @@ -104,6 +107,11 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut loading: true, }); + case AuthActionTypes.SET_AUTH_COOKIE_STATUS: + return Object.assign({}, state, { + externalAuth: (action as SetAuthCookieStatus).payload + }); + case AuthActionTypes.AUTHENTICATED_ERROR: case AuthActionTypes.RETRIEVE_AUTHENTICATED_EPERSON_ERROR: return Object.assign({}, state, { diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index 3034c00197..6604936cde 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -25,7 +25,7 @@ import { import { CookieService } from '../services/cookie.service'; import { getAuthenticatedUserId, - getAuthenticationToken, + getAuthenticationToken, getExternalAuthCookieStatus, getRedirectUrl, isAuthenticated, isAuthenticatedLoaded, @@ -36,7 +36,7 @@ import { AppState } from '../../app.reducer'; import { CheckAuthenticationTokenAction, RefreshTokenAction, - ResetAuthenticationMessagesAction, + ResetAuthenticationMessagesAction, SetAuthCookieStatus, SetRedirectUrlAction, SetUserAsIdleAction, UnsetUserAsIdleAction @@ -156,6 +156,24 @@ export class AuthService { return this.store.pipe(select(isAuthenticatedLoaded)); } + /** + * Used to set the external authentication status when authenticating via an + * external authentication system (e.g. Shibboleth). + * @param external + */ + public setExternalAuthStatus(external: boolean) { + this.store.dispatch(new SetAuthCookieStatus(external)); + } + + /** + * Returns true if an external authentication system (e.g. Shibboleth) is being used + * for authentication. Returns false otherwise. + */ + public isExternalAuthentication(): Observable { + return this.store.pipe( + select(getExternalAuthCookieStatus)); + } + /** * Returns the href link to authenticated user * @returns {string} diff --git a/src/app/core/auth/selectors.ts b/src/app/core/auth/selectors.ts index ce8d38d6ba..aba739edf6 100644 --- a/src/app/core/auth/selectors.ts +++ b/src/app/core/auth/selectors.ts @@ -116,6 +116,8 @@ const _getRedirectUrl = (state: AuthState) => state.redirectUrl; const _getAuthenticationMethods = (state: AuthState) => state.authMethods; +const _getExternalAuthCookieStatus = (state: AuthState) => state.externalAuth; + /** * Returns true if the user is idle. * @function _isIdle @@ -178,6 +180,16 @@ export const isAuthenticated = createSelector(getAuthState, _isAuthenticated); */ export const isAuthenticatedLoaded = createSelector(getAuthState, _isAuthenticatedLoaded); +/** + * Returns the authentication cookie status. Expect to be true when external authentication + * is used. + * @function getExternalAuthCookieStatus + * @param {AuthState} state + * @param {any} props + * @return {boolean} + */ +export const getExternalAuthCookieStatus = createSelector(getAuthState, _getExternalAuthCookieStatus); + /** * Returns true if the authentication request is loading. * @function isAuthenticationLoading 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/core.module.ts b/src/app/core/core.module.ts index 0237a9eb53..319b42d58b 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -176,6 +176,7 @@ import { VocabularyEntryDetailsDataService } from './submission/vocabularies/voc import { IdentifierData } from '../shared/object-list/identifier-data/identifier-data.model'; import { Subscription } from '../shared/subscriptions/models/subscription.model'; import { SupervisionOrderDataService } from './supervision-order/supervision-order-data.service'; +import { ItemRequest } from './shared/item-request.model'; /** * When not in production, endpoint responses can be mocked for testing purposes @@ -369,6 +370,7 @@ export const models = AccessStatusObject, IdentifierData, Subscription, + ItemRequest, ]; @NgModule({ diff --git a/src/app/core/data/item-data.service.ts b/src/app/core/data/item-data.service.ts index 80da91acb3..c3fa84dd6c 100644 --- a/src/app/core/data/item-data.service.ts +++ b/src/app/core/data/item-data.service.ts @@ -246,10 +246,10 @@ export abstract class BaseItemDataService extends IdentifiableDataService * Get the endpoint to move the item * @param itemId */ - public getMoveItemEndpoint(itemId: string): Observable { + public getMoveItemEndpoint(itemId: string, inheritPolicies: boolean): Observable { return this.halService.getEndpoint(this.linkPath).pipe( map((endpoint: string) => this.getIDHref(endpoint, itemId)), - map((endpoint: string) => `${endpoint}/owningCollection`), + map((endpoint: string) => `${endpoint}/owningCollection?inheritPolicies=${inheritPolicies}`) ); } @@ -258,14 +258,14 @@ export abstract class BaseItemDataService extends IdentifiableDataService * @param itemId * @param collection */ - public moveToCollection(itemId: string, collection: Collection): Observable> { + public moveToCollection(itemId: string, collection: Collection, inheritPolicies: boolean): Observable> { const options: HttpOptions = Object.create({}); let headers = new HttpHeaders(); headers = headers.append('Content-Type', 'text/uri-list'); options.headers = headers; const requestId = this.requestService.generateRequestId(); - const hrefObs = this.getMoveItemEndpoint(itemId); + const hrefObs = this.getMoveItemEndpoint(itemId, inheritPolicies); hrefObs.pipe( find((href: string) => hasValue(href)), 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.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 5a072f64b8..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 @@
- - + +
diff --git a/src/app/footer/themed-footer.component.ts b/src/app/footer/themed-footer.component.ts index c52a0af29f..e8f64f3434 100644 --- a/src/app/footer/themed-footer.component.ts +++ b/src/app/footer/themed-footer.component.ts @@ -7,7 +7,7 @@ import { FooterComponent } from './footer.component'; */ @Component({ selector: 'ds-themed-footer', - styleUrls: ['footer.component.scss'], + styleUrls: [], templateUrl: '../shared/theme-support/themed.component.html', }) export class ThemedFooterComponent extends ThemedComponent { @@ -20,6 +20,6 @@ export class ThemedFooterComponent extends ThemedComponent { } protected importUnthemedComponent(): Promise { - return import(`./footer.component`); + return import('./footer.component'); } } diff --git a/src/app/header-nav-wrapper/themed-header-navbar-wrapper.component.ts b/src/app/header-nav-wrapper/themed-header-navbar-wrapper.component.ts index 7f9c181fe2..02d09c44ef 100644 --- a/src/app/header-nav-wrapper/themed-header-navbar-wrapper.component.ts +++ b/src/app/header-nav-wrapper/themed-header-navbar-wrapper.component.ts @@ -3,11 +3,11 @@ import { ThemedComponent } from '../shared/theme-support/themed.component'; import { HeaderNavbarWrapperComponent } from './header-navbar-wrapper.component'; /** - * Themed wrapper for BreadcrumbsComponent + * Themed wrapper for {@link HeaderNavbarWrapperComponent} */ @Component({ selector: 'ds-themed-header-navbar-wrapper', - styleUrls: ['./themed-header-navbar-wrapper.component.scss'], + styleUrls: [], templateUrl: '../shared/theme-support/themed.component.html', }) export class ThemedHeaderNavbarWrapperComponent extends ThemedComponent { @@ -20,6 +20,6 @@ export class ThemedHeaderNavbarWrapperComponent extends ThemedComponent { - return import(`./header-navbar-wrapper.component`); + return import('./header-navbar-wrapper.component'); } } diff --git a/src/app/home-page/themed-home-page.component.ts b/src/app/home-page/themed-home-page.component.ts index e50f955cb1..c0ef723b38 100644 --- a/src/app/home-page/themed-home-page.component.ts +++ b/src/app/home-page/themed-home-page.component.ts @@ -8,8 +8,6 @@ import { Component } from '@angular/core'; templateUrl: '../shared/theme-support/themed.component.html', }) export class ThemedHomePageComponent extends ThemedComponent { - protected inAndOutputNames: (keyof HomePageComponent & keyof this)[]; - protected getComponentName(): string { return 'HomePageComponent'; 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/edit-item-page/item-move/item-move.component.spec.ts b/src/app/item-page/edit-item-page/item-move/item-move.component.spec.ts index d200891629..43ac47e37b 100644 --- a/src/app/item-page/edit-item-page/item-move/item-move.component.spec.ts +++ b/src/app/item-page/edit-item-page/item-move/item-move.component.spec.ts @@ -134,9 +134,10 @@ describe('ItemMoveComponent', () => { }); comp.selectedCollectionName = 'selected-collection-id'; comp.selectedCollection = collection1; + comp.inheritPolicies = false; comp.moveToCollection(); - expect(itemDataService.moveToCollection).toHaveBeenCalledWith('item-id', collection1); + expect(itemDataService.moveToCollection).toHaveBeenCalledWith('item-id', collection1, false); }); it('should call notificationsService success message on success', () => { comp.moveToCollection(); diff --git a/src/app/item-page/edit-item-page/item-move/item-move.component.ts b/src/app/item-page/edit-item-page/item-move/item-move.component.ts index b7ab761fe5..4f53fa7df1 100644 --- a/src/app/item-page/edit-item-page/item-move/item-move.component.ts +++ b/src/app/item-page/edit-item-page/item-move/item-move.component.ts @@ -104,7 +104,7 @@ export class ItemMoveComponent implements OnInit { */ moveToCollection() { this.processing = true; - const move$ = this.itemDataService.moveToCollection(this.item.id, this.selectedCollection) + const move$ = this.itemDataService.moveToCollection(this.item.id, this.selectedCollection, this.inheritPolicies) .pipe(getFirstCompletedRemoteData()); move$.subscribe((response: RemoteData) => { 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 2341d85984..1d83181395 100644 --- a/src/app/item-page/full/full-item-page.component.html +++ b/src/app/item-page/full/full-item-page.component.html @@ -6,9 +6,8 @@
- - - + +