diff --git a/.github/disabled-workflows/pull_request_opened.yml b/.github/disabled-workflows/pull_request_opened.yml deleted file mode 100644 index 0dc718c0b9..0000000000 --- a/.github/disabled-workflows/pull_request_opened.yml +++ /dev/null @@ -1,26 +0,0 @@ -# This workflow runs whenever a new pull request is created -# TEMPORARILY DISABLED. Unfortunately this doesn't work for PRs created from forked repositories (which is how we tend to create PRs). -# There is no known workaround yet. See https://github.community/t/how-to-use-github-token-for-prs-from-forks/16818 -name: Pull Request opened - -# Only run for newly opened PRs against the "main" branch -on: - pull_request: - types: [opened] - branches: - - main - -jobs: - automation: - runs-on: ubuntu-latest - steps: - # Assign the PR to whomever created it. This is useful for visualizing assignments on project boards - # See https://github.com/marketplace/actions/pull-request-assigner - - name: Assign PR to creator - uses: thomaseizinger/assign-pr-creator-action@v1.0.0 - # Note, this authentication token is created automatically - # See: https://docs.github.com/en/actions/configuring-and-managing-workflows/authenticating-with-the-github_token - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - # Ignore errors. It is possible the PR was created by someone who cannot be assigned - continue-on-error: true diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 219074780e..e2680420a2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -43,11 +43,11 @@ jobs: steps: # https://github.com/actions/checkout - name: Checkout codebase - uses: actions/checkout@v3 + uses: actions/checkout@v4 # https://github.com/actions/setup-node - name: Install Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} @@ -118,7 +118,7 @@ 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@v5 + uses: cypress-io/github-action@v6 with: # Run tests in Chrome, headless mode (default) browser: chrome @@ -191,7 +191,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 # Download artifacts from previous 'tests' job - name: Download coverage artifacts @@ -203,10 +203,14 @@ jobs: # 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 + uses: Wandalen/wretry.action@v1.3.0 with: action: codecov/codecov-action@v3 - # Try upload 5 times max + # Ensure codecov-action throws an error when it fails to upload + # This allows us to auto-restart the action if an error is thrown + with: | + fail_ci_if_error: true + # Try re-running action 5 times max attempt_limit: 5 # Run again in 30 seconds attempt_delay: 30000 diff --git a/.github/workflows/codescan.yml b/.github/workflows/codescan.yml index 35a2e2d24a..d96e786cc3 100644 --- a/.github/workflows/codescan.yml +++ b/.github/workflows/codescan.yml @@ -5,12 +5,16 @@ # because CodeQL requires a fresh build with all tests *disabled*. name: "Code Scanning" -# Run this code scan for all pushes / PRs to main branch. Also run once a week. +# Run this code scan for all pushes / PRs to main or maintenance branches. Also run once a week. on: push: - branches: [ main ] + branches: + - main + - 'dspace-**' pull_request: - branches: [ main ] + branches: + - main + - 'dspace-**' # Don't run if PR is only updating static documentation paths-ignore: - '**/*.md' @@ -31,7 +35,7 @@ jobs: steps: # https://github.com/actions/checkout - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 # Initializes the CodeQL tools for scanning. # https://github.com/github/codeql-action diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 9a2c838d83..85a7216113 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -3,6 +3,9 @@ name: Docker images # Run this Build for all pushes to 'main' or maintenance branches, or tagged releases. # Also run for PRs to ensure PR doesn't break Docker build process +# NOTE: uses "reusable-docker-build.yml" in DSpace/DSpace to actually build each of the Docker images +# https://github.com/DSpace/DSpace/blob/main/.github/workflows/reusable-docker-build.yml +# on: push: branches: @@ -16,105 +19,41 @@ permissions: contents: read # to fetch code (actions/checkout) jobs: - docker: + ############################################################# + # Build/Push the 'dspace/dspace-angular' image + ############################################################# + dspace-angular: # Ensure this job never runs on forked repos. It's only executed for 'dspace/dspace-angular' if: github.repository == 'dspace/dspace-angular' - runs-on: ubuntu-latest - env: - # Define tags to use for Docker images based on Git tags/branches (for docker/metadata-action) - # For a new commit on default branch (main), use the literal tag 'dspace-7_x' on Docker image. - # For a new commit on other branches, use the branch name as the tag for Docker image. - # For a new tag, copy that tag name as the tag for Docker image. - IMAGE_TAGS: | - type=raw,value=dspace-7_x,enable=${{ endsWith(github.ref, github.event.repository.default_branch) }} - type=ref,event=branch,enable=${{ !endsWith(github.ref, github.event.repository.default_branch) }} - type=ref,event=tag - # Define default tag "flavor" for docker/metadata-action per - # https://github.com/docker/metadata-action#flavor-input - # We turn off 'latest' tag by default. - TAGS_FLAVOR: | - latest=false - # Architectures / Platforms for which we will build Docker images - # If this is a PR, we ONLY build for AMD64. For PRs we only do a sanity check test to ensure Docker builds work. - # If this is NOT a PR (e.g. a tag or merge commit), also build for ARM64. - PLATFORMS: linux/amd64${{ github.event_name != 'pull_request' && ', linux/arm64' || '' }} + # Use the reusable-docker-build.yml script from DSpace/DSpace repo to build our Docker image + uses: DSpace/DSpace/.github/workflows/reusable-docker-build.yml@main + with: + build_id: dspace-angular + image_name: dspace/dspace-angular + dockerfile_path: ./Dockerfile + secrets: + DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} + DOCKER_ACCESS_TOKEN: ${{ secrets.DOCKER_ACCESS_TOKEN }} - steps: - # https://github.com/actions/checkout - - name: Checkout codebase - uses: actions/checkout@v3 - - # https://github.com/docker/setup-buildx-action - - name: Setup Docker Buildx - uses: docker/setup-buildx-action@v2 - - # https://github.com/docker/setup-qemu-action - - name: Set up QEMU emulation to build for multiple architectures - uses: docker/setup-qemu-action@v2 - - # https://github.com/docker/login-action - - name: Login to DockerHub - # Only login if not a PR, as PRs only trigger a Docker build and not a push - if: github.event_name != 'pull_request' - uses: docker/login-action@v2 - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_ACCESS_TOKEN }} - - ############################################### - # Build/Push the 'dspace/dspace-angular' image - ############################################### - # https://github.com/docker/metadata-action - # Get Metadata for docker_build step below - - name: Sync metadata (tags, labels) from GitHub to Docker for 'dspace-angular' image - id: meta_build - uses: docker/metadata-action@v4 - with: - images: dspace/dspace-angular - tags: ${{ env.IMAGE_TAGS }} - flavor: ${{ env.TAGS_FLAVOR }} - - # https://github.com/docker/build-push-action - - name: Build and push 'dspace-angular' image - id: docker_build - uses: docker/build-push-action@v3 - with: - context: . - file: ./Dockerfile - 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.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 }} + ############################################################# + # Build/Push the 'dspace/dspace-angular' image ('-dist' tag) + ############################################################# + dspace-angular-dist: + # Ensure this job never runs on forked repos. It's only executed for 'dspace/dspace-angular' + if: github.repository == 'dspace/dspace-angular' + # Use the reusable-docker-build.yml script from DSpace/DSpace repo to build our Docker image + uses: DSpace/DSpace/.github/workflows/reusable-docker-build.yml@main + with: + build_id: dspace-angular-dist + image_name: dspace/dspace-angular + dockerfile_path: ./Dockerfile.dist + # 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. + tags_flavor: suffix=-dist + secrets: + DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} + DOCKER_ACCESS_TOKEN: ${{ secrets.DOCKER_ACCESS_TOKEN }} + # Enable redeploy of sandbox & demo if the branch for this image matches the deployment branch of + # these sites as specified in reusable-docker-build.xml + REDEPLOY_SANDBOX_URL: ${{ secrets.REDEPLOY_SANDBOX_URL }} + REDEPLOY_DEMO_URL: ${{ secrets.REDEPLOY_DEMO_URL }} \ No newline at end of file diff --git a/.github/workflows/label_merge_conflicts.yml b/.github/workflows/label_merge_conflicts.yml index c1396b6f45..ccc6c401c0 100644 --- a/.github/workflows/label_merge_conflicts.yml +++ b/.github/workflows/label_merge_conflicts.yml @@ -1,11 +1,12 @@ # This workflow checks open PRs for merge conflicts and labels them when conflicts are found name: Check for merge conflicts -# Run whenever the "main" branch is updated -# NOTE: This means merge conflicts are only checked for when a PR is merged to main. +# Run this for all pushes (i.e. merges) to 'main' or maintenance branches on: push: - branches: [ main ] + branches: + - main + - 'dspace-**' # So that the `conflict_label_name` is removed if conflicts are resolved, # we allow this to run for `pull_request_target` so that github secrets are available. pull_request_target: @@ -24,6 +25,8 @@ jobs: # See: https://github.com/prince-chrismc/label-merge-conflicts-action - name: Auto-label PRs with merge conflicts uses: prince-chrismc/label-merge-conflicts-action@v3 + # Ignore any failures -- may occur (randomly?) for older, outdated PRs. + continue-on-error: true # 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/.github/workflows/port_merged_pull_request.yml b/.github/workflows/port_merged_pull_request.yml new file mode 100644 index 0000000000..857f22755e --- /dev/null +++ b/.github/workflows/port_merged_pull_request.yml @@ -0,0 +1,46 @@ +# This workflow will attempt to port a merged pull request to +# the branch specified in a "port to" label (if exists) +name: Port merged Pull Request + +# Only run for merged PRs against the "main" or maintenance branches +# We allow this to run for `pull_request_target` so that github secrets are available +# (This is required when the PR comes from a forked repo) +on: + pull_request_target: + types: [ closed ] + branches: + - main + - 'dspace-**' + +permissions: + contents: write # so action can add comments + pull-requests: write # so action can create pull requests + +jobs: + port_pr: + runs-on: ubuntu-latest + # Don't run on closed *unmerged* pull requests + if: github.event.pull_request.merged + steps: + # Checkout code + - uses: actions/checkout@v4 + # Port PR to other branch (ONLY if labeled with "port to") + # See https://github.com/korthout/backport-action + - name: Create backport pull requests + uses: korthout/backport-action@v2 + with: + # Trigger based on a "port to [branch]" label on PR + # (This label must specify the branch name to port to) + label_pattern: '^port to ([^ ]+)$' + # Title to add to the (newly created) port PR + pull_title: '[Port ${target_branch}] ${pull_title}' + # Description to add to the (newly created) port PR + pull_description: 'Port of #${pull_number} by @${pull_author} to `${target_branch}`.' + # Copy all labels from original PR to (newly created) port PR + # NOTE: The labels matching 'label_pattern' are automatically excluded + copy_labels_pattern: '.*' + # Skip any merge commits in the ported PR. This means only non-merge commits are cherry-picked to the new PR + merge_commits: 'skip' + # Use a personal access token (PAT) to create PR as 'dspace-bot' user. + # A PAT is required in order for the new PR to trigger its own actions (for CI checks) + github_token: ${{ secrets.PR_PORT_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/pull_request_opened.yml b/.github/workflows/pull_request_opened.yml new file mode 100644 index 0000000000..f16e81c9fd --- /dev/null +++ b/.github/workflows/pull_request_opened.yml @@ -0,0 +1,24 @@ +# This workflow runs whenever a new pull request is created +name: Pull Request opened + +# Only run for newly opened PRs against the "main" or maintenance branches +# We allow this to run for `pull_request_target` so that github secrets are available +# (This is required to assign a PR back to the creator when the PR comes from a forked repo) +on: + pull_request_target: + types: [ opened ] + branches: + - main + - 'dspace-**' + +permissions: + pull-requests: write + +jobs: + automation: + runs-on: ubuntu-latest + steps: + # Assign the PR to whomever created it. This is useful for visualizing assignments on project boards + # See https://github.com/toshimaru/auto-author-assign + - name: Assign PR to creator + uses: toshimaru/auto-author-assign@v2.0.1 diff --git a/Dockerfile.dist b/Dockerfile.dist index 2a6a66fc06..e4b467ae26 100644 --- a/Dockerfile.dist +++ b/Dockerfile.dist @@ -2,7 +2,7 @@ # 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 . +# docker build -f Dockerfile.dist -t dspace/dspace-angular:latest-dist . FROM node:18-alpine as build diff --git a/README.md b/README.md index 689c64a292..ebc24f8b91 100644 --- a/README.md +++ b/README.md @@ -157,8 +157,8 @@ DSPACE_UI_SSL => DSPACE_SSL The same settings can also be overwritten by setting system environment variables instead, E.g.: ```bash -export DSPACE_HOST=api7.dspace.org -export DSPACE_UI_PORT=4200 +export DSPACE_HOST=demo.dspace.org +export DSPACE_UI_PORT=4000 ``` The priority works as follows: **environment variable** overrides **variable in `.env` file** overrides external config set by `DSPACE_APP_CONFIG_PATH` overrides **`config.(prod or dev).yml`** @@ -288,7 +288,7 @@ E2E tests (aka integration tests) use [Cypress.io](https://www.cypress.io/). Con The test files can be found in the `./cypress/integration/` folder. Before you can run e2e tests, two things are REQUIRED: -1. You MUST be running the DSpace backend (i.e. REST API) locally. The e2e tests will *NOT* succeed if run against our demo REST API (https://api7.dspace.org/server/), as that server is uncontrolled and may have content added/removed at any time. +1. You MUST be running the DSpace backend (i.e. REST API) locally. The e2e tests will *NOT* succeed if run against our demo/sandbox REST API (https://demo.dspace.org/server/ or https://sandbox.dspace.org/server/), as those sites may have content added/removed at any time. * After starting up your backend on localhost, make sure either your `config.prod.yml` or `config.dev.yml` has its `rest` settings defined to use that localhost backend. * If you'd prefer, you may instead use environment variables as described at [Configuring](#configuring). For example: ``` diff --git a/config/config.example.yml b/config/config.example.yml index ea38303fa3..69a9ffd320 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -22,7 +22,7 @@ ui: # 'synced' with the 'dspace.server.url' setting in your backend's local.cfg. rest: ssl: true - host: api7.dspace.org + host: sandbox.dspace.org port: 443 # NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript nameSpace: /server @@ -208,6 +208,9 @@ languages: - code: pt-BR label: Português do Brasil active: true + - code: sr-lat + label: Srpski (lat) + active: true - code: fi label: Suomi active: true @@ -232,6 +235,9 @@ languages: - code: el label: Ελληνικά active: true + - code: sr-cyr + label: Српски + active: true - code: uk label: Yкраї́нська active: true @@ -292,33 +298,33 @@ themes: # # # A theme with a handle property will match the community, collection or item with the given # # handle, and all collections and/or items within it - # - name: 'custom', - # handle: '10673/1233' + # - name: custom + # handle: 10673/1233 # # # A theme with a regex property will match the route using a regular expression. If it # # matches the route for a community or collection it will also apply to all collections # # and/or items within it - # - name: 'custom', - # regex: 'collections\/e8043bc2.*' + # - name: custom + # regex: collections\/e8043bc2.* # # # A theme with a uuid property will match the community, collection or item with the given # # ID, and all collections and/or items within it - # - name: 'custom', - # uuid: '0958c910-2037-42a9-81c7-dca80e3892b4' + # - name: custom + # uuid: 0958c910-2037-42a9-81c7-dca80e3892b4 # # # The extends property specifies an ancestor theme (by name). Whenever a themed component is not found # # in the current theme, its ancestor theme(s) will be checked recursively before falling back to default. - # - name: 'custom-A', - # extends: 'custom-B', + # - name: custom-A + # extends: custom-B # # Any of the matching properties above can be used - # handle: '10673/34' + # handle: 10673/34 # - # - name: 'custom-B', - # extends: 'custom', - # handle: '10673/12' + # - name: custom-B + # extends: custom + # handle: 10673/12 # # # A theme with only a name will match every route - # name: 'custom' + # name: custom # # # This theme will use the default bootstrap styling for DSpace components # - name: BASE_THEME_NAME @@ -379,4 +385,4 @@ vocabularies: # Default collection/community sorting order at Advanced search, Create/update community and collection when there are not a query. comcolSelectionSort: sortField: 'dc.title' - sortDirection: 'ASC' \ No newline at end of file + sortDirection: 'ASC' diff --git a/config/config.yml b/config/config.yml index b5eecd112f..109db60ca9 100644 --- a/config/config.yml +++ b/config/config.yml @@ -1,5 +1,5 @@ rest: ssl: true - host: api7.dspace.org + host: sandbox.dspace.org port: 443 nameSpace: /server diff --git a/cypress.config.ts b/cypress.config.ts index 91eeb9838b..458b035a48 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -9,8 +9,9 @@ export default defineConfig({ openMode: 0, }, env: { - // Global constants used in DSpace e2e tests (see also ./cypress/support/e2e.ts) - // May be overridden in our cypress.json config file using specified environment variables. + // Global DSpace environment variables used in all our Cypress e2e tests + // May be modified in this config, or overridden in a variety of ways. + // See Cypress environment variable docs: https://docs.cypress.io/guides/guides/environment-variables // Default values listed here are all valid for the Demo Entities Data set available at // https://github.com/DSpace-Labs/AIP-Files/releases/tag/demo-entities-data // (This is the data set used in our CI environment) @@ -21,12 +22,14 @@ export default defineConfig({ // Community/collection/publication used for view/edit tests DSPACE_TEST_COMMUNITY: '0958c910-2037-42a9-81c7-dca80e3892b4', DSPACE_TEST_COLLECTION: '282164f5-d325-4740-8dd1-fa4d6d3e7200', - DSPACE_TEST_ENTITY_PUBLICATION: 'e98b0f27-5c19-49a0-960d-eb6ad5287067', + DSPACE_TEST_ENTITY_PUBLICATION: '6160810f-1e53-40db-81ef-f6621a727398', // Search term (should return results) used in search tests DSPACE_TEST_SEARCH_TERM: 'test', - // Collection used for submission tests + // Main Collection used for submission tests. Should be able to accept normal Item objects DSPACE_TEST_SUBMIT_COLLECTION_NAME: 'Sample Collection', DSPACE_TEST_SUBMIT_COLLECTION_UUID: '9d8334e9-25d3-4a67-9cea-3dffdef80144', + // Collection used for Person entity submission tests. MUST be configured with EntityType=Person. + DSPACE_TEST_SUBMIT_PERSON_COLLECTION_NAME: 'People', // Account used to test basic submission process DSPACE_TEST_SUBMIT_USER: 'dspacedemo+submit@gmail.com', DSPACE_TEST_SUBMIT_USER_PASSWORD: 'dspace', diff --git a/cypress/e2e/admin-sidebar.cy.ts b/cypress/e2e/admin-sidebar.cy.ts new file mode 100644 index 0000000000..7612eb5313 --- /dev/null +++ b/cypress/e2e/admin-sidebar.cy.ts @@ -0,0 +1,28 @@ +import { Options } from 'cypress-axe'; +import { testA11y } from 'cypress/support/utils'; + +describe('Admin Sidebar', () => { + beforeEach(() => { + // Must login as an Admin for sidebar to appear + cy.visit('/login'); + cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); + }); + + it('should be pinnable and pass accessibility tests', () => { + // Pin the sidebar open + cy.get('#sidebar-collapse-toggle').click(); + + // Click on every expandable section to open all menus + cy.get('ds-expandable-admin-sidebar-section').click({multiple: true}); + + // Analyze for accessibility + testA11y('ds-admin-sidebar', + { + rules: { + // Currently all expandable sections have nested interactive elements + // See https://github.com/DSpace/dspace-angular/issues/2178 + 'nested-interactive': { enabled: false }, + } + } as Options); + }); +}); diff --git a/cypress/e2e/breadcrumbs.cy.ts b/cypress/e2e/breadcrumbs.cy.ts index ea6acdafcd..0cddbc723c 100644 --- a/cypress/e2e/breadcrumbs.cy.ts +++ b/cypress/e2e/breadcrumbs.cy.ts @@ -1,10 +1,9 @@ -import { TEST_ENTITY_PUBLICATION } from 'cypress/support/e2e'; import { testA11y } from 'cypress/support/utils'; describe('Breadcrumbs', () => { it('should pass accessibility tests', () => { // Visit an Item, as those have more breadcrumbs - cy.visit('/entities/publication/'.concat(TEST_ENTITY_PUBLICATION)); + cy.visit('/entities/publication/'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION'))); // Wait for breadcrumbs to be visible cy.get('ds-breadcrumbs').should('be.visible'); diff --git a/cypress/e2e/collection-edit.cy.ts b/cypress/e2e/collection-edit.cy.ts new file mode 100644 index 0000000000..3e7ecf6141 --- /dev/null +++ b/cypress/e2e/collection-edit.cy.ts @@ -0,0 +1,128 @@ +import { testA11y } from 'cypress/support/utils'; + +const COLLECTION_EDIT_PAGE = '/collections/'.concat(Cypress.env('DSPACE_TEST_COLLECTION')).concat('/edit'); + +beforeEach(() => { + // All tests start with visiting the Edit Collection Page + cy.visit(COLLECTION_EDIT_PAGE); + + // This page is restricted, so we will be shown the login form. Fill it out & submit. + cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); +}); + +describe('Edit Collection > Edit Metadata tab', () => { + it('should pass accessibility tests', () => { + // tag must be loaded + cy.get('ds-edit-collection').should('be.visible'); + + // Analyze for accessibility issues + testA11y('ds-edit-collection'); + }); +}); + +describe('Edit Collection > Assign Roles tab', () => { + + it('should pass accessibility tests', () => { + cy.get('a[data-test="roles"]').click(); + + // tag must be loaded + cy.get('ds-collection-roles').should('be.visible'); + + // Analyze for accessibility issues + testA11y('ds-collection-roles'); + }); +}); + +describe('Edit Collection > Content Source tab', () => { + + it('should pass accessibility tests', () => { + cy.get('a[data-test="source"]').click(); + + // tag must be loaded + cy.get('ds-collection-source').should('be.visible'); + + // Check the external source checkbox (to display all fields on the page) + cy.get('#externalSourceCheck').check(); + + // Wait for the source controls to appear + cy.get('ds-collection-source-controls').should('be.visible'); + + // Analyze entire page for accessibility issues + testA11y('ds-collection-source'); + }); +}); + +describe('Edit Collection > Curate tab', () => { + + it('should pass accessibility tests', () => { + cy.get('a[data-test="curate"]').click(); + + // tag must be loaded + cy.get('ds-collection-curate').should('be.visible'); + + // Analyze for accessibility issues + testA11y('ds-collection-curate'); + }); +}); + +describe('Edit Collection > Access Control tab', () => { + + it('should pass accessibility tests', () => { + cy.get('a[data-test="access-control"]').click(); + + // tag must be loaded + cy.get('ds-collection-access-control').should('be.visible'); + + // Analyze for accessibility issues + testA11y('ds-collection-access-control'); + }); +}); + +describe('Edit Collection > Authorizations tab', () => { + + it('should pass accessibility tests', () => { + cy.get('a[data-test="authorizations"]').click(); + + // tag must be loaded + cy.get('ds-collection-authorizations').should('be.visible'); + + // Analyze for accessibility issues + testA11y('ds-collection-authorizations'); + }); +}); + +describe('Edit Collection > Item Mapper tab', () => { + + it('should pass accessibility tests', () => { + cy.get('a[data-test="mapper"]').click(); + + // tag must be loaded + cy.get('ds-collection-item-mapper').should('be.visible'); + + // Analyze entire page for accessibility issues + testA11y('ds-collection-item-mapper'); + + // Click on the "Map new Items" tab + cy.get('li[data-test="mapTab"] a').click(); + + // Make sure search form is now visible + cy.get('ds-search-form').should('be.visible'); + + // Analyze entire page (again) for accessibility issues + testA11y('ds-collection-item-mapper'); + }); +}); + + +describe('Edit Collection > Delete page', () => { + + it('should pass accessibility tests', () => { + cy.get('a[data-test="delete-button"]').click(); + + // tag must be loaded + cy.get('ds-delete-collection').should('be.visible'); + + // Analyze for accessibility issues + testA11y('ds-delete-collection'); + }); +}); diff --git a/cypress/e2e/collection-page.cy.ts b/cypress/e2e/collection-page.cy.ts index a034b4361d..55c10cc6e2 100644 --- a/cypress/e2e/collection-page.cy.ts +++ b/cypress/e2e/collection-page.cy.ts @@ -1,10 +1,9 @@ -import { TEST_COLLECTION } from 'cypress/support/e2e'; import { testA11y } from 'cypress/support/utils'; describe('Collection Page', () => { it('should pass accessibility tests', () => { - cy.visit('/collections/'.concat(TEST_COLLECTION)); + cy.visit('/collections/'.concat(Cypress.env('DSPACE_TEST_COLLECTION'))); // tag must be loaded cy.get('ds-collection-page').should('be.visible'); diff --git a/cypress/e2e/collection-statistics.cy.ts b/cypress/e2e/collection-statistics.cy.ts index 6df4e9a454..43bf67ce51 100644 --- a/cypress/e2e/collection-statistics.cy.ts +++ b/cypress/e2e/collection-statistics.cy.ts @@ -1,11 +1,11 @@ -import { REGEX_MATCH_NON_EMPTY_TEXT, TEST_COLLECTION } from 'cypress/support/e2e'; +import { REGEX_MATCH_NON_EMPTY_TEXT } from 'cypress/support/e2e'; import { testA11y } from 'cypress/support/utils'; describe('Collection Statistics Page', () => { - const COLLECTIONSTATISTICSPAGE = '/statistics/collections/'.concat(TEST_COLLECTION); + const COLLECTIONSTATISTICSPAGE = '/statistics/collections/'.concat(Cypress.env('DSPACE_TEST_COLLECTION')); it('should load if you click on "Statistics" from a Collection page', () => { - cy.visit('/collections/'.concat(TEST_COLLECTION)); + cy.visit('/collections/'.concat(Cypress.env('DSPACE_TEST_COLLECTION'))); cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click(); cy.location('pathname').should('eq', COLLECTIONSTATISTICSPAGE); }); @@ -18,7 +18,7 @@ describe('Collection Statistics Page', () => { it('should contain a "Total visits per month" section', () => { cy.visit(COLLECTIONSTATISTICSPAGE); // Check just for existence because this table is empty in CI environment as it's historical data - cy.get('.'.concat(TEST_COLLECTION).concat('_TotalVisitsPerMonth')).should('exist'); + cy.get('.'.concat(Cypress.env('DSPACE_TEST_COLLECTION')).concat('_TotalVisitsPerMonth')).should('exist'); }); it('should pass accessibility tests', () => { diff --git a/cypress/e2e/community-edit.cy.ts b/cypress/e2e/community-edit.cy.ts new file mode 100644 index 0000000000..8fc1a7733e --- /dev/null +++ b/cypress/e2e/community-edit.cy.ts @@ -0,0 +1,86 @@ +import { testA11y } from 'cypress/support/utils'; + +const COMMUNITY_EDIT_PAGE = '/communities/'.concat(Cypress.env('DSPACE_TEST_COMMUNITY')).concat('/edit'); + +beforeEach(() => { + // All tests start with visiting the Edit Community Page + cy.visit(COMMUNITY_EDIT_PAGE); + + // This page is restricted, so we will be shown the login form. Fill it out & submit. + cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); +}); + +describe('Edit Community > Edit Metadata tab', () => { + it('should pass accessibility tests', () => { + // tag must be loaded + cy.get('ds-edit-community').should('be.visible'); + + // Analyze for accessibility issues + testA11y('ds-edit-community'); + }); +}); + +describe('Edit Community > Assign Roles tab', () => { + + it('should pass accessibility tests', () => { + cy.get('a[data-test="roles"]').click(); + + // tag must be loaded + cy.get('ds-community-roles').should('be.visible'); + + // Analyze for accessibility issues + testA11y('ds-community-roles'); + }); +}); + +describe('Edit Community > Curate tab', () => { + + it('should pass accessibility tests', () => { + cy.get('a[data-test="curate"]').click(); + + // tag must be loaded + cy.get('ds-community-curate').should('be.visible'); + + // Analyze for accessibility issues + testA11y('ds-community-curate'); + }); +}); + +describe('Edit Community > Access Control tab', () => { + + it('should pass accessibility tests', () => { + cy.get('a[data-test="access-control"]').click(); + + // tag must be loaded + cy.get('ds-community-access-control').should('be.visible'); + + // Analyze for accessibility issues + testA11y('ds-community-access-control'); + }); +}); + +describe('Edit Community > Authorizations tab', () => { + + it('should pass accessibility tests', () => { + cy.get('a[data-test="authorizations"]').click(); + + // tag must be loaded + cy.get('ds-community-authorizations').should('be.visible'); + + // Analyze for accessibility issues + testA11y('ds-community-authorizations'); + }); +}); + +describe('Edit Community > Delete page', () => { + + it('should pass accessibility tests', () => { + cy.get('a[data-test="delete-button"]').click(); + + // tag must be loaded + cy.get('ds-delete-community').should('be.visible'); + + // Analyze for accessibility issues + testA11y('ds-delete-community'); + }); +}); diff --git a/cypress/e2e/community-list.cy.ts b/cypress/e2e/community-list.cy.ts index 7b60b59dbc..c371f6ceae 100644 --- a/cypress/e2e/community-list.cy.ts +++ b/cypress/e2e/community-list.cy.ts @@ -1,4 +1,3 @@ -import { Options } from 'cypress-axe'; import { testA11y } from 'cypress/support/utils'; describe('Community List Page', () => { @@ -13,13 +12,6 @@ describe('Community List Page', () => { cy.get('[data-test="expand-button"]').click({ multiple: true }); // Analyze for accessibility issues - // Disable heading-order checks until it is fixed - testA11y('ds-community-list-page', - { - rules: { - 'heading-order': { enabled: false } - } - } as Options - ); + testA11y('ds-community-list-page'); }); }); diff --git a/cypress/e2e/community-page.cy.ts b/cypress/e2e/community-page.cy.ts index 6c628e21ce..386bb592a0 100644 --- a/cypress/e2e/community-page.cy.ts +++ b/cypress/e2e/community-page.cy.ts @@ -1,15 +1,14 @@ -import { TEST_COMMUNITY } from 'cypress/support/e2e'; import { testA11y } from 'cypress/support/utils'; describe('Community Page', () => { it('should pass accessibility tests', () => { - cy.visit('/communities/'.concat(TEST_COMMUNITY)); + cy.visit('/communities/'.concat(Cypress.env('DSPACE_TEST_COMMUNITY'))); // tag must be loaded cy.get('ds-community-page').should('be.visible'); // Analyze for accessibility issues - testA11y('ds-community-page',); + testA11y('ds-community-page'); }); }); diff --git a/cypress/e2e/community-statistics.cy.ts b/cypress/e2e/community-statistics.cy.ts index 710450e797..ca306eff5c 100644 --- a/cypress/e2e/community-statistics.cy.ts +++ b/cypress/e2e/community-statistics.cy.ts @@ -1,11 +1,11 @@ -import { REGEX_MATCH_NON_EMPTY_TEXT, TEST_COMMUNITY } from 'cypress/support/e2e'; +import { REGEX_MATCH_NON_EMPTY_TEXT } from 'cypress/support/e2e'; import { testA11y } from 'cypress/support/utils'; describe('Community Statistics Page', () => { - const COMMUNITYSTATISTICSPAGE = '/statistics/communities/'.concat(TEST_COMMUNITY); + const COMMUNITYSTATISTICSPAGE = '/statistics/communities/'.concat(Cypress.env('DSPACE_TEST_COMMUNITY')); it('should load if you click on "Statistics" from a Community page', () => { - cy.visit('/communities/'.concat(TEST_COMMUNITY)); + cy.visit('/communities/'.concat(Cypress.env('DSPACE_TEST_COMMUNITY'))); cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click(); cy.location('pathname').should('eq', COMMUNITYSTATISTICSPAGE); }); @@ -18,7 +18,7 @@ describe('Community Statistics Page', () => { it('should contain a "Total visits per month" section', () => { cy.visit(COMMUNITYSTATISTICSPAGE); // Check just for existence because this table is empty in CI environment as it's historical data - cy.get('.'.concat(TEST_COMMUNITY).concat('_TotalVisitsPerMonth')).should('exist'); + cy.get('.'.concat(Cypress.env('DSPACE_TEST_COMMUNITY')).concat('_TotalVisitsPerMonth')).should('exist'); }); it('should pass accessibility tests', () => { diff --git a/cypress/e2e/header.cy.ts b/cypress/e2e/header.cy.ts index 236208db68..9852216e43 100644 --- a/cypress/e2e/header.cy.ts +++ b/cypress/e2e/header.cy.ts @@ -8,12 +8,6 @@ describe('Header', () => { cy.get('ds-header').should('be.visible'); // Analyze for accessibility - testA11y({ - include: ['ds-header'], - exclude: [ - ['#search-navbar-container'], // search in navbar has duplicative ID. Will be fixed in #1174 - ['.dropdownLogin'] // "Log in" link has color contrast issues. Will be fixed in #1149 - ], - }); + testA11y('ds-header'); }); }); diff --git a/cypress/e2e/homepage-statistics.cy.ts b/cypress/e2e/homepage-statistics.cy.ts index 2a1ab9785a..ff7dbeb852 100644 --- a/cypress/e2e/homepage-statistics.cy.ts +++ b/cypress/e2e/homepage-statistics.cy.ts @@ -1,4 +1,4 @@ -import { REGEX_MATCH_NON_EMPTY_TEXT, TEST_ENTITY_PUBLICATION } from 'cypress/support/e2e'; +import { REGEX_MATCH_NON_EMPTY_TEXT } from 'cypress/support/e2e'; import { testA11y } from 'cypress/support/utils'; import '../support/commands'; @@ -11,8 +11,8 @@ describe('Site Statistics Page', () => { it('should pass accessibility tests', () => { // generate 2 view events on an Item's page - cy.generateViewEvent(TEST_ENTITY_PUBLICATION, 'item'); - cy.generateViewEvent(TEST_ENTITY_PUBLICATION, 'item'); + cy.generateViewEvent(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION'), 'item'); + cy.generateViewEvent(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION'), 'item'); cy.visit('/statistics'); diff --git a/cypress/e2e/item-edit.cy.ts b/cypress/e2e/item-edit.cy.ts new file mode 100644 index 0000000000..b4c01a1a94 --- /dev/null +++ b/cypress/e2e/item-edit.cy.ts @@ -0,0 +1,135 @@ +import { Options } from 'cypress-axe'; +import { testA11y } from 'cypress/support/utils'; + +const ITEM_EDIT_PAGE = '/items/'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION')).concat('/edit'); + +beforeEach(() => { + // All tests start with visiting the Edit Item Page + cy.visit(ITEM_EDIT_PAGE); + + // This page is restricted, so we will be shown the login form. Fill it out & submit. + cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); +}); + +describe('Edit Item > Edit Metadata tab', () => { + it('should pass accessibility tests', () => { + cy.get('a[data-test="metadata"]').click(); + + // tag must be loaded + cy.get('ds-edit-item-page').should('be.visible'); + + // Analyze for accessibility issues + testA11y('ds-edit-item-page'); + }); +}); + +describe('Edit Item > Status tab', () => { + + it('should pass accessibility tests', () => { + cy.get('a[data-test="status"]').click(); + + // tag must be loaded + cy.get('ds-item-status').should('be.visible'); + + // Analyze for accessibility issues + testA11y('ds-item-status'); + }); +}); + +describe('Edit Item > Bitstreams tab', () => { + + it('should pass accessibility tests', () => { + cy.get('a[data-test="bitstreams"]').click(); + + // tag must be loaded + cy.get('ds-item-bitstreams').should('be.visible'); + + // Table of item bitstreams must also be loaded + cy.get('div.item-bitstreams').should('be.visible'); + + // Analyze for accessibility issues + testA11y('ds-item-bitstreams', + { + rules: { + // Currently Bitstreams page loads a pagination component per Bundle + // and they all use the same 'id="p-dad"'. + 'duplicate-id': { enabled: false }, + } + } as Options + ); + }); +}); + +describe('Edit Item > Curate tab', () => { + + it('should pass accessibility tests', () => { + cy.get('a[data-test="curate"]').click(); + + // tag must be loaded + cy.get('ds-item-curate').should('be.visible'); + + // Analyze for accessibility issues + testA11y('ds-item-curate'); + }); +}); + +describe('Edit Item > Relationships tab', () => { + + it('should pass accessibility tests', () => { + cy.get('a[data-test="relationships"]').click(); + + // tag must be loaded + cy.get('ds-item-relationships').should('be.visible'); + + // Analyze for accessibility issues + testA11y('ds-item-relationships'); + }); +}); + +describe('Edit Item > Version History tab', () => { + + it('should pass accessibility tests', () => { + cy.get('a[data-test="versionhistory"]').click(); + + // tag must be loaded + cy.get('ds-item-version-history').should('be.visible'); + + // Analyze for accessibility issues + testA11y('ds-item-version-history'); + }); +}); + +describe('Edit Item > Access Control tab', () => { + + it('should pass accessibility tests', () => { + cy.get('a[data-test="access-control"]').click(); + + // tag must be loaded + cy.get('ds-item-access-control').should('be.visible'); + + // Analyze for accessibility issues + testA11y('ds-item-access-control'); + }); +}); + +describe('Edit Item > Collection Mapper tab', () => { + + it('should pass accessibility tests', () => { + cy.get('a[data-test="mapper"]').click(); + + // tag must be loaded + cy.get('ds-item-collection-mapper').should('be.visible'); + + // Analyze entire page for accessibility issues + testA11y('ds-item-collection-mapper'); + + // Click on the "Map new collections" tab + cy.get('li[data-test="mapTab"] a').click(); + + // Make sure search form is now visible + cy.get('ds-search-form').should('be.visible'); + + // Analyze entire page (again) for accessibility issues + testA11y('ds-item-collection-mapper'); + }); +}); diff --git a/cypress/e2e/item-page.cy.ts b/cypress/e2e/item-page.cy.ts index 9eed711776..a6a208e9f4 100644 --- a/cypress/e2e/item-page.cy.ts +++ b/cypress/e2e/item-page.cy.ts @@ -1,10 +1,8 @@ -import { Options } from 'cypress-axe'; -import { TEST_ENTITY_PUBLICATION } from 'cypress/support/e2e'; import { testA11y } from 'cypress/support/utils'; describe('Item Page', () => { - const ITEMPAGE = '/items/'.concat(TEST_ENTITY_PUBLICATION); - const ENTITYPAGE = '/entities/publication/'.concat(TEST_ENTITY_PUBLICATION); + const ITEMPAGE = '/items/'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION')); + const ENTITYPAGE = '/entities/publication/'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION')); // Test that entities will redirect to /entities/[type]/[uuid] when accessed via /items/[uuid] it('should redirect to the entity page when navigating to an item page', () => { @@ -19,13 +17,16 @@ describe('Item Page', () => { cy.get('ds-item-page').should('be.visible'); // Analyze for accessibility issues - // Disable heading-order checks until it is fixed - testA11y('ds-item-page', - { - rules: { - 'heading-order': { enabled: false } - } - } as Options - ); + testA11y('ds-item-page'); + }); + + it('should pass accessibility tests on full item page', () => { + cy.visit(ENTITYPAGE + '/full'); + + // tag must be loaded + cy.get('ds-full-item-page').should('be.visible'); + + // Analyze for accessibility issues + testA11y('ds-full-item-page'); }); }); diff --git a/cypress/e2e/item-statistics.cy.ts b/cypress/e2e/item-statistics.cy.ts index 9b90cb24af..b856744cba 100644 --- a/cypress/e2e/item-statistics.cy.ts +++ b/cypress/e2e/item-statistics.cy.ts @@ -1,11 +1,11 @@ -import { REGEX_MATCH_NON_EMPTY_TEXT, TEST_ENTITY_PUBLICATION } from 'cypress/support/e2e'; +import { REGEX_MATCH_NON_EMPTY_TEXT } from 'cypress/support/e2e'; import { testA11y } from 'cypress/support/utils'; describe('Item Statistics Page', () => { - const ITEMSTATISTICSPAGE = '/statistics/items/'.concat(TEST_ENTITY_PUBLICATION); + const ITEMSTATISTICSPAGE = '/statistics/items/'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION')); it('should load if you click on "Statistics" from an Item/Entity page', () => { - cy.visit('/entities/publication/'.concat(TEST_ENTITY_PUBLICATION)); + cy.visit('/entities/publication/'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION'))); cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click(); cy.location('pathname').should('eq', ITEMSTATISTICSPAGE); }); @@ -24,7 +24,7 @@ describe('Item Statistics Page', () => { it('should contain a "Total visits per month" section', () => { cy.visit(ITEMSTATISTICSPAGE); // Check just for existence because this table is empty in CI environment as it's historical data - cy.get('.'.concat(TEST_ENTITY_PUBLICATION).concat('_TotalVisitsPerMonth')).should('exist'); + cy.get('.'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION')).concat('_TotalVisitsPerMonth')).should('exist'); }); it('should pass accessibility tests', () => { diff --git a/cypress/e2e/login-modal.cy.ts b/cypress/e2e/login-modal.cy.ts index b169634cfa..c56b98fd26 100644 --- a/cypress/e2e/login-modal.cy.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/e2e'; +import { testA11y } from 'cypress/support/utils'; 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/'.concat(TEST_ENTITY_PUBLICATION); + const ENTITYPAGE = '/entities/publication/'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION')); cy.visit(ENTITYPAGE); // Login menu should exist @@ -46,7 +46,7 @@ describe('Login Modal', () => { page.openLoginMenu(); cy.get('.form-login').should('be.visible'); - page.submitLoginAndPasswordByPressingButton(TEST_ADMIN_USER, TEST_ADMIN_PASSWORD); + page.submitLoginAndPasswordByPressingButton(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); cy.get('ds-log-in').should('not.exist'); // Verify we are still on the same page @@ -66,7 +66,7 @@ describe('Login Modal', () => { cy.get('.form-login').should('be.visible'); // Login, and the tag should no longer exist - page.submitLoginAndPasswordByPressingEnter(TEST_ADMIN_USER, TEST_ADMIN_PASSWORD); + page.submitLoginAndPasswordByPressingEnter(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); cy.get('.form-login').should('not.exist'); // Verify we are still on homepage @@ -80,7 +80,7 @@ describe('Login Modal', () => { it('should support logout', () => { // First authenticate & access homepage - cy.login(TEST_ADMIN_USER, TEST_ADMIN_PASSWORD); + cy.login(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); cy.visit('/'); // Verify ds-log-in tag doesn't exist, but ds-log-out tag does exist @@ -108,6 +108,9 @@ describe('Login Modal', () => { cy.get('ds-themed-navbar [data-test="register"]').click(); cy.location('pathname').should('eq', '/register'); cy.get('ds-register-email').should('exist'); + + // Test accessibility of this page + testA11y('ds-register-email'); }); it('should allow forgot password', () => { @@ -122,5 +125,26 @@ describe('Login Modal', () => { cy.get('ds-themed-navbar [data-test="forgot"]').click(); cy.location('pathname').should('eq', '/forgot'); cy.get('ds-forgot-email').should('exist'); + + // Test accessibility of this page + testA11y('ds-forgot-email'); + }); + + it('should pass accessibility tests in menus', () => { + cy.visit('/'); + + // Open login menu & verify accessibility + page.openLoginMenu(); + cy.get('ds-log-in').should('exist'); + testA11y('ds-log-in'); + + // Now login + page.submitLoginAndPasswordByPressingButton(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); + cy.get('ds-log-in').should('not.exist'); + + // Open user menu, verify user menu accesibility + page.openUserMenu(); + cy.get('ds-user-menu').should('be.visible'); + testA11y('ds-user-menu'); }); }); diff --git a/cypress/e2e/my-dspace.cy.ts b/cypress/e2e/my-dspace.cy.ts index 79786c298a..c48656ffcc 100644 --- a/cypress/e2e/my-dspace.cy.ts +++ b/cypress/e2e/my-dspace.cy.ts @@ -1,5 +1,3 @@ -import { Options } from 'cypress-axe'; -import { TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD, TEST_SUBMIT_COLLECTION_NAME } from 'cypress/support/e2e'; import { testA11y } from 'cypress/support/utils'; describe('My DSpace page', () => { @@ -7,7 +5,7 @@ describe('My DSpace page', () => { cy.visit('/mydspace'); // This page is restricted, so we will be shown the login form. Fill it out & submit. - cy.loginViaForm(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD); + cy.loginViaForm(Cypress.env('DSPACE_TEST_SUBMIT_USER'), Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD')); cy.get('ds-my-dspace-page').should('be.visible'); @@ -19,28 +17,14 @@ describe('My DSpace page', () => { cy.get('.filter-toggle').click({ multiple: true }); // Analyze for accessibility issues - testA11y( - { - include: ['ds-my-dspace-page'], - exclude: [ - ['nouislider'] // Date filter slider is missing ARIA labels. Will be fixed by #1175 - ], - }, - { - rules: { - // Search filters fail these two "moderate" impact rules - 'heading-order': { enabled: false }, - 'landmark-unique': { enabled: false } - } - } as Options - ); + testA11y('ds-my-dspace-page'); }); it('should have a working detailed view that passes accessibility tests', () => { cy.visit('/mydspace'); // This page is restricted, so we will be shown the login form. Fill it out & submit. - cy.loginViaForm(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD); + cy.loginViaForm(Cypress.env('DSPACE_TEST_SUBMIT_USER'), Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD')); cy.get('ds-my-dspace-page').should('be.visible'); @@ -49,16 +33,8 @@ describe('My DSpace page', () => { cy.get('ds-object-detail').should('be.visible'); - // Analyze for accessibility issues - testA11y('ds-my-dspace-page', - { - rules: { - // Search filters fail these two "moderate" impact rules - 'heading-order': { enabled: false }, - 'landmark-unique': { enabled: false } - } - } as Options - ); + // Analyze for accessibility issues + testA11y('ds-my-dspace-page'); }); // NOTE: Deleting existing submissions is exercised by submission.spec.ts @@ -66,7 +42,7 @@ describe('My DSpace page', () => { cy.visit('/mydspace'); // This page is restricted, so we will be shown the login form. Fill it out & submit. - cy.loginViaForm(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD); + cy.loginViaForm(Cypress.env('DSPACE_TEST_SUBMIT_USER'), Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD')); // Open the New Submission dropdown cy.get('button[data-test="submission-dropdown"]').click(); @@ -77,10 +53,10 @@ describe('My DSpace page', () => { cy.get('ds-create-item-parent-selector').should('be.visible'); // Type in a known Collection name in the search box - cy.get('ds-authorized-collection-selector input[type="search"]').type(TEST_SUBMIT_COLLECTION_NAME); + cy.get('ds-authorized-collection-selector input[type="search"]').type(Cypress.env('DSPACE_TEST_SUBMIT_COLLECTION_NAME')); // Click on the button matching that known Collection name - cy.get('ds-authorized-collection-selector button[title="'.concat(TEST_SUBMIT_COLLECTION_NAME).concat('"]')).click(); + cy.get('ds-authorized-collection-selector button[title="'.concat(Cypress.env('DSPACE_TEST_SUBMIT_COLLECTION_NAME')).concat('"]')).click(); // New URL should include /workspaceitems, as we've started a new submission cy.url().should('include', '/workspaceitems'); @@ -89,7 +65,7 @@ describe('My DSpace page', () => { cy.get('ds-submission-edit').should('be.visible'); // A Collection menu button should exist & its value should be the selected collection - cy.get('#collectionControlsMenuButton span').should('have.text', TEST_SUBMIT_COLLECTION_NAME); + cy.get('#collectionControlsMenuButton span').should('have.text', Cypress.env('DSPACE_TEST_SUBMIT_COLLECTION_NAME')); // Now that we've created a submission, we'll test that we can go back and Edit it. // Get our Submission URL, to parse out the ID of this new submission @@ -138,7 +114,7 @@ describe('My DSpace page', () => { cy.visit('/mydspace'); // This page is restricted, so we will be shown the login form. Fill it out & submit. - cy.loginViaForm(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD); + cy.loginViaForm(Cypress.env('DSPACE_TEST_SUBMIT_USER'), Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD')); // Open the New Import dropdown cy.get('button[data-test="import-dropdown"]').click(); @@ -150,6 +126,9 @@ describe('My DSpace page', () => { // The external import searchbox should be visible cy.get('ds-submission-import-external-searchbar').should('be.visible'); + + // Test for accessibility issues + testA11y('ds-submission-import-external'); }); }); diff --git a/cypress/e2e/pagenotfound.cy.ts b/cypress/e2e/pagenotfound.cy.ts index 43e3c3af24..d02aa8541c 100644 --- a/cypress/e2e/pagenotfound.cy.ts +++ b/cypress/e2e/pagenotfound.cy.ts @@ -1,8 +1,13 @@ +import { testA11y } from 'cypress/support/utils'; + 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('be.visible'); + + // Analyze for accessibility issues + testA11y('ds-pagenotfound'); }); it('should not contain element ds-pagenotfound when navigating to existing page', () => { diff --git a/cypress/e2e/search-navbar.cy.ts b/cypress/e2e/search-navbar.cy.ts index 648db17fe6..9dd93c7a2d 100644 --- a/cypress/e2e/search-navbar.cy.ts +++ b/cypress/e2e/search-navbar.cy.ts @@ -1,5 +1,3 @@ -import { TEST_SEARCH_TERM } from 'cypress/support/e2e'; - const page = { fillOutQueryInNavBar(query) { // Click the magnifying glass @@ -17,7 +15,7 @@ const page = { describe('Search from Navigation Bar', () => { // NOTE: these tests currently assume this query will return results! - const query = TEST_SEARCH_TERM; + const query = Cypress.env('DSPACE_TEST_SEARCH_TERM'); it('should go to search page with correct query if submitted (from home)', () => { cy.visit('/'); diff --git a/cypress/e2e/search-page.cy.ts b/cypress/e2e/search-page.cy.ts index 24519cc236..429f4e6da4 100644 --- a/cypress/e2e/search-page.cy.ts +++ b/cypress/e2e/search-page.cy.ts @@ -1,8 +1,10 @@ import { Options } from 'cypress-axe'; -import { TEST_SEARCH_TERM } from 'cypress/support/e2e'; import { testA11y } from 'cypress/support/utils'; describe('Search Page', () => { + // NOTE: these tests currently assume this query will return results! + const query = Cypress.env('DSPACE_TEST_SEARCH_TERM'); + it('should redirect to the correct url when query was set and submit button was triggered', () => { const queryString = 'Another interesting query string'; cy.visit('/search'); @@ -13,8 +15,8 @@ describe('Search Page', () => { }); it('should load results and pass accessibility tests', () => { - cy.visit('/search?query='.concat(TEST_SEARCH_TERM)); - cy.get('[data-test="search-box"]').should('have.value', TEST_SEARCH_TERM); + cy.visit('/search?query='.concat(query)); + cy.get('[data-test="search-box"]').should('have.value', query); // tag must be loaded cy.get('ds-search-page').should('be.visible'); @@ -27,25 +29,11 @@ describe('Search Page', () => { cy.get('[data-test="filter-toggle"]').click({ multiple: true }); // Analyze for accessibility issues - testA11y( - { - include: ['ds-search-page'], - exclude: [ - ['nouislider'] // Date filter slider is missing ARIA labels. Will be fixed by #1175 - ], - }, - { - rules: { - // Search filters fail these two "moderate" impact rules - 'heading-order': { enabled: false }, - 'landmark-unique': { enabled: false } - } - } as Options - ); + testA11y('ds-search-page'); }); it('should have a working grid view that passes accessibility tests', () => { - cy.visit('/search?query='.concat(TEST_SEARCH_TERM)); + cy.visit('/search?query='.concat(query)); // Click button in sidebar to display grid view cy.get('ds-search-sidebar [data-test="grid-view"]').click(); @@ -60,9 +48,8 @@ describe('Search Page', () => { testA11y('ds-search-page', { rules: { - // Search filters fail these two "moderate" impact rules - 'heading-order': { enabled: false }, - 'landmark-unique': { enabled: false } + // Card titles fail this test currently + 'heading-order': { enabled: false } } } as Options ); diff --git a/cypress/e2e/submission.cy.ts b/cypress/e2e/submission.cy.ts index ed10b2d13a..4402410f23 100644 --- a/cypress/e2e/submission.cy.ts +++ b/cypress/e2e/submission.cy.ts @@ -1,14 +1,16 @@ -import { TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD, TEST_SUBMIT_COLLECTION_NAME, TEST_SUBMIT_COLLECTION_UUID } from 'cypress/support/e2e'; +import { testA11y } from 'cypress/support/utils'; +//import { TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD, TEST_SUBMIT_COLLECTION_NAME, TEST_SUBMIT_COLLECTION_UUID, TEST_ADMIN_USER, TEST_ADMIN_PASSWORD } from 'cypress/support/e2e'; +import { Options } from 'cypress-axe'; describe('New Submission page', () => { - // NOTE: We already test that new submissions can be started from MyDSpace in my-dspace.spec.ts + // NOTE: We already test that new Item submissions can be started from MyDSpace in my-dspace.spec.ts it('should create a new submission when using /submit path & pass accessibility', () => { // Test that calling /submit with collection & entityType will create a new submission - cy.visit('/submit?collection='.concat(TEST_SUBMIT_COLLECTION_UUID).concat('&entityType=none')); + cy.visit('/submit?collection='.concat(Cypress.env('DSPACE_TEST_SUBMIT_COLLECTION_UUID')).concat('&entityType=none')); // This page is restricted, so we will be shown the login form. Fill it out & submit. - cy.loginViaForm(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD); + cy.loginViaForm(Cypress.env('DSPACE_TEST_SUBMIT_USER'), Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD')); // Should redirect to /workspaceitems, as we've started a new submission cy.url().should('include', '/workspaceitems'); @@ -17,7 +19,7 @@ describe('New Submission page', () => { cy.get('ds-submission-edit').should('be.visible'); // A Collection menu button should exist & it's value should be the selected collection - cy.get('#collectionControlsMenuButton span').should('have.text', TEST_SUBMIT_COLLECTION_NAME); + cy.get('#collectionControlsMenuButton span').should('have.text', Cypress.env('DSPACE_TEST_SUBMIT_COLLECTION_NAME')); // 4 sections should be visible by default cy.get('div#section_traditionalpageone').should('be.visible'); @@ -25,6 +27,25 @@ describe('New Submission page', () => { cy.get('div#section_upload').should('be.visible'); cy.get('div#section_license').should('be.visible'); + // Test entire page for accessibility + testA11y('ds-submission-edit', + { + rules: { + // Author & Subject fields have invalid "aria-multiline" attrs. + // See https://github.com/DSpace/dspace-angular/issues/1272 + 'aria-allowed-attr': { enabled: false }, + // All panels are accordians & fail "aria-required-children" and "nested-interactive". + // Seem to require updating ng-bootstrap and https://github.com/DSpace/dspace-angular/issues/2216 + 'aria-required-children': { enabled: false }, + 'nested-interactive': { enabled: false }, + // All select boxes fail to have a name / aria-label. + // This is a bug in ng-dynamic-forms and may require https://github.com/DSpace/dspace-angular/issues/2216 + 'select-name': { enabled: false }, + } + + } as Options + ); + // Discard button should work // Clicking it will display a confirmation, which we will confirm with another click cy.get('button#discard').click(); @@ -33,10 +54,10 @@ describe('New Submission page', () => { it('should block submission & show errors if required fields are missing', () => { // Create a new submission - cy.visit('/submit?collection='.concat(TEST_SUBMIT_COLLECTION_UUID).concat('&entityType=none')); + cy.visit('/submit?collection='.concat(Cypress.env('DSPACE_TEST_SUBMIT_COLLECTION_UUID')).concat('&entityType=none')); // This page is restricted, so we will be shown the login form. Fill it out & submit. - cy.loginViaForm(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD); + cy.loginViaForm(Cypress.env('DSPACE_TEST_SUBMIT_USER'), Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD')); // Attempt an immediate deposit without filling out any fields cy.get('button#deposit').click(); @@ -93,10 +114,10 @@ describe('New Submission page', () => { it('should allow for deposit if all required fields completed & file uploaded', () => { // Create a new submission - cy.visit('/submit?collection='.concat(TEST_SUBMIT_COLLECTION_UUID).concat('&entityType=none')); + cy.visit('/submit?collection='.concat(Cypress.env('DSPACE_TEST_SUBMIT_COLLECTION_UUID')).concat('&entityType=none')); // This page is restricted, so we will be shown the login form. Fill it out & submit. - cy.loginViaForm(TEST_SUBMIT_USER, TEST_SUBMIT_USER_PASSWORD); + cy.loginViaForm(Cypress.env('DSPACE_TEST_SUBMIT_USER'), Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD')); // Fill out all required fields (Title, Date) cy.get('input#dc_title').type('DSpace logo uploaded via e2e tests'); @@ -131,4 +152,76 @@ describe('New Submission page', () => { cy.get('ds-notification div.alert-success').should('be.visible'); }); + it('is possible to submit a new "Person" and that form passes accessibility', () => { + // To submit a different entity type, we'll start from MyDSpace + cy.visit('/mydspace'); + + // This page is restricted, so we will be shown the login form. Fill it out & submit. + // NOTE: At this time, we MUST login as admin to submit Person objects + cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); + + // Open the New Submission dropdown + cy.get('button[data-test="submission-dropdown"]').click(); + // Click on the "Person" type in that dropdown + cy.get('#entityControlsDropdownMenu button[title="Person"]').click(); + + // This should display the (popup window) + cy.get('ds-create-item-parent-selector').should('be.visible'); + + // Type in a known Collection name in the search box + cy.get('ds-authorized-collection-selector input[type="search"]').type(Cypress.env('DSPACE_TEST_SUBMIT_PERSON_COLLECTION_NAME')); + + // Click on the button matching that known Collection name + cy.get('ds-authorized-collection-selector button[title="'.concat(Cypress.env('DSPACE_TEST_SUBMIT_PERSON_COLLECTION_NAME')).concat('"]')).click(); + + // New URL should include /workspaceitems, as we've started a new submission + cy.url().should('include', '/workspaceitems'); + + // The Submission edit form tag should be visible + cy.get('ds-submission-edit').should('be.visible'); + + // A Collection menu button should exist & its value should be the selected collection + cy.get('#collectionControlsMenuButton span').should('have.text', Cypress.env('DSPACE_TEST_SUBMIT_PERSON_COLLECTION_NAME')); + + // 3 sections should be visible by default + cy.get('div#section_personStep').should('be.visible'); + cy.get('div#section_upload').should('be.visible'); + cy.get('div#section_license').should('be.visible'); + + // Test entire page for accessibility + testA11y('ds-submission-edit', + { + rules: { + // All panels are accordians & fail "aria-required-children" and "nested-interactive". + // Seem to require updating ng-bootstrap and https://github.com/DSpace/dspace-angular/issues/2216 + 'aria-required-children': { enabled: false }, + 'nested-interactive': { enabled: false }, + } + + } as Options + ); + + // Click the lookup button next to "Publication" field + cy.get('button[data-test="lookup-button"]').click(); + + // A popup modal window should be visible + cy.get('ds-dynamic-lookup-relation-modal').should('be.visible'); + + // Popup modal should also pass accessibility tests + //testA11y('ds-dynamic-lookup-relation-modal'); + testA11y({ + include: ['ds-dynamic-lookup-relation-modal'], + exclude: [ + ['ul.nav-tabs'] // Tabs at top of model have several issues which seem to be caused by ng-bootstrap + ], + }); + + // Close popup window + cy.get('ds-dynamic-lookup-relation-modal button.close').click(); + + // Back on the form, click the discard button to remove new submission + // Clicking it will display a confirmation, which we will confirm with another click + cy.get('button#discard').click(); + cy.get('button#discard_submit').click(); + }); }); diff --git a/cypress/plugins/index.ts b/cypress/plugins/index.ts index ead38afb92..cc3dccba38 100644 --- a/cypress/plugins/index.ts +++ b/cypress/plugins/index.ts @@ -1,5 +1,11 @@ const fs = require('fs'); +// These two global variables are used to store information about the REST API used +// by these e2e tests. They are filled out prior to running any tests in the before() +// method of e2e.ts. They can then be accessed by any tests via the getters below. +let REST_BASE_URL: string; +let REST_DOMAIN: string; + // Plugins enable you to tap into, modify, or extend the internal behavior of Cypress // For more info, visit https://on.cypress.io/plugins-api module.exports = (on, config) => { @@ -30,6 +36,24 @@ module.exports = (on, config) => { } return null; + }, + // Save value of REST Base URL, looked up before all tests. + // This allows other tests to use it easily via getRestBaseURL() below. + saveRestBaseURL(url: string) { + return (REST_BASE_URL = url); + }, + // Retrieve currently saved value of REST Base URL + getRestBaseURL() { + return REST_BASE_URL ; + }, + // Save value of REST Domain, looked up before all tests. + // This allows other tests to use it easily via getRestBaseDomain() below. + saveRestBaseDomain(domain: string) { + return (REST_DOMAIN = domain); + }, + // Retrieve currently saved value of REST Domain + getRestBaseDomain() { + return REST_DOMAIN ; } }); }; diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index c70c4e37e1..7da454e2d0 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -5,11 +5,7 @@ import { AuthTokenInfo, TOKENITEM } from 'src/app/core/auth/models/auth-token-info.model'; import { DSPACE_XSRF_COOKIE, XSRF_REQUEST_HEADER } from 'src/app/core/xsrf/xsrf.constants'; - -// NOTE: FALLBACK_TEST_REST_BASE_URL is only used if Cypress cannot read the REST API BaseURL -// from the Angular UI's config.json. See 'login()'. -export const FALLBACK_TEST_REST_BASE_URL = 'http://localhost:8080/server'; -export const FALLBACK_TEST_REST_DOMAIN = 'localhost'; +import { v4 as uuidv4 } from 'uuid'; // Declare Cypress namespace to help with Intellisense & code completion in IDEs // ALL custom commands MUST be listed here for code completion to work @@ -41,6 +37,13 @@ declare global { * @param dsoType type of DSpace Object (e.g. "item", "collection", "community") */ generateViewEvent(uuid: string, dsoType: string): typeof generateViewEvent; + + /** + * Create a new CSRF token and add to required Cookie. CSRF Token is returned + * in chainable in order to allow it to be sent also in required CSRF header. + * @returns Chainable reference to allow CSRF token to also be sent in header. + */ + createCSRFCookie(): Chainable; } } } @@ -54,59 +57,32 @@ declare global { * @param password password to login as */ function login(email: string, password: string): void { - // Cypress doesn't have access to the running application in Node.js. - // So, it's not possible to inject or load the AppConfig or environment of the Angular UI. - // Instead, we'll read our running application's config.json, which contains the configs & - // is regenerated at runtime each time the Angular UI application starts up. - cy.task('readUIConfig').then((str: string) => { - // Parse config into a JSON object - const config = JSON.parse(str); + // Create a fake CSRF cookie/token to use in POST + cy.createCSRFCookie().then((csrfToken: string) => { + // get our REST API's base URL, also needed for POST + cy.task('getRestBaseURL').then((baseRestUrl: string) => { + // Now, send login POST request including that CSRF token + cy.request({ + method: 'POST', + url: baseRestUrl + '/api/authn/login', + headers: { [XSRF_REQUEST_HEADER]: csrfToken}, + form: true, // indicates the body should be form urlencoded + body: { user: email, password: password } + }).then((resp) => { + // We expect a successful login + expect(resp.status).to.eq(200); + // We expect to have a valid authorization header returned (with our auth token) + expect(resp.headers).to.have.property('authorization'); - // Find the URL of our REST API. Have a fallback ready, just in case 'rest.baseUrl' cannot be found. - let baseRestUrl = FALLBACK_TEST_REST_BASE_URL; - if (!config.rest.baseUrl) { - console.warn("Could not load 'rest.baseUrl' from config.json. Falling back to " + FALLBACK_TEST_REST_BASE_URL); - } else { - //console.log("Found 'rest.baseUrl' in config.json. Using this REST API for login: ".concat(config.rest.baseUrl)); - baseRestUrl = config.rest.baseUrl; - } + // Initialize our AuthTokenInfo object from the authorization header. + const authheader = resp.headers.authorization as string; + const authinfo: AuthTokenInfo = new AuthTokenInfo(authheader); - // Now find domain of our REST API, again with a fallback. - let baseDomain = FALLBACK_TEST_REST_DOMAIN; - if (!config.rest.host) { - console.warn("Could not load 'rest.host' from config.json. Falling back to " + FALLBACK_TEST_REST_DOMAIN); - } else { - baseDomain = config.rest.host; - } - - // Create a fake CSRF Token. Set it in the required server-side cookie - const csrfToken = 'fakeLoginCSRFToken'; - cy.setCookie(DSPACE_XSRF_COOKIE, csrfToken, { 'domain': baseDomain }); - - // Now, send login POST request including that CSRF token - cy.request({ - method: 'POST', - url: baseRestUrl + '/api/authn/login', - headers: { [XSRF_REQUEST_HEADER]: csrfToken}, - form: true, // indicates the body should be form urlencoded - body: { user: email, password: password } - }).then((resp) => { - // We expect a successful login - expect(resp.status).to.eq(200); - // We expect to have a valid authorization header returned (with our auth token) - expect(resp.headers).to.have.property('authorization'); - - // Initialize our AuthTokenInfo object from the authorization header. - const authheader = resp.headers.authorization as string; - const authinfo: AuthTokenInfo = new AuthTokenInfo(authheader); - - // Save our AuthTokenInfo object to our dsAuthInfo UI cookie - // This ensures the UI will recognize we are logged in on next "visit()" - cy.setCookie(TOKENITEM, JSON.stringify(authinfo)); + // Save our AuthTokenInfo object to our dsAuthInfo UI cookie + // This ensures the UI will recognize we are logged in on next "visit()" + cy.setCookie(TOKENITEM, JSON.stringify(authinfo)); + }); }); - - // Remove cookie with fake CSRF token, as it's no longer needed - cy.clearCookie(DSPACE_XSRF_COOKIE); }); } // Add as a Cypress command (i.e. assign to 'cy.login') @@ -141,54 +117,53 @@ Cypress.Commands.add('loginViaForm', loginViaForm); * @param dsoType type of DSpace Object (e.g. "item", "collection", "community") */ function generateViewEvent(uuid: string, dsoType: string): void { - // Cypress doesn't have access to the running application in Node.js. - // So, it's not possible to inject or load the AppConfig or environment of the Angular UI. - // Instead, we'll read our running application's config.json, which contains the configs & - // is regenerated at runtime each time the Angular UI application starts up. - cy.task('readUIConfig').then((str: string) => { - // Parse config into a JSON object - const config = JSON.parse(str); - - // Find the URL of our REST API. Have a fallback ready, just in case 'rest.baseUrl' cannot be found. - let baseRestUrl = FALLBACK_TEST_REST_BASE_URL; - if (!config.rest.baseUrl) { - console.warn("Could not load 'rest.baseUrl' from config.json. Falling back to " + FALLBACK_TEST_REST_BASE_URL); - } else { - baseRestUrl = config.rest.baseUrl; - } - - // Now find domain of our REST API, again with a fallback. - let baseDomain = FALLBACK_TEST_REST_DOMAIN; - if (!config.rest.host) { - console.warn("Could not load 'rest.host' from config.json. Falling back to " + FALLBACK_TEST_REST_DOMAIN); - } else { - baseDomain = config.rest.host; - } - - // Create a fake CSRF Token. Set it in the required server-side cookie - const csrfToken = 'fakeGenerateViewEventCSRFToken'; - cy.setCookie(DSPACE_XSRF_COOKIE, csrfToken, { 'domain': baseDomain }); - - // Now, send 'statistics/viewevents' POST request including that fake CSRF token in required header - cy.request({ - method: 'POST', - url: baseRestUrl + '/api/statistics/viewevents', - headers: { - [XSRF_REQUEST_HEADER] : csrfToken, - // use a known public IP address to avoid being seen as a "bot" - 'X-Forwarded-For': '1.1.1.1', - }, - //form: true, // indicates the body should be form urlencoded - body: { targetId: uuid, targetType: dsoType }, - }).then((resp) => { - // We expect a 201 (which means statistics event was created) - expect(resp.status).to.eq(201); + // Create a fake CSRF cookie/token to use in POST + cy.createCSRFCookie().then((csrfToken: string) => { + // get our REST API's base URL, also needed for POST + cy.task('getRestBaseURL').then((baseRestUrl: string) => { + // Now, send 'statistics/viewevents' POST request including that fake CSRF token in required header + cy.request({ + method: 'POST', + url: baseRestUrl + '/api/statistics/viewevents', + headers: { + [XSRF_REQUEST_HEADER] : csrfToken, + // use a known public IP address to avoid being seen as a "bot" + 'X-Forwarded-For': '1.1.1.1', + // Use a user-agent of a Firefox browser on Windows. This again avoids being seen as a "bot" + 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/119.0', + }, + //form: true, // indicates the body should be form urlencoded + body: { targetId: uuid, targetType: dsoType }, + }).then((resp) => { + // We expect a 201 (which means statistics event was created) + expect(resp.status).to.eq(201); + }); }); - - // Remove cookie with fake CSRF token, as it's no longer needed - cy.clearCookie(DSPACE_XSRF_COOKIE); }); } // Add as a Cypress command (i.e. assign to 'cy.generateViewEvent') Cypress.Commands.add('generateViewEvent', generateViewEvent); + +/** + * Can be used by tests to generate a random XSRF/CSRF token and save it to + * the required XSRF/CSRF cookie for usage when sending POST requests or similar. + * The generated CSRF token is returned in a Chainable to allow it to be also sent + * in the CSRF HTTP Header. + * @returns a Cypress Chainable which can be used to get the generated CSRF Token + */ +function createCSRFCookie(): Cypress.Chainable { + // Generate a new token which is a random UUID + const csrfToken: string = uuidv4(); + + // Save it to our required cookie + cy.task('getRestBaseDomain').then((baseDomain: string) => { + // Create a fake CSRF Token. Set it in the required server-side cookie + cy.setCookie(DSPACE_XSRF_COOKIE, csrfToken, { 'domain': baseDomain }); + }); + + // return the generated token wrapped in a chainable + return cy.wrap(csrfToken); +} +// Add as a Cypress command (i.e. assign to 'cy.createCSRFCookie') +Cypress.Commands.add('createCSRFCookie', createCSRFCookie); diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts index dd7ee1824c..f6c6865052 100644 --- a/cypress/support/e2e.ts +++ b/cypress/support/e2e.ts @@ -19,45 +19,54 @@ import './commands'; // Import Cypress Axe tools for all tests // https://github.com/component-driven/cypress-axe import 'cypress-axe'; +import { DSPACE_XSRF_COOKIE } from 'src/app/core/xsrf/xsrf.constants'; + + +// Runs once before all tests +before(() => { + // Cypress doesn't have access to the running application in Node.js. + // So, it's not possible to inject or load the AppConfig or environment of the Angular UI. + // Instead, we'll read our running application's config.json, which contains the configs & + // is regenerated at runtime each time the Angular UI application starts up. + cy.task('readUIConfig').then((str: string) => { + // Parse config into a JSON object + const config = JSON.parse(str); + + // Find URL of our REST API & save to global variable via task + let baseRestUrl = FALLBACK_TEST_REST_BASE_URL; + if (!config.rest.baseUrl) { + console.warn("Could not load 'rest.baseUrl' from config.json. Falling back to " + FALLBACK_TEST_REST_BASE_URL); + } else { + baseRestUrl = config.rest.baseUrl; + } + cy.task('saveRestBaseURL', baseRestUrl); + + // Find domain of our REST API & save to global variable via task. + let baseDomain = FALLBACK_TEST_REST_DOMAIN; + if (!config.rest.host) { + console.warn("Could not load 'rest.host' from config.json. Falling back to " + FALLBACK_TEST_REST_DOMAIN); + } else { + baseDomain = config.rest.host; + } + cy.task('saveRestBaseDomain', baseDomain); + + }); +}); // Runs once before the first test in each "block" beforeEach(() => { // Pre-agree to all Klaro cookies by setting the klaro-anonymous cookie // This just ensures it doesn't get in the way of matching other objects in the page. cy.setCookie('klaro-anonymous', '{%22authentication%22:true%2C%22preferences%22:true%2C%22acknowledgement%22:true%2C%22google-analytics%22:true%2C%22google-recaptcha%22:true}'); + + // Remove any CSRF cookies saved from prior tests + cy.clearCookie(DSPACE_XSRF_COOKIE); }); -// For better stability between tests, we visit "about:blank" (i.e. blank page) after each test. -// This ensures any remaining/outstanding XHR requests are killed, so they don't affect the next test. -// Borrowed from: https://glebbahmutov.com/blog/visit-blank-page-between-tests/ -/*afterEach(() => { - cy.window().then((win) => { - win.location.href = 'about:blank'; - }); -});*/ - - -// Global constants used in tests -// May be overridden in our cypress.json config file using specified environment variables. -// Default values listed here are all valid for the Demo Entities Data set available at -// https://github.com/DSpace-Labs/AIP-Files/releases/tag/demo-entities-data -// (This is the data set used in our CI environment) - -// Admin account used for administrative tests -export const TEST_ADMIN_USER = Cypress.env('DSPACE_TEST_ADMIN_USER') || 'dspacedemo+admin@gmail.com'; -export const TEST_ADMIN_PASSWORD = Cypress.env('DSPACE_TEST_ADMIN_PASSWORD') || 'dspace'; -// Community/collection/publication used for view/edit tests -export const TEST_COLLECTION = Cypress.env('DSPACE_TEST_COLLECTION') || '282164f5-d325-4740-8dd1-fa4d6d3e7200'; -export const TEST_COMMUNITY = Cypress.env('DSPACE_TEST_COMMUNITY') || '0958c910-2037-42a9-81c7-dca80e3892b4'; -export const TEST_ENTITY_PUBLICATION = Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION') || 'e98b0f27-5c19-49a0-960d-eb6ad5287067'; -// Search term (should return results) used in search tests -export const TEST_SEARCH_TERM = Cypress.env('DSPACE_TEST_SEARCH_TERM') || 'test'; -// Collection used for submission tests -export const TEST_SUBMIT_COLLECTION_NAME = Cypress.env('DSPACE_TEST_SUBMIT_COLLECTION_NAME') || 'Sample Collection'; -export const TEST_SUBMIT_COLLECTION_UUID = Cypress.env('DSPACE_TEST_SUBMIT_COLLECTION_UUID') || '9d8334e9-25d3-4a67-9cea-3dffdef80144'; -export const TEST_SUBMIT_USER = Cypress.env('DSPACE_TEST_SUBMIT_USER') || 'dspacedemo+submit@gmail.com'; -export const TEST_SUBMIT_USER_PASSWORD = Cypress.env('DSPACE_TEST_SUBMIT_USER_PASSWORD') || 'dspace'; - +// NOTE: FALLBACK_TEST_REST_BASE_URL is only used if Cypress cannot read the REST API BaseURL +// from the Angular UI's config.json. See 'before()' above. +const FALLBACK_TEST_REST_BASE_URL = 'http://localhost:8080/server'; +const FALLBACK_TEST_REST_DOMAIN = 'localhost'; // USEFUL REGEX for testing diff --git a/docker/README.md b/docker/README.md index 42deb793f9..d0cee3f52a 100644 --- a/docker/README.md +++ b/docker/README.md @@ -23,14 +23,14 @@ the Docker compose scripts in this 'docker' folder. This Dockerfile is used to build a *development* DSpace 7 Angular UI image, published as 'dspace/dspace-angular' ``` -docker build -t dspace/dspace-angular:dspace-7_x . +docker build -t dspace/dspace-angular:latest . ``` This image is built *automatically* after each commit is made to the `main` branch. Admins to our DockerHub repo can manually publish with the following command. ``` -docker push dspace/dspace-angular:dspace-7_x +docker push dspace/dspace-angular:latest ``` ### Dockerfile.dist @@ -39,7 +39,7 @@ The `Dockerfile.dist` is used to generate a *production* build and runtime envir ```bash # build the latest image -docker build -f Dockerfile.dist -t dspace/dspace-angular:dspace-7_x-dist . +docker build -f Dockerfile.dist -t dspace/dspace-angular:latest-dist . ``` A default/demo version of this image is built *automatically*. @@ -101,8 +101,8 @@ 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/). +This allows you to run the Angular UI in *production* mode, pointing it at the demo or sandbox backend +(https://demo.dspace.org/server/ or https://sandbox.dspace.org/server/). ``` docker-compose -f docker/docker-compose-dist.yml pull diff --git a/docker/cli.yml b/docker/cli.yml index 54b83d4503..223ec356b9 100644 --- a/docker/cli.yml +++ b/docker/cli.yml @@ -16,7 +16,7 @@ version: "3.7" services: dspace-cli: - image: "${DOCKER_OWNER:-dspace}/dspace-cli:${DSPACE_VER:-dspace-7_x}" + image: "${DOCKER_OWNER:-dspace}/dspace-cli:${DSPACE_VER:-latest}" container_name: dspace-cli environment: # Below syntax may look odd, but it is how to override dspace.cfg settings via env variables. diff --git a/docker/docker-compose-ci.yml b/docker/docker-compose-ci.yml index 9ec8fe664a..edbb5b0759 100644 --- a/docker/docker-compose-ci.yml +++ b/docker/docker-compose-ci.yml @@ -35,7 +35,7 @@ services: solr__D__statistics__P__autoCommit: 'false' depends_on: - dspacedb - image: dspace/dspace:dspace-7_x-test + image: dspace/dspace:latest-test networks: dspacenet: ports: diff --git a/docker/docker-compose-dist.yml b/docker/docker-compose-dist.yml index 1c75539da9..38278085cd 100644 --- a/docker/docker-compose-dist.yml +++ b/docker/docker-compose-dist.yml @@ -24,10 +24,10 @@ services: # 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_HOST: sandbox.dspace.org DSPACE_REST_PORT: 443 DSPACE_REST_NAMESPACE: /server - image: dspace/dspace-angular:dspace-7_x-dist + image: dspace/dspace-angular:${DSPACE_VER:-latest}-dist build: context: .. dockerfile: Dockerfile.dist diff --git a/docker/docker-compose-rest.yml b/docker/docker-compose-rest.yml index e5f62600e7..ea766600ef 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: "${DOCKER_OWNER:-dspace}/dspace:${DSPACE_VER:-dspace-7_x-test}" + image: "${DOCKER_OWNER:-dspace}/dspace:${DSPACE_VER:-latest-test}" depends_on: - dspacedb networks: @@ -82,7 +82,7 @@ services: # DSpace Solr container dspacesolr: container_name: dspacesolr - image: "${DOCKER_OWNER:-dspace}/dspace-solr:${DSPACE_VER:-dspace-7_x}" + image: "${DOCKER_OWNER:-dspace}/dspace-solr:${DSPACE_VER:-latest}" # Needs main 'dspace' container to start first to guarantee access to solr_configs depends_on: - dspace diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 1387b1de39..1071b8d6ce 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -24,7 +24,7 @@ services: DSPACE_REST_HOST: localhost DSPACE_REST_PORT: 8080 DSPACE_REST_NAMESPACE: /server - image: dspace/dspace-angular:dspace-7_x + image: dspace/dspace-angular:${DSPACE_VER:-latest} build: context: .. dockerfile: Dockerfile diff --git a/docs/Configuration.md b/docs/Configuration.md index 62fa444cc0..01fd83c94d 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -48,7 +48,7 @@ dspace-angular connects to your DSpace installation by using its REST endpoint. ```yaml rest: ssl: true - host: api7.dspace.org + host: demo.dspace.org port: 443 nameSpace: /server } @@ -57,7 +57,7 @@ rest: Alternately you can set the following environment variables. If any of these are set, it will override all configuration files: ``` DSPACE_REST_SSL=true - DSPACE_REST_HOST=api7.dspace.org + DSPACE_REST_HOST=demo.dspace.org DSPACE_REST_PORT=443 DSPACE_REST_NAMESPACE=/server ``` diff --git a/package.json b/package.json index 719b13b23b..e5347742c8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dspace-angular", - "version": "7.6.0", + "version": "8.0.0-next", "scripts": { "ng": "ng", "config:watch": "nodemon", @@ -15,14 +15,14 @@ "analyze": "webpack-bundle-analyzer dist/browser/stats.json", "build": "ng build --configuration development", "build:stats": "ng build --stats-json", - "build:prod": "yarn run build:ssr", + "build:prod": "cross-env NODE_ENV=production yarn run build:ssr", "build:ssr": "ng build --configuration production && ng run dspace-angular:server:production", "test": "ng test --source-map=true --watch=false --configuration test", "test:watch": "nodemon --exec \"ng test --source-map=true --watch=true --configuration test\"", "test:headless": "ng test --source-map=true --watch=false --configuration test --browsers=ChromeHeadless --code-coverage", "lint": "ng lint", "lint-fix": "ng lint --fix=true", - "e2e": "ng e2e", + "e2e": "cross-env NODE_ENV=production ng e2e", "clean:dev:config": "rimraf src/assets/config.json", "clean:coverage": "rimraf coverage", "clean:dist": "rimraf dist", @@ -82,7 +82,7 @@ "@types/grecaptcha": "^3.0.4", "angular-idle-preload": "3.0.0", "angulartics2": "^12.2.0", - "axios": "^0.27.2", + "axios": "^1.6.0", "bootstrap": "^4.6.1", "cerialize": "0.1.18", "cli-progress": "^3.12.0", @@ -99,6 +99,7 @@ "fast-json-patch": "^3.1.1", "filesize": "^6.1.0", "http-proxy-middleware": "^1.0.5", + "http-terminator": "^3.2.0", "isbot": "^3.6.10", "js-cookie": "2.2.1", "js-yaml": "^4.1.0", @@ -116,12 +117,12 @@ "morgan": "^1.10.0", "ng-mocks": "^14.10.0", "ng2-file-upload": "1.4.0", - "ng2-nouislider": "^1.8.3", + "ng2-nouislider": "^2.0.0", "ngx-infinite-scroll": "^15.0.0", "ngx-pagination": "6.0.3", "ngx-sortablejs": "^11.1.0", - "ngx-ui-switch": "^14.0.3", - "nouislider": "^14.6.3", + "ngx-ui-switch": "^14.1.0", + "nouislider": "^15.7.1", "pem": "1.14.7", "prop-types": "^15.8.1", "react-copy-to-clipboard": "^5.1.0", @@ -159,11 +160,11 @@ "@types/sanitize-html": "^2.9.0", "@typescript-eslint/eslint-plugin": "^5.59.1", "@typescript-eslint/parser": "^5.59.1", - "axe-core": "^4.7.0", + "axe-core": "^4.7.2", "compression-webpack-plugin": "^9.2.0", "copy-webpack-plugin": "^6.4.1", "cross-env": "^7.0.3", - "cypress": "12.10.0", + "cypress": "12.17.4", "cypress-axe": "^1.4.0", "deep-freeze": "0.0.1", "eslint": "^8.39.0", diff --git a/server.ts b/server.ts index 23327c2058..da085f372f 100644 --- a/server.ts +++ b/server.ts @@ -32,6 +32,7 @@ import isbot from 'isbot'; import { createCertificate } from 'pem'; import { createServer } from 'https'; import { json } from 'body-parser'; +import { createHttpTerminator } from 'http-terminator'; import { readFileSync } from 'fs'; import { join } from 'path'; @@ -320,22 +321,23 @@ function initCache() { if (botCacheEnabled()) { // Initialize a new "least-recently-used" item cache (where least recently used pages are removed first) // See https://www.npmjs.com/package/lru-cache - // When enabled, each page defaults to expiring after 1 day + // When enabled, each page defaults to expiring after 1 day (defined in default-app-config.ts) botCache = new LRU( { max: environment.cache.serverSide.botCache.max, - ttl: environment.cache.serverSide.botCache.timeToLive || 24 * 60 * 60 * 1000, // 1 day - allowStale: environment.cache.serverSide.botCache.allowStale ?? true // if object is stale, return stale value before deleting + ttl: environment.cache.serverSide.botCache.timeToLive, + allowStale: environment.cache.serverSide.botCache.allowStale }); } if (anonymousCacheEnabled()) { // NOTE: While caches may share SSR pages, this cache must be kept separately because the timeToLive // may expire pages more frequently. - // When enabled, each page defaults to expiring after 10 seconds (to minimize anonymous users seeing out-of-date content) + // When enabled, each page defaults to expiring after 10 seconds (defined in default-app-config.ts) + // to minimize anonymous users seeing out-of-date content anonymousCache = new LRU( { max: environment.cache.serverSide.anonymousCache.max, - ttl: environment.cache.serverSide.anonymousCache.timeToLive || 10 * 1000, // 10 seconds - allowStale: environment.cache.serverSide.anonymousCache.allowStale ?? true // if object is stale, return stale value before deleting + ttl: environment.cache.serverSide.anonymousCache.timeToLive, + allowStale: environment.cache.serverSide.anonymousCache.allowStale }); } } @@ -487,7 +489,7 @@ function saveToCache(req, page: any) { */ function hasNotSucceeded(statusCode) { const rgx = new RegExp(/^20+/); - return !rgx.test(statusCode) + return !rgx.test(statusCode); } function retrieveHeaders(response) { @@ -525,23 +527,46 @@ function serverStarted() { * @param keys SSL credentials */ function createHttpsServer(keys) { - createServer({ + const listener = createServer({ key: keys.serviceKey, cert: keys.certificate }, app).listen(environment.ui.port, environment.ui.host, () => { serverStarted(); }); + + // Graceful shutdown when signalled + const terminator = createHttpTerminator({server: listener}); + process.on('SIGINT', () => { + void (async ()=> { + console.debug('Closing HTTPS server on signal'); + await terminator.terminate().catch(e => { console.error(e); }); + console.debug('HTTPS server closed'); + })(); + }); } +/** + * Create an HTTP server with the configured port and host. + */ function run() { const port = environment.ui.port || 4000; const host = environment.ui.host || '/'; // Start up the Node server const server = app(); - server.listen(port, host, () => { + const listener = server.listen(port, host, () => { serverStarted(); }); + + // Graceful shutdown when signalled + const terminator = createHttpTerminator({server: listener}); + process.on('SIGINT', () => { + void (async () => { + console.debug('Closing HTTP server on signal'); + await terminator.terminate().catch(e => { console.error(e); }); + console.debug('HTTP server closed.');return undefined; + })(); + }); } function start() { diff --git a/src/app/access-control/access-control-routing-paths.ts b/src/app/access-control/access-control-routing-paths.ts index 259aa311e7..31f39f1c47 100644 --- a/src/app/access-control/access-control-routing-paths.ts +++ b/src/app/access-control/access-control-routing-paths.ts @@ -1,12 +1,22 @@ import { URLCombiner } from '../core/url-combiner/url-combiner'; import { getAccessControlModuleRoute } from '../app-routing-paths'; -export const GROUP_EDIT_PATH = 'groups'; +export const EPERSON_PATH = 'epeople'; + +export function getEPersonsRoute(): string { + return new URLCombiner(getAccessControlModuleRoute(), EPERSON_PATH).toString(); +} + +export function getEPersonEditRoute(id: string): string { + return new URLCombiner(getEPersonsRoute(), id, 'edit').toString(); +} + +export const GROUP_PATH = 'groups'; export function getGroupsRoute() { - return new URLCombiner(getAccessControlModuleRoute(), GROUP_EDIT_PATH).toString(); + return new URLCombiner(getAccessControlModuleRoute(), GROUP_PATH).toString(); } export function getGroupEditRoute(id: string) { - return new URLCombiner(getAccessControlModuleRoute(), GROUP_EDIT_PATH, id).toString(); + return new URLCombiner(getGroupsRoute(), id, 'edit').toString(); } diff --git a/src/app/access-control/access-control-routing.module.ts b/src/app/access-control/access-control-routing.module.ts index 6f6de6cb26..97d049ad83 100644 --- a/src/app/access-control/access-control-routing.module.ts +++ b/src/app/access-control/access-control-routing.module.ts @@ -3,7 +3,7 @@ import { RouterModule } from '@angular/router'; import { EPeopleRegistryComponent } from './epeople-registry/epeople-registry.component'; import { GroupFormComponent } from './group-registry/group-form/group-form.component'; import { GroupsRegistryComponent } from './group-registry/groups-registry.component'; -import { GROUP_EDIT_PATH } from './access-control-routing-paths'; +import { EPERSON_PATH, GROUP_PATH } from './access-control-routing-paths'; import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver'; import { GroupPageGuard } from './group-registry/group-page.guard'; import { @@ -13,12 +13,14 @@ import { SiteAdministratorGuard } from '../core/data/feature-authorization/feature-authorization-guard/site-administrator.guard'; import { BulkAccessComponent } from './bulk-access/bulk-access.component'; +import { EPersonFormComponent } from './epeople-registry/eperson-form/eperson-form.component'; +import { EPersonResolver } from './epeople-registry/eperson-resolver.service'; @NgModule({ imports: [ RouterModule.forChild([ { - path: 'epeople', + path: EPERSON_PATH, component: EPeopleRegistryComponent, resolve: { breadcrumb: I18nBreadcrumbResolver @@ -27,7 +29,26 @@ import { BulkAccessComponent } from './bulk-access/bulk-access.component'; canActivate: [SiteAdministratorGuard] }, { - path: GROUP_EDIT_PATH, + path: `${EPERSON_PATH}/create`, + component: EPersonFormComponent, + resolve: { + breadcrumb: I18nBreadcrumbResolver, + }, + data: { title: 'admin.access-control.epeople.add.title', breadcrumbKey: 'admin.access-control.epeople.add' }, + canActivate: [SiteAdministratorGuard], + }, + { + path: `${EPERSON_PATH}/:id/edit`, + component: EPersonFormComponent, + resolve: { + breadcrumb: I18nBreadcrumbResolver, + ePerson: EPersonResolver, + }, + data: { title: 'admin.access-control.epeople.edit.title', breadcrumbKey: 'admin.access-control.epeople.edit' }, + canActivate: [SiteAdministratorGuard], + }, + { + path: GROUP_PATH, component: GroupsRegistryComponent, resolve: { breadcrumb: I18nBreadcrumbResolver @@ -36,7 +57,7 @@ import { BulkAccessComponent } from './bulk-access/bulk-access.component'; canActivate: [GroupAdministratorGuard] }, { - path: `${GROUP_EDIT_PATH}/newGroup`, + path: `${GROUP_PATH}/create`, component: GroupFormComponent, resolve: { breadcrumb: I18nBreadcrumbResolver @@ -45,7 +66,7 @@ import { BulkAccessComponent } from './bulk-access/bulk-access.component'; canActivate: [GroupAdministratorGuard] }, { - path: `${GROUP_EDIT_PATH}/:groupId`, + path: `${GROUP_PATH}/:groupId/edit`, component: GroupFormComponent, resolve: { breadcrumb: I18nBreadcrumbResolver diff --git a/src/app/access-control/bulk-access/browse/bulk-access-browse.component.html b/src/app/access-control/bulk-access/browse/bulk-access-browse.component.html index c716aedb8b..6e967b53b5 100644 --- a/src/app/access-control/bulk-access/browse/bulk-access-browse.component.html +++ b/src/app/access-control/bulk-access/browse/bulk-access-browse.component.html @@ -1,15 +1,15 @@ -
-
-
+
+
@@ -17,51 +17,53 @@
- -
+
diff --git a/src/app/access-control/bulk-access/bulk-access.component.html b/src/app/access-control/bulk-access/bulk-access.component.html index 382caf85f4..c164cc5c31 100644 --- a/src/app/access-control/bulk-access/bulk-access.component.html +++ b/src/app/access-control/bulk-access/bulk-access.component.html @@ -1,4 +1,5 @@
+

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

diff --git a/src/app/access-control/bulk-access/settings/bulk-access-settings.component.html b/src/app/access-control/bulk-access/settings/bulk-access-settings.component.html index 01f36ef03f..c41053874e 100644 --- a/src/app/access-control/bulk-access/settings/bulk-access-settings.component.html +++ b/src/app/access-control/bulk-access/settings/bulk-access-settings.component.html @@ -1,13 +1,13 @@ -
- -
-
+
+
@@ -15,7 +15,7 @@
- + diff --git a/src/app/access-control/epeople-registry/epeople-registry.component.html b/src/app/access-control/epeople-registry/epeople-registry.component.html index e3a8e2c590..bf7b9a2060 100644 --- a/src/app/access-control/epeople-registry/epeople-registry.component.html +++ b/src/app/access-control/epeople-registry/epeople-registry.component.html @@ -2,98 +2,93 @@
- +

{{labelPrefix + 'head' | translate}}

-
+
- - -
- -
-
- -
-
-
- - + + +
+ +
+
+
+ + -
-
- -
- - - - - -
- - - - - - - - - - - - - - - - - -
{{labelPrefix + 'table.id' | translate}}{{labelPrefix + 'table.name' | translate}}{{labelPrefix + 'table.email' | translate}}{{labelPrefix + 'table.edit' | translate}}
{{epersonDto.eperson.id}}{{ dsoNameService.getName(epersonDto.eperson) }}{{epersonDto.eperson.email}} -
- - -
-
-
- -
- - +
+ +
+ + + + + +
+ + + + + + + + + + + + + + + + + +
{{labelPrefix + 'table.id' | translate}}{{labelPrefix + 'table.name' | translate}}{{labelPrefix + 'table.email' | translate}}{{labelPrefix + 'table.edit' | translate}}
{{epersonDto.eperson.id}}{{ dsoNameService.getName(epersonDto.eperson) }}{{epersonDto.eperson.email}} +
+ + +
+
+
+ +
+ +
diff --git a/src/app/access-control/epeople-registry/epeople-registry.component.spec.ts b/src/app/access-control/epeople-registry/epeople-registry.component.spec.ts index 4a09913862..e2cee5e935 100644 --- a/src/app/access-control/epeople-registry/epeople-registry.component.spec.ts +++ b/src/app/access-control/epeople-registry/epeople-registry.component.spec.ts @@ -203,36 +203,6 @@ describe('EPeopleRegistryComponent', () => { }); }); - describe('toggleEditEPerson', () => { - describe('when you click on first edit eperson button', () => { - beforeEach(fakeAsync(() => { - const editButtons = fixture.debugElement.queryAll(By.css('.access-control-editEPersonButton')); - editButtons[0].triggerEventHandler('click', { - preventDefault: () => {/**/ - } - }); - tick(); - fixture.detectChanges(); - })); - - it('editEPerson form is toggled', () => { - const ePeopleIds = fixture.debugElement.queryAll(By.css('#epeople tr td:first-child')); - ePersonDataServiceStub.getActiveEPerson().subscribe((activeEPerson: EPerson) => { - if (ePeopleIds[0] && activeEPerson === ePeopleIds[0].nativeElement.textContent) { - expect(component.isEPersonFormShown).toEqual(false); - } else { - expect(component.isEPersonFormShown).toEqual(true); - } - - }); - }); - - it('EPerson search section is hidden', () => { - expect(fixture.debugElement.query(By.css('#search'))).toBeNull(); - }); - }); - }); - describe('deleteEPerson', () => { describe('when you click on first delete eperson button', () => { let ePeopleIdsFoundBeforeDelete; diff --git a/src/app/access-control/epeople-registry/epeople-registry.component.ts b/src/app/access-control/epeople-registry/epeople-registry.component.ts index fb045ebb88..41a6648479 100644 --- a/src/app/access-control/epeople-registry/epeople-registry.component.ts +++ b/src/app/access-control/epeople-registry/epeople-registry.component.ts @@ -22,6 +22,7 @@ import { PageInfo } from '../../core/shared/page-info.model'; import { NoContent } from '../../core/shared/NoContent.model'; import { PaginationService } from '../../core/pagination/pagination.service'; import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { getEPersonEditRoute, getEPersonsRoute } from '../access-control-routing-paths'; @Component({ selector: 'ds-epeople-registry', @@ -64,11 +65,6 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy { currentPage: 1 }); - /** - * Whether or not to show the EPerson form - */ - isEPersonFormShown: boolean; - // The search form searchForm; @@ -114,17 +110,11 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy { */ initialisePage() { this.searching$.next(true); - this.isEPersonFormShown = false; this.search({scope: this.currentSearchScope, query: this.currentSearchQuery}); - this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => { - if (eperson != null && eperson.id) { - this.isEPersonFormShown = true; - } - })); this.subs.push(this.ePeople$.pipe( switchMap((epeople: PaginatedList) => { if (epeople.pageInfo.totalElements > 0) { - return combineLatest([...epeople.page.map((eperson: EPerson) => { + return combineLatest(epeople.page.map((eperson: EPerson) => { return this.authorizationService.isAuthorized(FeatureID.CanDelete, hasValue(eperson) ? eperson.self : undefined).pipe( map((authorized) => { const epersonDtoModel: EpersonDtoModel = new EpersonDtoModel(); @@ -133,7 +123,7 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy { return epersonDtoModel; }) ); - })]).pipe(map((dtos: EpersonDtoModel[]) => { + })).pipe(map((dtos: EpersonDtoModel[]) => { return buildPaginatedList(epeople.pageInfo, dtos); })); } else { @@ -160,14 +150,14 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy { const query: string = data.query; const scope: string = data.scope; if (query != null && this.currentSearchQuery !== query) { - this.router.navigate([this.epersonService.getEPeoplePageRouterLink()], { + void this.router.navigate([getEPersonsRoute()], { queryParamsHandling: 'merge' }); this.currentSearchQuery = query; this.paginationService.resetPage(this.config.id); } if (scope != null && this.currentSearchScope !== scope) { - this.router.navigate([this.epersonService.getEPeoplePageRouterLink()], { + void this.router.navigate([getEPersonsRoute()], { queryParamsHandling: 'merge' }); this.currentSearchScope = scope; @@ -205,30 +195,13 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy { return this.epersonService.getActiveEPerson(); } - /** - * Start editing the selected EPerson - * @param ePerson - */ - toggleEditEPerson(ePerson: EPerson) { - this.getActiveEPerson().pipe(take(1)).subscribe((activeEPerson: EPerson) => { - if (ePerson === activeEPerson) { - this.epersonService.cancelEditEPerson(); - this.isEPersonFormShown = false; - } else { - this.epersonService.editEPerson(ePerson); - this.isEPersonFormShown = true; - } - }); - this.scrollToTop(); - } - /** * Deletes EPerson, show notification on success/failure & updates EPeople list */ deleteEPerson(ePerson: EPerson) { if (hasValue(ePerson.id)) { const modalRef = this.modalService.open(ConfirmationModalComponent); - modalRef.componentInstance.dso = ePerson; + modalRef.componentInstance.name = this.dsoNameService.getName(ePerson); modalRef.componentInstance.headerLabel = 'confirmation-modal.delete-eperson.header'; modalRef.componentInstance.infoLabel = 'confirmation-modal.delete-eperson.info'; modalRef.componentInstance.cancelLabel = 'confirmation-modal.delete-eperson.cancel'; @@ -242,7 +215,7 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy { if (restResponse.hasSucceeded) { this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', {name: this.dsoNameService.getName(ePerson)})); } else { - this.notificationsService.error('Error occured when trying to delete EPerson with id: ' + ePerson.id + ' with code: ' + restResponse.statusCode + ' and message: ' + restResponse.errorMessage); + this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.deleted.success', { id: ePerson.id, statusCode: restResponse.statusCode, errorMessage: restResponse.errorMessage })); } }); } @@ -264,16 +237,6 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy { this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); } - scrollToTop() { - (function smoothscroll() { - const currentScroll = document.documentElement.scrollTop || document.body.scrollTop; - if (currentScroll > 0) { - window.requestAnimationFrame(smoothscroll); - window.scrollTo(0, currentScroll - (currentScroll / 8)); - } - })(); - } - /** * Reset all input-fields to be empty and search all search */ @@ -284,20 +247,7 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy { this.search({query: ''}); } - /** - * This method will set everything to stale, which will cause the lists on this page to update. - */ - reset(): void { - this.epersonService.getBrowseEndpoint().pipe( - take(1), - switchMap((href: string) => { - return this.requestService.setStaleByHrefSubstring(href).pipe( - take(1), - ); - }) - ).subscribe(()=>{ - this.epersonService.cancelEditEPerson(); - this.isEPersonFormShown = false; - }); + getEditEPeoplePage(id: string): string { + return getEPersonEditRoute(id); } } 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 228449a8a5..747d30bb89 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 @@ -1,89 +1,97 @@ -
+
+
+
- -

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

-
+
- -

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

-
+ +

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

+
- -
- -
-
- -
-
- - -
- -
+ +

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

+
- + +
+ +
+
+ +
+
+ + +
+ +
-
-
{{messagePrefix + '.groupsEPersonIsMemberOf' | translate}}
+ - +
+

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

- + -
- - - - - - - - - - - - - - - -
{{messagePrefix + '.table.id' | translate}}{{messagePrefix + '.table.name' | translate}}{{messagePrefix + '.table.collectionOrCommunity' | translate}}
{{group.id}} - - {{ dsoNameService.getName(group) }} - - {{ dsoNameService.getName(undefined) }}
-
+ - +
+ + + + + + + + + + + + + + + +
{{messagePrefix + '.table.id' | translate}}{{messagePrefix + '.table.name' | translate}}{{messagePrefix + '.table.collectionOrCommunity' | translate}}
{{group.id}} + + {{ dsoNameService.getName(group) }} + + {{ dsoNameService.getName(undefined) }}
+
-
diff --git a/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.spec.ts b/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.spec.ts index fb911e709c..b9aeeb0af2 100644 --- a/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.spec.ts +++ b/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.spec.ts @@ -31,6 +31,10 @@ import { PaginationServiceStub } from '../../../shared/testing/pagination-servic import { FindListOptions } from '../../../core/data/find-list-options.model'; import { ValidateEmailNotTaken } from './validators/email-taken.validator'; import { EpersonRegistrationService } from '../../../core/data/eperson-registration.service'; +import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; +import { ActivatedRoute, Router } from '@angular/router'; +import { RouterStub } from '../../../shared/testing/router.stub'; +import { ActivatedRouteStub } from '../../../shared/testing/active-router.stub'; describe('EPersonFormComponent', () => { let component: EPersonFormComponent; @@ -43,6 +47,8 @@ describe('EPersonFormComponent', () => { let authorizationService: AuthorizationDataService; let groupsDataService: GroupDataService; let epersonRegistrationService: EpersonRegistrationService; + let route: ActivatedRouteStub; + let router: RouterStub; let paginationService; @@ -106,6 +112,9 @@ describe('EPersonFormComponent', () => { }, getEPersonByEmail(email): Observable> { return createSuccessfulRemoteDataObject$(null); + }, + findById(_id: string, _useCachedVersionIfAvailable = true, _reRequestOnStale = true, ..._linksToFollow: FollowLinkConfig[]): Observable> { + return createSuccessfulRemoteDataObject$(null); } }; builderService = Object.assign(getMockFormBuilderService(),{ @@ -182,6 +191,8 @@ describe('EPersonFormComponent', () => { }); paginationService = new PaginationServiceStub(); + route = new ActivatedRouteStub(); + router = new RouterStub(); TestBed.configureTestingModule({ imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule, TranslateModule.forRoot({ @@ -202,6 +213,8 @@ describe('EPersonFormComponent', () => { { provide: PaginationService, useValue: paginationService }, { provide: RequestService, useValue: jasmine.createSpyObj('requestService', ['removeByHrefSubstring'])}, { provide: EpersonRegistrationService, useValue: epersonRegistrationService }, + { provide: ActivatedRoute, useValue: route }, + { provide: Router, useValue: router }, EPeopleRegistryComponent ], schemas: [NO_ERRORS_SCHEMA] @@ -263,24 +276,18 @@ describe('EPersonFormComponent', () => { fixture.detectChanges(); }); describe('firstName, lastName and email should be required', () => { - it('form should be invalid because the firstName is required', waitForAsync(() => { - fixture.whenStable().then(() => { - expect(component.formGroup.controls.firstName.valid).toBeFalse(); - expect(component.formGroup.controls.firstName.errors.required).toBeTrue(); - }); - })); - it('form should be invalid because the lastName is required', waitForAsync(() => { - fixture.whenStable().then(() => { - expect(component.formGroup.controls.lastName.valid).toBeFalse(); - expect(component.formGroup.controls.lastName.errors.required).toBeTrue(); - }); - })); - it('form should be invalid because the email is required', waitForAsync(() => { - fixture.whenStable().then(() => { - expect(component.formGroup.controls.email.valid).toBeFalse(); - expect(component.formGroup.controls.email.errors.required).toBeTrue(); - }); - })); + it('form should be invalid because the firstName is required', () => { + expect(component.formGroup.controls.firstName.valid).toBeFalse(); + expect(component.formGroup.controls.firstName.errors.required).toBeTrue(); + }); + it('form should be invalid because the lastName is required', () => { + expect(component.formGroup.controls.lastName.valid).toBeFalse(); + expect(component.formGroup.controls.lastName.errors.required).toBeTrue(); + }); + it('form should be invalid because the email is required', () => { + expect(component.formGroup.controls.email.valid).toBeFalse(); + expect(component.formGroup.controls.email.errors.required).toBeTrue(); + }); }); describe('after inserting information firstName,lastName and email not required', () => { @@ -290,24 +297,18 @@ describe('EPersonFormComponent', () => { component.formGroup.controls.email.setValue('test@test.com'); fixture.detectChanges(); }); - it('firstName should be valid because the firstName is set', waitForAsync(() => { - fixture.whenStable().then(() => { + it('firstName should be valid because the firstName is set', () => { expect(component.formGroup.controls.firstName.valid).toBeTrue(); expect(component.formGroup.controls.firstName.errors).toBeNull(); - }); - })); - it('lastName should be valid because the lastName is set', waitForAsync(() => { - fixture.whenStable().then(() => { + }); + it('lastName should be valid because the lastName is set', () => { expect(component.formGroup.controls.lastName.valid).toBeTrue(); expect(component.formGroup.controls.lastName.errors).toBeNull(); - }); - })); - it('email should be valid because the email is set', waitForAsync(() => { - fixture.whenStable().then(() => { + }); + it('email should be valid because the email is set', () => { expect(component.formGroup.controls.email.valid).toBeTrue(); expect(component.formGroup.controls.email.errors).toBeNull(); - }); - })); + }); }); @@ -316,12 +317,10 @@ describe('EPersonFormComponent', () => { component.formGroup.controls.email.setValue('test@test'); fixture.detectChanges(); }); - it('email should not be valid because the email pattern', waitForAsync(() => { - fixture.whenStable().then(() => { + it('email should not be valid because the email pattern', () => { expect(component.formGroup.controls.email.valid).toBeFalse(); expect(component.formGroup.controls.email.errors.pattern).toBeTruthy(); - }); - })); + }); }); describe('after already utilized email', () => { @@ -336,12 +335,10 @@ describe('EPersonFormComponent', () => { fixture.detectChanges(); }); - it('email should not be valid because email is already taken', waitForAsync(() => { - fixture.whenStable().then(() => { + it('email should not be valid because email is already taken', () => { expect(component.formGroup.controls.email.valid).toBeFalse(); expect(component.formGroup.controls.email.errors.emailTaken).toBeTruthy(); - }); - })); + }); }); @@ -393,11 +390,9 @@ describe('EPersonFormComponent', () => { fixture.detectChanges(); }); - it('should emit a new eperson using the correct values', waitForAsync(() => { - fixture.whenStable().then(() => { - expect(component.submitForm.emit).toHaveBeenCalledWith(expected); - }); - })); + it('should emit a new eperson using the correct values', () => { + expect(component.submitForm.emit).toHaveBeenCalledWith(expected); + }); }); describe('with an active eperson', () => { @@ -428,11 +423,9 @@ describe('EPersonFormComponent', () => { fixture.detectChanges(); }); - it('should emit the existing eperson using the correct values', waitForAsync(() => { - fixture.whenStable().then(() => { - expect(component.submitForm.emit).toHaveBeenCalledWith(expectedWithId); - }); - })); + it('should emit the existing eperson using the correct values', () => { + expect(component.submitForm.emit).toHaveBeenCalledWith(expectedWithId); + }); }); }); @@ -491,16 +484,16 @@ describe('EPersonFormComponent', () => { }); - it('the delete button should be active if the eperson can be deleted', () => { + it('the delete button should be visible if the ePerson can be deleted', () => { const deleteButton = fixture.debugElement.query(By.css('.delete-button')); - expect(deleteButton.nativeElement.disabled).toBe(false); + expect(deleteButton).not.toBeNull(); }); - it('the delete button should be disabled if the eperson cannot be deleted', () => { + it('the delete button should be hidden if the ePerson cannot be deleted', () => { component.canDelete$ = observableOf(false); fixture.detectChanges(); const deleteButton = fixture.debugElement.query(By.css('.delete-button')); - expect(deleteButton.nativeElement.disabled).toBe(true); + expect(deleteButton).toBeNull(); }); it('should call the epersonFormComponent delete when clicked on the button', () => { 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 d009d56058..94bca29d31 100644 --- a/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.ts +++ b/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.ts @@ -38,6 +38,8 @@ import { Registration } from '../../../core/shared/registration.model'; import { EpersonRegistrationService } from '../../../core/data/eperson-registration.service'; import { TYPE_REQUEST_FORGOT } from '../../../register-email-form/register-email-form.component'; import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; +import { ActivatedRoute, Router } from '@angular/router'; +import { getEPersonsRoute } from '../../access-control-routing-paths'; @Component({ selector: 'ds-eperson-form', @@ -194,6 +196,8 @@ export class EPersonFormComponent implements OnInit, OnDestroy { public requestService: RequestService, private epersonRegistrationService: EpersonRegistrationService, public dsoNameService: DSONameService, + protected route: ActivatedRoute, + protected router: Router, ) { this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => { this.epersonInitial = eperson; @@ -213,7 +217,9 @@ export class EPersonFormComponent implements OnInit, OnDestroy { * This method will initialise the page */ initialisePage() { - + this.subs.push(this.epersonService.findById(this.route.snapshot.params.id).subscribe((ePersonRD: RemoteData) => { + this.epersonService.editEPerson(ePersonRD.payload); + })); observableCombineLatest([ this.translateService.get(`${this.messagePrefix}.firstName`), this.translateService.get(`${this.messagePrefix}.lastName`), @@ -339,6 +345,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy { onCancel() { this.epersonService.cancelEditEPerson(); this.cancelForm.emit(); + void this.router.navigate([getEPersonsRoute()]); } /** @@ -390,6 +397,8 @@ export class EPersonFormComponent implements OnInit, OnDestroy { if (rd.hasSucceeded) { this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.created.success', { name: this.dsoNameService.getName(ePersonToCreate) })); this.submitForm.emit(ePersonToCreate); + this.epersonService.clearEPersonRequests(); + void this.router.navigateByUrl(getEPersonsRoute()); } else { this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.created.failure', { name: this.dsoNameService.getName(ePersonToCreate) })); this.cancelForm.emit(); @@ -429,6 +438,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy { if (rd.hasSucceeded) { this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.edited.success', { name: this.dsoNameService.getName(editedEperson) })); this.submitForm.emit(editedEperson); + void this.router.navigateByUrl(getEPersonsRoute()); } else { this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.edited.failure', { name: this.dsoNameService.getName(editedEperson) })); this.cancelForm.emit(); @@ -468,7 +478,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy { take(1), switchMap((eperson: EPerson) => { const modalRef = this.modalService.open(ConfirmationModalComponent); - modalRef.componentInstance.dso = eperson; + modalRef.componentInstance.name = this.dsoNameService.getName(eperson); modalRef.componentInstance.headerLabel = 'confirmation-modal.delete-eperson.header'; modalRef.componentInstance.infoLabel = 'confirmation-modal.delete-eperson.info'; modalRef.componentInstance.cancelLabel = 'confirmation-modal.delete-eperson.cancel'; @@ -495,6 +505,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy { ).subscribe(({ restResponse, eperson }: { restResponse: RemoteData | null, eperson: EPerson }) => { if (restResponse?.hasSucceeded) { this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', { name: this.dsoNameService.getName(eperson) })); + void this.router.navigate([getEPersonsRoute()]); } else { this.notificationsService.error(`Error occurred when trying to delete EPerson with id: ${eperson?.id} with code: ${restResponse?.statusCode} and message: ${restResponse?.errorMessage}`); } @@ -541,16 +552,6 @@ export class EPersonFormComponent implements OnInit, OnDestroy { } } - /** - * This method will ensure that the page gets reset and that the cache is cleared - */ - reset() { - this.epersonService.getActiveEPerson().pipe(take(1)).subscribe((eperson: EPerson) => { - this.requestService.removeByHrefSubstring(eperson.self); - }); - this.initialisePage(); - } - /** * Checks for the given ePerson if there is already an ePerson in the system with that email * and shows notification if this is the case diff --git a/src/app/access-control/epeople-registry/eperson-resolver.service.ts b/src/app/access-control/epeople-registry/eperson-resolver.service.ts new file mode 100644 index 0000000000..1db8e70d89 --- /dev/null +++ b/src/app/access-control/epeople-registry/eperson-resolver.service.ts @@ -0,0 +1,53 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; +import { Observable } from 'rxjs'; +import { EPerson } from '../../core/eperson/models/eperson.model'; +import { RemoteData } from '../../core/data/remote-data'; +import { getFirstCompletedRemoteData } from '../../core/shared/operators'; +import { ResolvedAction } from '../../core/resolving/resolver.actions'; +import { EPersonDataService } from '../../core/eperson/eperson-data.service'; +import { Store } from '@ngrx/store'; +import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; + +export const EPERSON_EDIT_FOLLOW_LINKS: FollowLinkConfig[] = [ + followLink('groups'), +]; + +/** + * This class represents a resolver that requests a specific {@link EPerson} before the route is activated + */ +@Injectable({ + providedIn: 'root', +}) +export class EPersonResolver implements Resolve> { + + constructor( + protected ePersonService: EPersonDataService, + protected store: Store, + ) { + } + + /** + * Method for resolving a {@link EPerson} based on the parameters in the current route + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + * @param {RouterStateSnapshot} state The current RouterStateSnapshot + * @returns `Observable<>` Emits the found {@link EPerson} based on the parameters in the current + * route, or an error if something went wrong + */ + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable> { + const ePersonRD$: Observable> = this.ePersonService.findById(route.params.id, + true, + false, + ...EPERSON_EDIT_FOLLOW_LINKS, + ).pipe( + getFirstCompletedRemoteData(), + ); + + ePersonRD$.subscribe((ePersonRD: RemoteData) => { + this.store.dispatch(new ResolvedAction(state.url, ePersonRD.payload)); + }); + + return ePersonRD$; + } + +} diff --git a/src/app/access-control/group-registry/group-form/group-form.component.html b/src/app/access-control/group-registry/group-form/group-form.component.html index 77a81a8daa..491762a437 100644 --- a/src/app/access-control/group-registry/group-form/group-form.component.html +++ b/src/app/access-control/group-registry/group-form/group-form.component.html @@ -2,14 +2,14 @@
-
+
-

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

+

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

- -

+ +

-
-
-
diff --git a/src/app/access-control/group-registry/group-form/group-form.component.ts b/src/app/access-control/group-registry/group-form/group-form.component.ts index 3c0547cca5..0cad1b1dd8 100644 --- a/src/app/access-control/group-registry/group-form/group-form.component.ts +++ b/src/app/access-control/group-registry/group-form/group-form.component.ts @@ -10,7 +10,6 @@ import { } from '@ng-dynamic-forms/core'; import { TranslateService } from '@ngx-translate/core'; import { - ObservedValueOf, combineLatest as observableCombineLatest, Observable, of as observableOf, @@ -37,7 +36,7 @@ import { getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload } from '../../../core/shared/operators'; -import { AlertType } from '../../../shared/alert/aletr-type'; +import { AlertType } from '../../../shared/alert/alert-type'; import { ConfirmationModalComponent } from '../../../shared/confirmation-modal/confirmation-modal.component'; import { hasValue, isNotEmpty, hasValueOperator } from '../../../shared/empty.util'; import { FormBuilderService } from '../../../shared/form/builder/form-builder.service'; @@ -48,6 +47,7 @@ import { Operation } from 'fast-json-patch'; import { ValidateGroupExists } from './validators/group-exists.validator'; import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; import { environment } from '../../../../environments/environment'; +import { getGroupEditRoute, getGroupsRoute } from '../../access-control-routing-paths'; @Component({ selector: 'ds-group-form', @@ -165,19 +165,19 @@ export class GroupFormComponent implements OnInit, OnDestroy { this.canEdit$ = this.groupDataService.getActiveGroup().pipe( hasValueOperator(), switchMap((group: Group) => { - return observableCombineLatest( + return observableCombineLatest([ this.authorizationService.isAuthorized(FeatureID.CanDelete, isNotEmpty(group) ? group.self : undefined), this.hasLinkedDSO(group), - (isAuthorized: ObservedValueOf>, hasLinkedDSO: ObservedValueOf>) => { - return isAuthorized && !hasLinkedDSO; - }); - }) + ]).pipe( + map(([isAuthorized, hasLinkedDSO]: [boolean, boolean]) => isAuthorized && !hasLinkedDSO), + ); + }), ); - observableCombineLatest( + observableCombineLatest([ this.translateService.get(`${this.messagePrefix}.groupName`), this.translateService.get(`${this.messagePrefix}.groupCommunity`), this.translateService.get(`${this.messagePrefix}.groupDescription`) - ).subscribe(([groupName, groupCommunity, groupDescription]) => { + ]).subscribe(([groupName, groupCommunity, groupDescription]) => { this.groupName = new DynamicInputModel({ id: 'groupName', label: groupName, @@ -215,12 +215,12 @@ export class GroupFormComponent implements OnInit, OnDestroy { } this.subs.push( - observableCombineLatest( + observableCombineLatest([ this.groupDataService.getActiveGroup(), this.canEdit$, this.groupDataService.getActiveGroup() .pipe(filter((activeGroup) => hasValue(activeGroup)),switchMap((activeGroup) => this.getLinkedDSO(activeGroup).pipe(getFirstSucceededRemoteDataPayload()))) - ).subscribe(([activeGroup, canEdit, linkedObject]) => { + ]).subscribe(([activeGroup, canEdit, linkedObject]) => { if (activeGroup != null) { @@ -230,12 +230,14 @@ export class GroupFormComponent implements OnInit, OnDestroy { this.groupBeingEdited = activeGroup; if (linkedObject?.name) { - this.formBuilderService.insertFormGroupControl(1, this.formGroup, this.formModel, this.groupCommunity); - this.formGroup.patchValue({ - groupName: activeGroup.name, - groupCommunity: linkedObject?.name ?? '', - groupDescription: activeGroup.firstMetadataValue('dc.description'), - }); + if (!this.formGroup.controls.groupCommunity) { + this.formBuilderService.insertFormGroupControl(1, this.formGroup, this.formModel, this.groupCommunity); + this.formGroup.patchValue({ + groupName: activeGroup.name, + groupCommunity: linkedObject?.name ?? '', + groupDescription: activeGroup.firstMetadataValue('dc.description'), + }); + } } else { this.formModel = [ this.groupName, @@ -263,7 +265,7 @@ export class GroupFormComponent implements OnInit, OnDestroy { onCancel() { this.groupDataService.cancelEditGroup(); this.cancelForm.emit(); - this.router.navigate([this.groupDataService.getGroupRegistryRouterLink()]); + void this.router.navigate([getGroupsRoute()]); } /** @@ -310,7 +312,7 @@ export class GroupFormComponent implements OnInit, OnDestroy { const groupSelfLink = rd.payload._links.self.href; this.setActiveGroupWithLink(groupSelfLink); this.groupDataService.clearGroupsRequests(); - this.router.navigateByUrl(this.groupDataService.getGroupEditPageRouterLinkWithID(rd.payload.uuid)); + void this.router.navigateByUrl(getGroupEditRoute(rd.payload.uuid)); } } else { this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.created.failure', { name: groupToCreate.name })); @@ -418,7 +420,7 @@ export class GroupFormComponent implements OnInit, OnDestroy { delete() { this.groupDataService.getActiveGroup().pipe(take(1)).subscribe((group: Group) => { const modalRef = this.modalService.open(ConfirmationModalComponent); - modalRef.componentInstance.dso = group; + modalRef.componentInstance.name = this.dsoNameService.getName(group); modalRef.componentInstance.headerLabel = this.messagePrefix + '.delete-group.modal.header'; modalRef.componentInstance.infoLabel = this.messagePrefix + '.delete-group.modal.info'; modalRef.componentInstance.cancelLabel = this.messagePrefix + '.delete-group.modal.cancel'; diff --git a/src/app/access-control/group-registry/group-form/members-list/members-list.component.html b/src/app/access-control/group-registry/group-form/members-list/members-list.component.html index cc9bf34d64..fd7e776472 100644 --- a/src/app/access-control/group-registry/group-form/members-list/members-list.component.html +++ b/src/app/access-control/group-registry/group-form/members-list/members-list.component.html @@ -1,110 +1,12 @@ -

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

+

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

- +

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

-
-
- -
-
-
- - - - -
-
-
- -
-
- - - -
- - - - - - - - - - - - - - - - - -
{{messagePrefix + '.table.id' | translate}}{{messagePrefix + '.table.name' | translate}}{{messagePrefix + '.table.identity' | translate}}{{messagePrefix + '.table.edit' | translate}}
{{ePerson.eperson.id}} - - {{ dsoNameService.getName(ePerson.eperson) }} - - - {{messagePrefix + '.table.email' | translate}}: {{ ePerson.eperson.email ? ePerson.eperson.email : '-' }}
- {{messagePrefix + '.table.netid' | translate}}: {{ ePerson.eperson.netid ? ePerson.eperson.netid : '-' }} -
-
- - - -
-
-
- -
- - - -

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

- - @@ -119,32 +21,104 @@ - - {{ePerson.eperson.id}} + + {{eperson.id}} - - {{ dsoNameService.getName(ePerson.eperson) }} + + {{ dsoNameService.getName(eperson) }} - {{messagePrefix + '.table.email' | translate}}: {{ ePerson.eperson.email ? ePerson.eperson.email : '-' }}
- {{messagePrefix + '.table.netid' | translate}}: {{ ePerson.eperson.netid ? ePerson.eperson.netid : '-' }} + {{messagePrefix + '.table.email' | translate}}: {{ eperson.email ? eperson.email : '-' }}
+ {{messagePrefix + '.table.netid' | translate}}: {{ eperson.netid ? eperson.netid : '-' }}
- -
+ +
+ + + + + +
+
+
+ + + + +
+
+
+ +
+
+ + + +
+ + + + + + + + + + + + + + +
{{messagePrefix + '.table.id' | translate}}{{messagePrefix + '.table.name' | translate}}{{messagePrefix + '.table.identity' | translate}}{{messagePrefix + '.table.edit' | translate}}
{{eperson.id}} + + {{ dsoNameService.getName(eperson) }} + + + {{messagePrefix + '.table.email' | translate}}: {{ eperson.email ? eperson.email : '-' }}
+ {{messagePrefix + '.table.netid' | translate}}: {{ eperson.netid ? eperson.netid : '-' }} +
+
+
@@ -156,9 +130,10 @@ - diff --git a/src/app/access-control/group-registry/group-form/members-list/members-list.component.spec.ts b/src/app/access-control/group-registry/group-form/members-list/members-list.component.spec.ts index 7c8db399bc..99ee9827e8 100644 --- a/src/app/access-control/group-registry/group-form/members-list/members-list.component.spec.ts +++ b/src/app/access-control/group-registry/group-form/members-list/members-list.component.spec.ts @@ -17,7 +17,7 @@ import { Group } from '../../../../core/eperson/models/group.model'; import { PageInfo } from '../../../../core/shared/page-info.model'; import { FormBuilderService } from '../../../../shared/form/builder/form-builder.service'; import { NotificationsService } from '../../../../shared/notifications/notifications.service'; -import { GroupMock, GroupMock2 } from '../../../../shared/testing/group-mock'; +import { GroupMock } from '../../../../shared/testing/group-mock'; import { MembersListComponent } from './members-list.component'; import { EPersonMock, EPersonMock2 } from '../../../../shared/testing/eperson.mock'; import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils'; @@ -39,28 +39,26 @@ describe('MembersListComponent', () => { let ePersonDataServiceStub: any; let groupsDataServiceStub: any; let activeGroup; - let allEPersons: EPerson[]; - let allGroups: Group[]; let epersonMembers: EPerson[]; - let subgroupMembers: Group[]; + let epersonNonMembers: EPerson[]; let paginationService; beforeEach(waitForAsync(() => { activeGroup = GroupMock; epersonMembers = [EPersonMock2]; - subgroupMembers = [GroupMock2]; - allEPersons = [EPersonMock, EPersonMock2]; - allGroups = [GroupMock, GroupMock2]; + epersonNonMembers = [EPersonMock]; ePersonDataServiceStub = { activeGroup: activeGroup, epersonMembers: epersonMembers, - subgroupMembers: subgroupMembers, + epersonNonMembers: epersonNonMembers, + // This method is used to get all the current members findListByHref(_href: string): Observable>> { return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), groupsDataServiceStub.getEPersonMembers())); }, - searchByScope(scope: string, query: string): Observable>> { + // This method is used to search across *non-members* + searchNonMembers(query: string, group: string): Observable>> { if (query === '') { - return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), allEPersons)); + return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), epersonNonMembers)); } return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])); }, @@ -70,29 +68,26 @@ describe('MembersListComponent', () => { clearLinkRequests() { // empty }, - getEPeoplePageRouterLink(): string { - return '/access-control/epeople'; - } }; groupsDataServiceStub = { activeGroup: activeGroup, epersonMembers: epersonMembers, - subgroupMembers: subgroupMembers, - allGroups: allGroups, + epersonNonMembers: epersonNonMembers, getActiveGroup(): Observable { return observableOf(activeGroup); }, getEPersonMembers() { return this.epersonMembers; }, - searchGroups(query: string): Observable>> { - if (query === '') { - return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), this.allGroups)); - } - return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])); - }, - addMemberToGroup(parentGroup, eperson: EPerson): Observable { - this.epersonMembers = [...this.epersonMembers, eperson]; + addMemberToGroup(parentGroup, epersonToAdd: EPerson): Observable { + // Add eperson to list of members + this.epersonMembers = [...this.epersonMembers, epersonToAdd]; + // Remove eperson from list of non-members + this.epersonNonMembers.forEach( (eperson: EPerson, index: number) => { + if (eperson.id === epersonToAdd.id) { + this.epersonNonMembers.splice(index, 1); + } + }); return observableOf(new RestResponse(true, 200, 'Success')); }, clearGroupsRequests() { @@ -105,14 +100,14 @@ describe('MembersListComponent', () => { return '/access-control/groups/' + group.id; }, deleteMemberFromGroup(parentGroup, epersonToDelete: EPerson): Observable { - this.epersonMembers = this.epersonMembers.find((eperson: EPerson) => { - if (eperson.id !== epersonToDelete.id) { - return eperson; + // Remove eperson from list of members + this.epersonMembers.forEach( (eperson: EPerson, index: number) => { + if (eperson.id === epersonToDelete.id) { + this.epersonMembers.splice(index, 1); } }); - if (this.epersonMembers === undefined) { - this.epersonMembers = []; - } + // Add eperson to list of non-members + this.epersonNonMembers = [...this.epersonNonMembers, epersonToDelete]; return observableOf(new RestResponse(true, 200, 'Success')); } }; @@ -160,13 +155,37 @@ describe('MembersListComponent', () => { expect(comp).toBeDefined(); })); - it('should show list of eperson members of current active group', () => { - const epersonIdsFound = fixture.debugElement.queryAll(By.css('#ePeopleMembersOfGroup tr td:first-child')); - expect(epersonIdsFound.length).toEqual(1); - epersonMembers.map((eperson: EPerson) => { - expect(epersonIdsFound.find((foundEl) => { - return (foundEl.nativeElement.textContent.trim() === eperson.uuid); - })).toBeTruthy(); + describe('current members list', () => { + it('should show list of eperson members of current active group', () => { + const epersonIdsFound = fixture.debugElement.queryAll(By.css('#ePeopleMembersOfGroup tr td:first-child')); + expect(epersonIdsFound.length).toEqual(1); + epersonMembers.map((eperson: EPerson) => { + expect(epersonIdsFound.find((foundEl) => { + return (foundEl.nativeElement.textContent.trim() === eperson.uuid); + })).toBeTruthy(); + }); + }); + + it('should show a delete button next to each member', () => { + const epersonsFound = fixture.debugElement.queryAll(By.css('#ePeopleMembersOfGroup tbody tr')); + epersonsFound.map((foundEPersonRowElement: DebugElement) => { + const addButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-plus')); + const deleteButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-trash-alt')); + expect(addButton).toBeNull(); + expect(deleteButton).not.toBeNull(); + }); + }); + + describe('if first delete button is pressed', () => { + beforeEach(() => { + const deleteButton: DebugElement = fixture.debugElement.query(By.css('#ePeopleMembersOfGroup tbody .fa-trash-alt')); + deleteButton.nativeElement.click(); + fixture.detectChanges(); + }); + it('then no ePerson remains as a member of the active group.', () => { + const epersonsFound = fixture.debugElement.queryAll(By.css('#ePeopleMembersOfGroup tbody tr')); + expect(epersonsFound.length).toEqual(0); + }); }); }); @@ -174,76 +193,40 @@ describe('MembersListComponent', () => { describe('when searching without query', () => { let epersonsFound: DebugElement[]; beforeEach(fakeAsync(() => { - spyOn(component, 'isMemberOfGroup').and.callFake((ePerson: EPerson) => { - return observableOf(activeGroup.epersons.includes(ePerson)); - }); component.search({ scope: 'metadata', query: '' }); tick(); fixture.detectChanges(); epersonsFound = fixture.debugElement.queryAll(By.css('#epersonsSearch tbody tr')); - // Stop using the fake spy function (because otherwise the clicking on the buttons will not change anything - // because they don't change the value of activeGroup.epersons) - jasmine.getEnv().allowRespy(true); - spyOn(component, 'isMemberOfGroup').and.callThrough(); })); - it('should display all epersons', () => { - expect(epersonsFound.length).toEqual(2); + it('should display only non-members of the group', () => { + const epersonIdsFound = fixture.debugElement.queryAll(By.css('#epersonsSearch tbody tr td:first-child')); + expect(epersonIdsFound.length).toEqual(1); + epersonNonMembers.map((eperson: EPerson) => { + expect(epersonIdsFound.find((foundEl) => { + return (foundEl.nativeElement.textContent.trim() === eperson.uuid); + })).toBeTruthy(); + }); }); - describe('if eperson is already a eperson', () => { - it('should have delete button, else it should have add button', () => { - const memberIds: string[] = activeGroup.epersons.map((ePerson: EPerson) => ePerson.id); - epersonsFound.map((foundEPersonRowElement: DebugElement) => { - const epersonId: DebugElement = foundEPersonRowElement.query(By.css('td:first-child')); - const addButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-plus')); - const deleteButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-trash-alt')); - if (memberIds.includes(epersonId.nativeElement.textContent)) { - expect(addButton).toBeNull(); - expect(deleteButton).not.toBeNull(); - } else { - expect(deleteButton).toBeNull(); - expect(addButton).not.toBeNull(); - } - }); + it('should display an add button next to non-members, not a delete button', () => { + epersonsFound.map((foundEPersonRowElement: DebugElement) => { + const addButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-plus')); + const deleteButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-trash-alt')); + expect(addButton).not.toBeNull(); + expect(deleteButton).toBeNull(); }); }); describe('if first add button is pressed', () => { - beforeEach(fakeAsync(() => { + beforeEach(() => { const addButton: DebugElement = fixture.debugElement.query(By.css('#epersonsSearch tbody .fa-plus')); addButton.nativeElement.click(); - tick(); fixture.detectChanges(); - })); - it('then all the ePersons are member of the active group', () => { - epersonsFound = fixture.debugElement.queryAll(By.css('#epersonsSearch tbody tr')); - expect(epersonsFound.length).toEqual(2); - epersonsFound.map((foundEPersonRowElement: DebugElement) => { - const addButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-plus')); - const deleteButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-trash-alt')); - expect(addButton).toBeNull(); - expect(deleteButton).not.toBeNull(); - }); }); - }); - - describe('if first delete button is pressed', () => { - beforeEach(fakeAsync(() => { - const deleteButton: DebugElement = fixture.debugElement.query(By.css('#epersonsSearch tbody .fa-trash-alt')); - deleteButton.nativeElement.click(); - tick(); - fixture.detectChanges(); - })); - it('then no ePerson is member of the active group', () => { + it('then all (two) ePersons are member of the active group. No non-members left', () => { epersonsFound = fixture.debugElement.queryAll(By.css('#epersonsSearch tbody tr')); - expect(epersonsFound.length).toEqual(2); - epersonsFound.map((foundEPersonRowElement: DebugElement) => { - const addButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-plus')); - const deleteButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-trash-alt')); - expect(deleteButton).toBeNull(); - expect(addButton).not.toBeNull(); - }); + expect(epersonsFound.length).toEqual(0); }); }); }); diff --git a/src/app/access-control/group-registry/group-form/members-list/members-list.component.ts b/src/app/access-control/group-registry/group-form/members-list/members-list.component.ts index b3e686c012..6129d4d02d 100644 --- a/src/app/access-control/group-registry/group-form/members-list/members-list.component.ts +++ b/src/app/access-control/group-registry/group-form/members-list/members-list.component.ts @@ -4,38 +4,34 @@ import { Router } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; import { Observable, - of as observableOf, Subscription, - BehaviorSubject, - combineLatest as observableCombineLatest, - ObservedValueOf, + BehaviorSubject } from 'rxjs'; -import { defaultIfEmpty, map, mergeMap, switchMap, take } from 'rxjs/operators'; -import { buildPaginatedList, PaginatedList } from '../../../../core/data/paginated-list.model'; +import { map, switchMap, take } from 'rxjs/operators'; +import { PaginatedList } from '../../../../core/data/paginated-list.model'; import { RemoteData } from '../../../../core/data/remote-data'; import { EPersonDataService } from '../../../../core/eperson/eperson-data.service'; import { GroupDataService } from '../../../../core/eperson/group-data.service'; import { EPerson } from '../../../../core/eperson/models/eperson.model'; import { Group } from '../../../../core/eperson/models/group.model'; import { - getFirstSucceededRemoteData, getFirstCompletedRemoteData, getAllCompletedRemoteData, getRemoteDataPayload } from '../../../../core/shared/operators'; import { NotificationsService } from '../../../../shared/notifications/notifications.service'; import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model'; -import { EpersonDtoModel } from '../../../../core/eperson/models/eperson-dto.model'; import { PaginationService } from '../../../../core/pagination/pagination.service'; import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service'; +import { getEPersonEditRoute } from '../../../access-control-routing-paths'; /** * Keys to keep track of specific subscriptions */ enum SubKey { ActiveGroup, - MembersDTO, - SearchResultsDTO, + Members, + SearchResults, } /** @@ -96,11 +92,11 @@ export class MembersListComponent implements OnInit, OnDestroy { /** * EPeople being displayed in search result, initially all members, after search result of search */ - ePeopleSearchDtos: BehaviorSubject> = new BehaviorSubject>(undefined); + ePeopleSearch: BehaviorSubject> = new BehaviorSubject>(undefined); /** * List of EPeople members of currently active group being edited */ - ePeopleMembersOfGroupDtos: BehaviorSubject> = new BehaviorSubject>(undefined); + ePeopleMembersOfGroup: BehaviorSubject> = new BehaviorSubject>(undefined); /** * Pagination config used to display the list of EPeople that are result of EPeople search @@ -129,7 +125,6 @@ export class MembersListComponent implements OnInit, OnDestroy { // Current search in edit group - epeople search form currentSearchQuery: string; - currentSearchScope: string; // Whether or not user has done a EPeople search yet searchDone: boolean; @@ -137,6 +132,8 @@ export class MembersListComponent implements OnInit, OnDestroy { // current active group being edited groupBeingEdited: Group; + readonly getEPersonEditRoute = getEPersonEditRoute; + constructor( protected groupDataService: GroupDataService, public ePersonDataService: EPersonDataService, @@ -148,18 +145,17 @@ export class MembersListComponent implements OnInit, OnDestroy { public dsoNameService: DSONameService, ) { this.currentSearchQuery = ''; - this.currentSearchScope = 'metadata'; } ngOnInit(): void { this.searchForm = this.formBuilder.group(({ - scope: 'metadata', query: '', })); this.subs.set(SubKey.ActiveGroup, this.groupDataService.getActiveGroup().subscribe((activeGroup: Group) => { if (activeGroup != null) { this.groupBeingEdited = activeGroup; this.retrieveMembers(this.config.currentPage); + this.search({query: ''}); } })); } @@ -171,8 +167,8 @@ export class MembersListComponent implements OnInit, OnDestroy { * @private */ retrieveMembers(page: number): void { - this.unsubFrom(SubKey.MembersDTO); - this.subs.set(SubKey.MembersDTO, + this.unsubFrom(SubKey.Members); + this.subs.set(SubKey.Members, this.paginationService.getCurrentPagination(this.config.id, this.config).pipe( switchMap((currentPagination) => { return this.ePersonDataService.findListByHref(this.groupBeingEdited._links.epersons.href, { @@ -189,49 +185,12 @@ export class MembersListComponent implements OnInit, OnDestroy { return rd; } }), - switchMap((epersonListRD: RemoteData>) => { - const dtos$ = observableCombineLatest([...epersonListRD.payload.page.map((member: EPerson) => { - const dto$: Observable = observableCombineLatest( - this.isMemberOfGroup(member), (isMember: ObservedValueOf>) => { - const epersonDtoModel: EpersonDtoModel = new EpersonDtoModel(); - epersonDtoModel.eperson = member; - epersonDtoModel.memberOfGroup = isMember; - return epersonDtoModel; - }); - return dto$; - })]); - return dtos$.pipe(defaultIfEmpty([]), map((dtos: EpersonDtoModel[]) => { - return buildPaginatedList(epersonListRD.payload.pageInfo, dtos); - })); - })) - .subscribe((paginatedListOfDTOs: PaginatedList) => { - this.ePeopleMembersOfGroupDtos.next(paginatedListOfDTOs); + getRemoteDataPayload()) + .subscribe((paginatedListOfEPersons: PaginatedList) => { + this.ePeopleMembersOfGroup.next(paginatedListOfEPersons); })); } - /** - * Whether the given ePerson is a member of the group currently being edited - * @param possibleMember EPerson that is a possible member (being tested) of the group currently being edited - */ - isMemberOfGroup(possibleMember: EPerson): Observable { - return this.groupDataService.getActiveGroup().pipe(take(1), - mergeMap((group: Group) => { - if (group != null) { - return this.ePersonDataService.findListByHref(group._links.epersons.href, { - currentPage: 1, - elementsPerPage: 9999 - }) - .pipe( - getFirstSucceededRemoteData(), - getRemoteDataPayload(), - map((listEPeopleInGroup: PaginatedList) => listEPeopleInGroup.page.filter((ePersonInList: EPerson) => ePersonInList.id === possibleMember.id)), - map((epeople: EPerson[]) => epeople.length > 0)); - } else { - return observableOf(false); - } - })); - } - /** * Unsubscribe from a subscription if it's still subscribed, and remove it from the map of * active subscriptions @@ -248,14 +207,18 @@ export class MembersListComponent implements OnInit, OnDestroy { /** * Deletes a given EPerson from the members list of the group currently being edited - * @param ePerson EPerson we want to delete as member from group that is currently being edited + * @param eperson EPerson we want to delete as member from group that is currently being edited */ - deleteMemberFromGroup(ePerson: EpersonDtoModel) { - ePerson.memberOfGroup = false; + deleteMemberFromGroup(eperson: EPerson) { this.groupDataService.getActiveGroup().pipe(take(1)).subscribe((activeGroup: Group) => { if (activeGroup != null) { - const response = this.groupDataService.deleteMemberFromGroup(activeGroup, ePerson.eperson); - this.showNotifications('deleteMember', response, this.dsoNameService.getName(ePerson.eperson), activeGroup); + const response = this.groupDataService.deleteMemberFromGroup(activeGroup, eperson); + this.showNotifications('deleteMember', response, this.dsoNameService.getName(eperson), activeGroup); + // Reload search results (if there is an active query). + // This will potentially add this deleted subgroup into the list of search results. + if (this.currentSearchQuery != null) { + this.search({query: this.currentSearchQuery}); + } } else { this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.noActiveGroup')); } @@ -264,14 +227,18 @@ export class MembersListComponent implements OnInit, OnDestroy { /** * Adds a given EPerson to the members list of the group currently being edited - * @param ePerson EPerson we want to add as member to group that is currently being edited + * @param eperson EPerson we want to add as member to group that is currently being edited */ - addMemberToGroup(ePerson: EpersonDtoModel) { - ePerson.memberOfGroup = true; + addMemberToGroup(eperson: EPerson) { this.groupDataService.getActiveGroup().pipe(take(1)).subscribe((activeGroup: Group) => { if (activeGroup != null) { - const response = this.groupDataService.addMemberToGroup(activeGroup, ePerson.eperson); - this.showNotifications('addMember', response, this.dsoNameService.getName(ePerson.eperson), activeGroup); + const response = this.groupDataService.addMemberToGroup(activeGroup, eperson); + this.showNotifications('addMember', response, this.dsoNameService.getName(eperson), activeGroup); + // Reload search results (if there is an active query). + // This will potentially add this deleted subgroup into the list of search results. + if (this.currentSearchQuery != null) { + this.search({query: this.currentSearchQuery}); + } } else { this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.noActiveGroup')); } @@ -279,37 +246,25 @@ export class MembersListComponent implements OnInit, OnDestroy { } /** - * Search in the EPeople by name, email or metadata - * @param data Contains scope and query param + * Search all EPeople who are NOT a member of the current group by name, email or metadata + * @param data Contains query param */ search(data: any) { - this.unsubFrom(SubKey.SearchResultsDTO); - this.subs.set(SubKey.SearchResultsDTO, + this.unsubFrom(SubKey.SearchResults); + this.subs.set(SubKey.SearchResults, this.paginationService.getCurrentPagination(this.configSearch.id, this.configSearch).pipe( switchMap((paginationOptions) => { - const query: string = data.query; - const scope: string = data.scope; if (query != null && this.currentSearchQuery !== query && this.groupBeingEdited) { - this.router.navigate([], { - queryParamsHandling: 'merge' - }); this.currentSearchQuery = query; this.paginationService.resetPage(this.configSearch.id); } - if (scope != null && this.currentSearchScope !== scope && this.groupBeingEdited) { - this.router.navigate([], { - queryParamsHandling: 'merge' - }); - this.currentSearchScope = scope; - this.paginationService.resetPage(this.configSearch.id); - } this.searchDone = true; - return this.ePersonDataService.searchByScope(this.currentSearchScope, this.currentSearchQuery, { + return this.ePersonDataService.searchNonMembers(this.currentSearchQuery, this.groupBeingEdited.id, { currentPage: paginationOptions.currentPage, elementsPerPage: paginationOptions.pageSize - }); + }, false, true); }), getAllCompletedRemoteData(), map((rd: RemoteData) => { @@ -319,23 +274,9 @@ export class MembersListComponent implements OnInit, OnDestroy { return rd; } }), - switchMap((epersonListRD: RemoteData>) => { - const dtos$ = observableCombineLatest([...epersonListRD.payload.page.map((member: EPerson) => { - const dto$: Observable = observableCombineLatest( - this.isMemberOfGroup(member), (isMember: ObservedValueOf>) => { - const epersonDtoModel: EpersonDtoModel = new EpersonDtoModel(); - epersonDtoModel.eperson = member; - epersonDtoModel.memberOfGroup = isMember; - return epersonDtoModel; - }); - return dto$; - })]); - return dtos$.pipe(defaultIfEmpty([]), map((dtos: EpersonDtoModel[]) => { - return buildPaginatedList(epersonListRD.payload.pageInfo, dtos); - })); - })) - .subscribe((paginatedListOfDTOs: PaginatedList) => { - this.ePeopleSearchDtos.next(paginatedListOfDTOs); + getRemoteDataPayload()) + .subscribe((paginatedListOfEPersons: PaginatedList) => { + this.ePeopleSearch.next(paginatedListOfEPersons); })); } diff --git a/src/app/access-control/group-registry/group-form/subgroup-list/subgroups-list.component.html b/src/app/access-control/group-registry/group-form/subgroup-list/subgroups-list.component.html index d009f0283e..85fe8974ed 100644 --- a/src/app/access-control/group-registry/group-form/subgroup-list/subgroups-list.component.html +++ b/src/app/access-control/group-registry/group-form/subgroup-list/subgroups-list.component.html @@ -1,6 +1,55 @@

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

+

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

+ + + +
+ + + + + + + + + + + + + + + + + +
{{messagePrefix + '.table.id' | translate}}{{messagePrefix + '.table.name' | translate}}{{messagePrefix + '.table.collectionOrCommunity' | translate}}{{messagePrefix + '.table.edit' | translate}}
{{group.id}} + + {{ dsoNameService.getName(group) }} + + {{ dsoNameService.getName((group.object | async)?.payload)}} +
+ +
+
+
+
+ + +
- - - {{ messagePrefix + '.table.edit.currentGroup' | translate }} - -
-

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

- - - -
- - - - - - - - - - - - - - - - - -
{{messagePrefix + '.table.id' | translate}}{{messagePrefix + '.table.name' | translate}}{{messagePrefix + '.table.collectionOrCommunity' | translate}}{{messagePrefix + '.table.edit' | translate}}
{{group.id}} - - {{ dsoNameService.getName(group) }} - - {{ dsoNameService.getName((group.object | async)?.payload)}} -
- -
-
-
-
- - - diff --git a/src/app/access-control/group-registry/group-form/subgroup-list/subgroups-list.component.spec.ts b/src/app/access-control/group-registry/group-form/subgroup-list/subgroups-list.component.spec.ts index ac5750dcac..6fe7c2cf67 100644 --- a/src/app/access-control/group-registry/group-form/subgroup-list/subgroups-list.component.spec.ts +++ b/src/app/access-control/group-registry/group-form/subgroup-list/subgroups-list.component.spec.ts @@ -1,12 +1,12 @@ import { CommonModule } from '@angular/common'; import { NO_ERRORS_SCHEMA, DebugElement } from '@angular/core'; -import { ComponentFixture, fakeAsync, flush, inject, TestBed, tick, waitForAsync } from '@angular/core/testing'; +import { ComponentFixture, fakeAsync, flush, inject, TestBed, waitForAsync } from '@angular/core/testing'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { BrowserModule, By } from '@angular/platform-browser'; import { Router } from '@angular/router'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; -import { Observable, of as observableOf, BehaviorSubject } from 'rxjs'; +import { Observable, of as observableOf } from 'rxjs'; import { RestResponse } from '../../../../core/cache/response.models'; import { buildPaginatedList, PaginatedList } from '../../../../core/data/paginated-list.model'; import { RemoteData } from '../../../../core/data/remote-data'; @@ -18,19 +18,18 @@ import { NotificationsService } from '../../../../shared/notifications/notificat import { GroupMock, GroupMock2 } from '../../../../shared/testing/group-mock'; import { SubgroupsListComponent } from './subgroups-list.component'; import { - createSuccessfulRemoteDataObject$, - createSuccessfulRemoteDataObject + createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils'; import { RouterMock } from '../../../../shared/mocks/router.mock'; import { getMockFormBuilderService } from '../../../../shared/mocks/form-builder-service.mock'; import { getMockTranslateService } from '../../../../shared/mocks/translate.service.mock'; import { TranslateLoaderMock } from '../../../../shared/testing/translate-loader.mock'; import { NotificationsServiceStub } from '../../../../shared/testing/notifications-service.stub'; -import { map } from 'rxjs/operators'; import { PaginationService } from '../../../../core/pagination/pagination.service'; import { PaginationServiceStub } from '../../../../shared/testing/pagination-service.stub'; import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service'; import { DSONameServiceMock } from '../../../../shared/mocks/dso-name.service.mock'; +import { EPersonMock2 } from 'src/app/shared/testing/eperson.mock'; describe('SubgroupsListComponent', () => { let component: SubgroupsListComponent; @@ -39,44 +38,70 @@ describe('SubgroupsListComponent', () => { let builderService: FormBuilderService; let ePersonDataServiceStub: any; let groupsDataServiceStub: any; - let activeGroup; + let activeGroup: Group; let subgroups: Group[]; - let allGroups: Group[]; + let groupNonMembers: Group[]; let routerStub; let paginationService; + // Define a new mock activegroup for all tests below + let mockActiveGroup: Group = Object.assign(new Group(), { + handle: null, + subgroups: [GroupMock2], + epersons: [EPersonMock2], + selfRegistered: false, + permanent: false, + _links: { + self: { + href: 'https://rest.api/server/api/eperson/groups/activegroupid', + }, + subgroups: { href: 'https://rest.api/server/api/eperson/groups/activegroupid/subgroups' }, + object: { href: 'https://rest.api/server/api/eperson/groups/activegroupid/object' }, + epersons: { href: 'https://rest.api/server/api/eperson/groups/activegroupid/epersons' } + }, + _name: 'activegroupname', + id: 'activegroupid', + uuid: 'activegroupid', + type: 'group', + }); beforeEach(waitForAsync(() => { - activeGroup = GroupMock; + activeGroup = mockActiveGroup; subgroups = [GroupMock2]; - allGroups = [GroupMock, GroupMock2]; + groupNonMembers = [GroupMock]; ePersonDataServiceStub = {}; groupsDataServiceStub = { activeGroup: activeGroup, - subgroups$: new BehaviorSubject(subgroups), + subgroups: subgroups, + groupNonMembers: groupNonMembers, getActiveGroup(): Observable { return observableOf(this.activeGroup); }, getSubgroups(): Group { - return this.activeGroup; + return this.subgroups; }, + // This method is used to get all the current subgroups findListByHref(_href: string): Observable>> { - return this.subgroups$.pipe( - map((currentGroups: Group[]) => { - return createSuccessfulRemoteDataObject(buildPaginatedList(new PageInfo(), currentGroups)); - }) - ); + return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), groupsDataServiceStub.getSubgroups())); }, getGroupEditPageRouterLink(group: Group): string { return '/access-control/groups/' + group.id; }, - searchGroups(query: string): Observable>> { + // This method is used to get all groups which are NOT currently a subgroup member + searchNonMemberGroups(query: string, group: string): Observable>> { if (query === '') { - return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), allGroups)); + return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), groupNonMembers)); } return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])); }, - addSubGroupToGroup(parentGroup, subgroup: Group): Observable { - this.subgroups$.next([...this.subgroups$.getValue(), subgroup]); + addSubGroupToGroup(parentGroup, subgroupToAdd: Group): Observable { + // Add group to list of subgroups + this.subgroups = [...this.subgroups, subgroupToAdd]; + // Remove group from list of non-members + this.groupNonMembers.forEach( (group: Group, index: number) => { + if (group.id === subgroupToAdd.id) { + this.groupNonMembers.splice(index, 1); + } + }); return observableOf(new RestResponse(true, 200, 'Success')); }, clearGroupsRequests() { @@ -85,12 +110,15 @@ describe('SubgroupsListComponent', () => { clearGroupLinkRequests() { // empty }, - deleteSubGroupFromGroup(parentGroup, subgroup: Group): Observable { - this.subgroups$.next(this.subgroups$.getValue().filter((group: Group) => { - if (group.id !== subgroup.id) { - return group; + deleteSubGroupFromGroup(parentGroup, subgroupToDelete: Group): Observable { + // Remove group from list of subgroups + this.subgroups.forEach( (group: Group, index: number) => { + if (group.id === subgroupToDelete.id) { + this.subgroups.splice(index, 1); } - })); + }); + // Add group to list of non-members + this.groupNonMembers = [...this.groupNonMembers, subgroupToDelete]; return observableOf(new RestResponse(true, 200, 'Success')); } }; @@ -99,7 +127,7 @@ describe('SubgroupsListComponent', () => { translateService = getMockTranslateService(); paginationService = new PaginationServiceStub(); - TestBed.configureTestingModule({ + return TestBed.configureTestingModule({ imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule, TranslateModule.forRoot({ loader: { @@ -137,30 +165,38 @@ describe('SubgroupsListComponent', () => { expect(comp).toBeDefined(); })); - it('should show list of subgroups of current active group', () => { - const groupIdsFound = fixture.debugElement.queryAll(By.css('#subgroupsOfGroup tr td:first-child')); - expect(groupIdsFound.length).toEqual(1); - activeGroup.subgroups.map((group: Group) => { - expect(groupIdsFound.find((foundEl) => { - return (foundEl.nativeElement.textContent.trim() === group.uuid); - })).toBeTruthy(); - }); - }); - - describe('if first group delete button is pressed', () => { - let groupsFound: DebugElement[]; - beforeEach(fakeAsync(() => { - const addButton = fixture.debugElement.query(By.css('#subgroupsOfGroup tbody .deleteButton')); - addButton.triggerEventHandler('click', { - preventDefault: () => {/**/ - } + describe('current subgroup list', () => { + it('should show list of subgroups of current active group', () => { + const groupIdsFound = fixture.debugElement.queryAll(By.css('#subgroupsOfGroup tr td:first-child')); + expect(groupIdsFound.length).toEqual(1); + subgroups.map((group: Group) => { + expect(groupIdsFound.find((foundEl) => { + return (foundEl.nativeElement.textContent.trim() === group.uuid); + })).toBeTruthy(); + }); + }); + + it('should show a delete button next to each subgroup', () => { + const subgroupsFound = fixture.debugElement.queryAll(By.css('#subgroupsOfGroup tbody tr')); + subgroupsFound.map((foundGroupRowElement: DebugElement) => { + const addButton: DebugElement = foundGroupRowElement.query(By.css('td:last-child .fa-plus')); + const deleteButton: DebugElement = foundGroupRowElement.query(By.css('td:last-child .fa-trash-alt')); + expect(addButton).toBeNull(); + expect(deleteButton).not.toBeNull(); + }); + }); + + describe('if first group delete button is pressed', () => { + let groupsFound: DebugElement[]; + beforeEach(() => { + const deleteButton = fixture.debugElement.query(By.css('#subgroupsOfGroup tbody .deleteButton')); + deleteButton.nativeElement.click(); + fixture.detectChanges(); + }); + it('then no subgroup remains as a member of the active group', () => { + groupsFound = fixture.debugElement.queryAll(By.css('#subgroupsOfGroup tbody tr')); + expect(groupsFound.length).toEqual(0); }); - tick(); - fixture.detectChanges(); - })); - it('one less subgroup in list from 1 to 0 (of 2 total groups)', () => { - groupsFound = fixture.debugElement.queryAll(By.css('#subgroupsOfGroup tbody tr')); - expect(groupsFound.length).toEqual(0); }); }); @@ -169,54 +205,38 @@ describe('SubgroupsListComponent', () => { let groupsFound: DebugElement[]; beforeEach(fakeAsync(() => { component.search({ query: '' }); + fixture.detectChanges(); groupsFound = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr')); })); - it('should display all groups', () => { - fixture.detectChanges(); - groupsFound = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr')); - expect(groupsFound.length).toEqual(2); - groupsFound = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr')); + it('should display only non-member groups (i.e. groups that are not a subgroup)', () => { const groupIdsFound: DebugElement[] = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr td:first-child')); - allGroups.map((group: Group) => { + expect(groupIdsFound.length).toEqual(1); + groupNonMembers.map((group: Group) => { expect(groupIdsFound.find((foundEl: DebugElement) => { return (foundEl.nativeElement.textContent.trim() === group.uuid); })).toBeTruthy(); }); }); - describe('if group is already a subgroup', () => { - it('should have delete button, else it should have add button', () => { + it('should display an add button next to non-member groups, not a delete button', () => { + groupsFound.map((foundGroupRowElement: DebugElement) => { + const addButton: DebugElement = foundGroupRowElement.query(By.css('td:last-child .fa-plus')); + const deleteButton: DebugElement = foundGroupRowElement.query(By.css('td:last-child .fa-trash-alt')); + expect(addButton).not.toBeNull(); + expect(deleteButton).toBeNull(); + }); + }); + + describe('if first add button is pressed', () => { + beforeEach(() => { + const addButton: DebugElement = fixture.debugElement.query(By.css('#groupsSearch tbody .fa-plus')); + addButton.nativeElement.click(); fixture.detectChanges(); + }); + it('then all (two) Groups are subgroups of the active group. No non-members left', () => { groupsFound = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr')); - const getSubgroups = groupsDataServiceStub.getSubgroups().subgroups; - if (getSubgroups !== undefined && getSubgroups.length > 0) { - groupsFound.map((foundGroupRowElement: DebugElement) => { - const groupId: DebugElement = foundGroupRowElement.query(By.css('td:first-child')); - const addButton: DebugElement = foundGroupRowElement.query(By.css('td:last-child .fa-plus')); - const deleteButton: DebugElement = foundGroupRowElement.query(By.css('td:last-child .fa-trash-alt')); - expect(addButton).toBeNull(); - if (activeGroup.id === groupId.nativeElement.textContent) { - expect(deleteButton).toBeNull(); - } else { - expect(deleteButton).not.toBeNull(); - } - }); - } else { - const subgroupIds: string[] = activeGroup.subgroups.map((group: Group) => group.id); - groupsFound.map((foundGroupRowElement: DebugElement) => { - const groupId: DebugElement = foundGroupRowElement.query(By.css('td:first-child')); - const addButton: DebugElement = foundGroupRowElement.query(By.css('td:last-child .fa-plus')); - const deleteButton: DebugElement = foundGroupRowElement.query(By.css('td:last-child .fa-trash-alt')); - if (subgroupIds.includes(groupId.nativeElement.textContent)) { - expect(addButton).toBeNull(); - expect(deleteButton).not.toBeNull(); - } else { - expect(deleteButton).toBeNull(); - expect(addButton).not.toBeNull(); - } - }); - } + expect(groupsFound.length).toEqual(0); }); }); }); diff --git a/src/app/access-control/group-registry/group-form/subgroup-list/subgroups-list.component.ts b/src/app/access-control/group-registry/group-form/subgroup-list/subgroups-list.component.ts index 0cff730c62..aea545e554 100644 --- a/src/app/access-control/group-registry/group-form/subgroup-list/subgroups-list.component.ts +++ b/src/app/access-control/group-registry/group-form/subgroup-list/subgroups-list.component.ts @@ -2,16 +2,15 @@ import { Component, Input, OnDestroy, OnInit } from '@angular/core'; import { UntypedFormBuilder } from '@angular/forms'; import { Router } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; -import { BehaviorSubject, Observable, of as observableOf, Subscription } from 'rxjs'; -import { map, mergeMap, switchMap, take } from 'rxjs/operators'; +import { BehaviorSubject, Observable, Subscription } from 'rxjs'; +import { map, switchMap, take } from 'rxjs/operators'; import { PaginatedList } from '../../../../core/data/paginated-list.model'; import { RemoteData } from '../../../../core/data/remote-data'; import { GroupDataService } from '../../../../core/eperson/group-data.service'; import { Group } from '../../../../core/eperson/models/group.model'; import { - getFirstCompletedRemoteData, - getFirstSucceededRemoteData, - getRemoteDataPayload + getAllCompletedRemoteData, + getFirstCompletedRemoteData } from '../../../../core/shared/operators'; import { NotificationsService } from '../../../../shared/notifications/notifications.service'; import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model'; @@ -103,6 +102,7 @@ export class SubgroupsListComponent implements OnInit, OnDestroy { if (activeGroup != null) { this.groupBeingEdited = activeGroup; this.retrieveSubGroups(); + this.search({query: ''}); } })); } @@ -131,47 +131,6 @@ export class SubgroupsListComponent implements OnInit, OnDestroy { })); } - /** - * Whether or not the given group is a subgroup of the group currently being edited - * @param possibleSubgroup Group that is a possible subgroup (being tested) of the group currently being edited - */ - isSubgroupOfGroup(possibleSubgroup: Group): Observable { - return this.groupDataService.getActiveGroup().pipe(take(1), - mergeMap((activeGroup: Group) => { - if (activeGroup != null) { - if (activeGroup.uuid === possibleSubgroup.uuid) { - return observableOf(false); - } else { - return this.groupDataService.findListByHref(activeGroup._links.subgroups.href, { - currentPage: 1, - elementsPerPage: 9999 - }) - .pipe( - getFirstSucceededRemoteData(), - getRemoteDataPayload(), - map((listTotalGroups: PaginatedList) => listTotalGroups.page.filter((groupInList: Group) => groupInList.id === possibleSubgroup.id)), - map((groups: Group[]) => groups.length > 0)); - } - } else { - return observableOf(false); - } - })); - } - - /** - * Whether or not the given group is the current group being edited - * @param group Group that is possibly the current group being edited - */ - isActiveGroup(group: Group): Observable { - return this.groupDataService.getActiveGroup().pipe(take(1), - mergeMap((activeGroup: Group) => { - if (activeGroup != null && activeGroup.uuid === group.uuid) { - return observableOf(true); - } - return observableOf(false); - })); - } - /** * Deletes given subgroup from the group currently being edited * @param subgroup Group we want to delete from the subgroups of the group currently being edited @@ -181,6 +140,11 @@ export class SubgroupsListComponent implements OnInit, OnDestroy { if (activeGroup != null) { const response = this.groupDataService.deleteSubGroupFromGroup(activeGroup, subgroup); this.showNotifications('deleteSubgroup', response, this.dsoNameService.getName(subgroup), activeGroup); + // Reload search results (if there is an active query). + // This will potentially add this deleted subgroup into the list of search results. + if (this.currentSearchQuery != null) { + this.search({query: this.currentSearchQuery}); + } } else { this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.noActiveGroup')); } @@ -197,6 +161,11 @@ export class SubgroupsListComponent implements OnInit, OnDestroy { if (activeGroup.uuid !== subgroup.uuid) { const response = this.groupDataService.addSubGroupToGroup(activeGroup, subgroup); this.showNotifications('addSubgroup', response, this.dsoNameService.getName(subgroup), activeGroup); + // Reload search results (if there is an active query). + // This will potentially remove this added subgroup from search results. + if (this.currentSearchQuery != null) { + this.search({query: this.currentSearchQuery}); + } } else { this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.subgroupToAddIsActiveGroup')); } @@ -207,28 +176,38 @@ export class SubgroupsListComponent implements OnInit, OnDestroy { } /** - * Search in the groups (searches by group name and by uuid exact match) + * Search all non-member groups (searches by group name and by uuid exact match). Used to search for + * groups that could be added to current group as a subgroup. * @param data Contains query param */ search(data: any) { - const query: string = data.query; - if (query != null && this.currentSearchQuery !== query) { - this.router.navigateByUrl(this.groupDataService.getGroupEditPageRouterLink(this.groupBeingEdited)); - this.currentSearchQuery = query; - this.configSearch.currentPage = 1; - } - this.searchDone = true; - this.unsubFrom(SubKey.SearchResults); - this.subs.set(SubKey.SearchResults, this.paginationService.getCurrentPagination(this.configSearch.id, this.configSearch).pipe( - switchMap((config) => this.groupDataService.searchGroups(this.currentSearchQuery, { - currentPage: config.currentPage, - elementsPerPage: config.pageSize - }, true, true, followLink('object') - )) - ).subscribe((rd: RemoteData>) => { - this.searchResults$.next(rd); - })); + this.subs.set(SubKey.SearchResults, + this.paginationService.getCurrentPagination(this.configSearch.id, this.configSearch).pipe( + switchMap((paginationOptions) => { + const query: string = data.query; + if (query != null && this.currentSearchQuery !== query && this.groupBeingEdited) { + this.currentSearchQuery = query; + this.paginationService.resetPage(this.configSearch.id); + } + this.searchDone = true; + + return this.groupDataService.searchNonMemberGroups(this.currentSearchQuery, this.groupBeingEdited.id, { + currentPage: paginationOptions.currentPage, + elementsPerPage: paginationOptions.pageSize + }, false, true, followLink('object')); + }), + getAllCompletedRemoteData(), + map((rd: RemoteData) => { + if (rd.hasFailed) { + this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure', { cause: rd.errorMessage })); + } else { + return rd; + } + })) + .subscribe((rd: RemoteData>) => { + this.searchResults$.next(rd); + })); } /** diff --git a/src/app/access-control/group-registry/groups-registry.component.html b/src/app/access-control/group-registry/groups-registry.component.html index 828aadc95a..bd39cbe94f 100644 --- a/src/app/access-control/group-registry/groups-registry.component.html +++ b/src/app/access-control/group-registry/groups-registry.component.html @@ -2,17 +2,17 @@
- +

{{messagePrefix + 'head' | translate}}

- +
diff --git a/src/app/access-control/group-registry/groups-registry.component.ts b/src/app/access-control/group-registry/groups-registry.component.ts index ccfd155e39..06a048ad72 100644 --- a/src/app/access-control/group-registry/groups-registry.component.ts +++ b/src/app/access-control/group-registry/groups-registry.component.ts @@ -216,18 +216,28 @@ export class GroupsRegistryComponent implements OnInit, OnDestroy { /** * Get the members (epersons embedded value of a group) + * NOTE: At this time we only grab the *first* member in order to receive the `totalElements` value + * needed for our HTML template. * @param group */ getMembers(group: Group): Observable>> { - return this.ePersonDataService.findListByHref(group._links.epersons.href).pipe(getFirstSucceededRemoteData()); + return this.ePersonDataService.findListByHref(group._links.epersons.href, { + currentPage: 1, + elementsPerPage: 1, + }).pipe(getFirstSucceededRemoteData()); } /** * Get the subgroups (groups embedded value of a group) + * NOTE: At this time we only grab the *first* subgroup in order to receive the `totalElements` value + * needed for our HTML template. * @param group */ getSubgroups(group: Group): Observable>> { - return this.groupService.findListByHref(group._links.subgroups.href).pipe(getFirstSucceededRemoteData()); + return this.groupService.findListByHref(group._links.subgroups.href, { + currentPage: 1, + elementsPerPage: 1, + }).pipe(getFirstSucceededRemoteData()); } /** diff --git a/src/app/admin/admin-curation-tasks/admin-curation-tasks.component.html b/src/app/admin/admin-curation-tasks/admin-curation-tasks.component.html index a702a7e6b0..ba3a6f45d7 100644 --- a/src/app/admin/admin-curation-tasks/admin-curation-tasks.component.html +++ b/src/app/admin/admin-curation-tasks/admin-curation-tasks.component.html @@ -1,4 +1,4 @@
-

{{'admin.curation-tasks.header' |translate }}

+

{{'admin.curation-tasks.header' |translate }}

diff --git a/src/app/admin/admin-import-batch-page/batch-import-page.component.html b/src/app/admin/admin-import-batch-page/batch-import-page.component.html index 1092443436..c51ee7597c 100644 --- a/src/app/admin/admin-import-batch-page/batch-import-page.component.html +++ b/src/app/admin/admin-import-batch-page/batch-import-page.component.html @@ -1,5 +1,5 @@
- +

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

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

selected collection: {{getDspaceObjectName()}}  @@ -28,7 +28,7 @@ {{'admin.batch-import.page.toggle.help' | translate}} - + -

+

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

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

diff --git a/src/app/admin/admin-notifications/admin-notifications-routing-paths.ts b/src/app/admin/admin-notifications/admin-notifications-routing-paths.ts new file mode 100644 index 0000000000..2820a9a2c7 --- /dev/null +++ b/src/app/admin/admin-notifications/admin-notifications-routing-paths.ts @@ -0,0 +1,8 @@ +import { URLCombiner } from '../../core/url-combiner/url-combiner'; +import { getNotificationsModuleRoute } from '../admin-routing-paths'; + +export const QUALITY_ASSURANCE_EDIT_PATH = 'quality-assurance'; + +export function getQualityAssuranceRoute(id: string) { + return new URLCombiner(getNotificationsModuleRoute(), QUALITY_ASSURANCE_EDIT_PATH, id).toString(); +} diff --git a/src/app/admin/admin-notifications/admin-notifications-routing.module.ts b/src/app/admin/admin-notifications/admin-notifications-routing.module.ts new file mode 100644 index 0000000000..63d555d7b7 --- /dev/null +++ b/src/app/admin/admin-notifications/admin-notifications-routing.module.ts @@ -0,0 +1,87 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; + +import { AuthenticatedGuard } from '../../core/auth/authenticated.guard'; +import { I18nBreadcrumbResolver } from '../../core/breadcrumbs/i18n-breadcrumb.resolver'; +import { I18nBreadcrumbsService } from '../../core/breadcrumbs/i18n-breadcrumbs.service'; +import { QUALITY_ASSURANCE_EDIT_PATH } from './admin-notifications-routing-paths'; +import { AdminQualityAssuranceTopicsPageComponent } from './admin-quality-assurance-topics-page/admin-quality-assurance-topics-page.component'; +import { AdminQualityAssuranceEventsPageComponent } from './admin-quality-assurance-events-page/admin-quality-assurance-events-page.component'; +import { AdminQualityAssuranceTopicsPageResolver } from './admin-quality-assurance-topics-page/admin-quality-assurance-topics-page-resolver.service'; +import { AdminQualityAssuranceEventsPageResolver } from './admin-quality-assurance-events-page/admin-quality-assurance-events-page.resolver'; +import { AdminQualityAssuranceSourcePageComponent } from './admin-quality-assurance-source-page-component/admin-quality-assurance-source-page.component'; +import { AdminQualityAssuranceSourcePageResolver } from './admin-quality-assurance-source-page-component/admin-quality-assurance-source-page-resolver.service'; +import { QualityAssuranceBreadcrumbResolver } from '../../core/breadcrumbs/quality-assurance-breadcrumb.resolver'; +import { QualityAssuranceBreadcrumbService } from '../../core/breadcrumbs/quality-assurance-breadcrumb.service'; +import { + SourceDataResolver +} from './admin-quality-assurance-source-page-component/admin-quality-assurance-source-data.resolver'; + +@NgModule({ + imports: [ + RouterModule.forChild([ + { + canActivate: [ AuthenticatedGuard ], + path: `${QUALITY_ASSURANCE_EDIT_PATH}/:sourceId`, + component: AdminQualityAssuranceTopicsPageComponent, + pathMatch: 'full', + resolve: { + breadcrumb: QualityAssuranceBreadcrumbResolver, + openaireQualityAssuranceTopicsParams: AdminQualityAssuranceTopicsPageResolver + }, + data: { + title: 'admin.quality-assurance.page.title', + breadcrumbKey: 'admin.quality-assurance', + showBreadcrumbsFluid: false + } + }, + { + canActivate: [ AuthenticatedGuard ], + path: `${QUALITY_ASSURANCE_EDIT_PATH}`, + component: AdminQualityAssuranceSourcePageComponent, + pathMatch: 'full', + resolve: { + breadcrumb: I18nBreadcrumbResolver, + openaireQualityAssuranceSourceParams: AdminQualityAssuranceSourcePageResolver, + sourceData: SourceDataResolver + }, + data: { + title: 'admin.notifications.source.breadcrumbs', + breadcrumbKey: 'admin.notifications.source', + showBreadcrumbsFluid: false + } + }, + { + canActivate: [ AuthenticatedGuard ], + path: `${QUALITY_ASSURANCE_EDIT_PATH}/:sourceId/:topicId`, + component: AdminQualityAssuranceEventsPageComponent, + pathMatch: 'full', + resolve: { + breadcrumb: QualityAssuranceBreadcrumbResolver, + openaireQualityAssuranceEventsParams: AdminQualityAssuranceEventsPageResolver + }, + data: { + title: 'admin.notifications.event.page.title', + breadcrumbKey: 'admin.notifications.event', + showBreadcrumbsFluid: false + } + } + ]) + ], + providers: [ + I18nBreadcrumbResolver, + I18nBreadcrumbsService, + SourceDataResolver, + AdminQualityAssuranceTopicsPageResolver, + AdminQualityAssuranceEventsPageResolver, + AdminQualityAssuranceSourcePageResolver, + QualityAssuranceBreadcrumbResolver, + QualityAssuranceBreadcrumbService + ] +}) +/** + * Routing module for the Notifications section of the admin sidebar + */ +export class AdminNotificationsRoutingModule { + +} diff --git a/src/app/admin/admin-notifications/admin-notifications.module.ts b/src/app/admin/admin-notifications/admin-notifications.module.ts new file mode 100644 index 0000000000..84475a1623 --- /dev/null +++ b/src/app/admin/admin-notifications/admin-notifications.module.ts @@ -0,0 +1,31 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { CoreModule } from '../../core/core.module'; +import { SharedModule } from '../../shared/shared.module'; +import { AdminNotificationsRoutingModule } from './admin-notifications-routing.module'; +import { AdminQualityAssuranceTopicsPageComponent } from './admin-quality-assurance-topics-page/admin-quality-assurance-topics-page.component'; +import { AdminQualityAssuranceEventsPageComponent } from './admin-quality-assurance-events-page/admin-quality-assurance-events-page.component'; +import { AdminQualityAssuranceSourcePageComponent } from './admin-quality-assurance-source-page-component/admin-quality-assurance-source-page.component'; +import {NotificationsModule} from '../../notifications/notifications.module'; + +@NgModule({ + imports: [ + CommonModule, + SharedModule, + CoreModule.forRoot(), + AdminNotificationsRoutingModule, + NotificationsModule + ], + declarations: [ + AdminQualityAssuranceTopicsPageComponent, + AdminQualityAssuranceEventsPageComponent, + AdminQualityAssuranceSourcePageComponent + ], + entryComponents: [] +}) +/** + * This module handles all components related to the notifications pages + */ +export class AdminNotificationsModule { + +} diff --git a/src/app/admin/admin-notifications/admin-quality-assurance-events-page/admin-quality-assurance-events-page.component.html b/src/app/admin/admin-notifications/admin-quality-assurance-events-page/admin-quality-assurance-events-page.component.html new file mode 100644 index 0000000000..315209d342 --- /dev/null +++ b/src/app/admin/admin-notifications/admin-quality-assurance-events-page/admin-quality-assurance-events-page.component.html @@ -0,0 +1 @@ + diff --git a/src/app/admin/admin-notifications/admin-quality-assurance-events-page/admin-quality-assurance-events-page.component.spec.ts b/src/app/admin/admin-notifications/admin-quality-assurance-events-page/admin-quality-assurance-events-page.component.spec.ts new file mode 100644 index 0000000000..b952078215 --- /dev/null +++ b/src/app/admin/admin-notifications/admin-quality-assurance-events-page/admin-quality-assurance-events-page.component.spec.ts @@ -0,0 +1,26 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { AdminQualityAssuranceEventsPageComponent } from './admin-quality-assurance-events-page.component'; + +describe('AdminQualityAssuranceEventsPageComponent', () => { + let component: AdminQualityAssuranceEventsPageComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ AdminQualityAssuranceEventsPageComponent ], + schemas: [NO_ERRORS_SCHEMA] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AdminQualityAssuranceEventsPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create AdminQualityAssuranceEventsPageComponent', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/admin/admin-notifications/admin-quality-assurance-events-page/admin-quality-assurance-events-page.component.ts b/src/app/admin/admin-notifications/admin-quality-assurance-events-page/admin-quality-assurance-events-page.component.ts new file mode 100644 index 0000000000..bd3470f301 --- /dev/null +++ b/src/app/admin/admin-notifications/admin-quality-assurance-events-page/admin-quality-assurance-events-page.component.ts @@ -0,0 +1,12 @@ +import { Component } from '@angular/core'; + +/** + * Component for the page that show the QA events related to a specific topic. + */ +@Component({ + selector: 'ds-quality-assurance-events-page', + templateUrl: './admin-quality-assurance-events-page.component.html' +}) +export class AdminQualityAssuranceEventsPageComponent { + +} diff --git a/src/app/admin/admin-notifications/admin-quality-assurance-events-page/admin-quality-assurance-events-page.resolver.ts b/src/app/admin/admin-notifications/admin-quality-assurance-events-page/admin-quality-assurance-events-page.resolver.ts new file mode 100644 index 0000000000..3139355629 --- /dev/null +++ b/src/app/admin/admin-notifications/admin-quality-assurance-events-page/admin-quality-assurance-events-page.resolver.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@angular/core'; +import { Resolve, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; + +/** + * Interface for the route parameters. + */ +export interface AdminQualityAssuranceEventsPageParams { + pageId?: string; + pageSize?: number; + currentPage?: number; +} + +/** + * This class represents a resolver that retrieve the route data before the route is activated. + */ +@Injectable() +export class AdminQualityAssuranceEventsPageResolver implements Resolve { + + /** + * Method for resolving the parameters in the current route. + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + * @param {RouterStateSnapshot} state The current RouterStateSnapshot + * @returns AdminQualityAssuranceEventsPageParams Emits the route parameters + */ + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): AdminQualityAssuranceEventsPageParams { + return { + pageId: route.queryParams.pageId, + pageSize: parseInt(route.queryParams.pageSize, 10), + currentPage: parseInt(route.queryParams.page, 10) + }; + } +} diff --git a/src/app/admin/admin-notifications/admin-quality-assurance-source-page-component/admin-quality-assurance-source-data.resolver.ts b/src/app/admin/admin-notifications/admin-quality-assurance-source-page-component/admin-quality-assurance-source-data.resolver.ts new file mode 100644 index 0000000000..a6bfd6e7fe --- /dev/null +++ b/src/app/admin/admin-notifications/admin-quality-assurance-source-page-component/admin-quality-assurance-source-data.resolver.ts @@ -0,0 +1,47 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot, Router } from '@angular/router'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { PaginatedList } from '../../../core/data/paginated-list.model'; +import { QualityAssuranceSourceObject } from '../../../core/notifications/qa/models/quality-assurance-source.model'; +import { QualityAssuranceSourceService } from '../../../notifications/qa/source/quality-assurance-source.service'; +import {environment} from '../../../../environments/environment'; +/** + * This class represents a resolver that retrieve the route data before the route is activated. + */ +@Injectable() +export class SourceDataResolver implements Resolve> { + private pageSize = environment.qualityAssuranceConfig.pageSize; + /** + * Initialize the effect class variables. + * @param {QualityAssuranceSourceService} qualityAssuranceSourceService + */ + constructor( + private qualityAssuranceSourceService: QualityAssuranceSourceService, + private router: Router + ) { } + /** + * Method for resolving the parameters in the current route. + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + * @param {RouterStateSnapshot} state The current RouterStateSnapshot + * @returns Observable + */ + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + return this.qualityAssuranceSourceService.getSources(this.pageSize, 0).pipe( + map((sources: PaginatedList) => { + if (sources.page.length === 1) { + this.router.navigate([this.getResolvedUrl(route) + '/' + sources.page[0].id]); + } + return sources.page; + })); + } + + /** + * + * @param route url path + * @returns url path + */ + getResolvedUrl(route: ActivatedRouteSnapshot): string { + return route.pathFromRoot.map(v => v.url.map(segment => segment.toString()).join('/')).join('/'); + } +} diff --git a/src/app/admin/admin-notifications/admin-quality-assurance-source-page-component/admin-quality-assurance-source-page-resolver.service.ts b/src/app/admin/admin-notifications/admin-quality-assurance-source-page-component/admin-quality-assurance-source-page-resolver.service.ts new file mode 100644 index 0000000000..ac9bdb48d6 --- /dev/null +++ b/src/app/admin/admin-notifications/admin-quality-assurance-source-page-component/admin-quality-assurance-source-page-resolver.service.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; + +/** + * Interface for the route parameters. + */ +export interface AdminQualityAssuranceSourcePageParams { + pageId?: string; + pageSize?: number; + currentPage?: number; +} + +/** + * This class represents a resolver that retrieve the route data before the route is activated. + */ +@Injectable() +export class AdminQualityAssuranceSourcePageResolver implements Resolve { + + /** + * Method for resolving the parameters in the current route. + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + * @param {RouterStateSnapshot} state The current RouterStateSnapshot + * @returns AdminQualityAssuranceSourcePageParams Emits the route parameters + */ + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): AdminQualityAssuranceSourcePageParams { + return { + pageId: route.queryParams.pageId, + pageSize: parseInt(route.queryParams.pageSize, 10), + currentPage: parseInt(route.queryParams.page, 10) + }; + } +} diff --git a/src/app/admin/admin-notifications/admin-quality-assurance-source-page-component/admin-quality-assurance-source-page.component.html b/src/app/admin/admin-notifications/admin-quality-assurance-source-page-component/admin-quality-assurance-source-page.component.html new file mode 100644 index 0000000000..709103cf3d --- /dev/null +++ b/src/app/admin/admin-notifications/admin-quality-assurance-source-page-component/admin-quality-assurance-source-page.component.html @@ -0,0 +1 @@ + diff --git a/src/app/admin/admin-notifications/admin-quality-assurance-source-page-component/admin-quality-assurance-source-page.component.spec.ts b/src/app/admin/admin-notifications/admin-quality-assurance-source-page-component/admin-quality-assurance-source-page.component.spec.ts new file mode 100644 index 0000000000..451c911c4c --- /dev/null +++ b/src/app/admin/admin-notifications/admin-quality-assurance-source-page-component/admin-quality-assurance-source-page.component.spec.ts @@ -0,0 +1,27 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AdminQualityAssuranceSourcePageComponent } from './admin-quality-assurance-source-page.component'; + +describe('AdminQualityAssuranceSourcePageComponent', () => { + let component: AdminQualityAssuranceSourcePageComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ AdminQualityAssuranceSourcePageComponent ], + schemas: [NO_ERRORS_SCHEMA] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(AdminQualityAssuranceSourcePageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create AdminQualityAssuranceSourcePageComponent', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/admin/admin-notifications/admin-quality-assurance-source-page-component/admin-quality-assurance-source-page.component.ts b/src/app/admin/admin-notifications/admin-quality-assurance-source-page-component/admin-quality-assurance-source-page.component.ts new file mode 100644 index 0000000000..447e5a2e55 --- /dev/null +++ b/src/app/admin/admin-notifications/admin-quality-assurance-source-page-component/admin-quality-assurance-source-page.component.ts @@ -0,0 +1,10 @@ +import { Component } from '@angular/core'; + +/** + * Component for the page that show the QA sources. + */ +@Component({ + selector: 'ds-admin-quality-assurance-source-page-component', + templateUrl: './admin-quality-assurance-source-page.component.html', +}) +export class AdminQualityAssuranceSourcePageComponent {} diff --git a/src/app/admin/admin-notifications/admin-quality-assurance-topics-page/admin-quality-assurance-topics-page-resolver.service.ts b/src/app/admin/admin-notifications/admin-quality-assurance-topics-page/admin-quality-assurance-topics-page-resolver.service.ts new file mode 100644 index 0000000000..47500d1878 --- /dev/null +++ b/src/app/admin/admin-notifications/admin-quality-assurance-topics-page/admin-quality-assurance-topics-page-resolver.service.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; + +/** + * Interface for the route parameters. + */ +export interface AdminQualityAssuranceTopicsPageParams { + pageId?: string; + pageSize?: number; + currentPage?: number; +} + +/** + * This class represents a resolver that retrieve the route data before the route is activated. + */ +@Injectable() +export class AdminQualityAssuranceTopicsPageResolver implements Resolve { + + /** + * Method for resolving the parameters in the current route. + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + * @param {RouterStateSnapshot} state The current RouterStateSnapshot + * @returns AdminQualityAssuranceTopicsPageParams Emits the route parameters + */ + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): AdminQualityAssuranceTopicsPageParams { + return { + pageId: route.queryParams.pageId, + pageSize: parseInt(route.queryParams.pageSize, 10), + currentPage: parseInt(route.queryParams.page, 10) + }; + } +} diff --git a/src/app/admin/admin-notifications/admin-quality-assurance-topics-page/admin-quality-assurance-topics-page.component.html b/src/app/admin/admin-notifications/admin-quality-assurance-topics-page/admin-quality-assurance-topics-page.component.html new file mode 100644 index 0000000000..fc905ad724 --- /dev/null +++ b/src/app/admin/admin-notifications/admin-quality-assurance-topics-page/admin-quality-assurance-topics-page.component.html @@ -0,0 +1 @@ + diff --git a/src/app/admin/admin-notifications/admin-quality-assurance-topics-page/admin-quality-assurance-topics-page.component.spec.ts b/src/app/admin/admin-notifications/admin-quality-assurance-topics-page/admin-quality-assurance-topics-page.component.spec.ts new file mode 100644 index 0000000000..a32f60f017 --- /dev/null +++ b/src/app/admin/admin-notifications/admin-quality-assurance-topics-page/admin-quality-assurance-topics-page.component.spec.ts @@ -0,0 +1,26 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { AdminQualityAssuranceTopicsPageComponent } from './admin-quality-assurance-topics-page.component'; + +describe('AdminQualityAssuranceTopicsPageComponent', () => { + let component: AdminQualityAssuranceTopicsPageComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ AdminQualityAssuranceTopicsPageComponent ], + schemas: [NO_ERRORS_SCHEMA] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AdminQualityAssuranceTopicsPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create AdminQualityAssuranceTopicsPageComponent', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/admin/admin-notifications/admin-quality-assurance-topics-page/admin-quality-assurance-topics-page.component.ts b/src/app/admin/admin-notifications/admin-quality-assurance-topics-page/admin-quality-assurance-topics-page.component.ts new file mode 100644 index 0000000000..f17d3448d5 --- /dev/null +++ b/src/app/admin/admin-notifications/admin-quality-assurance-topics-page/admin-quality-assurance-topics-page.component.ts @@ -0,0 +1,12 @@ +import { Component } from '@angular/core'; + +/** + * Component for the page that show the QA topics related to a specific source. + */ +@Component({ + selector: 'ds-notification-qa-page', + templateUrl: './admin-quality-assurance-topics-page.component.html' +}) +export class AdminQualityAssuranceTopicsPageComponent { + +} diff --git a/src/app/admin/admin-registries/bitstream-formats/add-bitstream-format/add-bitstream-format.component.html b/src/app/admin/admin-registries/bitstream-formats/add-bitstream-format/add-bitstream-format.component.html index 2b65b369b2..d1be68633f 100644 --- a/src/app/admin/admin-registries/bitstream-formats/add-bitstream-format/add-bitstream-format.component.html +++ b/src/app/admin/admin-registries/bitstream-formats/add-bitstream-format/add-bitstream-format.component.html @@ -1,11 +1,11 @@
-

{{ 'admin.registries.bitstream-formats.create.new' | translate }}

+

{{ 'admin.registries.bitstream-formats.create.new' | translate }}

-
\ No newline at end of file +
diff --git a/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.html b/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.html index 0a2e9f0f92..894ef3dc7e 100644 --- a/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.html +++ b/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.html @@ -2,7 +2,7 @@
- +

{{'admin.registries.bitstream-formats.head' | translate}}

{{'admin.registries.bitstream-formats.description' | translate}}

{{'admin.registries.bitstream-formats.create.new' | translate}}

@@ -19,7 +19,7 @@ - + @@ -31,9 +31,11 @@ diff --git a/src/app/admin/admin-registries/bitstream-formats/edit-bitstream-format/edit-bitstream-format.component.html b/src/app/admin/admin-registries/bitstream-formats/edit-bitstream-format/edit-bitstream-format.component.html index f57ec9cd38..efcced2a87 100644 --- a/src/app/admin/admin-registries/bitstream-formats/edit-bitstream-format/edit-bitstream-format.component.html +++ b/src/app/admin/admin-registries/bitstream-formats/edit-bitstream-format/edit-bitstream-format.component.html @@ -1,11 +1,11 @@
-

{{'admin.registries.bitstream-formats.edit.head' | translate:{format: (bitstreamFormatRD$ | async)?.payload.shortDescription} }}

+

{{'admin.registries.bitstream-formats.edit.head' | translate:{format: (bitstreamFormatRD$ | async)?.payload.shortDescription} }}

-
\ No newline at end of file + diff --git a/src/app/admin/admin-registries/metadata-registry/metadata-registry.component.html b/src/app/admin/admin-registries/metadata-registry/metadata-registry.component.html index 35bffad185..d3be5df08e 100644 --- a/src/app/admin/admin-registries/metadata-registry/metadata-registry.component.html +++ b/src/app/admin/admin-registries/metadata-registry/metadata-registry.component.html @@ -2,7 +2,7 @@
{{'admin.registries.bitstream-formats.table.selected' | translate}} {{'admin.registries.bitstream-formats.table.id' | translate}} {{'admin.registries.bitstream-formats.table.name' | translate}} {{'admin.registries.bitstream-formats.table.mimetype' | translate}} {{bitstreamFormat.id}}
- + @@ -34,6 +34,7 @@ [checked]="isSelected(schema) | async" (change)="selectMetadataSchema(schema, $event)" > + {{((isSelected(schema) | async) ? 'admin.registries.metadata.schemas.deselect' : 'admin.registries.metadata.schemas.select') | translate}} diff --git a/src/app/admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.html b/src/app/admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.html index a4a4613565..85d1e90692 100644 --- a/src/app/admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.html +++ b/src/app/admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.html @@ -1,11 +1,11 @@
-

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

+

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

-

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

+

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

{ - const values = { - prefix: this.name.value, - namespace: this.namespace.value - }; - if (schema == null) { - this.registryService.createOrUpdateMetadataSchema(Object.assign(new MetadataSchema(), values)).subscribe((newSchema) => { - this.submitForm.emit(newSchema); + this.registryService + .getActiveMetadataSchema() + .pipe( + take(1), + switchMap((schema: MetadataSchema) => { + const metadataValues = { + prefix: this.name.value, + namespace: this.namespace.value, + }; + + let createOrUpdate$: Observable; + + if (schema == null) { + createOrUpdate$ = + this.registryService.createOrUpdateMetadataSchema( + Object.assign(new MetadataSchema(), metadataValues) + ); + } else { + const updatedSchema = Object.assign( + new MetadataSchema(), + schema, + { + namespace: metadataValues.namespace, + } + ); + createOrUpdate$ = + this.registryService.createOrUpdateMetadataSchema( + updatedSchema + ); + } + + return createOrUpdate$; + }), + tap(() => { + this.registryService.clearMetadataSchemaRequests().subscribe(); + }) + ) + .subscribe((updatedOrCreatedSchema: MetadataSchema) => { + this.submitForm.emit(updatedOrCreatedSchema); + this.clearFields(); + this.registryService.cancelEditMetadataSchema(); }); - } else { - this.registryService.createOrUpdateMetadataSchema(Object.assign(new MetadataSchema(), schema, { - id: schema.id, - prefix: schema.prefix, - namespace: values.namespace, - })).subscribe((updatedSchema: MetadataSchema) => { - this.submitForm.emit(updatedSchema); - }); - } - this.clearFields(); - this.registryService.cancelEditMetadataSchema(); - } - ); } /** diff --git a/src/app/admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.html b/src/app/admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.html index 4e29fbb6e8..44b6bfb697 100644 --- a/src/app/admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.html +++ b/src/app/admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.html @@ -1,11 +1,11 @@
-

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

+

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

-

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

+

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

- +

{{'admin.registries.schema.head' | translate}}: "{{schema?.prefix}}"

{{'admin.registries.schema.description' | translate:{ namespace: schema?.namespace } }}

@@ -10,7 +10,7 @@ [metadataSchema]="schema" (submitForm)="forceUpdateFields()"> -

{{'admin.registries.schema.fields.head' | translate}}

+

{{'admin.registries.schema.fields.head' | translate}}

- + @@ -33,15 +33,14 @@ - - + diff --git a/src/app/admin/admin-registries/metadata-schema/metadata-schema.component.ts b/src/app/admin/admin-registries/metadata-schema/metadata-schema.component.ts index d0827e6e4d..4dec129ead 100644 --- a/src/app/admin/admin-registries/metadata-schema/metadata-schema.component.ts +++ b/src/app/admin/admin-registries/metadata-schema/metadata-schema.component.ts @@ -1,6 +1,6 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, OnInit, OnDestroy } from '@angular/core'; import { RegistryService } from '../../../core/registry/registry.service'; -import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; import { BehaviorSubject, combineLatest as observableCombineLatest, @@ -32,7 +32,7 @@ import { PaginationService } from '../../../core/pagination/pagination.service'; * A component used for managing all existing metadata fields within the current metadata schema. * The admin can create, edit or delete metadata fields here. */ -export class MetadataSchemaComponent implements OnInit { +export class MetadataSchemaComponent implements OnInit, OnDestroy { /** * The metadata schema */ @@ -60,7 +60,6 @@ export class MetadataSchemaComponent implements OnInit { constructor(private registryService: RegistryService, private route: ActivatedRoute, private notificationsService: NotificationsService, - private router: Router, private paginationService: PaginationService, private translateService: TranslateService) { @@ -86,7 +85,7 @@ export class MetadataSchemaComponent implements OnInit { */ private updateFields() { this.metadataFields$ = this.paginationService.getCurrentPagination(this.config.id, this.config).pipe( - switchMap((currentPagination) => combineLatest(this.metadataSchema$, this.needsUpdate$, observableOf(currentPagination))), + switchMap((currentPagination) => combineLatest([this.metadataSchema$, this.needsUpdate$, observableOf(currentPagination)])), switchMap(([schema, update, currentPagination]: [MetadataSchema, boolean, PaginationComponentOptions]) => { if (update) { this.needsUpdate$.next(false); @@ -193,10 +192,10 @@ export class MetadataSchemaComponent implements OnInit { showNotification(success: boolean, amount: number) { const prefix = 'admin.registries.schema.notification'; const suffix = success ? 'success' : 'failure'; - const messages = observableCombineLatest( + const messages = observableCombineLatest([ this.translateService.get(success ? `${prefix}.${suffix}` : `${prefix}.${suffix}`), this.translateService.get(`${prefix}.field.deleted.${suffix}`, { amount: amount }) - ); + ]); messages.subscribe(([head, content]) => { if (success) { this.notificationsService.success(head, content); @@ -207,6 +206,7 @@ export class MetadataSchemaComponent implements OnInit { } ngOnDestroy(): void { this.paginationService.clearPagination(this.config.id); + this.registryService.deselectAllMetadataField(); } } diff --git a/src/app/admin/admin-routing-paths.ts b/src/app/admin/admin-routing-paths.ts index 3168ea93c9..30f801cecb 100644 --- a/src/app/admin/admin-routing-paths.ts +++ b/src/app/admin/admin-routing-paths.ts @@ -2,7 +2,12 @@ import { URLCombiner } from '../core/url-combiner/url-combiner'; import { getAdminModuleRoute } from '../app-routing-paths'; export const REGISTRIES_MODULE_PATH = 'registries'; +export const NOTIFICATIONS_MODULE_PATH = 'notifications'; export function getRegistriesModuleRoute() { return new URLCombiner(getAdminModuleRoute(), REGISTRIES_MODULE_PATH).toString(); } + +export function getNotificationsModuleRoute() { + return new URLCombiner(getAdminModuleRoute(), NOTIFICATIONS_MODULE_PATH).toString(); +} diff --git a/src/app/admin/admin-routing.module.ts b/src/app/admin/admin-routing.module.ts index 8e4f13b164..a7d19a6935 100644 --- a/src/app/admin/admin-routing.module.ts +++ b/src/app/admin/admin-routing.module.ts @@ -6,12 +6,17 @@ import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.reso import { AdminWorkflowPageComponent } from './admin-workflow-page/admin-workflow-page.component'; import { I18nBreadcrumbsService } from '../core/breadcrumbs/i18n-breadcrumbs.service'; import { AdminCurationTasksComponent } from './admin-curation-tasks/admin-curation-tasks.component'; -import { REGISTRIES_MODULE_PATH } from './admin-routing-paths'; +import { REGISTRIES_MODULE_PATH, NOTIFICATIONS_MODULE_PATH } from './admin-routing-paths'; import { BatchImportPageComponent } from './admin-import-batch-page/batch-import-page.component'; @NgModule({ imports: [ RouterModule.forChild([ + { + path: NOTIFICATIONS_MODULE_PATH, + loadChildren: () => import('./admin-notifications/admin-notifications.module') + .then((m) => m.AdminNotificationsModule), + }, { path: REGISTRIES_MODULE_PATH, loadChildren: () => import('./admin-registries/admin-registries.module') diff --git a/src/app/admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.ts b/src/app/admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.ts index d6cd803622..b195526d1c 100644 --- a/src/app/admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.ts +++ b/src/app/admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.ts @@ -12,8 +12,7 @@ import { Router } from '@angular/router'; * Represents a non-expandable section in the admin sidebar */ @Component({ - /* eslint-disable @angular-eslint/component-selector */ - selector: 'li[ds-admin-sidebar-section]', + selector: 'ds-admin-sidebar-section', templateUrl: './admin-sidebar-section.component.html', styleUrls: ['./admin-sidebar-section.component.scss'], diff --git a/src/app/admin/admin-sidebar/admin-sidebar.component.html b/src/app/admin/admin-sidebar/admin-sidebar.component.html index ef220b834b..ca69501382 100644 --- a/src/app/admin/admin-sidebar/admin-sidebar.component.html +++ b/src/app/admin/admin-sidebar/admin-sidebar.component.html @@ -26,30 +26,29 @@ - +
  • - +
  • diff --git a/src/app/admin/admin-sidebar/admin-sidebar.component.spec.ts b/src/app/admin/admin-sidebar/admin-sidebar.component.spec.ts index 88efd2a711..9522be29ce 100644 --- a/src/app/admin/admin-sidebar/admin-sidebar.component.spec.ts +++ b/src/app/admin/admin-sidebar/admin-sidebar.component.spec.ts @@ -143,7 +143,7 @@ describe('AdminSidebarComponent', () => { describe('when the collapse link is clicked', () => { beforeEach(() => { spyOn(menuService, 'toggleMenu'); - const sidebarToggler = fixture.debugElement.query(By.css('#sidebar-collapse-toggle > a')); + const sidebarToggler = fixture.debugElement.query(By.css('#sidebar-collapse-toggle > button')); sidebarToggler.triggerEventHandler('click', { preventDefault: () => {/**/ } diff --git a/src/app/admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.ts b/src/app/admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.ts index 4555c0fa93..8591fef97e 100644 --- a/src/app/admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.ts +++ b/src/app/admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.ts @@ -15,8 +15,7 @@ import { Router } from '@angular/router'; * Represents a expandable section in the sidebar */ @Component({ - /* eslint-disable @angular-eslint/component-selector */ - selector: 'li[ds-expandable-admin-sidebar-section]', + selector: 'ds-expandable-admin-sidebar-section', templateUrl: './expandable-admin-sidebar-section.component.html', styleUrls: ['./expandable-admin-sidebar-section.component.scss'], animations: [rotate, slide, bgColor] diff --git a/src/app/admin/admin-workflow-page/admin-workflow-search-results/actions/workspace-item/workspace-item-admin-workflow-actions.component.ts b/src/app/admin/admin-workflow-page/admin-workflow-search-results/actions/workspace-item/workspace-item-admin-workflow-actions.component.ts index 36678460da..8db862dd5a 100644 --- a/src/app/admin/admin-workflow-page/admin-workflow-search-results/actions/workspace-item/workspace-item-admin-workflow-actions.component.ts +++ b/src/app/admin/admin-workflow-page/admin-workflow-search-results/actions/workspace-item/workspace-item-admin-workflow-actions.component.ts @@ -124,7 +124,7 @@ export class WorkspaceItemAdminWorkflowActionsComponent implements OnInit { */ deleteSupervisionOrder(supervisionOrderEntry: SupervisionOrderListEntry) { const modalRef = this.modalService.open(ConfirmationModalComponent); - modalRef.componentInstance.dso = supervisionOrderEntry.group; + modalRef.componentInstance.name = this.dsoNameService.getName(supervisionOrderEntry.group); modalRef.componentInstance.headerLabel = this.messagePrefix + '.delete-supervision.modal.header'; modalRef.componentInstance.infoLabel = this.messagePrefix + '.delete-supervision.modal.info'; modalRef.componentInstance.cancelLabel = this.messagePrefix + '.delete-supervision.modal.cancel'; diff --git a/src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.html b/src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.html index af3afe98f8..1467ffb0cc 100644 --- a/src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.html +++ b/src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.html @@ -1,5 +1,5 @@
    -

    {{'bitstream.download.page' | translate:{ bitstream: dsoNameService.getName((bitstream$ | async)) } }}

    +

    {{'bitstream.download.page' | translate:{ bitstream: dsoNameService.getName((bitstream$ | async)) } }}

    diff --git a/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.html b/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.html index 4d7b3e657e..521c771cfa 100644 --- a/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.html +++ b/src/app/collection-page/edit-collection-page/collection-source/collection-source-controls/collection-source-controls.component.html @@ -1,6 +1,6 @@
    -

    {{ 'collection.source.controls.head' | translate }}

    +

    {{ 'collection.source.controls.head' | translate }}

    {{'collection.source.controls.harvest.status' | translate}} {{contentSource?.harvestStatus}} diff --git a/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.html b/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.html index d7b0d0c475..b0c155ac95 100644 --- a/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.html +++ b/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.html @@ -18,7 +18,7 @@  {{"item.edit.metadata.save-button" | translate}}
    -

    {{ 'collection.edit.tabs.source.head' | translate }}

    +

    {{ 'collection.edit.tabs.source.head' | translate }}

    @@ -26,7 +26,7 @@ for="externalSourceCheck">{{ 'collection.edit.tabs.source.external' | translate }}
    -

    {{ 'collection.edit.tabs.source.form.head' | translate }}

    +

    {{ 'collection.edit.tabs.source.form.head' | translate }}

    -

    {{ 'collection.edit.template.head' | translate:{ collection: dsoNameService.getName(collection) } }}

    +

    {{ 'collection.edit.template.head' | translate:{ collection: dsoNameService.getName(collection) } }}

    diff --git a/src/app/collection-page/edit-item-template-page/edit-item-template-page.component.ts b/src/app/collection-page/edit-item-template-page/edit-item-template-page.component.ts index 6425996fd2..238ec5e37a 100644 --- a/src/app/collection-page/edit-item-template-page/edit-item-template-page.component.ts +++ b/src/app/collection-page/edit-item-template-page/edit-item-template-page.component.ts @@ -8,7 +8,7 @@ import { ItemTemplateDataService } from '../../core/data/item-template-data.serv import { getCollectionEditRoute } from '../collection-page-routing-paths'; import { Item } from '../../core/shared/item.model'; import { getFirstSucceededRemoteDataPayload } from '../../core/shared/operators'; -import { AlertType } from '../../shared/alert/aletr-type'; +import { AlertType } from '../../shared/alert/alert-type'; import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; @Component({ diff --git a/src/app/community-list-page/community-list-page.component.html b/src/app/community-list-page/community-list-page.component.html index 9759f4405d..4392fb87d0 100644 --- a/src/app/community-list-page/community-list-page.component.html +++ b/src/app/community-list-page/community-list-page.component.html @@ -1,4 +1,4 @@
    -

    {{ 'communityList.title' | translate }}

    +

    {{ 'communityList.title' | translate }}

    diff --git a/src/app/community-list-page/community-list-service.ts b/src/app/community-list-page/community-list-service.ts index 99e9dbeb0d..bbf1c7cdb5 100644 --- a/src/app/community-list-page/community-list-service.ts +++ b/src/app/community-list-page/community-list-service.ts @@ -24,8 +24,9 @@ import { FlatNode } from './flat-node.model'; import { ShowMoreFlatNode } from './show-more-flat-node.model'; import { FindListOptions } from '../core/data/find-list-options.model'; import { AppConfig, APP_CONFIG } from 'src/config/app-config.interface'; +import { v4 as uuidv4 } from 'uuid'; -// Helper method to combine an flatten an array of observables of flatNode arrays +// Helper method to combine and flatten an array of observables of flatNode arrays export const combineAndFlatten = (obsList: Observable[]): Observable => observableCombineLatest([...obsList]).pipe( map((matrix: any[][]) => [].concat(...matrix)), @@ -186,7 +187,7 @@ export class CommunityListService { return this.transformCommunity(community, level, parent, expandedNodes); }); if (currentPage < listOfPaginatedCommunities.totalPages && currentPage === listOfPaginatedCommunities.currentPage) { - obsList = [...obsList, observableOf([showMoreFlatNode('community', level, parent)])]; + obsList = [...obsList, observableOf([showMoreFlatNode(`community-${uuidv4()}`, level, parent)])]; } return combineAndFlatten(obsList); @@ -199,7 +200,7 @@ export class CommunityListService { * Transforms a community in a list of FlatNodes containing firstly a flatnode of the community itself, * followed by flatNodes of its possible subcommunities and collection * It gets called recursively for each subcommunity to add its subcommunities and collections to the list - * Number of subcommunities and collections added, is dependant on the current page the parent is at for respectively subcommunities and collections. + * Number of subcommunities and collections added, is dependent on the current page the parent is at for respectively subcommunities and collections. * @param community Community being transformed * @param level Depth of the community in the list, subcommunities and collections go one level deeper * @param parent Flatnode of the parent community @@ -257,7 +258,7 @@ export class CommunityListService { let nodes = rd.payload.page .map((collection: Collection) => toFlatNode(collection, observableOf(false), level + 1, false, communityFlatNode)); if (currentCollectionPage < rd.payload.totalPages && currentCollectionPage === rd.payload.currentPage) { - nodes = [...nodes, showMoreFlatNode('collection', level + 1, communityFlatNode)]; + nodes = [...nodes, showMoreFlatNode(`collection-${uuidv4()}`, level + 1, communityFlatNode)]; } return nodes; } else { @@ -275,7 +276,7 @@ export class CommunityListService { /** * Checks if a community has subcommunities or collections by querying the respective services with a pageSize = 0 - * Returns an observable that combines the result.payload.totalElements fo the observables that the + * Returns an observable that combines the result.payload.totalElements of the observables that the * respective services return when queried * @param community Community being checked whether it is expandable (if it has subcommunities or collections) */ 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 d6fd77e79b..7ccf24a761 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 @@ -1,14 +1,14 @@ - +
    - +
    - @@ -24,32 +24,32 @@
    - + +
    - + {{node.payload.shortDescription}} @@ -58,10 +58,9 @@
    - +
    @@ -69,22 +68,19 @@
    - + {{node.payload.shortDescription}} diff --git a/src/app/community-list-page/community-list/community-list.component.scss b/src/app/community-list-page/community-list/community-list.component.scss new file mode 100644 index 0000000000..2e33380a29 --- /dev/null +++ b/src/app/community-list-page/community-list/community-list.component.scss @@ -0,0 +1,4 @@ +::ng-deep .fa-chevron-right::before { + display: block; + width: 16px; +} diff --git a/src/app/community-list-page/community-list/community-list.component.spec.ts b/src/app/community-list-page/community-list/community-list.component.spec.ts index ce6b27dbeb..cec1b555ab 100644 --- a/src/app/community-list-page/community-list/community-list.component.spec.ts +++ b/src/app/community-list-page/community-list/community-list.component.spec.ts @@ -5,7 +5,7 @@ import { CommunityListService, showMoreFlatNode, toFlatNode } from '../community import { CdkTreeModule } from '@angular/cdk/tree'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; -import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { CUSTOM_ELEMENTS_SCHEMA, DebugElement } from '@angular/core'; import { RouterTestingModule } from '@angular/router/testing'; import { Community } from '../../core/shared/community.model'; import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; @@ -17,6 +17,7 @@ import { By } from '@angular/platform-browser'; import { isEmpty, isNotEmpty } from '../../shared/empty.util'; import { FlatNode } from '../flat-node.model'; import { RouterLinkWithHref } from '@angular/router'; +import { v4 as uuidv4 } from 'uuid'; describe('CommunityListComponent', () => { let component: CommunityListComponent; @@ -138,7 +139,7 @@ describe('CommunityListComponent', () => { } if (expandedNodes === null || isEmpty(expandedNodes)) { if (showMoreTopComNode) { - return observableOf([...mockTopFlatnodesUnexpanded.slice(0, endPageIndex), showMoreFlatNode('community', 0, null)]); + return observableOf([...mockTopFlatnodesUnexpanded.slice(0, endPageIndex), showMoreFlatNode(`community-${uuidv4()}`, 0, null)]); } else { return observableOf(mockTopFlatnodesUnexpanded.slice(0, endPageIndex)); } @@ -165,21 +166,21 @@ describe('CommunityListComponent', () => { const endSubComIndex = this.pageSize * expandedParent.currentCommunityPage; flatnodes = [...flatnodes, ...subComFlatnodes.slice(0, endSubComIndex)]; if (subComFlatnodes.length > endSubComIndex) { - flatnodes = [...flatnodes, showMoreFlatNode('community', topNode.level + 1, expandedParent)]; + flatnodes = [...flatnodes, showMoreFlatNode(`community-${uuidv4()}`, topNode.level + 1, expandedParent)]; } } if (isNotEmpty(collFlatnodes)) { const endColIndex = this.pageSize * expandedParent.currentCollectionPage; flatnodes = [...flatnodes, ...collFlatnodes.slice(0, endColIndex)]; if (collFlatnodes.length > endColIndex) { - flatnodes = [...flatnodes, showMoreFlatNode('collection', topNode.level + 1, expandedParent)]; + flatnodes = [...flatnodes, showMoreFlatNode(`collection-${uuidv4()}`, topNode.level + 1, expandedParent)]; } } } } }); if (showMoreTopComNode) { - flatnodes = [...flatnodes, showMoreFlatNode('community', 0, null)]; + flatnodes = [...flatnodes, showMoreFlatNode(`community-${uuidv4()}`, 0, null)]; } return observableOf(flatnodes); } @@ -299,12 +300,14 @@ describe('CommunityListComponent', () => { describe('second top community node is expanded and has more children (collections) than page size of collection', () => { describe('children of second top com are added (page-limited pageSize 2)', () => { - let allNodes; + let allNodes: DebugElement[]; beforeEach(fakeAsync(() => { - const chevronExpand = fixture.debugElement.queryAll(By.css('.expandable-node button')); - const chevronExpandSpan = fixture.debugElement.queryAll(By.css('.expandable-node button span')); - if (chevronExpandSpan[1].nativeElement.classList.contains('fa-chevron-right')) { - chevronExpand[1].nativeElement.click(); + const toggleButtons: DebugElement[] = fixture.debugElement.queryAll(By.css('.expandable-node button')); + const toggleButtonText: DebugElement = toggleButtons[1].query(By.css('span')); + expect(toggleButtonText).not.toBeNull(); + + if (toggleButtonText.nativeElement.classList.contains('fa-chevron-right')) { + toggleButtons[1].nativeElement.click(); tick(); fixture.detectChanges(); } @@ -314,17 +317,18 @@ describe('CommunityListComponent', () => { allNodes = [...expandableNodesFound, ...childlessNodesFound]; })); it('tree contains 2 (page-limited) top com, 2 (page-limited) coll of 2nd top com, a show more for those page-limited coll and show more for page-limited top com', () => { - mockTopFlatnodesUnexpanded.slice(0, 2).map((topFlatnode: FlatNode) => { - expect(allNodes.find((foundEl) => { - return (foundEl.nativeElement.textContent.trim() === topFlatnode.name); - })).toBeTruthy(); - }); - mockCollectionsPage1.map((coll) => { - expect(allNodes.find((foundEl) => { - return (foundEl.nativeElement.textContent.trim() === coll.name); - })).toBeTruthy(); - }); + const allNodeNames: string[] = allNodes.map((node: DebugElement) => node.nativeElement.innerText.trim()); expect(allNodes.length).toEqual(4); + const flatNodes: string[] = mockTopFlatnodesUnexpanded.slice(0, 2).map((flatNode: FlatNode) => flatNode.name); + for (const flatNode of flatNodes) { + expect(allNodeNames).toContain(flatNode); + } + expect(flatNodes.length).toBe(2); + const page1CollectionNames: string[] = mockCollectionsPage1.map((collection: Collection) => collection.name); + for (const collectionName of page1CollectionNames) { + expect(allNodeNames).toContain(collectionName); + } + expect(page1CollectionNames.length).toBe(2); const showMoreEl = fixture.debugElement.queryAll(By.css('.show-more-node')); expect(showMoreEl.length).toEqual(2); }); diff --git a/src/app/community-list-page/community-list/community-list.component.ts b/src/app/community-list-page/community-list/community-list.component.ts index 5b2f930813..031a422469 100644 --- a/src/app/community-list-page/community-list/community-list.component.ts +++ b/src/app/community-list-page/community-list/community-list.component.ts @@ -19,6 +19,7 @@ import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; @Component({ selector: 'ds-community-list', templateUrl: './community-list.component.html', + styleUrls: ['./community-list.component.scss'], }) export class CommunityListComponent implements OnInit, OnDestroy { @@ -28,10 +29,9 @@ export class CommunityListComponent implements OnInit, OnDestroy { treeControl = new FlatTreeControl( (node: FlatNode) => node.level, (node: FlatNode) => true ); - dataSource: CommunityListDatasource; - paginationConfig: FindListOptions; + trackBy = (index, node: FlatNode) => node.id; constructor( protected communityListService: CommunityListService, @@ -58,24 +58,34 @@ export class CommunityListComponent implements OnInit, OnDestroy { this.communityListService.saveCommunityListStateToStore(this.expandedNodes, this.loadingNode); } - // whether or not this node has children (subcommunities or collections) + /** + * Whether this node has children (subcommunities or collections) + * @param _ + * @param node + */ hasChild(_: number, node: FlatNode) { return node.isExpandable$; } - // whether or not it is a show more node (contains no data, but is indication that there are more topcoms, subcoms or collections + /** + * Whether this is a show more node that contains no data, but indicates that there is + * one or more community or collection. + * @param _ + * @param node + */ isShowMore(_: number, node: FlatNode) { return node.isShowMoreNode; } /** - * Toggles the expanded variable of a node, adds it to the expanded nodes list and reloads the tree so this node is expanded + * Toggles the expanded variable of a node, adds it to the expanded nodes list and reloads the tree + * so this node is expanded * @param node Node we want to expand */ toggleExpanded(node: FlatNode) { this.loadingNode = node; if (node.isExpanded) { - this.expandedNodes = this.expandedNodes.filter((node2) => node2.name !== node.name); + this.expandedNodes = this.expandedNodes.filter((node2) => node2.id !== node.id); node.isExpanded = false; } else { this.expandedNodes.push(node); @@ -92,26 +102,28 @@ export class CommunityListComponent implements OnInit, OnDestroy { /** * Makes sure the next page of a node is added to the tree (top community, sub community of collection) - * > Finds its parent (if not top community) and increases its corresponding collection/subcommunity currentPage - * > Reloads tree with new page added to corresponding top community lis, sub community list or collection list - * @param node The show more node indicating whether it's an increase in top communities, sub communities or collections + * > Finds its parent (if not top community) and increases its corresponding collection/subcommunity + * currentPage + * > Reloads tree with new page added to corresponding top community lis, sub community list or + * collection list + * @param node The show more node indicating whether it's an increase in top communities, sub communities + * or collections */ getNextPage(node: FlatNode): void { this.loadingNode = node; if (node.parent != null) { - if (node.id === 'collection') { + if (node.id.startsWith('collection')) { const parentNodeInExpandedNodes = this.expandedNodes.find((node2: FlatNode) => node.parent.id === node2.id); parentNodeInExpandedNodes.currentCollectionPage++; } - if (node.id === 'community') { + if (node.id.startsWith('community')) { const parentNodeInExpandedNodes = this.expandedNodes.find((node2: FlatNode) => node.parent.id === node2.id); parentNodeInExpandedNodes.currentCommunityPage++; } - this.dataSource.loadCommunities(this.paginationConfig, this.expandedNodes); } else { this.paginationConfig.currentPage++; - this.dataSource.loadCommunities(this.paginationConfig, this.expandedNodes); } + this.dataSource.loadCommunities(this.paginationConfig, this.expandedNodes); } } diff --git a/src/app/community-list-page/show-more-flat-node.model.ts b/src/app/community-list-page/show-more-flat-node.model.ts index 801c9e7388..c7b7162d21 100644 --- a/src/app/community-list-page/show-more-flat-node.model.ts +++ b/src/app/community-list-page/show-more-flat-node.model.ts @@ -1,6 +1,6 @@ /** * The show more links in the community tree are also represented by a flatNode so we know where in - * the tree it should be rendered an who its parent is (needed for the action resulting in clicking this link) + * the tree it should be rendered and who its parent is (needed for the action resulting in clicking this link) */ export class ShowMoreFlatNode { } diff --git a/src/app/community-page/community-page.component.html b/src/app/community-page/community-page.component.html index 6d5262d933..194fb747d0 100644 --- a/src/app/community-page/community-page.component.html +++ b/src/app/community-page/community-page.component.html @@ -7,7 +7,7 @@ - + @@ -21,9 +21,6 @@ -
    - -
    diff --git a/src/app/community-page/delete-community-page/delete-community-page.component.html b/src/app/community-page/delete-community-page/delete-community-page.component.html index 6bb8460bc9..1e71fb7f49 100644 --- a/src/app/community-page/delete-community-page/delete-community-page.component.html +++ b/src/app/community-page/delete-community-page/delete-community-page.component.html @@ -2,16 +2,16 @@
    - +

    {{ 'community.delete.head' | translate}}

    {{ 'community.delete.text' | translate:{ dso: dsoNameService.getName(dso) } }}

    diff --git a/src/app/community-page/edit-community-page/community-curate/community-curate.component.html b/src/app/community-page/edit-community-page/community-curate/community-curate.component.html index 6c041d1725..5e11fdfbce 100644 --- a/src/app/community-page/edit-community-page/community-curate/community-curate.component.html +++ b/src/app/community-page/edit-community-page/community-curate/community-curate.component.html @@ -1,5 +1,5 @@
    -

    {{'community.curate.header' |translate:{community: (communityName$ |async)} }}

    +

    {{'community.curate.header' |translate:{community: (communityName$ |async)} }}

    diff --git a/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.ts b/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.ts index 3a77149e5b..ed14096ce0 100644 --- a/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.ts +++ b/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.ts @@ -1,7 +1,7 @@ import { Component, Input, OnDestroy, OnInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; -import { BehaviorSubject, combineLatest as observableCombineLatest } from 'rxjs'; +import { BehaviorSubject, combineLatest as observableCombineLatest, Subscription } from 'rxjs'; import { RemoteData } from '../../core/data/remote-data'; import { Collection } from '../../core/shared/collection.model'; @@ -50,6 +50,8 @@ export class CommunityPageSubCollectionListComponent implements OnInit, OnDestro */ subCollectionsRDObs: BehaviorSubject>> = new BehaviorSubject>>({} as any); + subscriptions: Subscription[] = []; + constructor( protected cds: CollectionDataService, protected paginationService: PaginationService, @@ -77,7 +79,7 @@ export class CommunityPageSubCollectionListComponent implements OnInit, OnDestro const pagination$ = this.paginationService.getCurrentPagination(this.config.id, this.config); const sort$ = this.paginationService.getCurrentSort(this.config.id, this.sortConfig); - observableCombineLatest([pagination$, sort$]).pipe( + this.subscriptions.push(observableCombineLatest([pagination$, sort$]).pipe( switchMap(([currentPagination, currentSort]) => { return this.cds.findByParent(this.community.id, { currentPage: currentPagination.currentPage, @@ -87,11 +89,12 @@ export class CommunityPageSubCollectionListComponent implements OnInit, OnDestro }) ).subscribe((results) => { this.subCollectionsRDObs.next(results); - }); + })); } ngOnDestroy(): void { - this.paginationService.clearPagination(this.config.id); + this.paginationService.clearPagination(this.config?.id); + this.subscriptions.map((subscription: Subscription) => subscription.unsubscribe()); } } diff --git a/src/app/community-page/sub-community-list/community-page-sub-community-list.component.ts b/src/app/community-page/sub-community-list/community-page-sub-community-list.component.ts index 5a0409a051..08c9509edb 100644 --- a/src/app/community-page/sub-community-list/community-page-sub-community-list.component.ts +++ b/src/app/community-page/sub-community-list/community-page-sub-community-list.component.ts @@ -1,7 +1,7 @@ import { Component, Input, OnDestroy, OnInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; -import { BehaviorSubject, combineLatest as observableCombineLatest } from 'rxjs'; +import { BehaviorSubject, combineLatest as observableCombineLatest, Subscription } from 'rxjs'; import { RemoteData } from '../../core/data/remote-data'; import { Community } from '../../core/shared/community.model'; @@ -52,6 +52,8 @@ export class CommunityPageSubCommunityListComponent implements OnInit, OnDestroy */ subCommunitiesRDObs: BehaviorSubject>> = new BehaviorSubject>>({} as any); + subscriptions: Subscription[] = []; + constructor( protected cds: CommunityDataService, protected paginationService: PaginationService, @@ -79,7 +81,7 @@ export class CommunityPageSubCommunityListComponent implements OnInit, OnDestroy const pagination$ = this.paginationService.getCurrentPagination(this.config.id, this.config); const sort$ = this.paginationService.getCurrentSort(this.config.id, this.sortConfig); - observableCombineLatest([pagination$, sort$]).pipe( + this.subscriptions.push(observableCombineLatest([pagination$, sort$]).pipe( switchMap(([currentPagination, currentSort]) => { return this.cds.findByParent(this.community.id, { currentPage: currentPagination.currentPage, @@ -89,11 +91,12 @@ export class CommunityPageSubCommunityListComponent implements OnInit, OnDestroy }) ).subscribe((results) => { this.subCommunitiesRDObs.next(results); - }); + })); } ngOnDestroy(): void { - this.paginationService.clearPagination(this.config.id); + this.paginationService.clearPagination(this.config?.id); + this.subscriptions.map((subscription: Subscription) => subscription.unsubscribe()); } } diff --git a/src/app/core/auth/auth.interceptor.ts b/src/app/core/auth/auth.interceptor.ts index 672879f436..4ad856fd88 100644 --- a/src/app/core/auth/auth.interceptor.ts +++ b/src/app/core/auth/auth.interceptor.ts @@ -152,12 +152,12 @@ export class AuthInterceptor implements HttpInterceptor { let authMethodModel: AuthMethod; if (splittedRealm.length === 1) { - authMethodModel = new AuthMethod(methodName); + authMethodModel = new AuthMethod(methodName, Number(j)); authMethodModels.push(authMethodModel); } else if (splittedRealm.length > 1) { let location = splittedRealm[1]; location = this.parseLocation(location); - authMethodModel = new AuthMethod(methodName, location); + authMethodModel = new AuthMethod(methodName, Number(j), location); authMethodModels.push(authMethodModel); } } @@ -165,7 +165,7 @@ export class AuthInterceptor implements HttpInterceptor { // make sure the email + password login component gets rendered first authMethodModels = this.sortAuthMethods(authMethodModels); } else { - authMethodModels.push(new AuthMethod(AuthMethodType.Password)); + authMethodModels.push(new AuthMethod(AuthMethodType.Password, 0)); } return authMethodModels; diff --git a/src/app/core/auth/auth.reducer.spec.ts b/src/app/core/auth/auth.reducer.spec.ts index c0619adf79..41c0312653 100644 --- a/src/app/core/auth/auth.reducer.spec.ts +++ b/src/app/core/auth/auth.reducer.spec.ts @@ -598,9 +598,9 @@ describe('authReducer', () => { authMethods: [], idle: false }; - const authMethods = [ - new AuthMethod(AuthMethodType.Password), - new AuthMethod(AuthMethodType.Shibboleth, 'location') + const authMethods: AuthMethod[] = [ + new AuthMethod(AuthMethodType.Password, 0), + new AuthMethod(AuthMethodType.Shibboleth, 1, 'location'), ]; const action = new RetrieveAuthMethodsSuccessAction(authMethods); const newState = authReducer(initialState, action); @@ -632,7 +632,7 @@ describe('authReducer', () => { loaded: false, blocking: false, loading: false, - authMethods: [new AuthMethod(AuthMethodType.Password)], + authMethods: [new AuthMethod(AuthMethodType.Password, 0)], idle: false }; expect(newState).toEqual(state); diff --git a/src/app/core/auth/auth.reducer.ts b/src/app/core/auth/auth.reducer.ts index ba9c41326a..437c19fd26 100644 --- a/src/app/core/auth/auth.reducer.ts +++ b/src/app/core/auth/auth.reducer.ts @@ -236,7 +236,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut return Object.assign({}, state, { loading: false, blocking: false, - authMethods: [new AuthMethod(AuthMethodType.Password)] + authMethods: [new AuthMethod(AuthMethodType.Password, 0)] }); case AuthActionTypes.SET_REDIRECT_URL: diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index 6604936cde..8b08b4f32d 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -119,7 +119,7 @@ export class AuthService { if (hasValue(rd.payload) && rd.payload.authenticated) { return rd.payload; } else { - throw (new Error('Invalid email or password')); + throw (new Error('auth.errors.invalid-user')); } })); diff --git a/src/app/core/auth/models/auth.method.ts b/src/app/core/auth/models/auth.method.ts index 0579ae0cd1..b84e7a308a 100644 --- a/src/app/core/auth/models/auth.method.ts +++ b/src/app/core/auth/models/auth.method.ts @@ -2,11 +2,12 @@ import { AuthMethodType } from './auth.method-type'; export class AuthMethod { authMethodType: AuthMethodType; + position: number; location?: string; - // isStandalonePage? = true; + constructor(authMethodName: string, position: number, location?: string) { + this.position = position; - constructor(authMethodName: string, location?: string) { switch (authMethodName) { case 'ip': { this.authMethodType = AuthMethodType.Ip; diff --git a/src/app/core/breadcrumbs/dso-name.service.ts b/src/app/core/breadcrumbs/dso-name.service.ts index ddd97705b0..8e4fb771c6 100644 --- a/src/app/core/breadcrumbs/dso-name.service.ts +++ b/src/app/core/breadcrumbs/dso-name.service.ts @@ -50,7 +50,7 @@ export class DSONameService { } }, OrgUnit: (dso: DSpaceObject): string => { - return dso.firstMetadataValue('organization.legalName'); + return dso.firstMetadataValue('organization.legalName') || this.translateService.instant('dso.name.untitled'); }, Default: (dso: DSpaceObject): string => { // If object doesn't have dc.title metadata use name property @@ -106,7 +106,7 @@ export class DSONameService { } return `${familyName}, ${givenName}`; } else if (entityType === 'OrgUnit') { - return this.firstMetadataValue(object, dso, 'organization.legalName'); + return this.firstMetadataValue(object, dso, 'organization.legalName') || this.translateService.instant('dso.name.untitled'); } return this.firstMetadataValue(object, dso, 'dc.title') || dso.name || this.translateService.instant('dso.name.untitled'); } diff --git a/src/app/core/breadcrumbs/quality-assurance-breadcrumb.resolver.spec.ts b/src/app/core/breadcrumbs/quality-assurance-breadcrumb.resolver.spec.ts new file mode 100644 index 0000000000..3544af62e7 --- /dev/null +++ b/src/app/core/breadcrumbs/quality-assurance-breadcrumb.resolver.spec.ts @@ -0,0 +1,31 @@ +import {QualityAssuranceBreadcrumbResolver} from './quality-assurance-breadcrumb.resolver'; + +describe('QualityAssuranceBreadcrumbResolver', () => { + describe('resolve', () => { + let resolver: QualityAssuranceBreadcrumbResolver; + let qualityAssuranceBreadcrumbService: any; + let route: any; + const fullPath = '/test/quality-assurance/'; + const expectedKey = 'testSourceId:testTopicId'; + + beforeEach(() => { + route = { + paramMap: { + get: function (param) { + return this[param]; + }, + sourceId: 'testSourceId', + topicId: 'testTopicId' + } + }; + qualityAssuranceBreadcrumbService = {}; + resolver = new QualityAssuranceBreadcrumbResolver(qualityAssuranceBreadcrumbService); + }); + + it('should resolve the breadcrumb config', () => { + const resolvedConfig = resolver.resolve(route as any, {url: fullPath + 'testSourceId'} as any); + const expectedConfig = { provider: qualityAssuranceBreadcrumbService, key: expectedKey, url: fullPath }; + expect(resolvedConfig).toEqual(expectedConfig); + }); + }); +}); diff --git a/src/app/core/breadcrumbs/quality-assurance-breadcrumb.resolver.ts b/src/app/core/breadcrumbs/quality-assurance-breadcrumb.resolver.ts new file mode 100644 index 0000000000..6eb351ab1a --- /dev/null +++ b/src/app/core/breadcrumbs/quality-assurance-breadcrumb.resolver.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@angular/core'; +import {QualityAssuranceBreadcrumbService} from './quality-assurance-breadcrumb.service'; +import {ActivatedRouteSnapshot, Resolve, RouterStateSnapshot} from '@angular/router'; +import {BreadcrumbConfig} from '../../breadcrumbs/breadcrumb/breadcrumb-config.model'; + +@Injectable({ + providedIn: 'root' +}) +export class QualityAssuranceBreadcrumbResolver implements Resolve> { + constructor(protected breadcrumbService: QualityAssuranceBreadcrumbService) {} + + /** + * Method that resolve QA item into a breadcrumb + * The parameter are retrieved by the url since part of the QA route config + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + * @param {RouterStateSnapshot} state The current RouterStateSnapshot + * @returns BreadcrumbConfig object + */ + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): BreadcrumbConfig { + const sourceId = route.paramMap.get('sourceId'); + const topicId = route.paramMap.get('topicId'); + let key = sourceId; + + if (topicId) { + key += `:${topicId}`; + } + const fullPath = state.url; + const url = fullPath.substr(0, fullPath.indexOf(sourceId)); + + return { provider: this.breadcrumbService, key, url }; + } +} diff --git a/src/app/core/breadcrumbs/quality-assurance-breadcrumb.service.spec.ts b/src/app/core/breadcrumbs/quality-assurance-breadcrumb.service.spec.ts new file mode 100644 index 0000000000..4fef767214 --- /dev/null +++ b/src/app/core/breadcrumbs/quality-assurance-breadcrumb.service.spec.ts @@ -0,0 +1,40 @@ +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; +import { getTestScheduler } from 'jasmine-marbles'; +import {QualityAssuranceBreadcrumbService} from './quality-assurance-breadcrumb.service'; + +describe('QualityAssuranceBreadcrumbService', () => { + let service: QualityAssuranceBreadcrumbService; + let dataService: any; + let translateService: any = { + instant: (str) => str, + }; + + let exampleString; + let exampleURL; + let exampleQaKey; + + function init() { + exampleString = 'sourceId'; + exampleURL = '/test/quality-assurance/'; + exampleQaKey = 'admin.quality-assurance.breadcrumbs'; + } + + beforeEach(waitForAsync(() => { + init(); + TestBed.configureTestingModule({}).compileComponents(); + })); + + beforeEach(() => { + service = new QualityAssuranceBreadcrumbService(dataService,translateService); + }); + + describe('getBreadcrumbs', () => { + it('should return a breadcrumb based on a string', () => { + const breadcrumbs = service.getBreadcrumbs(exampleString, exampleURL); + getTestScheduler().expectObservable(breadcrumbs).toBe('(a|)', { a: [new Breadcrumb(exampleQaKey, exampleURL), + new Breadcrumb(exampleString, exampleURL + exampleString)] + }); + }); + }); +}); diff --git a/src/app/core/breadcrumbs/quality-assurance-breadcrumb.service.ts b/src/app/core/breadcrumbs/quality-assurance-breadcrumb.service.ts new file mode 100644 index 0000000000..209ae0722c --- /dev/null +++ b/src/app/core/breadcrumbs/quality-assurance-breadcrumb.service.ts @@ -0,0 +1,53 @@ +import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; +import { BreadcrumbsProviderService } from './breadcrumbsProviderService'; +import { Observable, of as observableOf } from 'rxjs'; +import { Injectable } from '@angular/core'; +import { map } from 'rxjs/operators'; +import { getFirstCompletedRemoteData } from '../shared/operators'; +import { TranslateService } from '@ngx-translate/core'; +import { QualityAssuranceTopicDataService } from '../notifications/qa/topics/quality-assurance-topic-data.service'; + + + +/** + * Service to calculate QA breadcrumbs for a single part of the route + */ +@Injectable({ + providedIn: 'root' +}) +export class QualityAssuranceBreadcrumbService implements BreadcrumbsProviderService { + + private QUALITY_ASSURANCE_BREADCRUMB_KEY = 'admin.quality-assurance.breadcrumbs'; + constructor( + protected qualityAssuranceService: QualityAssuranceTopicDataService, + private translationService: TranslateService, + ) { + + } + + + /** + * Method to calculate the breadcrumbs + * @param key The key used to resolve the breadcrumb + * @param url The url to use as a link for this breadcrumb + */ + getBreadcrumbs(key: string, url: string): Observable { + const sourceId = key.split(':')[0]; + const topicId = key.split(':')[1]; + + if (topicId) { + return this.qualityAssuranceService.getTopic(topicId).pipe( + getFirstCompletedRemoteData(), + map((topic) => { + return [new Breadcrumb(this.translationService.instant(this.QUALITY_ASSURANCE_BREADCRUMB_KEY), url), + new Breadcrumb(sourceId, `${url}${sourceId}`), + new Breadcrumb(topicId, undefined)]; + }) + ); + } else { + return observableOf([new Breadcrumb(this.translationService.instant(this.QUALITY_ASSURANCE_BREADCRUMB_KEY), url), + new Breadcrumb(sourceId, `${url}${sourceId}`)]); + } + + } +} diff --git a/src/app/core/core.effects.ts b/src/app/core/core.effects.ts index b569df290d..1724e88743 100644 --- a/src/app/core/core.effects.ts +++ b/src/app/core/core.effects.ts @@ -7,6 +7,7 @@ import { ServerSyncBufferEffects } from './cache/server-sync-buffer.effects'; import { ObjectUpdatesEffects } from './data/object-updates/object-updates.effects'; import { RouteEffects } from './services/route.effects'; import { RouterEffects } from './router/router.effects'; +import { MenuEffects } from '../shared/menu/menu.effects'; export const coreEffects = [ RequestEffects, @@ -18,4 +19,5 @@ export const coreEffects = [ ObjectUpdatesEffects, RouteEffects, RouterEffects, + MenuEffects, ]; diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index dbca773375..b3abf5f877 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -157,6 +157,9 @@ import { SequenceService } from './shared/sequence.service'; import { CoreState } from './core-state.model'; import { GroupDataService } from './eperson/group-data.service'; import { SubmissionAccessesModel } from './config/models/config-submission-accesses.model'; +import { QualityAssuranceTopicObject } from './notifications/qa/models/quality-assurance-topic.model'; +import { QualityAssuranceEventObject } from './notifications/qa/models/quality-assurance-event.model'; +import { QualityAssuranceSourceObject } from './notifications/qa/models/quality-assurance-source.model'; import { RatingAdvancedWorkflowInfo } from './tasks/models/rating-advanced-workflow-info.model'; import { AdvancedWorkflowInfo } from './tasks/models/advanced-workflow-info.model'; import { SelectReviewerAdvancedWorkflowInfo } from './tasks/models/select-reviewer-advanced-workflow-info.model'; @@ -369,9 +372,12 @@ export const models = ShortLivedToken, Registration, UsageReport, + QualityAssuranceTopicObject, + QualityAssuranceEventObject, Root, SearchConfig, SubmissionAccessesModel, + QualityAssuranceSourceObject, AccessStatusObject, ResearcherProfile, OrcidQueue, diff --git a/src/app/core/data/base/base-data.service.spec.ts b/src/app/core/data/base/base-data.service.spec.ts index 098f075c10..75662a691f 100644 --- a/src/app/core/data/base/base-data.service.spec.ts +++ b/src/app/core/data/base/base-data.service.spec.ts @@ -95,6 +95,7 @@ describe('BaseDataService', () => { remoteDataMocks = { RequestPending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.RequestPending, undefined, undefined, undefined), ResponsePending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.ResponsePending, undefined, undefined, undefined), + ResponsePendingStale: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.ResponsePendingStale, undefined, undefined, undefined), Success: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Success, undefined, payload, statusCodeSuccess), SuccessStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.SuccessStale, undefined, payload, statusCodeSuccess), Error: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Error, errorMessage, undefined, statusCodeError), @@ -303,19 +304,21 @@ describe('BaseDataService', () => { it(`should not emit a cached stale RemoteData, but only start emitting after the state first changes to RequestPending`, () => { testScheduler.run(({ cold, expectObservable }) => { - spyOn(rdbService, 'buildSingle').and.returnValue(cold('a-b-c-d-e', { - a: remoteDataMocks.SuccessStale, - b: remoteDataMocks.RequestPending, - c: remoteDataMocks.ResponsePending, - d: remoteDataMocks.Success, - e: remoteDataMocks.SuccessStale, + spyOn(rdbService, 'buildSingle').and.returnValue(cold('a-b-c-d-e-f-g', { + a: remoteDataMocks.ResponsePendingStale, + b: remoteDataMocks.SuccessStale, + c: remoteDataMocks.ErrorStale, + d: remoteDataMocks.RequestPending, + e: remoteDataMocks.ResponsePending, + f: remoteDataMocks.Success, + g: remoteDataMocks.SuccessStale, })); - const expected = '--b-c-d-e'; + const expected = '------d-e-f-g'; const values = { - b: remoteDataMocks.RequestPending, - c: remoteDataMocks.ResponsePending, - d: remoteDataMocks.Success, - e: remoteDataMocks.SuccessStale, + d: remoteDataMocks.RequestPending, + e: remoteDataMocks.ResponsePending, + f: remoteDataMocks.Success, + g: remoteDataMocks.SuccessStale, }; expectObservable(service.findByHref(selfLink, true, true, ...linksToFollow)).toBe(expected, values); @@ -354,19 +357,21 @@ describe('BaseDataService', () => { it(`should not emit a cached stale RemoteData, but only start emitting after the state first changes to RequestPending`, () => { testScheduler.run(({ cold, expectObservable }) => { - spyOn(rdbService, 'buildSingle').and.returnValue(cold('a-b-c-d-e', { - a: remoteDataMocks.SuccessStale, - b: remoteDataMocks.RequestPending, - c: remoteDataMocks.ResponsePending, - d: remoteDataMocks.Success, - e: remoteDataMocks.SuccessStale, + spyOn(rdbService, 'buildSingle').and.returnValue(cold('a-b-c-d-e-f-g', { + a: remoteDataMocks.ResponsePendingStale, + b: remoteDataMocks.SuccessStale, + c: remoteDataMocks.ErrorStale, + d: remoteDataMocks.RequestPending, + e: remoteDataMocks.ResponsePending, + f: remoteDataMocks.Success, + g: remoteDataMocks.SuccessStale, })); - const expected = '--b-c-d-e'; + const expected = '------d-e-f-g'; const values = { - b: remoteDataMocks.RequestPending, - c: remoteDataMocks.ResponsePending, - d: remoteDataMocks.Success, - e: remoteDataMocks.SuccessStale, + d: remoteDataMocks.RequestPending, + e: remoteDataMocks.ResponsePending, + f: remoteDataMocks.Success, + g: remoteDataMocks.SuccessStale, }; expectObservable(service.findByHref(selfLink, false, true, ...linksToFollow)).toBe(expected, values); @@ -487,19 +492,21 @@ describe('BaseDataService', () => { it(`should not emit a cached stale RemoteData, but only start emitting after the state first changes to RequestPending`, () => { testScheduler.run(({ cold, expectObservable }) => { - spyOn(rdbService, 'buildList').and.returnValue(cold('a-b-c-d-e', { - a: remoteDataMocks.SuccessStale, - b: remoteDataMocks.RequestPending, - c: remoteDataMocks.ResponsePending, - d: remoteDataMocks.Success, - e: remoteDataMocks.SuccessStale, + spyOn(rdbService, 'buildList').and.returnValue(cold('a-b-c-d-e-f-g', { + a: remoteDataMocks.ResponsePendingStale, + b: remoteDataMocks.SuccessStale, + c: remoteDataMocks.ErrorStale, + d: remoteDataMocks.RequestPending, + e: remoteDataMocks.ResponsePending, + f: remoteDataMocks.Success, + g: remoteDataMocks.SuccessStale, })); - const expected = '--b-c-d-e'; + const expected = '------d-e-f-g'; const values = { - b: remoteDataMocks.RequestPending, - c: remoteDataMocks.ResponsePending, - d: remoteDataMocks.Success, - e: remoteDataMocks.SuccessStale, + d: remoteDataMocks.RequestPending, + e: remoteDataMocks.ResponsePending, + f: remoteDataMocks.Success, + g: remoteDataMocks.SuccessStale, }; expectObservable(service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow)).toBe(expected, values); @@ -538,21 +545,24 @@ describe('BaseDataService', () => { it(`should not emit a cached stale RemoteData, but only start emitting after the state first changes to RequestPending`, () => { testScheduler.run(({ cold, expectObservable }) => { - spyOn(rdbService, 'buildList').and.returnValue(cold('a-b-c-d-e', { - a: remoteDataMocks.SuccessStale, - b: remoteDataMocks.RequestPending, - c: remoteDataMocks.ResponsePending, - d: remoteDataMocks.Success, - e: remoteDataMocks.SuccessStale, + spyOn(rdbService, 'buildList').and.returnValue(cold('a-b-c-d-e-f-g', { + a: remoteDataMocks.ResponsePendingStale, + b: remoteDataMocks.SuccessStale, + c: remoteDataMocks.ErrorStale, + d: remoteDataMocks.RequestPending, + e: remoteDataMocks.ResponsePending, + f: remoteDataMocks.Success, + g: remoteDataMocks.SuccessStale, })); - const expected = '--b-c-d-e'; + const expected = '------d-e-f-g'; const values = { - b: remoteDataMocks.RequestPending, - c: remoteDataMocks.ResponsePending, - d: remoteDataMocks.Success, - e: remoteDataMocks.SuccessStale, + d: remoteDataMocks.RequestPending, + e: remoteDataMocks.ResponsePending, + f: remoteDataMocks.Success, + g: remoteDataMocks.SuccessStale, }; + expectObservable(service.findListByHref(selfLink, findListOptions, false, true, ...linksToFollow)).toBe(expected, values); }); }); diff --git a/src/app/core/data/base/base-data.service.ts b/src/app/core/data/base/base-data.service.ts index edd6d9e2a4..c7cd5b0a70 100644 --- a/src/app/core/data/base/base-data.service.ts +++ b/src/app/core/data/base/base-data.service.ts @@ -273,7 +273,7 @@ export class BaseDataService implements HALDataServic // call it isn't immediately returned, but we wait until the remote data for the new request // is created. If useCachedVersionIfAvailable is false it also ensures you don't get a // cached completed object - skipWhile((rd: RemoteData) => useCachedVersionIfAvailable ? rd.isStale : rd.hasCompleted), + skipWhile((rd: RemoteData) => rd.isStale || (!useCachedVersionIfAvailable && rd.hasCompleted)), this.reRequestStaleRemoteData(reRequestOnStale, () => this.findByHref(href$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow)), ); @@ -307,7 +307,7 @@ export class BaseDataService implements HALDataServic // call it isn't immediately returned, but we wait until the remote data for the new request // is created. If useCachedVersionIfAvailable is false it also ensures you don't get a // cached completed object - skipWhile((rd: RemoteData>) => useCachedVersionIfAvailable ? rd.isStale : rd.hasCompleted), + skipWhile((rd: RemoteData>) => rd.isStale || (!useCachedVersionIfAvailable && rd.hasCompleted)), this.reRequestStaleRemoteData(reRequestOnStale, () => this.findListByHref(href$, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow)), ); diff --git a/src/app/core/data/configuration-data.service.ts b/src/app/core/data/configuration-data.service.ts index de044e25e3..557e13f57b 100644 --- a/src/app/core/data/configuration-data.service.ts +++ b/src/app/core/data/configuration-data.service.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-classes-per-file */ import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; diff --git a/src/app/core/data/dso-redirect.service.spec.ts b/src/app/core/data/dso-redirect.service.spec.ts index 2122dc663a..9271bd5f7f 100644 --- a/src/app/core/data/dso-redirect.service.spec.ts +++ b/src/app/core/data/dso-redirect.service.spec.ts @@ -11,6 +11,8 @@ import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils import { Item } from '../shared/item.model'; import { EMBED_SEPARATOR } from './base/base-data.service'; import { HardRedirectService } from '../services/hard-redirect.service'; +import { environment } from '../../../environments/environment.test'; +import { AppConfig } from '../../../config/app-config.interface'; describe('DsoRedirectService', () => { let scheduler: TestScheduler; @@ -56,6 +58,7 @@ describe('DsoRedirectService', () => { }); service = new DsoRedirectService( + environment as AppConfig, requestService, rdbService, objectCache, @@ -107,7 +110,7 @@ describe('DsoRedirectService', () => { redir.subscribe(); scheduler.schedule(() => redir); scheduler.flush(); - expect(redirectService.redirect).toHaveBeenCalledWith('/items/' + remoteData.payload.uuid, 301); + expect(redirectService.redirect).toHaveBeenCalledWith(`${environment.ui.nameSpace}/items/${remoteData.payload.uuid}`, 301); }); it('should navigate to entities route with the corresponding entity type', () => { remoteData.payload.type = 'item'; @@ -124,7 +127,7 @@ describe('DsoRedirectService', () => { redir.subscribe(); scheduler.schedule(() => redir); scheduler.flush(); - expect(redirectService.redirect).toHaveBeenCalledWith('/entities/publication/' + remoteData.payload.uuid, 301); + expect(redirectService.redirect).toHaveBeenCalledWith(`${environment.ui.nameSpace}/entities/publication/${remoteData.payload.uuid}`, 301); }); it('should navigate to collections route', () => { @@ -133,7 +136,7 @@ describe('DsoRedirectService', () => { redir.subscribe(); scheduler.schedule(() => redir); scheduler.flush(); - expect(redirectService.redirect).toHaveBeenCalledWith('/collections/' + remoteData.payload.uuid, 301); + expect(redirectService.redirect).toHaveBeenCalledWith(`${environment.ui.nameSpace}/collections/${remoteData.payload.uuid}`, 301); }); it('should navigate to communities route', () => { @@ -142,7 +145,7 @@ describe('DsoRedirectService', () => { redir.subscribe(); scheduler.schedule(() => redir); scheduler.flush(); - expect(redirectService.redirect).toHaveBeenCalledWith('/communities/' + remoteData.payload.uuid, 301); + expect(redirectService.redirect).toHaveBeenCalledWith(`${environment.ui.nameSpace}/communities/${remoteData.payload.uuid}`, 301); }); }); diff --git a/src/app/core/data/dso-redirect.service.ts b/src/app/core/data/dso-redirect.service.ts index a27d1fb11f..4585df5b4b 100644 --- a/src/app/core/data/dso-redirect.service.ts +++ b/src/app/core/data/dso-redirect.service.ts @@ -6,7 +6,7 @@ * http://www.dspace.org/license/ */ /* eslint-disable max-classes-per-file */ -import { Injectable } from '@angular/core'; +import { Injectable, Inject } from '@angular/core'; import { Observable } from 'rxjs'; import { tap } from 'rxjs/operators'; import { hasValue } from '../../shared/empty.util'; @@ -21,6 +21,7 @@ import { DSpaceObject } from '../shared/dspace-object.model'; import { IdentifiableDataService } from './base/identifiable-data.service'; import { getDSORoute } from '../../app-routing-paths'; import { HardRedirectService } from '../services/hard-redirect.service'; +import { APP_CONFIG, AppConfig } from '../../../config/app-config.interface'; const ID_ENDPOINT = 'pid'; const UUID_ENDPOINT = 'dso'; @@ -70,6 +71,7 @@ export class DsoRedirectService { private dataService: DsoByIdOrUUIDDataService; constructor( + @Inject(APP_CONFIG) protected appConfig: AppConfig, protected requestService: RequestService, protected rdbService: RemoteDataBuildService, protected objectCache: ObjectCacheService, @@ -98,7 +100,7 @@ export class DsoRedirectService { let newRoute = getDSORoute(dso); if (hasValue(newRoute)) { // Use a "301 Moved Permanently" redirect for SEO purposes - this.hardRedirectService.redirect(newRoute, 301); + this.hardRedirectService.redirect(this.appConfig.ui.nameSpace.replace(/\/$/, '') + newRoute, 301); } } } diff --git a/src/app/core/data/feature-authorization/authorization-data.service.ts b/src/app/core/data/feature-authorization/authorization-data.service.ts index c43d335234..9573042272 100644 --- a/src/app/core/data/feature-authorization/authorization-data.service.ts +++ b/src/app/core/data/feature-authorization/authorization-data.service.ts @@ -74,7 +74,7 @@ export class AuthorizationDataService extends BaseDataService imp return []; } }), - catchError(() => observableOf(false)), + catchError(() => observableOf([])), oneAuthorizationMatchesFeature(featureId) ); } diff --git a/src/app/core/data/feature-authorization/authorization-utils.ts b/src/app/core/data/feature-authorization/authorization-utils.ts index d1b65f6123..a4e5e4d997 100644 --- a/src/app/core/data/feature-authorization/authorization-utils.ts +++ b/src/app/core/data/feature-authorization/authorization-utils.ts @@ -68,13 +68,13 @@ export const oneAuthorizationMatchesFeature = (featureID: FeatureID) => source.pipe( switchMap((authorizations: Authorization[]) => { if (isNotEmpty(authorizations)) { - return observableCombineLatest( + return observableCombineLatest([ ...authorizations .filter((authorization: Authorization) => hasValue(authorization.feature)) .map((authorization: Authorization) => authorization.feature.pipe( getFirstSucceededRemoteDataPayload() )) - ); + ]); } else { return observableOf([]); } diff --git a/src/app/core/data/feature-authorization/feature-id.ts b/src/app/core/data/feature-authorization/feature-id.ts index 8fef45a953..e2943f1762 100644 --- a/src/app/core/data/feature-authorization/feature-id.ts +++ b/src/app/core/data/feature-authorization/feature-id.ts @@ -34,4 +34,5 @@ export enum FeatureID { CanEditItem = 'canEditItem', CanRegisterDOI = 'canRegisterDOI', CanSubscribe = 'canSubscribeDso', + CanSeeQA = 'canSeeQA' } diff --git a/src/app/core/data/request-entry-state.model.spec.ts b/src/app/core/data/request-entry-state.model.spec.ts new file mode 100644 index 0000000000..7daa655566 --- /dev/null +++ b/src/app/core/data/request-entry-state.model.spec.ts @@ -0,0 +1,186 @@ +import { + isRequestPending, + isError, + isSuccess, + isErrorStale, + isSuccessStale, + isResponsePending, + isResponsePendingStale, + isLoading, + isStale, + hasFailed, + hasSucceeded, + hasCompleted, + RequestEntryState +} from './request-entry-state.model'; + +describe(`isRequestPending`, () => { + it(`should only return true if the given state is RequestPending`, () => { + expect(isRequestPending(RequestEntryState.RequestPending)).toBeTrue(); + + expect(isRequestPending(RequestEntryState.ResponsePending)).toBeFalse(); + expect(isRequestPending(RequestEntryState.Error)).toBeFalse(); + expect(isRequestPending(RequestEntryState.Success)).toBeFalse(); + expect(isRequestPending(RequestEntryState.ResponsePendingStale)).toBeFalse(); + expect(isRequestPending(RequestEntryState.ErrorStale)).toBeFalse(); + expect(isRequestPending(RequestEntryState.SuccessStale)).toBeFalse(); + }); +}); + +describe(`isError`, () => { + it(`should only return true if the given state is Error`, () => { + expect(isError(RequestEntryState.Error)).toBeTrue(); + + expect(isError(RequestEntryState.RequestPending)).toBeFalse(); + expect(isError(RequestEntryState.ResponsePending)).toBeFalse(); + expect(isError(RequestEntryState.Success)).toBeFalse(); + expect(isError(RequestEntryState.ResponsePendingStale)).toBeFalse(); + expect(isError(RequestEntryState.ErrorStale)).toBeFalse(); + expect(isError(RequestEntryState.SuccessStale)).toBeFalse(); + }); +}); + +describe(`isSuccess`, () => { + it(`should only return true if the given state is Success`, () => { + expect(isSuccess(RequestEntryState.Success)).toBeTrue(); + + expect(isSuccess(RequestEntryState.RequestPending)).toBeFalse(); + expect(isSuccess(RequestEntryState.ResponsePending)).toBeFalse(); + expect(isSuccess(RequestEntryState.Error)).toBeFalse(); + expect(isSuccess(RequestEntryState.ResponsePendingStale)).toBeFalse(); + expect(isSuccess(RequestEntryState.ErrorStale)).toBeFalse(); + expect(isSuccess(RequestEntryState.SuccessStale)).toBeFalse(); + }); +}); + +describe(`isErrorStale`, () => { + it(`should only return true if the given state is ErrorStale`, () => { + expect(isErrorStale(RequestEntryState.ErrorStale)).toBeTrue(); + + expect(isErrorStale(RequestEntryState.RequestPending)).toBeFalse(); + expect(isErrorStale(RequestEntryState.ResponsePending)).toBeFalse(); + expect(isErrorStale(RequestEntryState.Error)).toBeFalse(); + expect(isErrorStale(RequestEntryState.Success)).toBeFalse(); + expect(isErrorStale(RequestEntryState.ResponsePendingStale)).toBeFalse(); + expect(isErrorStale(RequestEntryState.SuccessStale)).toBeFalse(); + }); +}); + +describe(`isSuccessStale`, () => { + it(`should only return true if the given state is SuccessStale`, () => { + expect(isSuccessStale(RequestEntryState.SuccessStale)).toBeTrue(); + + expect(isSuccessStale(RequestEntryState.RequestPending)).toBeFalse(); + expect(isSuccessStale(RequestEntryState.ResponsePending)).toBeFalse(); + expect(isSuccessStale(RequestEntryState.Error)).toBeFalse(); + expect(isSuccessStale(RequestEntryState.Success)).toBeFalse(); + expect(isSuccessStale(RequestEntryState.ResponsePendingStale)).toBeFalse(); + expect(isSuccessStale(RequestEntryState.ErrorStale)).toBeFalse(); + }); +}); + +describe(`isResponsePending`, () => { + it(`should only return true if the given state is ResponsePending`, () => { + expect(isResponsePending(RequestEntryState.ResponsePending)).toBeTrue(); + + expect(isResponsePending(RequestEntryState.RequestPending)).toBeFalse(); + expect(isResponsePending(RequestEntryState.Error)).toBeFalse(); + expect(isResponsePending(RequestEntryState.Success)).toBeFalse(); + expect(isResponsePending(RequestEntryState.ResponsePendingStale)).toBeFalse(); + expect(isResponsePending(RequestEntryState.ErrorStale)).toBeFalse(); + expect(isResponsePending(RequestEntryState.SuccessStale)).toBeFalse(); + }); +}); + +describe(`isResponsePendingStale`, () => { + it(`should only return true if the given state is requestPending`, () => { + expect(isResponsePendingStale(RequestEntryState.ResponsePendingStale)).toBeTrue(); + + expect(isResponsePendingStale(RequestEntryState.RequestPending)).toBeFalse(); + expect(isResponsePendingStale(RequestEntryState.ResponsePending)).toBeFalse(); + expect(isResponsePendingStale(RequestEntryState.Error)).toBeFalse(); + expect(isResponsePendingStale(RequestEntryState.Success)).toBeFalse(); + expect(isResponsePendingStale(RequestEntryState.ErrorStale)).toBeFalse(); + expect(isResponsePendingStale(RequestEntryState.SuccessStale)).toBeFalse(); + }); +}); + +describe(`isLoading`, () => { + it(`should only return true if the given state is RequestPending, ResponsePending or ResponsePendingStale`, () => { + expect(isLoading(RequestEntryState.RequestPending)).toBeTrue(); + expect(isLoading(RequestEntryState.ResponsePending)).toBeTrue(); + expect(isLoading(RequestEntryState.ResponsePendingStale)).toBeTrue(); + + expect(isLoading(RequestEntryState.Error)).toBeFalse(); + expect(isLoading(RequestEntryState.Success)).toBeFalse(); + expect(isLoading(RequestEntryState.ErrorStale)).toBeFalse(); + expect(isLoading(RequestEntryState.SuccessStale)).toBeFalse(); + }); +}); + +describe(`hasFailed`, () => { + describe(`when the state is loading`, () => { + it(`should return undefined`, () => { + expect(hasFailed(RequestEntryState.RequestPending)).toBeUndefined(); + expect(hasFailed(RequestEntryState.ResponsePending)).toBeUndefined(); + expect(hasFailed(RequestEntryState.ResponsePendingStale)).toBeUndefined(); + }); + }); + + describe(`when the state has completed`, () => { + it(`should only return true if the given state is Error or ErrorStale`, () => { + expect(hasFailed(RequestEntryState.Error)).toBeTrue(); + expect(hasFailed(RequestEntryState.ErrorStale)).toBeTrue(); + + expect(hasFailed(RequestEntryState.Success)).toBeFalse(); + expect(hasFailed(RequestEntryState.SuccessStale)).toBeFalse(); + }); + }); +}); + +describe(`hasSucceeded`, () => { + describe(`when the state is loading`, () => { + it(`should return undefined`, () => { + expect(hasSucceeded(RequestEntryState.RequestPending)).toBeUndefined(); + expect(hasSucceeded(RequestEntryState.ResponsePending)).toBeUndefined(); + expect(hasSucceeded(RequestEntryState.ResponsePendingStale)).toBeUndefined(); + }); + }); + + describe(`when the state has completed`, () => { + it(`should only return true if the given state is Error or ErrorStale`, () => { + expect(hasSucceeded(RequestEntryState.Success)).toBeTrue(); + expect(hasSucceeded(RequestEntryState.SuccessStale)).toBeTrue(); + + expect(hasSucceeded(RequestEntryState.Error)).toBeFalse(); + expect(hasSucceeded(RequestEntryState.ErrorStale)).toBeFalse(); + }); + }); +}); + + +describe(`hasCompleted`, () => { + it(`should only return true if the given state is Error, Success, ErrorStale or SuccessStale`, () => { + expect(hasCompleted(RequestEntryState.Error)).toBeTrue(); + expect(hasCompleted(RequestEntryState.Success)).toBeTrue(); + expect(hasCompleted(RequestEntryState.ErrorStale)).toBeTrue(); + expect(hasCompleted(RequestEntryState.SuccessStale)).toBeTrue(); + + expect(hasCompleted(RequestEntryState.RequestPending)).toBeFalse(); + expect(hasCompleted(RequestEntryState.ResponsePending)).toBeFalse(); + expect(hasCompleted(RequestEntryState.ResponsePendingStale)).toBeFalse(); + }); +}); + +describe(`isStale`, () => { + it(`should only return true if the given state is ResponsePendingStale, SuccessStale or ErrorStale`, () => { + expect(isStale(RequestEntryState.ResponsePendingStale)).toBeTrue(); + expect(isStale(RequestEntryState.SuccessStale)).toBeTrue(); + expect(isStale(RequestEntryState.ErrorStale)).toBeTrue(); + + expect(isStale(RequestEntryState.RequestPending)).toBeFalse(); + expect(isStale(RequestEntryState.ResponsePending)).toBeFalse(); + expect(isStale(RequestEntryState.Error)).toBeFalse(); + expect(isStale(RequestEntryState.Success)).toBeFalse(); + }); +}); diff --git a/src/app/core/data/request-entry-state.model.ts b/src/app/core/data/request-entry-state.model.ts index a813b6e743..3aeace39d2 100644 --- a/src/app/core/data/request-entry-state.model.ts +++ b/src/app/core/data/request-entry-state.model.ts @@ -3,8 +3,9 @@ export enum RequestEntryState { ResponsePending = 'ResponsePending', Error = 'Error', Success = 'Success', + ResponsePendingStale = 'ResponsePendingStale', ErrorStale = 'ErrorStale', - SuccessStale = 'SuccessStale' + SuccessStale = 'SuccessStale', } /** @@ -42,12 +43,21 @@ export const isSuccessStale = (state: RequestEntryState) => */ export const isResponsePending = (state: RequestEntryState) => state === RequestEntryState.ResponsePending; + /** - * Returns true if the given state is RequestPending or ResponsePending, - * false otherwise + * Returns true if the given state is ResponsePendingStale, false otherwise + */ +export const isResponsePendingStale = (state: RequestEntryState) => + state === RequestEntryState.ResponsePendingStale; + +/** + * Returns true if the given state is RequestPending, RequestPendingStale, ResponsePending, or + * ResponsePendingStale, false otherwise */ export const isLoading = (state: RequestEntryState) => - isRequestPending(state) || isResponsePending(state); + isRequestPending(state) || + isResponsePending(state) || + isResponsePendingStale(state); /** * If isLoading is true for the given state, this method returns undefined, we can't know yet. @@ -82,7 +92,10 @@ export const hasCompleted = (state: RequestEntryState) => !isLoading(state); /** - * Returns true if the given state is SuccessStale or ErrorStale, false otherwise + * Returns true if the given state is isRequestPendingStale, isResponsePendingStale, SuccessStale or + * ErrorStale, false otherwise */ export const isStale = (state: RequestEntryState) => - isSuccessStale(state) || isErrorStale(state); + isResponsePendingStale(state) || + isSuccessStale(state) || + isErrorStale(state); diff --git a/src/app/core/data/request.reducer.spec.ts b/src/app/core/data/request.reducer.spec.ts index 05f074a96a..86b9c4cd5d 100644 --- a/src/app/core/data/request.reducer.spec.ts +++ b/src/app/core/data/request.reducer.spec.ts @@ -48,9 +48,16 @@ describe('requestReducer', () => { lastUpdated: 0 } }; + const testResponsePendingState = { + [id1]: { + state: RequestEntryState.ResponsePending, + lastUpdated: 0 + } + }; deepFreeze(testInitState); deepFreeze(testSuccessState); deepFreeze(testErrorState); + deepFreeze(testResponsePendingState); it('should return the current state when no valid actions have been made', () => { const action = new NullAction(); @@ -91,29 +98,94 @@ describe('requestReducer', () => { expect(newState[id1].response).toEqual(undefined); }); - it('should set state to Success for the given RestRequest in the state, in response to a SUCCESS action', () => { - const state = testInitState; + describe(`in response to a SUCCESS action`, () => { + let startState; + describe(`when the entry isn't stale`, () => { + beforeEach(() => { + startState = Object.assign({}, testInitState, { + [id1]: Object.assign({}, testInitState[id1], { + state: RequestEntryState.ResponsePending + }) + }); + deepFreeze(startState); + }); + it('should set state to Success for the given RestRequest in the state', () => { + const action = new RequestSuccessAction(id1, 200); + const newState = requestReducer(startState, action); - const action = new RequestSuccessAction(id1, 200); - const newState = requestReducer(state, action); + expect(newState[id1].request.uuid).toEqual(id1); + expect(newState[id1].request.href).toEqual(link1); + expect(newState[id1].state).toEqual(RequestEntryState.Success); + expect(newState[id1].response.statusCode).toEqual(200); + }); + }); + + describe(`when the entry is stale`, () => { + beforeEach(() => { + startState = Object.assign({}, testInitState, { + [id1]: Object.assign({}, testInitState[id1], { + state: RequestEntryState.ResponsePendingStale + }) + }); + deepFreeze(startState); + }); + it('should set state to SuccessStale for the given RestRequest in the state', () => { + const action = new RequestSuccessAction(id1, 200); + const newState = requestReducer(startState, action); + + expect(newState[id1].request.uuid).toEqual(id1); + expect(newState[id1].request.href).toEqual(link1); + expect(newState[id1].state).toEqual(RequestEntryState.SuccessStale); + expect(newState[id1].response.statusCode).toEqual(200); + }); + }); - expect(newState[id1].request.uuid).toEqual(id1); - expect(newState[id1].request.href).toEqual(link1); - expect(newState[id1].state).toEqual(RequestEntryState.Success); - expect(newState[id1].response.statusCode).toEqual(200); }); - it('should set state to Error for the given RestRequest in the state, in response to an ERROR action', () => { - const state = testInitState; + describe(`in response to an ERROR action`, () => { + let startState; + describe(`when the entry isn't stale`, () => { + beforeEach(() => { + startState = Object.assign({}, testInitState, { + [id1]: Object.assign({}, testInitState[id1], { + state: RequestEntryState.ResponsePending + }) + }); + deepFreeze(startState); + }); + it('should set state to Error for the given RestRequest in the state', () => { + const action = new RequestErrorAction(id1, 404, 'Not Found'); + const newState = requestReducer(startState, action); - const action = new RequestErrorAction(id1, 404, 'Not Found'); - const newState = requestReducer(state, action); + expect(newState[id1].request.uuid).toEqual(id1); + expect(newState[id1].request.href).toEqual(link1); + expect(newState[id1].state).toEqual(RequestEntryState.Error); + expect(newState[id1].response.statusCode).toEqual(404); + expect(newState[id1].response.errorMessage).toEqual('Not Found'); + }); + }); - expect(newState[id1].request.uuid).toEqual(id1); - expect(newState[id1].request.href).toEqual(link1); - expect(newState[id1].state).toEqual(RequestEntryState.Error); - expect(newState[id1].response.statusCode).toEqual(404); - expect(newState[id1].response.errorMessage).toEqual('Not Found'); + describe(`when the entry is stale`, () => { + beforeEach(() => { + startState = Object.assign({}, testInitState, { + [id1]: Object.assign({}, testInitState[id1], { + state: RequestEntryState.ResponsePendingStale + }) + }); + deepFreeze(startState); + }); + it('should set state to ErrorStale for the given RestRequest in the state', () => { + const action = new RequestErrorAction(id1, 404, 'Not Found'); + const newState = requestReducer(startState, action); + + expect(newState[id1].request.uuid).toEqual(id1); + expect(newState[id1].request.href).toEqual(link1); + expect(newState[id1].state).toEqual(RequestEntryState.ErrorStale); + expect(newState[id1].response.statusCode).toEqual(404); + expect(newState[id1].response.errorMessage).toEqual('Not Found'); + }); + + }); }); it('should update the response\'s timeCompleted for the given RestRequest in the state, in response to a RESET_TIMESTAMPS action', () => { @@ -145,28 +217,112 @@ describe('requestReducer', () => { expect(newState[id1]).toBeNull(); }); - describe(`for an entry with state: Success`, () => { - it(`should set the state to SuccessStale, in response to a STALE action`, () => { - const state = testSuccessState; + describe(`in response to a STALE action`, () => { + describe(`when the entry has been removed`, () => { + it(`shouldn't do anything`, () => { + const startState = { + [id1]: null + }; + deepFreeze(startState); - const action = new RequestStaleAction(id1); - const newState = requestReducer(state, action); + const action = new RequestStaleAction(id1); + const newState = requestReducer(startState, action); - expect(newState[id1].state).toEqual(RequestEntryState.SuccessStale); - expect(newState[id1].lastUpdated).toBe(action.lastUpdated); + expect(newState[id1]).toBeNull(); + }); + }); + + describe(`for stale entries`, () => { + it(`shouldn't do anything`, () => { + const rpsStartState = Object.assign({}, testInitState, { + [id1]: Object.assign({}, testInitState[id1], { + state: RequestEntryState.ResponsePendingStale + }) + }); + deepFreeze(rpsStartState); + + const action = new RequestStaleAction(id1); + let newState = requestReducer(rpsStartState, action); + + expect(newState[id1].state).toEqual(rpsStartState[id1].state); + expect(newState[id1].lastUpdated).toBe(rpsStartState[id1].lastUpdated); + + const ssStartState = Object.assign({}, testInitState, { + [id1]: Object.assign({}, testInitState[id1], { + state: RequestEntryState.SuccessStale + }) + }); + + newState = requestReducer(ssStartState, action); + + expect(newState[id1].state).toEqual(ssStartState[id1].state); + expect(newState[id1].lastUpdated).toBe(ssStartState[id1].lastUpdated); + + const esStartState = Object.assign({}, testInitState, { + [id1]: Object.assign({}, testInitState[id1], { + state: RequestEntryState.ErrorStale + }) + }); + + newState = requestReducer(esStartState, action); + + expect(newState[id1].state).toEqual(esStartState[id1].state); + expect(newState[id1].lastUpdated).toBe(esStartState[id1].lastUpdated); + + }); + }); + + describe(`for and entry with state: RequestPending`, () => { + it(`shouldn't do anything`, () => { + const startState = Object.assign({}, testInitState, { + [id1]: Object.assign({}, testInitState[id1], { + state: RequestEntryState.RequestPending + }) + }); + + const action = new RequestStaleAction(id1); + const newState = requestReducer(startState, action); + + expect(newState[id1].state).toEqual(startState[id1].state); + expect(newState[id1].lastUpdated).toBe(startState[id1].lastUpdated); + + }); + }); + + describe(`for an entry with state: ResponsePending`, () => { + it(`should set the state to ResponsePendingStale`, () => { + const state = testResponsePendingState; + + const action = new RequestStaleAction(id1); + const newState = requestReducer(state, action); + + expect(newState[id1].state).toEqual(RequestEntryState.ResponsePendingStale); + expect(newState[id1].lastUpdated).toBe(action.lastUpdated); + }); + }); + + describe(`for an entry with state: Success`, () => { + it(`should set the state to SuccessStale`, () => { + const state = testSuccessState; + + const action = new RequestStaleAction(id1); + const newState = requestReducer(state, action); + + expect(newState[id1].state).toEqual(RequestEntryState.SuccessStale); + expect(newState[id1].lastUpdated).toBe(action.lastUpdated); + }); + }); + + describe(`for an entry with state: Error`, () => { + it(`should set the state to ErrorStale`, () => { + const state = testErrorState; + + const action = new RequestStaleAction(id1); + const newState = requestReducer(state, action); + + expect(newState[id1].state).toEqual(RequestEntryState.ErrorStale); + expect(newState[id1].lastUpdated).toBe(action.lastUpdated); + }); }); }); - - describe(`for an entry with state: Error`, () => { - it(`should set the state to ErrorStale, in response to a STALE action`, () => { - const state = testErrorState; - - const action = new RequestStaleAction(id1); - const newState = requestReducer(state, action); - - expect(newState[id1].state).toEqual(RequestEntryState.ErrorStale); - expect(newState[id1].lastUpdated).toBe(action.lastUpdated); - }); - }); - }); diff --git a/src/app/core/data/request.reducer.ts b/src/app/core/data/request.reducer.ts index 9bf17faf8d..9cf4fee0e2 100644 --- a/src/app/core/data/request.reducer.ts +++ b/src/app/core/data/request.reducer.ts @@ -11,7 +11,13 @@ import { ResetResponseTimestampsAction } from './request.actions'; import { isNull } from '../../shared/empty.util'; -import { hasSucceeded, isStale, RequestEntryState } from './request-entry-state.model'; +import { + hasSucceeded, + isStale, + RequestEntryState, + isRequestPending, + isResponsePending +} from './request-entry-state.model'; import { RequestState } from './request-state.model'; // Object.create(null) ensures the object has no default js properties (e.g. `__proto__`) @@ -91,14 +97,17 @@ function executeRequest(storeState: RequestState, action: RequestExecuteAction): * the new storeState, with the response added to the request */ function completeSuccessRequest(storeState: RequestState, action: RequestSuccessAction): RequestState { - if (isNull(storeState[action.payload.uuid])) { + const prevEntry = storeState[action.payload.uuid]; + if (isNull(prevEntry)) { // after a request has been removed it's possible pending changes still come in. // Don't store them return storeState; } else { return Object.assign({}, storeState, { - [action.payload.uuid]: Object.assign({}, storeState[action.payload.uuid], { - state: RequestEntryState.Success, + [action.payload.uuid]: Object.assign({}, prevEntry, { + // If a response comes in for a request that's already stale, still store it otherwise + // components that are waiting for it might freeze + state: isStale(prevEntry.state) ? RequestEntryState.SuccessStale : RequestEntryState.Success, response: { timeCompleted: action.payload.timeCompleted, lastUpdated: action.payload.timeCompleted, @@ -124,14 +133,17 @@ function completeSuccessRequest(storeState: RequestState, action: RequestSuccess * the new storeState, with the response added to the request */ function completeFailedRequest(storeState: RequestState, action: RequestErrorAction): RequestState { - if (isNull(storeState[action.payload.uuid])) { + const prevEntry = storeState[action.payload.uuid]; + if (isNull(prevEntry)) { // after a request has been removed it's possible pending changes still come in. // Don't store them return storeState; } else { return Object.assign({}, storeState, { - [action.payload.uuid]: Object.assign({}, storeState[action.payload.uuid], { - state: RequestEntryState.Error, + [action.payload.uuid]: Object.assign({}, prevEntry, { + // If a response comes in for a request that's already stale, still store it otherwise + // components that are waiting for it might freeze + state: isStale(prevEntry.state) ? RequestEntryState.ErrorStale : RequestEntryState.Error, response: { timeCompleted: action.payload.timeCompleted, lastUpdated: action.payload.timeCompleted, @@ -155,22 +167,27 @@ function completeFailedRequest(storeState: RequestState, action: RequestErrorAct * the new storeState, set to stale */ function expireRequest(storeState: RequestState, action: RequestStaleAction): RequestState { - if (isNull(storeState[action.payload.uuid])) { - // after a request has been removed it's possible pending changes still come in. - // Don't store them + const prevEntry = storeState[action.payload.uuid]; + if (isNull(prevEntry) || isStale(prevEntry.state) || isRequestPending(prevEntry.state)) { + // No need to do anything if the entry doesn't exist, is already stale, or if the request is + // still pending, because that means it still needs to be sent to the server. Any response + // is guaranteed to have been generated after the request was set to stale. return storeState; } else { - const prevEntry = storeState[action.payload.uuid]; - if (isStale(prevEntry.state)) { - return storeState; + let nextRequestEntryState: RequestEntryState; + if (isResponsePending(prevEntry.state)) { + nextRequestEntryState = RequestEntryState.ResponsePendingStale; + } else if (hasSucceeded(prevEntry.state)) { + nextRequestEntryState = RequestEntryState.SuccessStale; } else { - return Object.assign({}, storeState, { - [action.payload.uuid]: Object.assign({}, prevEntry, { - state: hasSucceeded(prevEntry.state) ? RequestEntryState.SuccessStale : RequestEntryState.ErrorStale, - lastUpdated: action.lastUpdated - }) - }); + nextRequestEntryState = RequestEntryState.ErrorStale; } + return Object.assign({}, storeState, { + [action.payload.uuid]: Object.assign({}, prevEntry, { + state: nextRequestEntryState, + lastUpdated: action.lastUpdated + }) + }); } } diff --git a/src/app/core/data/request.service.spec.ts b/src/app/core/data/request.service.spec.ts index 108a588881..61091c7940 100644 --- a/src/app/core/data/request.service.spec.ts +++ b/src/app/core/data/request.service.spec.ts @@ -1,6 +1,6 @@ import { Store, StoreModule } from '@ngrx/store'; import { cold, getTestScheduler } from 'jasmine-marbles'; -import { EMPTY, of as observableOf } from 'rxjs'; +import { EMPTY, Observable, of as observableOf } from 'rxjs'; import { TestScheduler } from 'rxjs/testing'; import { getMockObjectCacheService } from '../../shared/mocks/object-cache.service.mock'; @@ -638,4 +638,87 @@ describe('RequestService', () => { expect(done$).toBeObservable(cold('-----(t|)', { t: true })); })); }); + + describe('setStaleByHref', () => { + const uuid = 'c574a42c-4818-47ac-bbe1-6c3cd622c81f'; + const href = 'https://rest.api/some/object'; + const freshRE: any = { + request: { uuid, href }, + state: RequestEntryState.Success + }; + const staleRE: any = { + request: { uuid, href }, + state: RequestEntryState.SuccessStale + }; + + it(`should call getByHref to retrieve the RequestEntry matching the href`, () => { + spyOn(service, 'getByHref').and.returnValue(observableOf(staleRE)); + service.setStaleByHref(href); + expect(service.getByHref).toHaveBeenCalledWith(href); + }); + + it(`should dispatch a RequestStaleAction for the RequestEntry returned by getByHref`, (done: DoneFn) => { + spyOn(service, 'getByHref').and.returnValue(observableOf(staleRE)); + spyOn(store, 'dispatch'); + service.setStaleByHref(href).subscribe(() => { + const requestStaleAction = new RequestStaleAction(uuid); + requestStaleAction.lastUpdated = jasmine.any(Number) as any; + expect(store.dispatch).toHaveBeenCalledWith(requestStaleAction); + done(); + }); + }); + + it(`should emit true when the request in the store is stale`, () => { + spyOn(service, 'getByHref').and.returnValue(cold('a-b', { + a: freshRE, + b: staleRE + })); + const result$ = service.setStaleByHref(href); + expect(result$).toBeObservable(cold('--(c|)', { c: true })); + }); + }); + + describe('setStaleByHrefSubstring', () => { + let dispatchSpy: jasmine.Spy; + let getByUUIDSpy: jasmine.Spy; + + beforeEach(() => { + dispatchSpy = spyOn(store, 'dispatch'); + getByUUIDSpy = spyOn(service, 'getByUUID').and.callThrough(); + }); + + describe('with an empty/no matching requests in the state', () => { + it('should return true', () => { + const done$: Observable = service.setStaleByHrefSubstring('https://rest.api/endpoint/selfLink'); + expect(done$).toBeObservable(cold('(a|)', { a: true })); + }); + }); + + describe('with a matching request in the state', () => { + beforeEach(() => { + const state = Object.assign({}, initialState, { + core: Object.assign({}, initialState.core, { + 'index': { + 'get-request/href-to-uuid': { + 'https://rest.api/endpoint/selfLink': '5f2a0d2a-effa-4d54-bd54-5663b960f9eb' + } + } + }) + }); + mockStore.setState(state); + }); + + it('should return an Observable that emits true as soon as the request is stale', () => { + dispatchSpy.and.callFake(() => { /* empty */ }); // don't actually set as stale + getByUUIDSpy.and.returnValue(cold('a-b--c--d-', { // but fake the state in the cache + a: { state: RequestEntryState.ResponsePending }, + b: { state: RequestEntryState.Success }, + c: { state: RequestEntryState.SuccessStale }, + d: { state: RequestEntryState.Error }, + })); + const done$: Observable = service.setStaleByHrefSubstring('https://rest.api/endpoint/selfLink'); + expect(done$).toBeObservable(cold('-----(a|)', { a: true })); + }); + }); + }); }); diff --git a/src/app/core/data/request.service.ts b/src/app/core/data/request.service.ts index 1f6680203e..9f43c3f599 100644 --- a/src/app/core/data/request.service.ts +++ b/src/app/core/data/request.service.ts @@ -2,8 +2,8 @@ import { Injectable } from '@angular/core'; import { HttpHeaders } from '@angular/common/http'; import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store'; -import { Observable } from 'rxjs'; -import { filter, map, take, tap } from 'rxjs/operators'; +import { Observable, from as observableFrom } from 'rxjs'; +import { filter, find, map, mergeMap, switchMap, take, tap, toArray } from 'rxjs/operators'; import cloneDeep from 'lodash/cloneDeep'; import { hasValue, isEmpty, isNotEmpty, hasNoValue } from '../../shared/empty.util'; import { ObjectCacheEntry } from '../cache/object-cache.reducer'; @@ -16,7 +16,7 @@ import { RequestExecuteAction, RequestStaleAction } from './request.actions'; -import { GetRequest} from './request.models'; +import { GetRequest } from './request.models'; import { CommitSSBAction } from '../cache/server-sync-buffer.actions'; import { RestRequestMethod } from './rest-request-method'; import { coreSelector } from '../core.selectors'; @@ -164,7 +164,7 @@ export class RequestService { this.getByHref(request.href).pipe( take(1)) .subscribe((re: RequestEntry) => { - isPending = (hasValue(re) && isLoading(re.state)); + isPending = (hasValue(re) && isLoading(re.state) && !isStale(re.state)); }); return isPending; } @@ -300,22 +300,42 @@ export class RequestService { * Set all requests that match (part of) the href to stale * * @param href A substring of the request(s) href - * @return Returns an observable emitting whether or not the cache is removed + * @return Returns an observable emitting when those requests are all stale */ setStaleByHrefSubstring(href: string): Observable { - this.store.pipe( + const requestUUIDs$ = this.store.pipe( select(uuidsFromHrefSubstringSelector(requestIndexSelector, href)), take(1) - ).subscribe((uuids: string[]) => { + ); + requestUUIDs$.subscribe((uuids: string[]) => { for (const uuid of uuids) { this.store.dispatch(new RequestStaleAction(uuid)); } }); this.requestsOnTheirWayToTheStore = this.requestsOnTheirWayToTheStore.filter((reqHref: string) => reqHref.indexOf(href) < 0); - return this.store.pipe( - select(uuidsFromHrefSubstringSelector(requestIndexSelector, href)), - map((uuids) => isEmpty(uuids)) + // emit true after all requests are stale + return requestUUIDs$.pipe( + switchMap((uuids: string[]) => { + if (isEmpty(uuids)) { + // if there were no matching requests, emit true immediately + return [true]; + } else { + // otherwise emit all request uuids in order + return observableFrom(uuids).pipe( + // retrieve the RequestEntry for each uuid + mergeMap((uuid: string) => this.getByUUID(uuid)), + // check whether it is undefined or stale + map((request: RequestEntry) => hasNoValue(request) || isStale(request.state)), + // if it is, complete + find((stale: boolean) => stale === true), + // after all observables above are completed, emit them as a single array + toArray(), + // when the array comes in, emit true + map(() => true) + ); + } + }) ); } @@ -331,7 +351,29 @@ export class RequestService { map((request: RequestEntry) => isStale(request.state)), filter((stale: boolean) => stale), take(1), - ); + ); + } + + /** + * Mark a request as stale + * @param href the href of the request + * @return an Observable that will emit true once the Request becomes stale + */ + setStaleByHref(href: string): Observable { + const requestEntry$ = this.getByHref(href); + + requestEntry$.pipe( + map((re: RequestEntry) => re.request.uuid), + take(1), + ).subscribe((uuid: string) => { + this.store.dispatch(new RequestStaleAction(uuid)); + }); + + return requestEntry$.pipe( + map((request: RequestEntry) => isStale(request.state)), + filter((stale: boolean) => stale), + take(1) + ); } /** @@ -344,10 +386,10 @@ export class RequestService { // if it's not a GET request if (request.method !== RestRequestMethod.GET) { return true; - // if it is a GET request, check it isn't pending + // if it is a GET request, check it isn't pending } else if (this.isPending(request)) { return false; - // if it is pending, check if we're allowed to use a cached version + // if it is pending, check if we're allowed to use a cached version } else if (!useCachedVersionIfAvailable) { return true; } else { diff --git a/src/app/core/data/root-data.service.spec.ts b/src/app/core/data/root-data.service.spec.ts index b65449d007..c34ad37531 100644 --- a/src/app/core/data/root-data.service.spec.ts +++ b/src/app/core/data/root-data.service.spec.ts @@ -1,16 +1,18 @@ import { RootDataService } from './root-data.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; -import { Observable, of } from 'rxjs'; +import { + createSuccessfulRemoteDataObject$, + createFailedRemoteDataObject$ +} from '../../shared/remote-data.utils'; +import { Observable } from 'rxjs'; import { RemoteData } from './remote-data'; import { Root } from './root.model'; -import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; import { cold } from 'jasmine-marbles'; describe('RootDataService', () => { let service: RootDataService; let halService: HALEndpointService; - let restService; + let requestService; let rootEndpoint; let findByHrefSpy; @@ -19,10 +21,10 @@ describe('RootDataService', () => { halService = jasmine.createSpyObj('halService', { getRootHref: rootEndpoint, }); - restService = jasmine.createSpyObj('halService', { - get: jasmine.createSpy('get'), - }); - service = new RootDataService(null, null, null, halService, restService); + requestService = jasmine.createSpyObj('requestService', [ + 'setStaleByHref', + ]); + service = new RootDataService(requestService, null, null, halService); findByHrefSpy = spyOn(service as any, 'findByHref'); findByHrefSpy.and.returnValue(createSuccessfulRemoteDataObject$({})); @@ -47,12 +49,8 @@ describe('RootDataService', () => { let result$: Observable; it('should return observable of true when root endpoint is available', () => { - const mockResponse = { - statusCode: 200, - statusText: 'OK' - } as RawRestResponse; + spyOn(service, 'findRoot').and.returnValue(createSuccessfulRemoteDataObject$({} as any)); - restService.get.and.returnValue(of(mockResponse)); result$ = service.checkServerAvailability(); expect(result$).toBeObservable(cold('(a|)', { @@ -61,12 +59,8 @@ describe('RootDataService', () => { }); it('should return observable of false when root endpoint is not available', () => { - const mockResponse = { - statusCode: 500, - statusText: 'Internal Server Error' - } as RawRestResponse; + spyOn(service, 'findRoot').and.returnValue(createFailedRemoteDataObject$('500')); - restService.get.and.returnValue(of(mockResponse)); result$ = service.checkServerAvailability(); expect(result$).toBeObservable(cold('(a|)', { @@ -75,4 +69,12 @@ describe('RootDataService', () => { }); }); + + describe(`invalidateRootCache`, () => { + it(`should set the cached root request to stale`, () => { + service.invalidateRootCache(); + expect(halService.getRootHref).toHaveBeenCalled(); + expect(requestService.setStaleByHref).toHaveBeenCalledWith(rootEndpoint); + }); + }); }); diff --git a/src/app/core/data/root-data.service.ts b/src/app/core/data/root-data.service.ts index 54fe614d3e..5431a2d1fb 100644 --- a/src/app/core/data/root-data.service.ts +++ b/src/app/core/data/root-data.service.ts @@ -7,12 +7,11 @@ import { HALEndpointService } from '../shared/hal-endpoint.service'; import { Observable, of as observableOf } from 'rxjs'; import { RemoteData } from './remote-data'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { DspaceRestService } from '../dspace-rest/dspace-rest.service'; -import { RawRestResponse } from '../dspace-rest/raw-rest-response.model'; import { catchError, map } from 'rxjs/operators'; import { BaseDataService } from './base/base-data.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { dataService } from './base/data-service.decorator'; +import { getFirstCompletedRemoteData } from '../shared/operators'; /** * A service to retrieve the {@link Root} object from the REST API. @@ -25,7 +24,6 @@ export class RootDataService extends BaseDataService { protected rdbService: RemoteDataBuildService, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, - protected restService: DspaceRestService, ) { super('', requestService, rdbService, objectCache, halService, 6 * 60 * 60 * 1000); } @@ -34,12 +32,13 @@ export class RootDataService extends BaseDataService { * Check if root endpoint is available */ checkServerAvailability(): Observable { - return this.restService.get(this.halService.getRootHref()).pipe( + return this.findRoot().pipe( catchError((err ) => { console.error(err); return observableOf(false); }), - map((res: RawRestResponse) => res.statusCode === 200) + getFirstCompletedRemoteData(), + map((rootRd: RemoteData) => rootRd.statusCode === 200) ); } @@ -60,6 +59,6 @@ export class RootDataService extends BaseDataService { * Set to sale the root endpoint cache hit */ invalidateRootCache() { - this.requestService.setStaleByHrefSubstring(this.halService.getRootHref()); + this.requestService.setStaleByHref(this.halService.getRootHref()); } } diff --git a/src/app/core/eperson/eperson-data.service.spec.ts b/src/app/core/eperson/eperson-data.service.spec.ts index b4b939eebf..c1bc3563a3 100644 --- a/src/app/core/eperson/eperson-data.service.spec.ts +++ b/src/app/core/eperson/eperson-data.service.spec.ts @@ -11,6 +11,7 @@ import { EPeopleRegistryCancelEPersonAction, EPeopleRegistryEditEPersonAction } from '../../access-control/epeople-registry/epeople-registry.actions'; +import { GroupMock } from '../../shared/testing/group-mock'; import { RequestParam } from '../cache/models/request-param.model'; import { ChangeAnalyzer } from '../data/change-analyzer'; import { PatchRequest, PostRequest } from '../data/request.models'; @@ -140,6 +141,30 @@ describe('EPersonDataService', () => { }); }); + describe('searchNonMembers', () => { + beforeEach(() => { + spyOn(service, 'searchBy'); + }); + + it('search with empty query and a group ID', () => { + service.searchNonMembers('', GroupMock.id); + const options = Object.assign(new FindListOptions(), { + searchParams: [Object.assign(new RequestParam('query', '')), + Object.assign(new RequestParam('group', GroupMock.id))] + }); + expect(service.searchBy).toHaveBeenCalledWith('isNotMemberOf', options, true, true); + }); + + it('search with query and a group ID', () => { + service.searchNonMembers('test', GroupMock.id); + const options = Object.assign(new FindListOptions(), { + searchParams: [Object.assign(new RequestParam('query', 'test')), + Object.assign(new RequestParam('group', GroupMock.id))] + }); + expect(service.searchBy).toHaveBeenCalledWith('isNotMemberOf', options, true, true); + }); + }); + describe('updateEPerson', () => { beforeEach(() => { spyOn(service, 'findByHref').and.returnValue(createSuccessfulRemoteDataObject$(EPersonMock)); diff --git a/src/app/core/eperson/eperson-data.service.ts b/src/app/core/eperson/eperson-data.service.ts index d30030365c..a85d471e7d 100644 --- a/src/app/core/eperson/eperson-data.service.ts +++ b/src/app/core/eperson/eperson-data.service.ts @@ -34,6 +34,7 @@ import { PatchData, PatchDataImpl } from '../data/base/patch-data'; import { DeleteData, DeleteDataImpl } from '../data/base/delete-data'; import { RestRequestMethod } from '../data/rest-request-method'; import { dataService } from '../data/base/data-service.decorator'; +import { getEPersonEditRoute } from '../../access-control/access-control-routing-paths'; const ePeopleRegistryStateSelector = (state: AppState) => state.epeopleRegistry; const editEPersonSelector = createSelector(ePeopleRegistryStateSelector, (ePeopleRegistryState: EPeopleRegistryState) => ePeopleRegistryState.editEPerson); @@ -176,6 +177,34 @@ export class EPersonDataService extends IdentifiableDataService impleme return this.searchBy(searchMethod, findListOptions, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); } + /** + * Searches for all EPerons which are *not* a member of a given group, via a passed in query + * (searches all EPerson metadata and by exact UUID). + * Endpoint used: /eperson/epesons/search/isNotMemberOf?query=<:string>&group=<:uuid> + * @param query search query param + * @param group UUID of group to exclude results from. Members of this group will never be returned. + * @param options + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + */ + public searchNonMembers(query: string, group: string, options?: FindListOptions, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { + const searchParams = [new RequestParam('query', query), new RequestParam('group', group)]; + let findListOptions = new FindListOptions(); + if (options) { + findListOptions = Object.assign(new FindListOptions(), options); + } + if (findListOptions.searchParams) { + findListOptions.searchParams = [...findListOptions.searchParams, ...searchParams]; + } else { + findListOptions.searchParams = searchParams; + } + return this.searchBy('isNotMemberOf', findListOptions, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + /** * Add a new patch to the object cache * The patch is derived from the differences between the given object and its version in the object cache @@ -281,15 +310,7 @@ export class EPersonDataService extends IdentifiableDataService impleme this.editEPerson(ePerson); } }); - return '/access-control/epeople'; - } - - /** - * Get EPeople admin page - * @param ePerson New EPerson to edit - */ - public getEPeoplePageRouterLink(): string { - return '/access-control/epeople'; + return getEPersonEditRoute(ePerson.id); } /** diff --git a/src/app/core/eperson/group-data.service.spec.ts b/src/app/core/eperson/group-data.service.spec.ts index b424aed1aa..6eecfd7fa1 100644 --- a/src/app/core/eperson/group-data.service.spec.ts +++ b/src/app/core/eperson/group-data.service.spec.ts @@ -43,11 +43,11 @@ describe('GroupDataService', () => { let rdbService; let objectCache; function init() { - restEndpointURL = 'https://dspace.4science.it/dspace-spring-rest/api/eperson'; + restEndpointURL = 'https://rest.api/server/api/eperson'; groupsEndpoint = `${restEndpointURL}/groups`; groups = [GroupMock, GroupMock2]; groups$ = createSuccessfulRemoteDataObject$(createPaginatedList(groups)); - rdbService = getMockRemoteDataBuildServiceHrefMap(undefined, { 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups': groups$ }); + rdbService = getMockRemoteDataBuildServiceHrefMap(undefined, { 'https://rest.api/server/api/eperson/groups': groups$ }); halService = new HALEndpointServiceStub(restEndpointURL); objectCache = getMockObjectCacheService(); TestBed.configureTestingModule({ @@ -111,6 +111,30 @@ describe('GroupDataService', () => { }); }); + describe('searchNonMemberGroups', () => { + beforeEach(() => { + spyOn(service, 'searchBy'); + }); + + it('search with empty query and a group ID', () => { + service.searchNonMemberGroups('', GroupMock.id); + const options = Object.assign(new FindListOptions(), { + searchParams: [Object.assign(new RequestParam('query', '')), + Object.assign(new RequestParam('group', GroupMock.id))] + }); + expect(service.searchBy).toHaveBeenCalledWith('isNotMemberOf', options, true, true); + }); + + it('search with query and a group ID', () => { + service.searchNonMemberGroups('test', GroupMock.id); + const options = Object.assign(new FindListOptions(), { + searchParams: [Object.assign(new RequestParam('query', 'test')), + Object.assign(new RequestParam('group', GroupMock.id))] + }); + expect(service.searchBy).toHaveBeenCalledWith('isNotMemberOf', options, true, true); + }); + }); + describe('addSubGroupToGroup', () => { beforeEach(() => { objectCache.getByHref.and.returnValue(observableOf({ diff --git a/src/app/core/eperson/group-data.service.ts b/src/app/core/eperson/group-data.service.ts index bb38e46758..683d026bb6 100644 --- a/src/app/core/eperson/group-data.service.ts +++ b/src/app/core/eperson/group-data.service.ts @@ -3,7 +3,7 @@ import { Injectable } from '@angular/core'; import { createSelector, select, Store } from '@ngrx/store'; import { Observable, zip as observableZip } from 'rxjs'; -import { filter, map, take } from 'rxjs/operators'; +import { take } from 'rxjs/operators'; import { GroupRegistryCancelGroupAction, GroupRegistryEditGroupAction @@ -40,6 +40,7 @@ import { DeleteData, DeleteDataImpl } from '../data/base/delete-data'; import { Operation } from 'fast-json-patch'; import { RestRequestMethod } from '../data/rest-request-method'; import { dataService } from '../data/base/data-service.decorator'; +import { getGroupEditRoute } from '../../access-control/access-control-routing-paths'; const groupRegistryStateSelector = (state: AppState) => state.groupRegistry; const editGroupSelector = createSelector(groupRegistryStateSelector, (groupRegistryState: GroupRegistryState) => groupRegistryState.editGroup); @@ -104,23 +105,31 @@ export class GroupDataService extends IdentifiableDataService implements } /** - * Check if the current user is member of to the indicated group - * - * @param groupName - * the group name - * @return boolean - * true if user is member of the indicated group, false otherwise + * Searches for all groups which are *not* a member of a given group, via a passed in query + * (searches in group name and by exact UUID). + * Endpoint used: /eperson/groups/search/isNotMemberOf?query=<:string>&group=<:uuid> + * @param query search query param + * @param group UUID of group to exclude results from. Members of this group will never be returned. + * @param options + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved */ - isMemberOf(groupName: string): Observable { - const searchHref = 'isMemberOf'; - const options = new FindListOptions(); - options.searchParams = [new RequestParam('groupName', groupName)]; - - return this.searchBy(searchHref, options).pipe( - filter((groups: RemoteData>) => !groups.isResponsePending), - take(1), - map((groups: RemoteData>) => groups.payload.totalElements > 0) - ); + public searchNonMemberGroups(query: string, group: string, options?: FindListOptions, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { + const searchParams = [new RequestParam('query', query), new RequestParam('group', group)]; + let findListOptions = new FindListOptions(); + if (options) { + findListOptions = Object.assign(new FindListOptions(), options); + } + if (findListOptions.searchParams) { + findListOptions.searchParams = [...findListOptions.searchParams, ...searchParams]; + } else { + findListOptions.searchParams = searchParams; + } + return this.searchBy('isNotMemberOf', findListOptions, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); } /** @@ -264,15 +273,15 @@ export class GroupDataService extends IdentifiableDataService implements * @param group Group we want edit page for */ public getGroupEditPageRouterLink(group: Group): string { - return this.getGroupEditPageRouterLinkWithID(group.id); + return getGroupEditRoute(group.id); } /** * Get Edit page of group * @param groupID Group ID we want edit page for */ - public getGroupEditPageRouterLinkWithID(groupId: string): string { - return '/access-control/groups/' + groupId; + public getGroupEditPageRouterLinkWithID(groupID: string): string { + return getGroupEditRoute(groupID); } /** diff --git a/src/app/core/eperson/models/eperson-dto.model.ts b/src/app/core/eperson/models/eperson-dto.model.ts index 0e79902196..5fa6c7ed68 100644 --- a/src/app/core/eperson/models/eperson-dto.model.ts +++ b/src/app/core/eperson/models/eperson-dto.model.ts @@ -13,9 +13,4 @@ export class EpersonDtoModel { * Whether or not the linked EPerson is able to be deleted */ public ableToDelete: boolean; - /** - * Whether or not this EPerson is member of group on page it is being used on - */ - public memberOfGroup: boolean; - } diff --git a/src/app/core/json-patch/json-patch-operations.reducer.ts b/src/app/core/json-patch/json-patch-operations.reducer.ts index 5e00027edb..86edd339e8 100644 --- a/src/app/core/json-patch/json-patch-operations.reducer.ts +++ b/src/app/core/json-patch/json-patch-operations.reducer.ts @@ -351,7 +351,28 @@ function addOperationToList(body: JsonPatchOperationObject[], actionType, target newBody.push(makeOperationEntry({ op: JsonPatchOperationType.move, from: fromPath, path: targetPath })); break; } - return newBody; + return dedupeOperationEntries(newBody); +} + +/** + * Dedupe operation entries by op and path. This prevents processing unnecessary patches in a single PATCH request. + * + * @param body JSON patch operation object entries + * @returns deduped JSON patch operation object entries + */ +function dedupeOperationEntries(body: JsonPatchOperationObject[]): JsonPatchOperationObject[] { + const ops = new Map(); + for (let i = body.length - 1; i >= 0; i--) { + const patch = body[i].operation; + const key = `${patch.op}-${patch.path}`; + if (!ops.has(key)) { + ops.set(key, patch); + } else { + body.splice(i, 1); + } + } + + return body; } function makeOperationEntry(operation) { diff --git a/src/app/core/notifications/qa/events/quality-assurance-event-data.service.spec.ts b/src/app/core/notifications/qa/events/quality-assurance-event-data.service.spec.ts new file mode 100644 index 0000000000..50d0e43a99 --- /dev/null +++ b/src/app/core/notifications/qa/events/quality-assurance-event-data.service.spec.ts @@ -0,0 +1,247 @@ +import { HttpClient } from '@angular/common/http'; + +import { TestScheduler } from 'rxjs/testing'; +import { of as observableOf } from 'rxjs'; +import { cold, getTestScheduler } from 'jasmine-marbles'; + +import { RequestService } from '../../../data/request.service'; +import { buildPaginatedList } from '../../../data/paginated-list.model'; +import { RemoteDataBuildService } from '../../../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../../../cache/object-cache.service'; +import { RestResponse } from '../../../cache/response.models'; +import { PageInfo } from '../../../shared/page-info.model'; +import { HALEndpointService } from '../../../shared/hal-endpoint.service'; +import { NotificationsService } from '../../../../shared/notifications/notifications.service'; +import { createSuccessfulRemoteDataObject } from '../../../../shared/remote-data.utils'; +import { QualityAssuranceEventDataService } from './quality-assurance-event-data.service'; +import { + qualityAssuranceEventObjectMissingPid, + qualityAssuranceEventObjectMissingPid2, + qualityAssuranceEventObjectMissingProjectFound +} from '../../../../shared/mocks/notifications.mock'; +import { ReplaceOperation } from 'fast-json-patch'; +import { RequestEntry } from '../../../data/request-entry.model'; +import { FindListOptions } from '../../../data/find-list-options.model'; + +describe('QualityAssuranceEventDataService', () => { + let scheduler: TestScheduler; + let service: QualityAssuranceEventDataService; + let serviceASAny: any; + let responseCacheEntry: RequestEntry; + let responseCacheEntryB: RequestEntry; + let responseCacheEntryC: RequestEntry; + let requestService: RequestService; + let rdbService: RemoteDataBuildService; + let objectCache: ObjectCacheService; + let halService: HALEndpointService; + let notificationsService: NotificationsService; + let http: HttpClient; + let comparator: any; + + const endpointURL = 'https://rest.api/rest/api/integration/qualityassurancetopics'; + const requestUUID = '8b3c913a-5a4b-438b-9181-be1a5b4a1c8a'; + const topic = 'ENRICH!MORE!PID'; + + const pageInfo = new PageInfo(); + const array = [qualityAssuranceEventObjectMissingPid, qualityAssuranceEventObjectMissingPid2]; + const paginatedList = buildPaginatedList(pageInfo, array); + const qaEventObjectRD = createSuccessfulRemoteDataObject(qualityAssuranceEventObjectMissingPid); + const qaEventObjectMissingProjectRD = createSuccessfulRemoteDataObject(qualityAssuranceEventObjectMissingProjectFound); + const paginatedListRD = createSuccessfulRemoteDataObject(paginatedList); + + const status = 'ACCEPTED'; + const operation: ReplaceOperation[] = [ + { + path: '/status', + op: 'replace', + value: status + } + ]; + + beforeEach(() => { + scheduler = getTestScheduler(); + + responseCacheEntry = new RequestEntry(); + responseCacheEntry.request = { href: 'https://rest.api/' } as any; + responseCacheEntry.response = new RestResponse(true, 200, 'Success'); + requestService = jasmine.createSpyObj('requestService', { + generateRequestId: requestUUID, + send: true, + removeByHrefSubstring: {}, + getByHref: jasmine.createSpy('getByHref'), + getByUUID: jasmine.createSpy('getByUUID') + }); + + responseCacheEntryB = new RequestEntry(); + responseCacheEntryB.request = { href: 'https://rest.api/' } as any; + responseCacheEntryB.response = new RestResponse(true, 201, 'Created'); + + responseCacheEntryC = new RequestEntry(); + responseCacheEntryC.request = { href: 'https://rest.api/' } as any; + responseCacheEntryC.response = new RestResponse(true, 204, 'No Content'); + + rdbService = jasmine.createSpyObj('rdbService', { + buildSingle: cold('(a)', { + a: qaEventObjectRD + }), + buildList: cold('(a)', { + a: paginatedListRD + }), + buildFromRequestUUID: jasmine.createSpy('buildFromRequestUUID'), + buildFromRequestUUIDAndAwait: jasmine.createSpy('buildFromRequestUUIDAndAwait') + }); + + objectCache = {} as ObjectCacheService; + halService = jasmine.createSpyObj('halService', { + getEndpoint: cold('a|', { a: endpointURL }) + }); + + notificationsService = {} as NotificationsService; + http = {} as HttpClient; + comparator = {} as any; + + service = new QualityAssuranceEventDataService( + requestService, + rdbService, + objectCache, + halService, + notificationsService, + comparator + ); + + serviceASAny = service; + + spyOn(serviceASAny.searchData, 'searchBy').and.callThrough(); + spyOn(serviceASAny, 'findById').and.callThrough(); + spyOn(serviceASAny.patchData, 'patch').and.callThrough(); + spyOn(serviceASAny, 'postOnRelated').and.callThrough(); + spyOn(serviceASAny, 'deleteOnRelated').and.callThrough(); + }); + + describe('getEventsByTopic', () => { + beforeEach(() => { + serviceASAny.requestService.getByHref.and.returnValue(observableOf(responseCacheEntry)); + serviceASAny.requestService.getByUUID.and.returnValue(observableOf(responseCacheEntry)); + serviceASAny.rdbService.buildFromRequestUUID.and.returnValue(observableOf(qaEventObjectRD)); + }); + + it('should proxy the call to searchData.searchBy', () => { + const options: FindListOptions = { + searchParams: [ + { + fieldName: 'topic', + fieldValue: topic + } + ] + }; + service.getEventsByTopic(topic); + expect(serviceASAny.searchData.searchBy).toHaveBeenCalledWith('findByTopic', options, true, true); + }); + + it('should return a RemoteData> for the object with the given Topic', () => { + const result = service.getEventsByTopic(topic); + const expected = cold('(a)', { + a: paginatedListRD + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('getEvent', () => { + beforeEach(() => { + serviceASAny.requestService.getByHref.and.returnValue(observableOf(responseCacheEntry)); + serviceASAny.requestService.getByUUID.and.returnValue(observableOf(responseCacheEntry)); + serviceASAny.rdbService.buildFromRequestUUID.and.returnValue(observableOf(qaEventObjectRD)); + }); + + it('should call findById', () => { + service.getEvent(qualityAssuranceEventObjectMissingPid.id).subscribe( + (res) => { + expect(serviceASAny.findById).toHaveBeenCalledWith(qualityAssuranceEventObjectMissingPid.id, true, true); + } + ); + }); + + it('should return a RemoteData for the object with the given URL', () => { + const result = service.getEvent(qualityAssuranceEventObjectMissingPid.id); + const expected = cold('(a)', { + a: qaEventObjectRD + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('patchEvent', () => { + beforeEach(() => { + serviceASAny.requestService.getByHref.and.returnValue(observableOf(responseCacheEntry)); + serviceASAny.requestService.getByUUID.and.returnValue(observableOf(responseCacheEntry)); + serviceASAny.rdbService.buildFromRequestUUID.and.returnValue(observableOf(qaEventObjectRD)); + serviceASAny.rdbService.buildFromRequestUUIDAndAwait.and.returnValue(observableOf(qaEventObjectRD)); + }); + + it('should proxy the call to patchData.patch', () => { + service.patchEvent(status, qualityAssuranceEventObjectMissingPid).subscribe( + (res) => { + expect(serviceASAny.patchData.patch).toHaveBeenCalledWith(qualityAssuranceEventObjectMissingPid, operation); + } + ); + }); + + it('should return a RemoteData with HTTP 200', () => { + const result = service.patchEvent(status, qualityAssuranceEventObjectMissingPid); + const expected = cold('(a|)', { + a: createSuccessfulRemoteDataObject(qualityAssuranceEventObjectMissingPid) + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('boundProject', () => { + beforeEach(() => { + serviceASAny.requestService.getByHref.and.returnValue(observableOf(responseCacheEntryB)); + serviceASAny.requestService.getByUUID.and.returnValue(observableOf(responseCacheEntryB)); + serviceASAny.rdbService.buildFromRequestUUID.and.returnValue(observableOf(qaEventObjectMissingProjectRD)); + }); + + it('should call postOnRelated', () => { + service.boundProject(qualityAssuranceEventObjectMissingProjectFound.id, requestUUID).subscribe( + (res) => { + expect(serviceASAny.postOnRelated).toHaveBeenCalledWith(qualityAssuranceEventObjectMissingProjectFound.id, requestUUID); + } + ); + }); + + it('should return a RestResponse with HTTP 201', () => { + const result = service.boundProject(qualityAssuranceEventObjectMissingProjectFound.id, requestUUID); + const expected = cold('(a|)', { + a: createSuccessfulRemoteDataObject(qualityAssuranceEventObjectMissingProjectFound) + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('removeProject', () => { + beforeEach(() => { + serviceASAny.requestService.getByHref.and.returnValue(observableOf(responseCacheEntryC)); + serviceASAny.requestService.getByUUID.and.returnValue(observableOf(responseCacheEntryC)); + serviceASAny.rdbService.buildFromRequestUUID.and.returnValue(observableOf(createSuccessfulRemoteDataObject({}))); + }); + + it('should call deleteOnRelated', () => { + service.removeProject(qualityAssuranceEventObjectMissingProjectFound.id).subscribe( + (res) => { + expect(serviceASAny.deleteOnRelated).toHaveBeenCalledWith(qualityAssuranceEventObjectMissingProjectFound.id); + } + ); + }); + + it('should return a RestResponse with HTTP 204', () => { + const result = service.removeProject(qualityAssuranceEventObjectMissingProjectFound.id); + const expected = cold('(a|)', { + a: createSuccessfulRemoteDataObject({}) + }); + expect(result).toBeObservable(expected); + }); + }); + +}); diff --git a/src/app/core/notifications/qa/events/quality-assurance-event-data.service.ts b/src/app/core/notifications/qa/events/quality-assurance-event-data.service.ts new file mode 100644 index 0000000000..7f7e68afaa --- /dev/null +++ b/src/app/core/notifications/qa/events/quality-assurance-event-data.service.ts @@ -0,0 +1,203 @@ +import { Injectable } from '@angular/core'; + +import { Observable } from 'rxjs'; +import { find, take } from 'rxjs/operators'; +import { ReplaceOperation } from 'fast-json-patch'; + +import { HALEndpointService } from '../../../shared/hal-endpoint.service'; +import { NotificationsService } from '../../../../shared/notifications/notifications.service'; +import { RemoteDataBuildService } from '../../../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../../../cache/object-cache.service'; +import { dataService } from '../../../data/base/data-service.decorator'; +import { RequestService } from '../../../data/request.service'; +import { RemoteData } from '../../../data/remote-data'; +import { QualityAssuranceEventObject } from '../models/quality-assurance-event.model'; +import { QUALITY_ASSURANCE_EVENT_OBJECT } from '../models/quality-assurance-event-object.resource-type'; +import { FollowLinkConfig } from '../../../../shared/utils/follow-link-config.model'; +import { PaginatedList } from '../../../data/paginated-list.model'; +import { NoContent } from '../../../shared/NoContent.model'; +import { FindListOptions } from '../../../data/find-list-options.model'; +import { IdentifiableDataService } from '../../../data/base/identifiable-data.service'; +import { CreateData, CreateDataImpl } from '../../../data/base/create-data'; +import { PatchData, PatchDataImpl } from '../../../data/base/patch-data'; +import { DeleteData, DeleteDataImpl } from '../../../data/base/delete-data'; +import { SearchData, SearchDataImpl } from '../../../data/base/search-data'; +import { DefaultChangeAnalyzer } from '../../../data/default-change-analyzer.service'; +import { hasValue } from '../../../../shared/empty.util'; +import { DeleteByIDRequest, PostRequest } from '../../../data/request.models'; + +/** + * The service handling all Quality Assurance topic REST requests. + */ +@Injectable() +@dataService(QUALITY_ASSURANCE_EVENT_OBJECT) +export class QualityAssuranceEventDataService extends IdentifiableDataService { + + private createData: CreateData; + private searchData: SearchData; + private patchData: PatchData; + private deleteData: DeleteData; + + /** + * Initialize service variables + * @param {RequestService} requestService + * @param {RemoteDataBuildService} rdbService + * @param {ObjectCacheService} objectCache + * @param {HALEndpointService} halService + * @param {NotificationsService} notificationsService + * @param {DefaultChangeAnalyzer} comparator + */ + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected comparator: DefaultChangeAnalyzer + ) { + super('qualityassuranceevents', requestService, rdbService, objectCache, halService); + this.createData = new CreateDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive); + this.deleteData = new DeleteDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive, this.constructIdEndpoint); + this.patchData = new PatchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, comparator, this.responseMsToLive, this.constructIdEndpoint); + this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + } + + /** + * Return the list of Quality Assurance events by topic. + * + * @param topic + * The Quality Assurance topic + * @param options + * Find list options object. + * @param linksToFollow + * List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved. + * @return Observable>> + * The list of Quality Assurance events. + */ + public getEventsByTopic(topic: string, options: FindListOptions = {}, ...linksToFollow: FollowLinkConfig[]): Observable>> { + options.searchParams = [ + { + fieldName: 'topic', + fieldValue: topic + } + ]; + return this.searchData.searchBy('findByTopic', options, true, true, ...linksToFollow); + } + + /** + * Clear findByTopic requests from cache + */ + public clearFindByTopicRequests() { + this.requestService.setStaleByHrefSubstring('findByTopic'); + } + + /** + * Return a single Quality Assurance event. + * + * @param id + * The Quality Assurance event id + * @param linksToFollow + * List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + * @return Observable> + * The Quality Assurance event. + */ + public getEvent(id: string, ...linksToFollow: FollowLinkConfig[]): Observable> { + return this.findById(id, true, true, ...linksToFollow); + } + + /** + * Save the new status of a Quality Assurance event. + * + * @param status + * The new status + * @param dso QualityAssuranceEventObject + * The event item + * @param reason + * The optional reason (not used for now; for future implementation) + * @return Observable + * The REST response. + */ + public patchEvent(status, dso, reason?: string): Observable> { + const operation: ReplaceOperation[] = [ + { + path: '/status', + op: 'replace', + value: status + } + ]; + return this.patchData.patch(dso, operation); + } + + /** + * Bound a project to a Quality Assurance event publication. + * + * @param itemId + * The Id of the Quality Assurance event + * @param projectId + * The project Id to bound + * @return Observable + * The REST response. + */ + public boundProject(itemId: string, projectId: string): Observable> { + return this.postOnRelated(itemId, projectId); + } + + /** + * Remove a project from a Quality Assurance event publication. + * + * @param itemId + * The Id of the Quality Assurance event + * @return Observable + * The REST response. + */ + public removeProject(itemId: string): Observable> { + return this.deleteOnRelated(itemId); + } + + /** + * Perform a delete operation on an endpoint related item. Ex.: endpoint//related + * @param objectId The item id + * @return the RestResponse as an Observable + */ + private deleteOnRelated(objectId: string): Observable> { + const requestId = this.requestService.generateRequestId(); + + const hrefObs = this.getIDHrefObs(objectId); + + hrefObs.pipe( + find((href: string) => hasValue(href)), + ).subscribe((href: string) => { + const request = new DeleteByIDRequest(requestId, href + '/related', objectId); + if (hasValue(this.responseMsToLive)) { + request.responseMsToLive = this.responseMsToLive; + } + this.requestService.send(request); + }); + + return this.rdbService.buildFromRequestUUID(requestId); + } + + /** + * Perform a post on an endpoint related item with ID. Ex.: endpoint//related?item= + * @param objectId The item id + * @param relatedItemId The related item Id + * @param body The optional POST body + * @return the RestResponse as an Observable + */ + private postOnRelated(objectId: string, relatedItemId: string, body?: any) { + const requestId = this.requestService.generateRequestId(); + const hrefObs = this.getIDHrefObs(objectId); + + hrefObs.pipe( + take(1) + ).subscribe((href: string) => { + const request = new PostRequest(requestId, href + '/related?item=' + relatedItemId, body); + if (hasValue(this.responseMsToLive)) { + request.responseMsToLive = this.responseMsToLive; + } + this.requestService.send(request); + }); + + return this.rdbService.buildFromRequestUUID(requestId); + } +} diff --git a/src/app/core/notifications/qa/models/quality-assurance-event-object.resource-type.ts b/src/app/core/notifications/qa/models/quality-assurance-event-object.resource-type.ts new file mode 100644 index 0000000000..84aff6ba2c --- /dev/null +++ b/src/app/core/notifications/qa/models/quality-assurance-event-object.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from '../../../shared/resource-type'; + +/** + * The resource type for the Quality Assurance event + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const QUALITY_ASSURANCE_EVENT_OBJECT = new ResourceType('qualityassuranceevent'); diff --git a/src/app/core/notifications/qa/models/quality-assurance-event.model.ts b/src/app/core/notifications/qa/models/quality-assurance-event.model.ts new file mode 100644 index 0000000000..0cdb4a5745 --- /dev/null +++ b/src/app/core/notifications/qa/models/quality-assurance-event.model.ts @@ -0,0 +1,171 @@ +/* eslint-disable max-classes-per-file */ +import { Observable } from 'rxjs'; +import { autoserialize, autoserializeAs, deserialize } from 'cerialize'; +import { QUALITY_ASSURANCE_EVENT_OBJECT } from './quality-assurance-event-object.resource-type'; +import { excludeFromEquals } from '../../../utilities/equals.decorators'; +import { ResourceType } from '../../../shared/resource-type'; +import { HALLink } from '../../../shared/hal-link.model'; +import { Item } from '../../../shared/item.model'; +import { ITEM } from '../../../shared/item.resource-type'; +import { link, typedObject } from '../../../cache/builders/build-decorators'; +import { RemoteData } from '../../../data/remote-data'; +import {CacheableObject} from '../../../cache/cacheable-object.model'; + +/** + * The interface representing the Quality Assurance event message + */ +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface QualityAssuranceEventMessageObject { + +} + +/** + * The interface representing the Quality Assurance event message + */ +export interface SourceQualityAssuranceEventMessageObject { + /** + * The type of 'value' + */ + type: string; + + /** + * The value suggested by Notifications + */ + value: string; + + /** + * The abstract suggested by Notifications + */ + abstract: string; + + /** + * The project acronym suggested by Notifications + */ + acronym: string; + + /** + * The project code suggested by Notifications + */ + code: string; + + /** + * The project funder suggested by Notifications + */ + funder: string; + + /** + * The project program suggested by Notifications + */ + fundingProgram?: string; + + /** + * The project jurisdiction suggested by Notifications + */ + jurisdiction: string; + + /** + * The project title suggested by Notifications + */ + title: string; + + /** + * The Source ID. + */ + sourceId: string; + + /** + * The PID href. + */ + pidHref: string; + +} + +/** + * The interface representing the Quality Assurance event model + */ +@typedObject +export class QualityAssuranceEventObject implements CacheableObject { + /** + * A string representing the kind of object, e.g. community, item, … + */ + static type = QUALITY_ASSURANCE_EVENT_OBJECT; + + /** + * The Quality Assurance event uuid inside DSpace + */ + @autoserialize + id: string; + + /** + * The universally unique identifier of this Quality Assurance event + */ + @autoserializeAs(String, 'id') + uuid: string; + + /** + * The Quality Assurance event original id (ex.: the source archive OAI-PMH identifier) + */ + @autoserialize + originalId: string; + + /** + * The title of the article to which the suggestion refers + */ + @autoserialize + title: string; + + /** + * Reliability of the suggestion (of the data inside 'message') + */ + @autoserialize + trust: number; + + /** + * The timestamp Quality Assurance event was saved in DSpace + */ + @autoserialize + eventDate: string; + + /** + * The Quality Assurance event status (ACCEPTED, REJECTED, DISCARDED, PENDING) + */ + @autoserialize + status: string; + + /** + * The suggestion data. Data may vary depending on the source + */ + @autoserialize + message: SourceQualityAssuranceEventMessageObject; + + /** + * The type of this ConfigObject + */ + @excludeFromEquals + @autoserialize + type: ResourceType; + + /** + * The links to all related resources returned by the rest api. + */ + @deserialize + _links: { + self: HALLink, + target: HALLink, + related: HALLink + }; + + /** + * The related publication DSpace item + * Will be undefined unless the {@item HALLink} has been resolved. + */ + @link(ITEM) + target?: Observable>; + + /** + * The related project for this Event + * Will be undefined unless the {@related HALLink} has been resolved. + */ + @link(ITEM) + related?: Observable>; +} diff --git a/src/app/core/notifications/qa/models/quality-assurance-source-object.resource-type.ts b/src/app/core/notifications/qa/models/quality-assurance-source-object.resource-type.ts new file mode 100644 index 0000000000..b4f64b24d1 --- /dev/null +++ b/src/app/core/notifications/qa/models/quality-assurance-source-object.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from '../../../shared/resource-type'; + +/** + * The resource type for the Quality Assurance source + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const QUALITY_ASSURANCE_SOURCE_OBJECT = new ResourceType('qualityassurancesource'); diff --git a/src/app/core/notifications/qa/models/quality-assurance-source.model.ts b/src/app/core/notifications/qa/models/quality-assurance-source.model.ts new file mode 100644 index 0000000000..f59467384f --- /dev/null +++ b/src/app/core/notifications/qa/models/quality-assurance-source.model.ts @@ -0,0 +1,52 @@ +import { autoserialize, deserialize } from 'cerialize'; + +import { excludeFromEquals } from '../../../utilities/equals.decorators'; +import { ResourceType } from '../../../shared/resource-type'; +import { HALLink } from '../../../shared/hal-link.model'; +import { typedObject } from '../../../cache/builders/build-decorators'; +import { QUALITY_ASSURANCE_SOURCE_OBJECT } from './quality-assurance-source-object.resource-type'; +import {CacheableObject} from '../../../cache/cacheable-object.model'; + +/** + * The interface representing the Quality Assurance source model + */ +@typedObject +export class QualityAssuranceSourceObject implements CacheableObject { + /** + * A string representing the kind of object, e.g. community, item, … + */ + static type = QUALITY_ASSURANCE_SOURCE_OBJECT; + + /** + * The Quality Assurance source id + */ + @autoserialize + id: string; + + /** + * The date of the last udate from Notifications + */ + @autoserialize + lastEvent: string; + + /** + * The total number of suggestions provided by Notifications for this source + */ + @autoserialize + totalEvents: number; + + /** + * The type of this ConfigObject + */ + @excludeFromEquals + @autoserialize + type: ResourceType; + + /** + * The links to all related resources returned by the rest api. + */ + @deserialize + _links: { + self: HALLink, + }; +} diff --git a/src/app/core/notifications/qa/models/quality-assurance-topic-object.resource-type.ts b/src/app/core/notifications/qa/models/quality-assurance-topic-object.resource-type.ts new file mode 100644 index 0000000000..e9fc57a307 --- /dev/null +++ b/src/app/core/notifications/qa/models/quality-assurance-topic-object.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from '../../../shared/resource-type'; + +/** + * The resource type for the Quality Assurance topic + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const QUALITY_ASSURANCE_TOPIC_OBJECT = new ResourceType('qualityassurancetopic'); diff --git a/src/app/core/notifications/qa/models/quality-assurance-topic.model.ts b/src/app/core/notifications/qa/models/quality-assurance-topic.model.ts new file mode 100644 index 0000000000..529980e5f7 --- /dev/null +++ b/src/app/core/notifications/qa/models/quality-assurance-topic.model.ts @@ -0,0 +1,58 @@ +import { autoserialize, deserialize } from 'cerialize'; + +import { QUALITY_ASSURANCE_TOPIC_OBJECT } from './quality-assurance-topic-object.resource-type'; +import { excludeFromEquals } from '../../../utilities/equals.decorators'; +import { ResourceType } from '../../../shared/resource-type'; +import { HALLink } from '../../../shared/hal-link.model'; +import { typedObject } from '../../../cache/builders/build-decorators'; +import {CacheableObject} from '../../../cache/cacheable-object.model'; + +/** + * The interface representing the Quality Assurance topic model + */ +@typedObject +export class QualityAssuranceTopicObject implements CacheableObject { + /** + * A string representing the kind of object, e.g. community, item, … + */ + static type = QUALITY_ASSURANCE_TOPIC_OBJECT; + + /** + * The Quality Assurance topic id + */ + @autoserialize + id: string; + + /** + * The Quality Assurance topic name to display + */ + @autoserialize + name: string; + + /** + * The date of the last udate from Notifications + */ + @autoserialize + lastEvent: string; + + /** + * The total number of suggestions provided by Notifications for this topic + */ + @autoserialize + totalEvents: number; + + /** + * The type of this ConfigObject + */ + @excludeFromEquals + @autoserialize + type: ResourceType; + + /** + * The links to all related resources returned by the rest api. + */ + @deserialize + _links: { + self: HALLink, + }; +} diff --git a/src/app/core/notifications/qa/source/quality-assurance-source-data.service.spec.ts b/src/app/core/notifications/qa/source/quality-assurance-source-data.service.spec.ts new file mode 100644 index 0000000000..50d9251bb8 --- /dev/null +++ b/src/app/core/notifications/qa/source/quality-assurance-source-data.service.spec.ts @@ -0,0 +1,125 @@ +import { HttpClient } from '@angular/common/http'; + +import { TestScheduler } from 'rxjs/testing'; +import { of as observableOf } from 'rxjs'; +import { cold, getTestScheduler } from 'jasmine-marbles'; + +import { RequestService } from '../../../data/request.service'; +import { buildPaginatedList } from '../../../data/paginated-list.model'; +import { RemoteDataBuildService } from '../../../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../../../cache/object-cache.service'; +import { RestResponse } from '../../../cache/response.models'; +import { PageInfo } from '../../../shared/page-info.model'; +import { HALEndpointService } from '../../../shared/hal-endpoint.service'; +import { NotificationsService } from '../../../../shared/notifications/notifications.service'; +import { createSuccessfulRemoteDataObject } from '../../../../shared/remote-data.utils'; +import { + qualityAssuranceSourceObjectMoreAbstract, + qualityAssuranceSourceObjectMorePid +} from '../../../../shared/mocks/notifications.mock'; +import { RequestEntry } from '../../../data/request-entry.model'; +import { QualityAssuranceSourceDataService } from './quality-assurance-source-data.service'; + +describe('QualityAssuranceSourceDataService', () => { + let scheduler: TestScheduler; + let service: QualityAssuranceSourceDataService; + let responseCacheEntry: RequestEntry; + let requestService: RequestService; + let rdbService: RemoteDataBuildService; + let objectCache: ObjectCacheService; + let halService: HALEndpointService; + let notificationsService: NotificationsService; + let http: HttpClient; + let comparator: any; + + const endpointURL = 'https://rest.api/rest/api/integration/qualityassurancesources'; + const requestUUID = '8b3c913a-5a4b-438b-9181-be1a5b4a1c8a'; + + const pageInfo = new PageInfo(); + const array = [qualityAssuranceSourceObjectMorePid, qualityAssuranceSourceObjectMoreAbstract]; + const paginatedList = buildPaginatedList(pageInfo, array); + const qaSourceObjectRD = createSuccessfulRemoteDataObject(qualityAssuranceSourceObjectMorePid); + const paginatedListRD = createSuccessfulRemoteDataObject(paginatedList); + + beforeEach(() => { + scheduler = getTestScheduler(); + + responseCacheEntry = new RequestEntry(); + responseCacheEntry.response = new RestResponse(true, 200, 'Success'); + requestService = jasmine.createSpyObj('requestService', { + generateRequestId: requestUUID, + send: true, + removeByHrefSubstring: {}, + getByHref: observableOf(responseCacheEntry), + getByUUID: observableOf(responseCacheEntry), + }); + + rdbService = jasmine.createSpyObj('rdbService', { + buildSingle: cold('(a)', { + a: qaSourceObjectRD + }), + buildList: cold('(a)', { + a: paginatedListRD + }), + }); + + objectCache = {} as ObjectCacheService; + halService = jasmine.createSpyObj('halService', { + getEndpoint: cold('a|', { a: endpointURL }) + }); + + notificationsService = {} as NotificationsService; + http = {} as HttpClient; + comparator = {} as any; + + service = new QualityAssuranceSourceDataService( + requestService, + rdbService, + objectCache, + halService, + notificationsService + ); + + spyOn((service as any).findAllData, 'findAll').and.callThrough(); + spyOn((service as any), 'findById').and.callThrough(); + }); + + describe('getSources', () => { + it('should call findAll', (done) => { + service.getSources().subscribe( + (res) => { + expect((service as any).findAllData.findAll).toHaveBeenCalledWith({}, true, true); + } + ); + done(); + }); + + it('should return a RemoteData> for the object with the given URL', () => { + const result = service.getSources(); + const expected = cold('(a)', { + a: paginatedListRD + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('getSource', () => { + it('should call findById', (done) => { + service.getSource(qualityAssuranceSourceObjectMorePid.id).subscribe( + (res) => { + expect((service as any).findById).toHaveBeenCalledWith(qualityAssuranceSourceObjectMorePid.id, true, true); + } + ); + done(); + }); + + it('should return a RemoteData for the object with the given URL', () => { + const result = service.getSource(qualityAssuranceSourceObjectMorePid.id); + const expected = cold('(a)', { + a: qaSourceObjectRD + }); + expect(result).toBeObservable(expected); + }); + }); + +}); diff --git a/src/app/core/notifications/qa/source/quality-assurance-source-data.service.ts b/src/app/core/notifications/qa/source/quality-assurance-source-data.service.ts new file mode 100644 index 0000000000..03a5da2e8c --- /dev/null +++ b/src/app/core/notifications/qa/source/quality-assurance-source-data.service.ts @@ -0,0 +1,87 @@ +import { Injectable } from '@angular/core'; + +import { Observable } from 'rxjs'; + +import { HALEndpointService } from '../../../shared/hal-endpoint.service'; +import { NotificationsService } from '../../../../shared/notifications/notifications.service'; +import { RemoteDataBuildService } from '../../../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../../../cache/object-cache.service'; +import { dataService } from '../../../data/base/data-service.decorator'; +import { RequestService } from '../../../data/request.service'; +import { RemoteData } from '../../../data/remote-data'; +import { QualityAssuranceSourceObject } from '../models/quality-assurance-source.model'; +import { QUALITY_ASSURANCE_SOURCE_OBJECT } from '../models/quality-assurance-source-object.resource-type'; +import { FollowLinkConfig } from '../../../../shared/utils/follow-link-config.model'; +import { PaginatedList } from '../../../data/paginated-list.model'; +import { FindListOptions } from '../../../data/find-list-options.model'; +import { IdentifiableDataService } from '../../../data/base/identifiable-data.service'; +import { FindAllData, FindAllDataImpl } from '../../../data/base/find-all-data'; + +/** + * The service handling all Quality Assurance source REST requests. + */ +@Injectable() +@dataService(QUALITY_ASSURANCE_SOURCE_OBJECT) +export class QualityAssuranceSourceDataService extends IdentifiableDataService { + + private findAllData: FindAllData; + + /** + * Initialize service variables + * @param {RequestService} requestService + * @param {RemoteDataBuildService} rdbService + * @param {ObjectCacheService} objectCache + * @param {HALEndpointService} halService + * @param {NotificationsService} notificationsService + */ + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService + ) { + super('qualityassurancesources', requestService, rdbService, objectCache, halService); + this.findAllData = new FindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + } + + /** + * Return the list of Quality Assurance source. + * + * @param options Find list options object. + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved. + * + * @return Observable>> + * The list of Quality Assurance source. + */ + public getSources(options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * Clear FindAll source requests from cache + */ + public clearFindAllSourceRequests() { + this.requestService.setStaleByHrefSubstring('qualityassurancesources'); + } + + /** + * Return a single Quality Assurance source. + * + * @param id The Quality Assurance source id + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved. + * + * @return Observable> The Quality Assurance source. + */ + public getSource(id: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { + return this.findById(id, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } +} diff --git a/src/app/core/notifications/qa/topics/quality-assurance-topic-data.service.spec.ts b/src/app/core/notifications/qa/topics/quality-assurance-topic-data.service.spec.ts new file mode 100644 index 0000000000..638ee3fa62 --- /dev/null +++ b/src/app/core/notifications/qa/topics/quality-assurance-topic-data.service.spec.ts @@ -0,0 +1,125 @@ +import { HttpClient } from '@angular/common/http'; + +import { TestScheduler } from 'rxjs/testing'; +import { of as observableOf } from 'rxjs'; +import { cold, getTestScheduler } from 'jasmine-marbles'; + +import { RequestService } from '../../../data/request.service'; +import { buildPaginatedList } from '../../../data/paginated-list.model'; +import { RemoteDataBuildService } from '../../../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../../../cache/object-cache.service'; +import { RestResponse } from '../../../cache/response.models'; +import { PageInfo } from '../../../shared/page-info.model'; +import { HALEndpointService } from '../../../shared/hal-endpoint.service'; +import { NotificationsService } from '../../../../shared/notifications/notifications.service'; +import { createSuccessfulRemoteDataObject } from '../../../../shared/remote-data.utils'; +import { QualityAssuranceTopicDataService } from './quality-assurance-topic-data.service'; +import { + qualityAssuranceTopicObjectMoreAbstract, + qualityAssuranceTopicObjectMorePid +} from '../../../../shared/mocks/notifications.mock'; +import { RequestEntry } from '../../../data/request-entry.model'; + +describe('QualityAssuranceTopicDataService', () => { + let scheduler: TestScheduler; + let service: QualityAssuranceTopicDataService; + let responseCacheEntry: RequestEntry; + let requestService: RequestService; + let rdbService: RemoteDataBuildService; + let objectCache: ObjectCacheService; + let halService: HALEndpointService; + let notificationsService: NotificationsService; + let http: HttpClient; + let comparator: any; + + const endpointURL = 'https://rest.api/rest/api/integration/qualityassurancetopics'; + const requestUUID = '8b3c913a-5a4b-438b-9181-be1a5b4a1c8a'; + + const pageInfo = new PageInfo(); + const array = [qualityAssuranceTopicObjectMorePid, qualityAssuranceTopicObjectMoreAbstract]; + const paginatedList = buildPaginatedList(pageInfo, array); + const qaTopicObjectRD = createSuccessfulRemoteDataObject(qualityAssuranceTopicObjectMorePid); + const paginatedListRD = createSuccessfulRemoteDataObject(paginatedList); + + beforeEach(() => { + scheduler = getTestScheduler(); + + responseCacheEntry = new RequestEntry(); + responseCacheEntry.response = new RestResponse(true, 200, 'Success'); + requestService = jasmine.createSpyObj('requestService', { + generateRequestId: requestUUID, + send: true, + removeByHrefSubstring: {}, + getByHref: observableOf(responseCacheEntry), + getByUUID: observableOf(responseCacheEntry), + }); + + rdbService = jasmine.createSpyObj('rdbService', { + buildSingle: cold('(a)', { + a: qaTopicObjectRD + }), + buildList: cold('(a)', { + a: paginatedListRD + }), + }); + + objectCache = {} as ObjectCacheService; + halService = jasmine.createSpyObj('halService', { + getEndpoint: cold('a|', { a: endpointURL }) + }); + + notificationsService = {} as NotificationsService; + http = {} as HttpClient; + comparator = {} as any; + + service = new QualityAssuranceTopicDataService( + requestService, + rdbService, + objectCache, + halService, + notificationsService + ); + + spyOn((service as any).findAllData, 'findAll').and.callThrough(); + spyOn((service as any), 'findById').and.callThrough(); + }); + + describe('getTopics', () => { + it('should call findListByHref', (done) => { + service.getTopics().subscribe( + (res) => { + expect((service as any).findAllData.findAll).toHaveBeenCalledWith({}, true, true); + } + ); + done(); + }); + + it('should return a RemoteData> for the object with the given URL', () => { + const result = service.getTopics(); + const expected = cold('(a)', { + a: paginatedListRD + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('getTopic', () => { + it('should call findByHref', (done) => { + service.getTopic(qualityAssuranceTopicObjectMorePid.id).subscribe( + (res) => { + expect((service as any).findById).toHaveBeenCalledWith(qualityAssuranceTopicObjectMorePid.id, true, true); + } + ); + done(); + }); + + it('should return a RemoteData for the object with the given URL', () => { + const result = service.getTopic(qualityAssuranceTopicObjectMorePid.id); + const expected = cold('(a)', { + a: qaTopicObjectRD + }); + expect(result).toBeObservable(expected); + }); + }); + +}); diff --git a/src/app/core/notifications/qa/topics/quality-assurance-topic-data.service.ts b/src/app/core/notifications/qa/topics/quality-assurance-topic-data.service.ts new file mode 100644 index 0000000000..2bf5195bf1 --- /dev/null +++ b/src/app/core/notifications/qa/topics/quality-assurance-topic-data.service.ts @@ -0,0 +1,88 @@ +import { Injectable } from '@angular/core'; + +import { Observable } from 'rxjs'; + +import { HALEndpointService } from '../../../shared/hal-endpoint.service'; +import { NotificationsService } from '../../../../shared/notifications/notifications.service'; +import { RemoteDataBuildService } from '../../../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../../../cache/object-cache.service'; +import { RequestService } from '../../../data/request.service'; +import { RemoteData } from '../../../data/remote-data'; +import { QualityAssuranceTopicObject } from '../models/quality-assurance-topic.model'; +import { FollowLinkConfig } from '../../../../shared/utils/follow-link-config.model'; +import { PaginatedList } from '../../../data/paginated-list.model'; +import { FindListOptions } from '../../../data/find-list-options.model'; +import { IdentifiableDataService } from '../../../data/base/identifiable-data.service'; +import { dataService } from '../../../data/base/data-service.decorator'; +import { QUALITY_ASSURANCE_TOPIC_OBJECT } from '../models/quality-assurance-topic-object.resource-type'; +import { FindAllData, FindAllDataImpl } from '../../../data/base/find-all-data'; + +/** + * The service handling all Quality Assurance topic REST requests. + */ +@Injectable() +@dataService(QUALITY_ASSURANCE_TOPIC_OBJECT) +export class QualityAssuranceTopicDataService extends IdentifiableDataService { + + private findAllData: FindAllData; + + /** + * Initialize service variables + * @param {RequestService} requestService + * @param {RemoteDataBuildService} rdbService + * @param {ObjectCacheService} objectCache + * @param {HALEndpointService} halService + * @param {NotificationsService} notificationsService + */ + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService + ) { + super('qualityassurancetopics', requestService, rdbService, objectCache, halService); + this.findAllData = new FindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + } + + /** + * Return the list of Quality Assurance topics. + * + * @param options Find list options object. + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved. + * + * @return Observable>> + * The list of Quality Assurance topics. + */ + public getTopics(options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * Clear FindAll topics requests from cache + */ + public clearFindAllTopicsRequests() { + this.requestService.setStaleByHrefSubstring('qualityassurancetopics'); + } + + /** + * Return a single Quality Assurance topic. + * + * @param id The Quality Assurance topic id + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved. + * + * @return Observable> + * The Quality Assurance topic. + */ + public getTopic(id: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { + return this.findById(id, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } +} diff --git a/src/app/core/server-check/server-check.guard.spec.ts b/src/app/core/server-check/server-check.guard.spec.ts index 1f126be5e5..f65a7deca7 100644 --- a/src/app/core/server-check/server-check.guard.spec.ts +++ b/src/app/core/server-check/server-check.guard.spec.ts @@ -1,68 +1,88 @@ import { ServerCheckGuard } from './server-check.guard'; -import { Router } from '@angular/router'; +import { Router, NavigationStart, UrlTree, NavigationEnd, RouterEvent } from '@angular/router'; -import { of } from 'rxjs'; -import { take } from 'rxjs/operators'; - -import { getPageInternalServerErrorRoute } from '../../app-routing-paths'; +import { of, ReplaySubject } from 'rxjs'; import { RootDataService } from '../data/root-data.service'; +import { TestScheduler } from 'rxjs/testing'; import SpyObj = jasmine.SpyObj; describe('ServerCheckGuard', () => { let guard: ServerCheckGuard; - let router: SpyObj; + let router: Router; + let eventSubject: ReplaySubject; let rootDataServiceStub: SpyObj; - - rootDataServiceStub = jasmine.createSpyObj('RootDataService', { - checkServerAvailability: jasmine.createSpy('checkServerAvailability'), - invalidateRootCache: jasmine.createSpy('invalidateRootCache') - }); - router = jasmine.createSpyObj('Router', { - navigateByUrl: jasmine.createSpy('navigateByUrl') - }); + let testScheduler: TestScheduler; + let redirectUrlTree: UrlTree; beforeEach(() => { + testScheduler = new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected); + }); + rootDataServiceStub = jasmine.createSpyObj('RootDataService', { + checkServerAvailability: jasmine.createSpy('checkServerAvailability'), + invalidateRootCache: jasmine.createSpy('invalidateRootCache'), + findRoot: jasmine.createSpy('findRoot') + }); + redirectUrlTree = new UrlTree(); + eventSubject = new ReplaySubject(1); + router = { + events: eventSubject.asObservable(), + navigateByUrl: jasmine.createSpy('navigateByUrl'), + parseUrl: jasmine.createSpy('parseUrl').and.returnValue(redirectUrlTree) + } as any; guard = new ServerCheckGuard(router, rootDataServiceStub); }); - afterEach(() => { - router.navigateByUrl.calls.reset(); - rootDataServiceStub.invalidateRootCache.calls.reset(); - }); - it('should be created', () => { expect(guard).toBeTruthy(); }); - describe('when root endpoint has succeeded', () => { + describe('when root endpoint request has succeeded', () => { beforeEach(() => { rootDataServiceStub.checkServerAvailability.and.returnValue(of(true)); }); - it('should not redirect to error page', () => { - guard.canActivateChild({} as any, {} as any).pipe( - take(1) - ).subscribe((canActivate: boolean) => { - expect(canActivate).toEqual(true); - expect(rootDataServiceStub.invalidateRootCache).not.toHaveBeenCalled(); - expect(router.navigateByUrl).not.toHaveBeenCalled(); + it('should return true', () => { + testScheduler.run(({ expectObservable }) => { + const result$ = guard.canActivateChild({} as any, {} as any); + expectObservable(result$).toBe('(a|)', { a: true }); }); }); }); - describe('when root endpoint has not succeeded', () => { + describe('when root endpoint request has not succeeded', () => { beforeEach(() => { rootDataServiceStub.checkServerAvailability.and.returnValue(of(false)); }); - it('should redirect to error page', () => { - guard.canActivateChild({} as any, {} as any).pipe( - take(1) - ).subscribe((canActivate: boolean) => { - expect(canActivate).toEqual(false); - expect(rootDataServiceStub.invalidateRootCache).toHaveBeenCalled(); - expect(router.navigateByUrl).toHaveBeenCalledWith(getPageInternalServerErrorRoute()); + it('should return a UrlTree with the route to the 500 error page', () => { + testScheduler.run(({ expectObservable }) => { + const result$ = guard.canActivateChild({} as any, {} as any); + expectObservable(result$).toBe('(b|)', { b: redirectUrlTree }); }); + expect(router.parseUrl).toHaveBeenCalledWith('/500'); + }); + }); + + describe(`listenForRouteChanges`, () => { + it(`should invalidate the root cache, when the method is first called`, () => { + testScheduler.run(() => { + guard.listenForRouteChanges(); + expect(rootDataServiceStub.invalidateRootCache).toHaveBeenCalledTimes(1); + }); + }); + + it(`should invalidate the root cache on every NavigationStart event`, () => { + testScheduler.run(() => { + guard.listenForRouteChanges(); + eventSubject.next(new NavigationStart(1,'')); + eventSubject.next(new NavigationEnd(1,'', '')); + eventSubject.next(new NavigationStart(2,'')); + eventSubject.next(new NavigationEnd(2,'', '')); + eventSubject.next(new NavigationStart(3,'')); + }); + // once when the method is first called, and then 3 times for NavigationStart events + expect(rootDataServiceStub.invalidateRootCache).toHaveBeenCalledTimes(1 + 3); }); }); }); diff --git a/src/app/core/server-check/server-check.guard.ts b/src/app/core/server-check/server-check.guard.ts index 8a0e26c01d..79c34c3659 100644 --- a/src/app/core/server-check/server-check.guard.ts +++ b/src/app/core/server-check/server-check.guard.ts @@ -1,8 +1,15 @@ import { Injectable } from '@angular/core'; -import { ActivatedRouteSnapshot, CanActivateChild, Router, RouterStateSnapshot } from '@angular/router'; +import { + ActivatedRouteSnapshot, + CanActivateChild, + Router, + RouterStateSnapshot, + UrlTree, + NavigationStart +} from '@angular/router'; import { Observable } from 'rxjs'; -import { take, tap } from 'rxjs/operators'; +import { take, map, filter } from 'rxjs/operators'; import { RootDataService } from '../data/root-data.service'; import { getPageInternalServerErrorRoute } from '../../app-routing-paths'; @@ -23,17 +30,36 @@ export class ServerCheckGuard implements CanActivateChild { */ canActivateChild( route: ActivatedRouteSnapshot, - state: RouterStateSnapshot): Observable { + state: RouterStateSnapshot + ): Observable { return this.rootDataService.checkServerAvailability().pipe( take(1), - tap((isAvailable: boolean) => { + map((isAvailable: boolean) => { if (!isAvailable) { - this.rootDataService.invalidateRootCache(); - this.router.navigateByUrl(getPageInternalServerErrorRoute()); + return this.router.parseUrl(getPageInternalServerErrorRoute()); + } else { + return true; } }) ); + } + /** + * Listen to all router events. Every time a new navigation starts, invalidate the cache + * for the root endpoint. That way we retrieve it once per routing operation to ensure the + * backend is not down. But if the guard is called multiple times during the same routing + * operation, the cached version is used. + */ + listenForRouteChanges(): void { + // we'll always be too late for the first NavigationStart event with the router subscribe below, + // so this statement is for the very first route operation. + this.rootDataService.invalidateRootCache(); + + this.router.events.pipe( + filter(event => event instanceof NavigationStart), + ).subscribe(() => { + this.rootDataService.invalidateRootCache(); + }); } } diff --git a/src/app/core/services/browser-hard-redirect.service.ts b/src/app/core/services/browser-hard-redirect.service.ts index 4ef9548899..827e83f0b7 100644 --- a/src/app/core/services/browser-hard-redirect.service.ts +++ b/src/app/core/services/browser-hard-redirect.service.ts @@ -38,8 +38,8 @@ export class BrowserHardRedirectService extends HardRedirectService { /** * Get the origin of the current URL * i.e. "://" [ ":" ] - * e.g. if the URL is https://demo7.dspace.org/search?query=test, - * the origin would be https://demo7.dspace.org + * e.g. if the URL is https://demo.dspace.org/search?query=test, + * the origin would be https://demo.dspace.org */ getCurrentOrigin(): string { return this.location.origin; diff --git a/src/app/core/services/hard-redirect.service.ts b/src/app/core/services/hard-redirect.service.ts index 826c7e4fa9..e6104cefb9 100644 --- a/src/app/core/services/hard-redirect.service.ts +++ b/src/app/core/services/hard-redirect.service.ts @@ -25,8 +25,8 @@ export abstract class HardRedirectService { /** * Get the origin of the current URL * i.e. "://" [ ":" ] - * e.g. if the URL is https://demo7.dspace.org/search?query=test, - * the origin would be https://demo7.dspace.org + * e.g. if the URL is https://demo.dspace.org/search?query=test, + * the origin would be https://demo.dspace.org */ abstract getCurrentOrigin(): string; } diff --git a/src/app/core/services/server-hard-redirect.service.ts b/src/app/core/services/server-hard-redirect.service.ts index 8c45cc864b..d71318d7b8 100644 --- a/src/app/core/services/server-hard-redirect.service.ts +++ b/src/app/core/services/server-hard-redirect.service.ts @@ -69,8 +69,8 @@ export class ServerHardRedirectService extends HardRedirectService { /** * Get the origin of the current URL * i.e. "://" [ ":" ] - * e.g. if the URL is https://demo7.dspace.org/search?query=test, - * the origin would be https://demo7.dspace.org + * e.g. if the URL is https://demo.dspace.org/search?query=test, + * the origin would be https://demo.dspace.org */ getCurrentOrigin(): string { return this.req.protocol + '://' + this.req.headers.host; diff --git a/src/app/core/shared/hal-endpoint.service.spec.ts b/src/app/core/shared/hal-endpoint.service.spec.ts index 56e890b318..b81d0806df 100644 --- a/src/app/core/shared/hal-endpoint.service.spec.ts +++ b/src/app/core/shared/hal-endpoint.service.spec.ts @@ -1,4 +1,3 @@ -import { cold, hot } from 'jasmine-marbles'; import { getMockRequestService } from '../../shared/mocks/request.service.mock'; import { RequestService } from '../data/request.service'; import { HALEndpointService } from './hal-endpoint.service'; @@ -7,12 +6,17 @@ import { combineLatest as observableCombineLatest, of as observableOf } from 'rx import { environment } from '../../../environments/environment'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { TestScheduler } from 'rxjs/testing'; +import { RemoteData } from '../data/remote-data'; +import { RequestEntryState } from '../data/request-entry-state.model'; describe('HALEndpointService', () => { let service: HALEndpointService; let requestService: RequestService; let rdbService: RemoteDataBuildService; let envConfig; + let testScheduler; + let remoteDataMocks; const endpointMap = { test: { href: 'https://rest.api/test' @@ -68,7 +72,30 @@ describe('HALEndpointService', () => { }; const linkPath = 'test'; + const timeStamp = new Date().getTime(); + const msToLive = 15 * 60 * 1000; + const payload = { + _links: endpointMaps[one] + }; + const statusCodeSuccess = 200; + const statusCodeError = 404; + const errorMessage = 'not found'; + remoteDataMocks = { + RequestPending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.RequestPending, undefined, undefined, undefined), + ResponsePending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.ResponsePending, undefined, undefined, undefined), + ResponsePendingStale: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.ResponsePendingStale, undefined, undefined, undefined), + Success: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Success, undefined, payload, statusCodeSuccess), + SuccessStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.SuccessStale, undefined, payload, statusCodeSuccess), + Error: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Error, errorMessage, undefined, statusCodeError), + ErrorStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.ErrorStale, errorMessage, undefined, statusCodeError), + }; + beforeEach(() => { + testScheduler = new TestScheduler((actual, expected) => { + // asserting the two objects are equal + // e.g. using chai. + expect(actual).toEqual(expected); + }); requestService = getMockRequestService(); rdbService = jasmine.createSpyObj('rdbService', { buildFromHref: createSuccessfulRemoteDataObject$({ @@ -111,20 +138,28 @@ describe('HALEndpointService', () => { }); it(`should return the endpoint URL for the service's linkPath`, () => { - spyOn(service as any, 'getEndpointAt').and - .returnValue(hot('a-', { a: 'https://rest.api/test' })); - const result = service.getEndpoint(linkPath); + testScheduler.run(({ cold, expectObservable }) => { + spyOn(service as any, 'getEndpointAt').and + .returnValue(cold('a-', { a: 'https://rest.api/test' })); + const result = service.getEndpoint(linkPath); - const expected = cold('(b|)', { b: endpointMap.test.href }); - expect(result).toBeObservable(expected); + const expected = '(b|)'; + const values = { + b: endpointMap.test.href + }; + expectObservable(result).toBe(expected, values); + }); }); it('should return undefined for a linkPath that isn\'t in the endpoint map', () => { - spyOn(service as any, 'getEndpointAt').and - .returnValue(hot('a-', { a: undefined })); - const result = service.getEndpoint('unknown'); - const expected = cold('(b|)', { b: undefined }); - expect(result).toBeObservable(expected); + testScheduler.run(({ cold, expectObservable }) => { + spyOn(service as any, 'getEndpointAt').and + .returnValue(cold('a-', { a: undefined })); + const result = service.getEndpoint('unknown'); + const expected = '(b|)'; + const values = { b: undefined }; + expectObservable(result).toBe(expected, values); + }); }); }); @@ -183,29 +218,118 @@ describe('HALEndpointService', () => { }); it('should return undefined as long as getRootEndpointMap hasn\'t fired', () => { - spyOn(service as any, 'getRootEndpointMap').and - .returnValue(hot('----')); + testScheduler.run(({ cold, expectObservable }) => { + spyOn(service as any, 'getRootEndpointMap').and + .returnValue(cold('----')); - const result = service.isEnabledOnRestApi(linkPath); - const expected = cold('b---', { b: undefined }); - expect(result).toBeObservable(expected); + const result = service.isEnabledOnRestApi(linkPath); + const expected = 'b---'; + const values = { b: undefined }; + expectObservable(result).toBe(expected, values); + }); }); it('should return true if the service\'s linkPath is in the endpoint map', () => { - spyOn(service as any, 'getRootEndpointMap').and - .returnValue(hot('--a-', { a: endpointMap })); - const result = service.isEnabledOnRestApi(linkPath); - const expected = cold('b-c-', { b: undefined, c: true }); - expect(result).toBeObservable(expected); + testScheduler.run(({ cold, expectObservable }) => { + spyOn(service as any, 'getRootEndpointMap').and + .returnValue(cold('--a-', { a: endpointMap })); + const result = service.isEnabledOnRestApi(linkPath); + const expected = 'b-c-'; + const values = { b: undefined, c: true }; + expectObservable(result).toBe(expected, values); + }); }); it('should return false if the service\'s linkPath isn\'t in the endpoint map', () => { - spyOn(service as any, 'getRootEndpointMap').and - .returnValue(hot('--a-', { a: endpointMap })); + testScheduler.run(({ cold, expectObservable }) => { + spyOn(service as any, 'getRootEndpointMap').and + .returnValue(cold('--a-', { a: endpointMap })); - const result = service.isEnabledOnRestApi('unknown'); - const expected = cold('b-c-', { b: undefined, c: false }); - expect(result).toBeObservable(expected); + const result = service.isEnabledOnRestApi('unknown'); + const expected = 'b-c-'; + const values = { b: undefined, c: false }; + expectObservable(result).toBe(expected, values); + }); + }); + + }); + + describe(`getEndpointMapAt`, () => { + const href = 'https://rest.api/some/sub/path'; + + it(`should call requestService.send with a new EndpointMapRequest for the given href. useCachedVersionIfAvailable should be true`, () => { + testScheduler.run(() => { + (service as any).getEndpointMapAt(href); + }); + const expected = new EndpointMapRequest(requestService.generateRequestId(), href); + expect(requestService.send).toHaveBeenCalledWith(expected, true); + }); + + it(`should call rdbService.buildFromHref with the given href`, () => { + testScheduler.run(() => { + (service as any).getEndpointMapAt(href); + }); + expect(rdbService.buildFromHref).toHaveBeenCalledWith(href); + }); + + describe(`when the RemoteData returned from rdbService is stale`, () => { + it(`should re-request it`, () => { + spyOn(service as any, 'getEndpointMapAt').and.callThrough(); + testScheduler.run(({ cold }) => { + (rdbService.buildFromHref as jasmine.Spy).and.returnValue(cold('a', { a: remoteDataMocks.ResponsePendingStale })); + // we need to subscribe to the result, to ensure the "tap" that does the re-request can fire + (service as any).getEndpointMapAt(href).subscribe(); + }); + expect((service as any).getEndpointMapAt).toHaveBeenCalledTimes(2); + }); + }); + + describe(`when the RemoteData returned from rdbService isn't stale`, () => { + it(`should not re-request it`, () => { + spyOn(service as any, 'getEndpointMapAt').and.callThrough(); + testScheduler.run(({ cold }) => { + (rdbService.buildFromHref as jasmine.Spy).and.returnValue(cold('a', { a: remoteDataMocks.ResponsePending })); + // we need to subscribe to the result, to ensure the "tap" that does the re-request can fire + (service as any).getEndpointMapAt(href).subscribe(); + }); + expect((service as any).getEndpointMapAt).toHaveBeenCalledTimes(1); + }); + }); + + it(`should emit exactly once, returning the endpoint map in the response, when the RemoteData completes`, () => { + testScheduler.run(({ cold, expectObservable }) => { + (rdbService.buildFromHref as jasmine.Spy).and.returnValue(cold('a-b-c-d-e-f-g-h-i-j-k-l', { + a: remoteDataMocks.RequestPending, + b: remoteDataMocks.ResponsePending, + c: remoteDataMocks.ResponsePendingStale, + d: remoteDataMocks.SuccessStale, + e: remoteDataMocks.RequestPending, + f: remoteDataMocks.ResponsePending, + g: remoteDataMocks.Success, + h: remoteDataMocks.SuccessStale, + i: remoteDataMocks.RequestPending, + k: remoteDataMocks.ResponsePending, + l: remoteDataMocks.Error, + })); + const expected = '------------(g|)'; + const values = { + g: endpointMaps[one] + }; + expectObservable((service as any).getEndpointMapAt(one)).toBe(expected, values); + }); + }); + + it(`should emit undefined when the response doesn't have a payload`, () => { + testScheduler.run(({ cold, expectObservable }) => { + (rdbService.buildFromHref as jasmine.Spy).and.returnValue(cold('a', { + a: remoteDataMocks.Error, + })); + const expected = '(a|)'; + const values = { + g: undefined + }; + expectObservable((service as any).getEndpointMapAt(href)).toBe(expected, values); + }); }); }); diff --git a/src/app/core/shared/hal-endpoint.service.ts b/src/app/core/shared/hal-endpoint.service.ts index 8b6316a6ce..07754616c7 100644 --- a/src/app/core/shared/hal-endpoint.service.ts +++ b/src/app/core/shared/hal-endpoint.service.ts @@ -1,5 +1,12 @@ import { Observable } from 'rxjs'; -import { distinctUntilChanged, map, startWith, switchMap, take } from 'rxjs/operators'; +import { + distinctUntilChanged, + map, + startWith, + switchMap, + take, + tap, filter +} from 'rxjs/operators'; import { RequestService } from '../data/request.service'; import { EndpointMapRequest } from '../data/request.models'; import { hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util'; @@ -9,7 +16,7 @@ import { EndpointMap } from '../cache/response.models'; import { getFirstCompletedRemoteData } from './operators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RemoteData } from '../data/remote-data'; -import { UnCacheableObject } from './uncacheable-object.model'; +import { CacheableObject } from '../cache/cacheable-object.model'; @Injectable() export class HALEndpointService { @@ -33,9 +40,18 @@ export class HALEndpointService { this.requestService.send(request, true); - return this.rdbService.buildFromHref(href).pipe( + return this.rdbService.buildFromHref(href).pipe( + // Re-request stale responses + tap((rd: RemoteData) => { + if (hasValue(rd) && rd.isStale) { + this.getEndpointMapAt(href); + } + }), + // Filter out all stale responses. We're only interested in a single, non-stale, + // completed RemoteData + filter((rd: RemoteData) => !rd.isStale), getFirstCompletedRemoteData(), - map((response: RemoteData) => { + map((response: RemoteData) => { if (hasValue(response.payload)) { return response.payload._links; } else { diff --git a/src/app/core/submission/models/workspaceitem-section-upload-file.model.ts b/src/app/core/submission/models/workspaceitem-section-upload-file.model.ts index 177473b7d5..725e646d76 100644 --- a/src/app/core/submission/models/workspaceitem-section-upload-file.model.ts +++ b/src/app/core/submission/models/workspaceitem-section-upload-file.model.ts @@ -29,6 +29,18 @@ export class WorkspaceitemSectionUploadFileObject { value: string; }; + /** + * The file format information + */ + format: { + shortDescription: string, + description: string, + mimetype: string, + supportLevel: string, + internal: boolean, + type: string + }; + /** * The file url */ diff --git a/src/app/curation-form/curation-form.component.spec.ts b/src/app/curation-form/curation-form.component.spec.ts index dc70b925e8..a0bdee21f4 100644 --- a/src/app/curation-form/curation-form.component.spec.ts +++ b/src/app/curation-form/curation-form.component.spec.ts @@ -1,4 +1,4 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ComponentFixture, TestBed, waitForAsync, fakeAsync, flush } from '@angular/core/testing'; import { TranslateModule } from '@ngx-translate/core'; import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { CurationFormComponent } from './curation-form.component'; @@ -16,6 +16,7 @@ import { ConfigurationDataService } from '../core/data/configuration-data.servic import { ConfigurationProperty } from '../core/shared/configuration-property.model'; import { getProcessDetailRoute } from '../process-page/process-page-routing.paths'; import { HandleService } from '../shared/handle.service'; +import { of as observableOf } from 'rxjs'; describe('CurationFormComponent', () => { let comp: CurationFormComponent; @@ -54,7 +55,7 @@ describe('CurationFormComponent', () => { }); handleService = { - normalizeHandle: (a) => a + normalizeHandle: (a: string) => observableOf(a), } as any; notificationsService = new NotificationsServiceStub(); @@ -151,12 +152,13 @@ describe('CurationFormComponent', () => { ], []); }); - it(`should show an error notification and return when an invalid dsoHandle is provided`, () => { + it(`should show an error notification and return when an invalid dsoHandle is provided`, fakeAsync(() => { comp.dsoHandle = 'test-handle'; - spyOn(handleService, 'normalizeHandle').and.returnValue(null); + spyOn(handleService, 'normalizeHandle').and.returnValue(observableOf(null)); comp.submit(); + flush(); expect(notificationsService.error).toHaveBeenCalled(); expect(scriptDataService.invoke).not.toHaveBeenCalled(); - }); + })); }); diff --git a/src/app/curation-form/curation-form.component.ts b/src/app/curation-form/curation-form.component.ts index 2c0e900a66..cc2c14f89f 100644 --- a/src/app/curation-form/curation-form.component.ts +++ b/src/app/curation-form/curation-form.component.ts @@ -1,22 +1,22 @@ -import { ChangeDetectorRef, Component, Input, OnInit } from '@angular/core'; +import { ChangeDetectorRef, Component, Input, OnDestroy, OnInit } from '@angular/core'; import { ScriptDataService } from '../core/data/processes/script-data.service'; import { UntypedFormControl, UntypedFormGroup } from '@angular/forms'; -import { getFirstCompletedRemoteData } from '../core/shared/operators'; -import { find, map } from 'rxjs/operators'; +import { getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload } from '../core/shared/operators'; +import { map } from 'rxjs/operators'; import { NotificationsService } from '../shared/notifications/notifications.service'; import { TranslateService } from '@ngx-translate/core'; import { hasValue, isEmpty, isNotEmpty } from '../shared/empty.util'; import { RemoteData } from '../core/data/remote-data'; import { Router } from '@angular/router'; -import { ProcessDataService } from '../core/data/processes/process-data.service'; import { Process } from '../process-page/processes/process.model'; import { ConfigurationDataService } from '../core/data/configuration-data.service'; import { ConfigurationProperty } from '../core/shared/configuration-property.model'; -import { Observable } from 'rxjs'; +import { Observable, Subscription } from 'rxjs'; import { getProcessDetailRoute } from '../process-page/process-page-routing.paths'; import { HandleService } from '../shared/handle.service'; export const CURATION_CFG = 'plugin.named.org.dspace.curate.CurationTask'; + /** * Component responsible for rendering the Curation Task form */ @@ -24,7 +24,7 @@ export const CURATION_CFG = 'plugin.named.org.dspace.curate.CurationTask'; selector: 'ds-curation-form', templateUrl: './curation-form.component.html' }) -export class CurationFormComponent implements OnInit { +export class CurationFormComponent implements OnDestroy, OnInit { config: Observable>; tasks: string[]; @@ -33,10 +33,11 @@ export class CurationFormComponent implements OnInit { @Input() dsoHandle: string; + subs: Subscription[] = []; + constructor( private scriptDataService: ScriptDataService, private configurationDataService: ConfigurationDataService, - private processDataService: ProcessDataService, private notificationsService: NotificationsService, private translateService: TranslateService, private handleService: HandleService, @@ -45,6 +46,10 @@ export class CurationFormComponent implements OnInit { ) { } + ngOnDestroy(): void { + this.subs.forEach((sub: Subscription) => sub.unsubscribe()); + } + ngOnInit(): void { this.form = new UntypedFormGroup({ task: new UntypedFormControl(''), @@ -52,16 +57,15 @@ export class CurationFormComponent implements OnInit { }); this.config = this.configurationDataService.findByPropertyName(CURATION_CFG); - this.config.pipe( - find((rd: RemoteData) => rd.hasSucceeded), - map((rd: RemoteData) => rd.payload) - ).subscribe((configProperties) => { + this.subs.push(this.config.pipe( + getFirstSucceededRemoteDataPayload(), + ).subscribe((configProperties: ConfigurationProperty) => { this.tasks = configProperties.values .filter((value) => isNotEmpty(value) && value.includes('=')) .map((value) => value.split('=')[1].trim()); this.form.get('task').patchValue(this.tasks[0]); this.cdr.detectChanges(); - }); + })); } /** @@ -77,33 +81,41 @@ export class CurationFormComponent implements OnInit { */ submit() { const taskName = this.form.get('task').value; - let handle; + let handle$: Observable; if (this.hasHandleValue()) { - handle = this.handleService.normalizeHandle(this.dsoHandle); - if (isEmpty(handle)) { - this.notificationsService.error(this.translateService.get('curation.form.submit.error.head'), - this.translateService.get('curation.form.submit.error.invalid-handle')); - return; - } + handle$ = this.handleService.normalizeHandle(this.dsoHandle).pipe( + map((handle: string | null) => { + if (isEmpty(handle)) { + this.notificationsService.error(this.translateService.get('curation.form.submit.error.head'), + this.translateService.get('curation.form.submit.error.invalid-handle')); + } + return handle; + }), + ); } else { - handle = this.handleService.normalizeHandle(this.form.get('handle').value); - if (isEmpty(handle)) { - handle = 'all'; - } + handle$ = this.handleService.normalizeHandle(this.form.get('handle').value).pipe( + map((handle: string | null) => isEmpty(handle) ? 'all' : handle), + ); } - this.scriptDataService.invoke('curate', [ - { name: '-t', value: taskName }, - { name: '-i', value: handle }, - ], []).pipe(getFirstCompletedRemoteData()).subscribe((rd: RemoteData) => { - if (rd.hasSucceeded) { - this.notificationsService.success(this.translateService.get('curation.form.submit.success.head'), - this.translateService.get('curation.form.submit.success.content')); - this.router.navigateByUrl(getProcessDetailRoute(rd.payload.processId)); - } else { - this.notificationsService.error(this.translateService.get('curation.form.submit.error.head'), - this.translateService.get('curation.form.submit.error.content')); + this.subs.push(handle$.subscribe((handle: string) => { + if (hasValue(handle)) { + this.subs.push(this.scriptDataService.invoke('curate', [ + { name: '-t', value: taskName }, + { name: '-i', value: handle }, + ], []).pipe( + getFirstCompletedRemoteData(), + ).subscribe((rd: RemoteData) => { + if (rd.hasSucceeded) { + this.notificationsService.success(this.translateService.get('curation.form.submit.success.head'), + this.translateService.get('curation.form.submit.success.content')); + void this.router.navigateByUrl(getProcessDetailRoute(rd.payload.processId)); + } else { + this.notificationsService.error(this.translateService.get('curation.form.submit.error.head'), + this.translateService.get('curation.form.submit.error.content')); + } + })); } - }); + })); } } diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.html b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.html index 525b42610b..47449bda5c 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.html +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component.html @@ -4,6 +4,7 @@
    {{ mdValue.newValue.value }}
    {{ mdRepresentationName$ | async }} @@ -13,31 +14,32 @@
    {{ mdValue.newValue.language }}
    - - - -
    -
    @@ -74,16 +74,19 @@
    diff --git a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.ts b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.ts index d67a7ea738..d44817be84 100644 --- a/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.ts +++ b/src/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component.ts @@ -1,5 +1,5 @@ import { Component, Inject, Injector, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'; -import { AlertType } from '../../shared/alert/aletr-type'; +import { AlertType } from '../../shared/alert/alert-type'; import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { DsoEditMetadataForm } from './dso-edit-metadata-form'; import { map } from 'rxjs/operators'; diff --git a/src/app/dso-shared/dso-edit-metadata/metadata-field-selector/metadata-field-selector.component.html b/src/app/dso-shared/dso-edit-metadata/metadata-field-selector/metadata-field-selector.component.html index 4c310bd81b..3f5af76051 100644 --- a/src/app/dso-shared/dso-edit-metadata/metadata-field-selector/metadata-field-selector.component.html +++ b/src/app/dso-shared/dso-edit-metadata/metadata-field-selector/metadata-field-selector.component.html @@ -1,6 +1,7 @@ diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-suggestions/org-unit-input-suggestions.component.html b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-suggestions/org-unit-input-suggestions.component.html index c4e31d3d81..58f39b8d0b 100644 --- a/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-suggestions/org-unit-input-suggestions.component.html +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-suggestions/org-unit-input-suggestions.component.html @@ -11,7 +11,9 @@ [dsDebounce]="debounceTime" (onDebounce)="find($event)" [placeholder]="placeholder" [ngModelOptions]="{standalone: true}" autocomplete="off"/> - +
    {{'admin.registries.metadata.schemas.table.selected' | translate}} {{'admin.registries.metadata.schemas.table.id' | translate}} {{'admin.registries.metadata.schemas.table.namespace' | translate}} {{'admin.registries.metadata.schemas.table.name' | translate}} {{schema.id}}
    {{'admin.registries.schema.fields.table.selected' | translate}} {{'admin.registries.schema.fields.table.id' | translate}} {{'admin.registries.schema.fields.table.field' | translate}} {{'admin.registries.schema.fields.table.scopenote' | translate}}
    - + + {{field.id}}{{schema?.prefix}}.{{field.element}}{{field.qualifier}}{{schema?.prefix}}.{{field.element}}{{field.qualifier ? '.' + field.qualifier : ''}} {{field.scopeNote}}
    + + + + + + + + + + + + + + + + + + + + +
    {{'quality-assurance.event.table.trust' | translate}}{{'quality-assurance.event.table.publication' | translate}} + {{'quality-assurance.event.table.details' | translate}} + + {{'quality-assurance.event.table.project-details' | translate}} + {{'quality-assurance.event.table.actions' | translate}}
    {{eventElement?.event?.trust}} + {{eventElement.title}} + {{eventElement.title}} + +

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

    +

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

    +
    +

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

    +
    +

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

    + +
    +

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

    +

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

    +

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

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

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

    + +
    +
    +
    +
    +

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

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

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

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

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

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

    {{title | translate}}

    +

    {{title | translate}}

    diff --git a/src/app/process-page/detail/process-detail-field/process-detail-field.component.spec.ts b/src/app/process-page/detail/process-detail-field/process-detail-field.component.spec.ts index 57b596b8b6..ba0c946bfd 100644 --- a/src/app/process-page/detail/process-detail-field/process-detail-field.component.spec.ts +++ b/src/app/process-page/detail/process-detail-field/process-detail-field.component.spec.ts @@ -32,7 +32,7 @@ describe('ProcessDetailFieldComponent', () => { }); it('should display the given title', () => { - const header = fixture.debugElement.query(By.css('h4')).nativeElement; + const header = fixture.debugElement.query(By.css('h2')).nativeElement; expect(header.textContent).toContain(title); }); }); diff --git a/src/app/process-page/detail/process-detail.component.html b/src/app/process-page/detail/process-detail.component.html index 5f905cbfff..4daee064df 100644 --- a/src/app/process-page/detail/process-detail.component.html +++ b/src/app/process-page/detail/process-detail.component.html @@ -1,9 +1,9 @@
    -

    +

    {{ 'process.detail.title' | translate:{ id: process?.processId, name: process?.scriptName } }} -

    +
    Refreshing in {{ seconds }}s @@ -53,7 +53,7 @@ -
    {{ (outputLogs$ | async) }}

    diff --git a/src/app/process-page/detail/process-detail.component.spec.ts b/src/app/process-page/detail/process-detail.component.spec.ts index 9552f9a092..9a0d89a882 100644 --- a/src/app/process-page/detail/process-detail.component.spec.ts +++ b/src/app/process-page/detail/process-detail.component.spec.ts @@ -147,7 +147,7 @@ describe('ProcessDetailComponent', () => { providers: [ { provide: ActivatedRoute, - useValue: { data: observableOf({ process: createSuccessfulRemoteDataObject(process) }) } + useValue: { data: observableOf({ process: createSuccessfulRemoteDataObject(process) }), snapshot: { params: { id: 1 } } }, }, { provide: ProcessDataService, useValue: processService }, { provide: BitstreamDataService, useValue: bitstreamDataService }, @@ -310,10 +310,11 @@ describe('ProcessDetailComponent', () => { }); it('should call refresh method every 5 seconds, until process is completed', fakeAsync(() => { - spyOn(component, 'refresh'); - spyOn(component, 'stopRefreshTimer'); + spyOn(component, 'refresh').and.callThrough(); + spyOn(component, 'stopRefreshTimer').and.callThrough(); - process.processStatus = ProcessStatus.COMPLETED; + // start off with a running process in order for the refresh counter starts counting up + process.processStatus = ProcessStatus.RUNNING; // set findbyId to return a completed process (processService.findById as jasmine.Spy).and.returnValue(observableOf(createSuccessfulRemoteDataObject(process))); @@ -336,6 +337,10 @@ describe('ProcessDetailComponent', () => { tick(1001); // 1 second + 1 ms by the setTimeout expect(component.refreshCounter$.value).toBe(0); // 1 - 1 + // set the process to completed right before the counter checks the process + process.processStatus = ProcessStatus.COMPLETED; + (processService.findById as jasmine.Spy).and.returnValue(observableOf(createSuccessfulRemoteDataObject(process))); + tick(1000); // 1 second expect(component.refresh).toHaveBeenCalledTimes(1); diff --git a/src/app/process-page/detail/process-detail.component.ts b/src/app/process-page/detail/process-detail.component.ts index a379dfe337..be0b6ad0f6 100644 --- a/src/app/process-page/detail/process-detail.component.ts +++ b/src/app/process-page/detail/process-detail.component.ts @@ -17,7 +17,7 @@ import { getFirstSucceededRemoteDataPayload } from '../../core/shared/operators'; import { URLCombiner } from '../../core/url-combiner/url-combiner'; -import { AlertType } from '../../shared/alert/aletr-type'; +import { AlertType } from '../../shared/alert/alert-type'; import { hasValue } from '../../shared/empty.util'; import { ProcessStatus } from '../processes/process-status.model'; import { Process } from '../processes/process.model'; diff --git a/src/app/process-page/form/process-form.component.html b/src/app/process-page/form/process-form.component.html index ce6d62efec..211129489e 100644 --- a/src/app/process-page/form/process-form.component.html +++ b/src/app/process-page/form/process-form.component.html @@ -1,18 +1,18 @@

    -

    +

    {{headerKey | translate}} -

    -
    + +
    - - + {{ 'process.new.cancel' | translate }} +
    - +
    diff --git a/src/app/process-page/form/process-parameters/parameter-select/parameter-select.component.html b/src/app/process-page/form/process-parameters/parameter-select/parameter-select.component.html index 4bf06bbade..1f1559b50b 100644 --- a/src/app/process-page/form/process-parameters/parameter-select/parameter-select.component.html +++ b/src/app/process-page/form/process-parameters/parameter-select/parameter-select.component.html @@ -1,16 +1,20 @@ -
    +
    - - + +
    diff --git a/src/app/process-page/form/process-parameters/parameter-select/parameter-select.component.spec.ts b/src/app/process-page/form/process-parameters/parameter-select/parameter-select.component.spec.ts index 56fece56b4..818a292e33 100644 --- a/src/app/process-page/form/process-parameters/parameter-select/parameter-select.component.spec.ts +++ b/src/app/process-page/form/process-parameters/parameter-select/parameter-select.component.spec.ts @@ -1,5 +1,5 @@ import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; - +import { TranslateModule } from '@ngx-translate/core'; import { ParameterSelectComponent } from './parameter-select.component'; import { NO_ERRORS_SCHEMA } from '@angular/core'; import { FormsModule } from '@angular/forms'; @@ -33,7 +33,10 @@ describe('ParameterSelectComponent', () => { beforeEach(waitForAsync(() => { init(); TestBed.configureTestingModule({ - imports: [FormsModule], + imports: [ + FormsModule, + TranslateModule.forRoot(), + ], declarations: [ParameterSelectComponent], schemas: [NO_ERRORS_SCHEMA] }) diff --git a/src/app/process-page/form/process-parameters/parameter-value-input/boolean-value-input/boolean-value-input.component.html b/src/app/process-page/form/process-parameters/parameter-value-input/boolean-value-input/boolean-value-input.component.html index 914b331413..68171a23b2 100644 --- a/src/app/process-page/form/process-parameters/parameter-value-input/boolean-value-input/boolean-value-input.component.html +++ b/src/app/process-page/form/process-parameters/parameter-value-input/boolean-value-input/boolean-value-input.component.html @@ -1 +1 @@ - + diff --git a/src/app/process-page/form/process-parameters/parameter-value-input/boolean-value-input/boolean-value-input.component.spec.ts b/src/app/process-page/form/process-parameters/parameter-value-input/boolean-value-input/boolean-value-input.component.spec.ts index 38f119ad5b..76b01b8709 100644 --- a/src/app/process-page/form/process-parameters/parameter-value-input/boolean-value-input/boolean-value-input.component.spec.ts +++ b/src/app/process-page/form/process-parameters/parameter-value-input/boolean-value-input/boolean-value-input.component.spec.ts @@ -1,5 +1,5 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; - +import { TranslateModule } from '@ngx-translate/core'; import { BooleanValueInputComponent } from './boolean-value-input.component'; describe('BooleanValueInputComponent', () => { @@ -8,6 +8,9 @@ describe('BooleanValueInputComponent', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot(), + ], declarations: [BooleanValueInputComponent] }) .compileComponents(); diff --git a/src/app/process-page/form/process-parameters/parameter-value-input/date-value-input/date-value-input.component.html b/src/app/process-page/form/process-parameters/parameter-value-input/date-value-input/date-value-input.component.html index f367d3779f..4e77f4ed1b 100644 --- a/src/app/process-page/form/process-parameters/parameter-value-input/date-value-input/date-value-input.component.html +++ b/src/app/process-page/form/process-parameters/parameter-value-input/date-value-input/date-value-input.component.html @@ -1,6 +1,6 @@ - +
    + class="alert alert-danger validation-error mb-0">
    {{'process.new.parameter.string.required' | translate}}
    diff --git a/src/app/process-page/form/process-parameters/parameter-value-input/date-value-input/date-value-input.component.scss b/src/app/process-page/form/process-parameters/parameter-value-input/date-value-input/date-value-input.component.scss index e69de29bb2..8c6325f95a 100644 --- a/src/app/process-page/form/process-parameters/parameter-value-input/date-value-input/date-value-input.component.scss +++ b/src/app/process-page/form/process-parameters/parameter-value-input/date-value-input/date-value-input.component.scss @@ -0,0 +1,5 @@ +:host { + display: flex; + flex-direction: column; + gap: calc(var(--bs-spacer) / 2); +} diff --git a/src/app/process-page/form/process-parameters/parameter-value-input/file-value-input/file-value-input.component.html b/src/app/process-page/form/process-parameters/parameter-value-input/file-value-input/file-value-input.component.html index cac3fbd82d..a741eacc86 100644 --- a/src/app/process-page/form/process-parameters/parameter-value-input/file-value-input/file-value-input.component.html +++ b/src/app/process-page/form/process-parameters/parameter-value-input/file-value-input/file-value-input.component.html @@ -1,5 +1,5 @@